добавление комментарией к коду
This commit is contained in:
parent
c187c66737
commit
37220d7d17
31
index.html
31
index.html
@ -17,13 +17,21 @@
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Декоративные фоновые пятна. Они не участвуют в логике и нужны только для глубины фона. -->
|
||||
<div class="ambient ambient-one"></div>
|
||||
<div class="ambient ambient-two"></div>
|
||||
|
||||
<main class="shell">
|
||||
<!--
|
||||
Основная сетка приложения:
|
||||
слева sticky-меню с деревом навигации, справа каталог карточек.
|
||||
Ширина левой колонки управляется CSS-переменной --sidebar-width и ресайзером.
|
||||
-->
|
||||
<div class="app-layout">
|
||||
<!-- Боковая панель: поиск, настройки и дерево навигации по всей структуре. -->
|
||||
<aside class="side-panel" aria-label="Навигация по экосистеме">
|
||||
<div class="hero-content">
|
||||
<!-- Шестеренка открывает fixed popup с настройками внешнего вида и поведения. -->
|
||||
<button
|
||||
class="settings-button"
|
||||
type="button"
|
||||
@ -36,6 +44,7 @@
|
||||
|
||||
<p class="eyebrow" id="page-title">Панель экосистемы</p>
|
||||
|
||||
<!-- Поиск фильтрует каталог и подсвечивает совпадения в дереве и карточках. -->
|
||||
<label class="search-field">
|
||||
<input
|
||||
type="search"
|
||||
@ -45,9 +54,12 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Дерево генерируется из ecosystemSections в script.js. -->
|
||||
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Вертикальная ручка для изменения ширины бокового меню мышью/указателем. -->
|
||||
<div
|
||||
class="sidebar-resizer"
|
||||
role="separator"
|
||||
@ -56,7 +68,18 @@
|
||||
data-sidebar-resizer
|
||||
></div>
|
||||
|
||||
<!-- Затемнение страницы за popup-настройками. -->
|
||||
<div class="settings-overlay" data-settings-overlay></div>
|
||||
|
||||
<!--
|
||||
Popup настроек:
|
||||
- тема;
|
||||
- режим отображения карточек;
|
||||
- видимость нумерации;
|
||||
- видимость тегов в дереве;
|
||||
- авто-раскрытие дерева при поиске;
|
||||
- цветовой акцент.
|
||||
-->
|
||||
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
@ -68,6 +91,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Переключение светлой/темной темы. Значение сохраняется в localStorage. -->
|
||||
<div class="control-group">
|
||||
<span>Тема</span>
|
||||
<button class="segmented is-active" type="button" data-theme="dark">
|
||||
@ -78,6 +102,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Режимы каталога: обычные/компактные плитки и списки. -->
|
||||
<div class="control-group view-group">
|
||||
<span>Вид</span>
|
||||
<button class="segmented is-active" type="button" data-view="cards">
|
||||
@ -94,6 +119,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Показывать номера разделов, подразделов и элементов. -->
|
||||
<label class="toggle-row">
|
||||
<span>
|
||||
<strong>Показывать нумерацию</strong>
|
||||
@ -102,6 +128,7 @@
|
||||
<input type="checkbox" data-show-numbers />
|
||||
</label>
|
||||
|
||||
<!-- Показывать поисковые теги рядом с файлами в дереве навигации. -->
|
||||
<label class="toggle-row">
|
||||
<span>
|
||||
<strong>Показывать теги дерева</strong>
|
||||
@ -110,6 +137,7 @@
|
||||
<input type="checkbox" data-show-tree-tags />
|
||||
</label>
|
||||
|
||||
<!-- Управляет тем, раскрывает ли поиск только ветки с совпадениями. -->
|
||||
<label class="toggle-row">
|
||||
<span>
|
||||
<strong>Авто раскрывать поиск</strong>
|
||||
@ -118,6 +146,7 @@
|
||||
<input type="checkbox" data-auto-collapse-search />
|
||||
</label>
|
||||
|
||||
<!-- Выбор акцентного цвета интерфейса. -->
|
||||
<div class="control-group accent-group">
|
||||
<span>Акцент</span>
|
||||
<button
|
||||
@ -147,8 +176,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Каталог карточек генерируется из ecosystemSections в script.js. -->
|
||||
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
|
||||
|
||||
<!-- Шаблон состояния, когда поиск не нашел ни одного элемента. -->
|
||||
<template id="empty-template">
|
||||
<div class="empty-state">
|
||||
<img src="assets/search.svg" alt="" />
|
||||
|
||||
145
script.js
145
script.js
@ -1,8 +1,31 @@
|
||||
// ============================================================================
|
||||
// Persistent storage keys
|
||||
// ============================================================================
|
||||
// STORAGE_KEY хранит пользовательские настройки интерфейса:
|
||||
// тему, акцент, режим отображения, ширину сайдбара и toggle-настройки.
|
||||
const STORAGE_KEY = "ecosystem-panel-preferences";
|
||||
|
||||
// COLLAPSE_STORAGE_KEY хранит ручные состояния раскрытия details:
|
||||
// отдельно для дерева навигации и отдельно для контентных секций.
|
||||
const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state";
|
||||
|
||||
// ============================================================================
|
||||
// Navigation data model
|
||||
// ============================================================================
|
||||
// Меняй навигацию здесь: разделы -> подразделы -> элементы.
|
||||
// url можно заменить на реальные адреса, tags помогают поиску.
|
||||
//
|
||||
// Структура:
|
||||
// - section: крупный раздел панели;
|
||||
// - group: подраздел внутри section;
|
||||
// - item: конечный кликабельный элемент.
|
||||
//
|
||||
// Каждый item имеет:
|
||||
// - number: человекочитаемый номер;
|
||||
// - title: название карточки;
|
||||
// - type: ключ из serviceMeta, который задает иконку и тип сервиса;
|
||||
// - tags: поисковые теги, которые также можно показывать в дереве;
|
||||
// - url: ссылка перехода. По умолчанию "#", чтобы заглушки не дергали страницу.
|
||||
const ecosystemSections = [
|
||||
{
|
||||
id: "user",
|
||||
@ -113,6 +136,8 @@ const ecosystemSections = [
|
||||
},
|
||||
];
|
||||
|
||||
// Метаданные типов сервисов.
|
||||
// item.type ссылается на ключ здесь: так карточки получают label и icon.
|
||||
const serviceMeta = {
|
||||
ceph: { label: "Ceph", icon: "assets/ceph.svg" },
|
||||
cloudbeaver: { label: "CloudBeaver", icon: "assets/database.svg" },
|
||||
@ -133,6 +158,10 @@ const serviceMeta = {
|
||||
xmpp: { label: "XMPP", icon: "assets/xmpp.svg" },
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Default UI preferences
|
||||
// ============================================================================
|
||||
// Значения используются при первом открытии или если localStorage пуст/битый.
|
||||
const defaultPreferences = {
|
||||
theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark",
|
||||
view: "cards",
|
||||
@ -144,8 +173,16 @@ const defaultPreferences = {
|
||||
};
|
||||
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
|
||||
|
||||
// ============================================================================
|
||||
// Runtime state and DOM references
|
||||
// ============================================================================
|
||||
// preferences - текущие настройки интерфейса, объединенные с localStorage.
|
||||
const preferences = { ...defaultPreferences, ...readPreferences() };
|
||||
|
||||
// collapseState - объект вида { [collapseKey]: boolean } для details.
|
||||
const collapseState = readCollapseState();
|
||||
|
||||
// Корневые DOM-узлы, которые нужны для рендера и событий.
|
||||
const catalog = document.querySelector("[data-catalog-root]");
|
||||
const navTree = document.querySelector("[data-nav-tree]");
|
||||
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 emptyTemplate = document.querySelector("#empty-template");
|
||||
|
||||
// ============================================================================
|
||||
// Initial render
|
||||
// ============================================================================
|
||||
// Сначала строим дерево, затем применяем настройки, затем рендерим каталог.
|
||||
// Такой порядок нужен, потому что applyPreferences выставляет data-атрибуты
|
||||
// и CSS-переменные, от которых зависит внешний вид уже созданного DOM.
|
||||
renderNavigationTree();
|
||||
applyPreferences(preferences);
|
||||
renderCatalog();
|
||||
|
||||
// ============================================================================
|
||||
// Event bindings
|
||||
// ============================================================================
|
||||
// Поиск перерисовывает и дерево, и каталог:
|
||||
// - дерево получает подсветку совпадений и авто-раскрытие веток;
|
||||
// - каталог фильтруется и тоже подсвечивает найденный текст.
|
||||
searchInput.addEventListener("input", () => {
|
||||
renderNavigationTree();
|
||||
renderCatalog();
|
||||
});
|
||||
|
||||
// Клик по файлу/папке в дереве фокусирует соответствующую секцию/карточку.
|
||||
// Само раскрытие details остается на нативном summary/caret.
|
||||
navTree.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-focus-target]");
|
||||
|
||||
@ -183,15 +234,19 @@ navTree.addEventListener("click", (event) => {
|
||||
focusCatalogTarget(button.dataset.focusTarget);
|
||||
});
|
||||
|
||||
// Сохраняем ручное раскрытие/сворачивание дерева и контента.
|
||||
// Используем capture=true, потому что toggle не всплывает как обычный click.
|
||||
navTree.addEventListener("toggle", handleDetailsToggle, true);
|
||||
catalog.addEventListener("toggle", handleDetailsToggle, true);
|
||||
|
||||
// Открытие popup-настроек по шестеренке.
|
||||
settingsToggle.addEventListener("click", () => {
|
||||
const isOpen = settingsPanel.classList.toggle("is-open");
|
||||
settingsOverlay.classList.toggle("is-open", isOpen);
|
||||
settingsToggle.setAttribute("aria-expanded", String(isOpen));
|
||||
});
|
||||
|
||||
// Закрытие popup-настроек всеми привычными способами.
|
||||
settingsClose.addEventListener("click", closeSettingsPanel);
|
||||
settingsOverlay.addEventListener("click", closeSettingsPanel);
|
||||
|
||||
@ -221,6 +276,7 @@ themeButtons.forEach((button) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Переключение режима отображения каталога.
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
preferences.view = button.dataset.view;
|
||||
@ -229,6 +285,7 @@ viewButtons.forEach((button) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Выбор цветового акцента.
|
||||
accentButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
preferences.accent = button.dataset.accent;
|
||||
@ -237,18 +294,21 @@ accentButtons.forEach((button) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Показывает/скрывает номера в секциях, группах и карточках.
|
||||
showNumbersInput.addEventListener("change", () => {
|
||||
preferences.showNumbers = showNumbersInput.checked;
|
||||
applyPreferences(preferences);
|
||||
savePreferences(preferences);
|
||||
});
|
||||
|
||||
// Показывает/скрывает поисковые теги в файловом дереве.
|
||||
showTreeTagsInput.addEventListener("change", () => {
|
||||
preferences.showTreeTags = showTreeTagsInput.checked;
|
||||
applyPreferences(preferences);
|
||||
savePreferences(preferences);
|
||||
});
|
||||
|
||||
// Включает/выключает авто-раскрытие веток дерева при поиске.
|
||||
autoCollapseSearchInput.addEventListener("change", () => {
|
||||
preferences.autoCollapseSearch = autoCollapseSearchInput.checked;
|
||||
applyPreferences(preferences);
|
||||
@ -256,6 +316,8 @@ autoCollapseSearchInput.addEventListener("change", () => {
|
||||
savePreferences(preferences);
|
||||
});
|
||||
|
||||
// Drag-resize бокового меню.
|
||||
// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную.
|
||||
sidebarResizer.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
document.body.classList.add("is-resizing-sidebar");
|
||||
@ -280,10 +342,22 @@ sidebarResizer.addEventListener("pointerdown", (event) => {
|
||||
sidebarResizer.addEventListener("pointercancel", handlePointerUp);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Data helpers
|
||||
// ============================================================================
|
||||
// Упрощает запись item-объектов в ecosystemSections.
|
||||
function createItem(number, title, type, tags = [], url = "#") {
|
||||
return { number, title, type, tags, url };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Catalog rendering
|
||||
// ============================================================================
|
||||
// Рендерит правую часть: details-разделы, details-подразделы и карточки.
|
||||
// При активном поиске:
|
||||
// - оставляет только группы с найденными элементами;
|
||||
// - принудительно раскрывает найденные ветки;
|
||||
// - подсвечивает совпадения через highlightText().
|
||||
function renderCatalog() {
|
||||
const query = normalize(searchInput.value);
|
||||
const forceOpen = Boolean(query);
|
||||
@ -325,6 +399,9 @@ function renderCatalog() {
|
||||
|
||||
}
|
||||
|
||||
// Создает корневой details для раздела контента.
|
||||
// forceOpen нужен для поиска: найденные ветки должны быть видимы независимо
|
||||
// от сохраненного collapsed-состояния.
|
||||
function createSectionElement(section, forceOpen = false, query = "") {
|
||||
const sectionElement = document.createElement("details");
|
||||
sectionElement.className = "catalog-section";
|
||||
@ -349,6 +426,8 @@ function createSectionElement(section, forceOpen = false, query = "") {
|
||||
return sectionElement;
|
||||
}
|
||||
|
||||
// Создает details-подраздел внутри раздела.
|
||||
// items уже отфильтрованы на уровне renderCatalog().
|
||||
function createGroupElement(group, items, forceOpen = false, query = "") {
|
||||
const groupElement = document.createElement("details");
|
||||
groupElement.className = "catalog-group";
|
||||
@ -380,6 +459,8 @@ function createGroupElement(group, items, forceOpen = false, query = "") {
|
||||
return groupElement;
|
||||
}
|
||||
|
||||
// Создает конечную карточку навигации.
|
||||
// Если url === "#", карточка считается заглушкой и не меняет текущий hash/scroll.
|
||||
function createCardElement(item, query = "") {
|
||||
const meta = serviceMeta[item.type] || serviceMeta.config;
|
||||
const link = document.createElement("a");
|
||||
@ -411,6 +492,11 @@ function createCardElement(item, query = "") {
|
||||
return link;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search matching and highlighting
|
||||
// ============================================================================
|
||||
// Проверяет, подходит ли конкретный item под строку поиска.
|
||||
// Поиск идет не только по title, но и по номеру, type, label, tags и родителям.
|
||||
function matchesQuery(item, group, section, query) {
|
||||
if (!query) {
|
||||
return true;
|
||||
@ -432,6 +518,8 @@ function matchesQuery(item, group, section, query) {
|
||||
return haystack.includes(query);
|
||||
}
|
||||
|
||||
// Проверяет, есть ли совпадение на уровне группы или ее элементов.
|
||||
// Используется для авто-раскрытия дерева при поиске.
|
||||
function groupMatchesQuery(group, section, query) {
|
||||
if (!query) {
|
||||
return true;
|
||||
@ -443,6 +531,7 @@ function groupMatchesQuery(group, section, query) {
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяет произвольный набор строк на наличие нормализованного query.
|
||||
function matchesText(values, query) {
|
||||
if (!query) {
|
||||
return true;
|
||||
@ -451,6 +540,11 @@ function matchesText(values, query) {
|
||||
return normalize(values.join(" ")).includes(query);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sidebar tree rendering
|
||||
// ============================================================================
|
||||
// Строит левое файловое дерево из той же структуры данных, что и каталог.
|
||||
// Это важно: дерево и карточки всегда остаются синхронизированными.
|
||||
function renderNavigationTree() {
|
||||
const query = normalize(searchInput.value);
|
||||
const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch);
|
||||
@ -511,6 +605,14 @@ function renderNavigationTree() {
|
||||
navTree.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Focusing from tree to content
|
||||
// ============================================================================
|
||||
// Переход из дерева к правому контенту:
|
||||
// - если текущий поиск скрывает цель, поиск очищается;
|
||||
// - родительские details раскрываются;
|
||||
// - состояние раскрытия сохраняется;
|
||||
// - целевой элемент скроллится в центр и подсвечивается.
|
||||
function focusCatalogTarget(targetId) {
|
||||
let target = document.getElementById(targetId);
|
||||
|
||||
@ -534,6 +636,10 @@ function focusCatalogTarget(targetId) {
|
||||
window.setTimeout(() => target.classList.remove("is-focused"), 1200);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Collapse state handling
|
||||
// ============================================================================
|
||||
// Сохраняет состояние каждого details, у которого есть data-collapse-key.
|
||||
function handleDetailsToggle(event) {
|
||||
const details = event.target;
|
||||
|
||||
@ -545,6 +651,8 @@ function handleDetailsToggle(event) {
|
||||
saveCollapseState();
|
||||
}
|
||||
|
||||
// Принудительно сохраняет родительский details как раскрытый.
|
||||
// Используется, когда пользователь прыгает к элементу из дерева.
|
||||
function persistOpenedAncestor(details) {
|
||||
if (!details?.dataset.collapseKey) {
|
||||
return;
|
||||
@ -554,6 +662,12 @@ function persistOpenedAncestor(details) {
|
||||
saveCollapseState();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Card pointer interaction
|
||||
// ============================================================================
|
||||
// Добавляет мягкий tilt/highlight эффект для карточек.
|
||||
// После каждого renderCatalog() обработчики нужно навешивать заново,
|
||||
// потому что карточки пересоздаются.
|
||||
function attachCardInteractions() {
|
||||
document.querySelectorAll(".nav-card").forEach((card) => {
|
||||
let leaveTimer;
|
||||
@ -591,6 +705,14 @@ function attachCardInteractions() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Preferences application
|
||||
// ============================================================================
|
||||
// Применяет preferences к DOM:
|
||||
// - data-theme / data-accent для CSS;
|
||||
// - CSS-переменную ширины сайдбара;
|
||||
// - класс режима отображения каталога;
|
||||
// - состояния toggle-контролов в настройках.
|
||||
function applyPreferences(nextPreferences) {
|
||||
if (!availableViews.includes(nextPreferences.view)) {
|
||||
nextPreferences.view = defaultPreferences.view;
|
||||
@ -614,6 +736,7 @@ function applyPreferences(nextPreferences) {
|
||||
setActiveButton(accentButtons, "accent", nextPreferences.accent);
|
||||
}
|
||||
|
||||
// Обновляет визуальное состояние группы кнопок.
|
||||
function setActiveButton(buttons, key, value) {
|
||||
buttons.forEach((button) => {
|
||||
const isActive = button.dataset[key] === value;
|
||||
@ -622,10 +745,16 @@ function setActiveButton(buttons, key, value) {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Generic helpers
|
||||
// ============================================================================
|
||||
// Нормализует строки для поиска: регистр + ё/е.
|
||||
function normalize(value) {
|
||||
return value.toLowerCase().replaceAll("ё", "е").trim();
|
||||
}
|
||||
|
||||
// Подсвечивает все совпадения query внутри value.
|
||||
// Возвращает HTML-строку, поэтому весь обычный текст пропускается через escapeHTML().
|
||||
function highlightText(value, query) {
|
||||
const text = String(value);
|
||||
const normalizedQuery = normalize(query);
|
||||
@ -649,6 +778,7 @@ function highlightText(value, query) {
|
||||
return result + escapeHTML(text.slice(cursor));
|
||||
}
|
||||
|
||||
// Минимальная HTML-экранизация для безопасной вставки строк в innerHTML.
|
||||
function escapeHTML(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
@ -658,6 +788,8 @@ function escapeHTML(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
// Стабильные id для секций, групп и элементов.
|
||||
// Они используются одновременно как DOM id и как цель tree focus.
|
||||
function getSectionId(section) {
|
||||
return `section-${section.id}`;
|
||||
}
|
||||
@ -666,6 +798,8 @@ function getGroupId(group) {
|
||||
return `group-${group.id}`;
|
||||
}
|
||||
|
||||
// Отдельные ключи для дерева, чтобы collapse-состояния дерева
|
||||
// не конфликтовали с collapse-состояниями контента.
|
||||
function getTreeSectionId(section) {
|
||||
return `tree-${getSectionId(section)}`;
|
||||
}
|
||||
@ -678,10 +812,15 @@ function getItemId(item) {
|
||||
return `item-${item.number.replaceAll(".", "-")}`;
|
||||
}
|
||||
|
||||
// Ограничивает число диапазоном.
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// localStorage helpers
|
||||
// ============================================================================
|
||||
// Читает настройки интерфейса.
|
||||
function readPreferences() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
|
||||
@ -690,14 +829,18 @@ function readPreferences() {
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняет настройки интерфейса.
|
||||
function savePreferences(nextPreferences) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences));
|
||||
}
|
||||
|
||||
// Возвращает сохраненное состояние details.
|
||||
// По умолчанию все ветки открыты, чтобы первый вход был максимально понятным.
|
||||
function getSavedOpenState(key) {
|
||||
return collapseState[key] ?? true;
|
||||
}
|
||||
|
||||
// Читает состояния раскрытия дерева/контента.
|
||||
function readCollapseState() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY)) || {};
|
||||
@ -706,10 +849,12 @@ function readCollapseState() {
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняет состояния раскрытия дерева/контента.
|
||||
function saveCollapseState() {
|
||||
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapseState));
|
||||
}
|
||||
|
||||
// Закрывает popup-настройки и синхронизирует aria-expanded.
|
||||
function closeSettingsPanel() {
|
||||
settingsPanel.classList.remove("is-open");
|
||||
settingsOverlay.classList.remove("is-open");
|
||||
|
||||
147
styles.css
147
styles.css
@ -1,3 +1,11 @@
|
||||
/* ==========================================================================
|
||||
01. Theme Tokens
|
||||
--------------------------------------------------------------------------
|
||||
Все ключевые цвета и размеры вынесены в CSS-переменные. JavaScript меняет
|
||||
data-theme/data-accent и --sidebar-width, а остальная верстка реагирует
|
||||
без прямого изменения inline-стилей у отдельных элементов.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #080a12;
|
||||
@ -17,6 +25,7 @@
|
||||
--sidebar-width: 390px;
|
||||
}
|
||||
|
||||
/* Светлая тема переопределяет только токены, структура стилей остается общей. */
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
@ -30,6 +39,7 @@ html[data-theme="light"] {
|
||||
--shadow: 0 24px 70px rgba(56, 65, 91, 0.18);
|
||||
}
|
||||
|
||||
/* Наборы акцентов. --accent-rgb нужен для прозрачных градиентов через rgba(). */
|
||||
html[data-accent="cyan"] {
|
||||
--accent: #06b6d4;
|
||||
--accent-2: #3b82f6;
|
||||
@ -48,6 +58,12 @@ html[data-accent="amber"] {
|
||||
--accent-rgb: 245, 158, 11;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
02. Base Reset And Page Background
|
||||
--------------------------------------------------------------------------
|
||||
Базовые правила страницы, плавная смена темы и фоновые декоративные слои.
|
||||
========================================================================== */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -79,6 +95,7 @@ a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Фоновые "ambient" пятна создают глубину, но не перехватывают клики. */
|
||||
.ambient {
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
@ -102,12 +119,20 @@ a {
|
||||
background: rgba(34, 211, 238, 0.3);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
03. Application Layout
|
||||
--------------------------------------------------------------------------
|
||||
.shell ограничивает общую ширину, .app-layout делит экран на:
|
||||
sidebar -> resizer -> catalog.
|
||||
========================================================================== */
|
||||
|
||||
.shell {
|
||||
width: min(1560px, calc(100% - 28px));
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* Левая колонка получает ширину из --sidebar-width, которую меняет JS. */
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr);
|
||||
@ -115,6 +140,13 @@ a {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
04. Sidebar Resizer
|
||||
--------------------------------------------------------------------------
|
||||
Узкая колонка между меню и каталогом. Pointer Events в script.js двигают
|
||||
CSS-переменную --sidebar-width и сохраняют значение в localStorage.
|
||||
========================================================================== */
|
||||
|
||||
.sidebar-resizer {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
@ -141,22 +173,31 @@ body.is-resizing-sidebar .sidebar-resizer::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Во время drag меняем курсор по всей странице и запрещаем выделение текста. */
|
||||
body.is-resizing-sidebar {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
05. Sidebar Panel
|
||||
--------------------------------------------------------------------------
|
||||
Sticky-панель слева: заголовок, шестеренка, поиск и дерево навигации.
|
||||
========================================================================== */
|
||||
|
||||
.side-panel {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* .hero остался как совместимый контейнерный класс после уплотнения шапки. */
|
||||
.hero {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Общая "стеклянная" поверхность для главных панелей. */
|
||||
.hero-content,
|
||||
.control-panel,
|
||||
.catalog-section,
|
||||
@ -185,6 +226,7 @@ body.is-resizing-sidebar {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Внутренний фон sidebar: градиент + hexagon pattern. */
|
||||
.hero-content::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@ -201,6 +243,7 @@ body.is-resizing-sidebar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Общий стиль маленьких uppercase-меток и номеров. */
|
||||
.eyebrow,
|
||||
.section-number,
|
||||
.group-number,
|
||||
@ -213,16 +256,19 @@ body.is-resizing-sidebar {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Нумерация управляется toggle-настройкой "Показывать нумерацию". */
|
||||
html:not([data-show-numbers="true"]) .section-number,
|
||||
html:not([data-show-numbers="true"]) .group-number,
|
||||
html:not([data-show-numbers="true"]) .item-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Теги в дереве управляются toggle-настройкой "Показывать теги дерева". */
|
||||
html[data-show-tree-tags="false"] .tree-node small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Подсветка совпадений поиска в дереве и в карточках. */
|
||||
.search-mark {
|
||||
border-radius: 4px;
|
||||
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));
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
06. Settings Popup
|
||||
--------------------------------------------------------------------------
|
||||
Настройки вынесены из sidebar в fixed popup, чтобы на мобильных устройствах
|
||||
они не перекрывались потоком контента и всегда были поверх страницы.
|
||||
========================================================================== */
|
||||
|
||||
.control-panel {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
@ -253,6 +306,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
/* Затемнение и blur-подложка за открытыми настройками. */
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@ -265,6 +319,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transition: opacity 180ms ease, visibility 180ms ease;
|
||||
}
|
||||
|
||||
/* Состояния открытия popup и overlay. */
|
||||
.settings-overlay.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
@ -278,6 +333,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Кнопки шестеренки и закрытия имеют один визуальный язык. */
|
||||
.settings-button,
|
||||
.settings-close {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Заголовок popup-настроек. */
|
||||
.settings-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -329,6 +386,13 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
07. Search Field And Control Inputs
|
||||
--------------------------------------------------------------------------
|
||||
Поиск живет в sidebar, а остальные контролы в popup. Здесь собраны общие
|
||||
стили input, toggle-row, segmented buttons и accent swatches.
|
||||
========================================================================== */
|
||||
|
||||
.search-field {
|
||||
margin-top: 15px;
|
||||
|
||||
@ -344,6 +408,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Иконка поиска рисуется CSS-ом, чтобы не добавлять лишний DOM. */
|
||||
.search-field::after {
|
||||
content: "⌕";
|
||||
position: absolute;
|
||||
@ -374,6 +439,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
/* Toggle-row используется для checkbox-настроек с описанием. */
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -400,6 +466,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Кастомный checkbox выглядит как компактный switch. */
|
||||
.toggle-row input {
|
||||
appearance: none;
|
||||
width: 48px;
|
||||
@ -434,6 +501,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Контейнеры групп кнопок настроек. */
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -450,6 +518,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
/* Для четырех режимов отображения кнопки складываются в компактную сетку. */
|
||||
.view-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@ -462,6 +531,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
flex-basis: calc(50% - 3px);
|
||||
}
|
||||
|
||||
/* Segmented buttons и color swatches имеют общий focus/hover baseline. */
|
||||
.segmented,
|
||||
.accent-swatch {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Акцентные кружки используют те же цвета, что и html[data-accent]. */
|
||||
.accent-swatch[data-accent="violet"] {
|
||||
background: linear-gradient(135deg, #8b5cf6, #22d3ee);
|
||||
}
|
||||
@ -523,6 +594,14 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
08. File-Tree Navigation
|
||||
--------------------------------------------------------------------------
|
||||
Левая навигация визуально имитирует дерево файлов:
|
||||
details/summary дают нативное раскрытие, а CSS добавляет линии, caret,
|
||||
"folder" для разделов/групп и "file" для конечных элементов.
|
||||
========================================================================== */
|
||||
|
||||
.nav-tree {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@ -531,6 +610,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Details в дереве intentionally выглядят не как карточки, а как вложенный список. */
|
||||
.tree-section,
|
||||
.tree-group {
|
||||
border: 0;
|
||||
@ -546,6 +626,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Summary получает caret, иконку папки и кликабельный текст. */
|
||||
.tree-section > summary,
|
||||
.tree-group > summary {
|
||||
display: grid;
|
||||
@ -559,11 +640,13 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
/* Нативный маркер details скрываем, так как рисуем свой caret. */
|
||||
.tree-section > summary::-webkit-details-marker,
|
||||
.tree-group > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Caret поворачивается через состояние [open] у details. */
|
||||
.tree-caret {
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
@ -583,6 +666,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Конечный элемент дерева: иконка файла, название и optional теги. */
|
||||
.tree-node {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
@ -608,11 +692,13 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Вертикальная линия вложенности дерева. */
|
||||
.tree-node::before {
|
||||
color: var(--accent);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* Папки и файлы нарисованы CSS-иконками, чтобы не тянуть отдельные SVG. */
|
||||
.tree-folder {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
}
|
||||
@ -631,6 +717,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Контейнер элементов внутри группы дерева. */
|
||||
.tree-items {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@ -646,6 +733,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
}
|
||||
|
||||
/* Теги дерева усечены ellipsis, чтобы длинные списки не ломали sidebar. */
|
||||
.tree-node small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@ -656,12 +744,20 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
09. Catalog Structure
|
||||
--------------------------------------------------------------------------
|
||||
Правая часть страницы. Разделы и подразделы тоже построены на details,
|
||||
поэтому пользователь может сворачивать не только дерево, но и контент.
|
||||
========================================================================== */
|
||||
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Главные glass-карточки разделов. */
|
||||
.catalog-section {
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
@ -673,6 +769,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Summary у разделов/групп заменяет заголовок и одновременно управляет collapse. */
|
||||
.catalog-section > summary,
|
||||
.catalog-group > summary {
|
||||
list-style: none;
|
||||
@ -684,6 +781,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Шапка раздела: номер, название, описание, счетчик и индикатор collapse. */
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -726,6 +824,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Счетчики помогают понять размер раздела без чтения всех карточек. */
|
||||
.section-count {
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
@ -737,6 +836,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Стрелка сворачивания поворачивается через [open]. */
|
||||
.collapse-indicator {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@ -755,6 +855,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Подразделы внутри секции. */
|
||||
.section-groups {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@ -794,6 +895,13 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
10. Navigation Cards
|
||||
--------------------------------------------------------------------------
|
||||
Конечные кликабельные элементы каталога. Карточки используют CSS-переменные
|
||||
--card-x/--card-y, которые JS обновляет при движении мыши для shine-эффекта.
|
||||
========================================================================== */
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@ -801,6 +909,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Базовая карточка с иконкой, контентом, тегами, стрелкой и shine-слоем. */
|
||||
.nav-card {
|
||||
--tilt-x: 0deg;
|
||||
--tilt-y: 0deg;
|
||||
@ -827,6 +936,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
scroll-margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Временная подсветка цели после клика в дереве. */
|
||||
.catalog-section.is-focused,
|
||||
.catalog-group.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);
|
||||
}
|
||||
|
||||
/* Градиентный бордер/подсветка под курсором. */
|
||||
.nav-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@ -849,6 +960,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hover задается классом .is-hovered из JS, чтобы убрать скачки на границах. */
|
||||
.nav-card.is-hovered,
|
||||
.nav-card:focus-visible {
|
||||
border-color: rgba(var(--accent-rgb), 0.52);
|
||||
@ -862,6 +974,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Иконка сервиса берется из serviceMeta. */
|
||||
.nav-card img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
@ -880,6 +993,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Тип сервиса выделен акцентом и помогает быстро считывать назначение. */
|
||||
.item-type {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
@ -887,6 +1001,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
/* Теги показывают, по каким словам карточку удобно искать. */
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -904,6 +1019,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Стрелка справа подчеркивает, что карточка ведет по ссылке. */
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
@ -926,6 +1042,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Декоративный shine слой, положение которого обновляется pointermove. */
|
||||
.shine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@ -945,10 +1062,18 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
transform: translateX(130%);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
11. View Modes
|
||||
--------------------------------------------------------------------------
|
||||
Режимы отображения переключаются классами на .catalog:
|
||||
cards-view, list-view, compact-cards-view, compact-list-view.
|
||||
========================================================================== */
|
||||
|
||||
.catalog.list-view .items-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* В list-режимах карточка вытягивается в строку. */
|
||||
.catalog.list-view .nav-card,
|
||||
.catalog.compact-list-view .nav-card {
|
||||
min-height: 96px;
|
||||
@ -964,10 +1089,12 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Compact modes скрывают второстепенную информацию и уменьшают отступы. */
|
||||
.catalog.compact-cards-view .items-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* Compact list на широких экранах использует 3 колонки. */
|
||||
.catalog.compact-list-view .items-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@ -977,6 +1104,7 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* В compact-режимах описания и теги скрыты ради плотности. */
|
||||
.catalog.compact-cards-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),
|
||||
@ -1047,6 +1175,12 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
12. Empty State
|
||||
--------------------------------------------------------------------------
|
||||
Показывается, когда фильтр поиска не оставил ни одной карточки.
|
||||
========================================================================== */
|
||||
|
||||
.empty-state {
|
||||
min-height: 280px;
|
||||
display: grid;
|
||||
@ -1069,6 +1203,13 @@ html[data-show-tree-tags="false"] .tree-node small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
13. Responsive Layout
|
||||
--------------------------------------------------------------------------
|
||||
На планшетах sidebar перестает быть отдельной колонкой, а на телефонах
|
||||
уменьшаются панели, кнопки и размеры карточек.
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.app-layout {
|
||||
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) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user