В силу личной специфики (я чаще работаю не над веб-страницами, а над интерфейсами для десктопных и мобильных приложений, которые пишу на HTML/CSS), я долго избегал рабочие процессы сложнее, чем «отредактировал CSS-файл и сохранил его», и открыл для себя CSS-препроцессинг довольно поздно, но… В наши дни он, в общем-то, ничуть не устарел, и актуален не меньше, чем раньше. Так что, если вы пишете CSS (а не генерируете его) для чего угодно (SPA, приложения, лендинги, веб-аппы и т.д.), но до сих пор не пользуетесь LESS или SASS — приглашаю под кат, где я, стараясь не опускаться до уровня «очередной-пересказ-учебника», немного расскажу о принципах LESS, инструментах, его текущем состоянии и поделюсь своими техниками и приёмами (с примерами). А если вы не пишете CSS, но знакомы с традиционными языками программирования, всё равно добро пожаловать: я провожу параллели между ними и LESS, а заодно рассказываю об очень полезных принципах проектирования от Алана Кея.

❯ Начинаем препроцессить

❯ LESS or SASS? That is the question

Как написано выше, активно использовать CSS-препроцессинг я начал не так давно, но уже успел его полюбить, и смело скажу: несмотря на мощный прогресс браузеров в последние годы, любой из этих языков всё ещё лучше написания голого CSS-кода! И так считаю не я один:

so it seems like all the hype of being finally able to throw out sass, bootstrap etc, and use vanilla css in 2024, was just that.. hype. Something so very simple that continues to make css frameworks or preprocessors an unfortunate necessity still

— Пользователь StackOverflow глубоко разочарован невозможностью использовать CSS-переменные в медиа-запросах

Хотя я и знаю людей, называющих любые DSL'и «птичьим языком», использование препроцессинга позволяет не только поддерживать проект в более читабельном состоянии, но и избегать нарушений DRY (избавиться от копипасты), которые в ванильном CSS, увы, неустранимы.

Не буду повторять тут аргументы, традиционно приводимые в пользу SASS («advanced features», «robustness» и т.д.), скажу лишь, что я бы остановился на нём, если бы активно использовал основанный на SASS фреймворк. Возможность ссылаться на «родные» миксины и переменные — великое благо. (Если вы знакомы с компилируемыми языками, думайте об этом, как о возможности использовать библиотеки в исходниках, а не в виде собранных модулей). Пример такого фреймворка — Bootstrap, о пересборке которого я рассказывал в прошлый раз.

Я же для своих проектов остановился на LESS (как нетрудно догадаться, о нём далее и пойдёт речь), и причина была в том, что он имплементирован на Javascript'е. Его же предполагается использовать для расширения базовых возможностей (написания плагинов).

❯ LESS: язык и инструменты

Собственно, LESS (он же LESSCSS) это сам язык, который со своей документацией живёт по адресу https://lesscss.org/. А Less.js — это основная (и единственная, о которой я знаю) имплементация его компилятора, чей монорепозиторий находится на Гитхабе: https://github.com/less/less.js.

В разделе обсуждений этого репозитория вполне можно позадавать вопросы из серии «Как сгенерировать такой-то CSS?», на которые отвечают разработчики и робот Дося (последний, впрочем, чаще всего невпопад). И хотя в readme.md официально указывают StackOverflow как самое подходящее «место для дискуссий», с учётом недавно разразившегося SO-кризиса шансы получить ответ на запутанный вопрос на Гитхабе, я бы сказал, выше.

Ещё на странице проекта на Гитхабе можно репортить баги, и их исправляют достаточно оперативно. (Мне, как суровому челябинскому мужику, удалось сломать компилятор на третий день изучения языка путём вложения :has() в :is(), после чего он жалобно сказал: Missing closing ')'. Вот то-то же!)

Вот то-то же.
Вот то-то же.

Текущая версия компилятора на момент написания статьи — 4.3.0 от 09 апреля 2025 года. Новые версии выходят примерно раз в 3 месяца.

Поскольку, как сказано выше, компилятор написан на Javascript, у кого-то может появиться искушение писать стилизацию на LESS, публиковать .less-файлы и компилировать их в CSS на лету, прямо в браузере пользователя. Делать так без очень веской причины я, конечно, ни в коем случае не рекомендую. Совершенно незачем при каждом запуске нагружать браузеры бедных юзеров расчётами, которые могут быть выполнены только один раз, при разработке. Соответственно, компиляцию стоит добавить на билд-сервер при сборке/деплое. Что касается машины разработчика, было бы неплохо иметь компилятор в аддоне к браузеру, но как оказалось, гораздо проще найти аддон для большинства IDE, который компилирует файлы .less при каждом сохранении.

Сам я, в силу обозначенной выше специфики (десктопные и мобильные приложения) пользуюсь Visual Studio 2022, и могу порекомендовать следующее расширение: Web Compiler 2022+, хотя вы легко найдёте подобное и для любой другой IDE. Что касается Web Compiler 2022+, это расширение требует наличия проекта (просто открыть папку с файлами не получится) и включения в него всех необходимых файлов .less. Первый раз каждый из них нужно откомпилировать вручную через контекстное меню, а затем достаточно просто сохранять файл. Скорость… приемлемая. В реальном проекте, содержащем около 10 тысяч строк на LESS, десятки обрабатываемых изображений и несколько плагинов, результат в виде CSS на средне-слабом компьютере генерируется за несколько секунд. Так что, или берите машину помощнее, или приготовьтесь к тому, что иногда после переключения в браузер придётся нажимать F5 пару раз, пока результат не обновится. Поскольку расширения для IDE — лишь обёртка над стандартным компилятором LESS.js, скорость в любой среде будет примерно одинаковая, и зависеть только от компьютера.

ℹ️ Web Compiler 2022+
Конкретно для этого расширения необходимые файлы можно указать явно при помощи файла compilerconfig.json, добавленного к проекту:
[
{
"inputFile": "css/main.less",
"outputFile": "css/main.css",
"options":
{
"sourceMap": true,
"relativeUrls": true
}
}
]

В отличие от других транспилируемых языков (компилируемых из одного высокоуровневого ЯП в другой), таких как TypeScript, LESS не стремится подменить собой целевой язык. Напротив, он сфокусирован на том, чтобы предоставить разработчику удобный способ генерации CSS.

Эта же черта (сфокусированность на генерации CSS) отличает его и от универсальных препроцессоров (типа PHP, который, в общем-то, тоже генерирует один код из другого). Во-первых, PHP имеет множество языковых конструкций общего назначения и универсальную стандартную библиотеку, которая позволяет многое, вплоть до подключения к СУБД. LESS же не очень гибок синтаксически (языковых конструкций в нём мало), а его стандартная библиотека хоть и поддерживает кое-какие I/O-операции (с файлами), но очень специфические. Как пошутил автор одного учебника по LESS, хоть компилятор и написан на JS, веб-сервер из него запустить не получится. Во-вторых, LESS не похож на универсальные препроцессоры тем, что для единообразия его синтаксис максимально приближен к CSS. Например, объявление переменной в нём выглядит так же, как объявление CSS-переменной:

// Объявление переменных:
@snow-src: '../images/snow.png';
@tree-blue-color: #a2e2ff;
@minus-value: -@value;

// Использование:
.cover
{
	background-image: url(@snow-src);
	color: @tree-blue-color;
	--direction: @minus-value;
}

Только в качестве префикса нужно поставить «собаку» @, а не двойной минус -- (ещё, как вы заметили, для использования переменной не требуется никаких функций вроде var()). А краеугольная конструкция LESS — миксины — выглядит как классы в CSS:

// Я миксин .text()
.text(@font-size, @font-weight)
{
	line-height: 1.5;
	font-size: @font-size;
	font-weight: @font-weight;
}

// Я условный миксин, используемый для визуальной отладки
.debugA when (@debug = true)
{
	background-color: aliceblue;
}

❯ Статические (compile-time) и динамические (runtime) языковые конструкции

Выше я показал объявление и использование переменных в LESS. Они появились вместе с самим LESS в 2009 году, задолго до CSS-переменных (которые были поддержаны во всех основных браузерах только восемь лет спустя, в 2017). Возникает закономерный вопрос: зачем нам вообще переменные в LESS, если они теперь нативно поддерживаются в CSS? Некоторые развивают мысль дальше: мол, и препроцессоры в целом теперь тоже не нужны. Ну, или будут не нужны, когда W3C наконец допилит нативные миксины. (А он не торопится: 15 мая 2025 года по ним был опубликован лишь First Public Working Draft). Доля правды в этом, конечно, есть. Если раньше для переменных, как способа сохранения значений и последующего многократного использования, альтернативы препроцессорам просто не было, то теперь она имеется.

Но препроцессор даёт возможность делать такие вещи, которые нативно не получится реализовать и после появления миксинов в CSS. Переменные в LESS являются частью этих вещей, следовательно, заменить их нативными нельзя.

Кроме того, архитектура CSS требует привязки переменных к элементам. Определите значение переменной для одного элемента, и оно будет определено для любого вложенного. Добавляя якобы «глобальные» переменные в :root, мы склонны забывать о том, что они не являются по-настоящему глобальными. В частности, именно поэтому их и нельзя использовать в медиа-запросах: медиа-запрос не является дочерним элементом <html>.

Наконец, это просто делает код выразительным — когда мы разделяем языковые объекты (такие как переменные) по времени использования. Переменные в CSS — динамические. Это значит, что к одной переменной можно привязать целую тему оформления, затем поменять её значение во время выполнения (из Javascript'а) и наблюдать, как изменилось представление всего документа целиком. Переменные же в LESS — статические (времени компиляции). На этапе представления документа браузером они уже не существуют, и их изменение не предусмотрено. Глядя на переменную, которая могла быть как нативной, так и препроцессорной, я сразу понимаю, для чего она нужна — чтобы просто не копипастить значение (если она начинается на @), или же она реально меняется в браузере у пользователя (если она начинается на --). Рекомендую и вам следовать этому правилу.

ℹ️ Но есть и маленькое исключение
Палитры я оформляю CSS-переменными, даже если не собираюсь их менять, поскольку удобно видеть их в DevTools в виде цветовой таблицы:

:root
{
--light-blue: #62aeff;
--bright-pink: #ff879c;
--wet-asphalt: #24303a;

@tree-blue-color: #a2e2ff;
// @tree-blue-color используется не сам по себе,
// а как seed для генерации производных цветов
// при помощи функций LESS:
--tree-blue-text: @tree-blue-color;
--tree-blue-background: lighten(@tree-blue-color, 10%);

}

Углубляемся в синтаксис

❯ Миксины

Основная конструкция в LESS, которую выше я назвал краеугольным камнем — миксины (mixins, они же примеси).

Миксины одновременно напоминают классы в объектно-ориентированных языках с наследованием (C++, Java) и функции в процедурных языках (Си). На классы они похожи тем, что позволяют создавать иерархии сущностей. А на функции они похожи тем, что могут принимать на вход параметры, обрабатывать их и возвращать значения (и даже в виде кортежей!).

Начнём с создания иерархий. Задумывались ли вы когда-нибудь о том, что в таксономии CSS, построенной на классах, чего-то не хватает? А не хватает в ней наследования, и сейчас я это покажу.

Принцип single responsibility (буква S в SOLID, если что) предписывает делать классы маленькими и выполняющими одну простую функцию. CSS-классы, которые следуют этому принципу, традиционно называют утилитами:

…
.rounded-0
{
	border-radius: 0 !important;
}

.rounded-1
{
	border-radius: 0.125rem !important;
}

.rounded-2
{
	border-radius: 0.25rem !important;
}

.rounded-3
{
	border-radius: 0.375rem !important;
}
…
.p-0
{
	padding: 0 !important;
}

.p-1
{
	padding: 0.25rem !important;
}

.p-2
{
	padding: 0.5rem !important;
}

.p-3
{
	padding: 1rem !important;
}
…

С началом применения утилит у нас возникает несколько проблем (отчасти, связанных между собой).

Допустим, что в нашем проекте есть визуально отображаемая карточка (например, карточка товара). Мы хотим, чтобы она имела среднее закругление краёв и средний внутренний отступ (не считая каких-то других свойств, присущих карточкам товаров). И в разметке у нас появляется следующая структура:

<div class="card rounded-2 p-2">
	Конфетки
</div>
<div class="card rounded-2 p-2">
	Бараночки
</div>
<div class="card rounded-2 p-2">
	Сани «Лебедь»
</div>

Представьте себе, во что со временем может превратиться такой код: десятки (это не преувеличение!) классов-утилит в каждом теге. В результате, оформление, которое когда-то специально вынесли в отдельные файлы, написанные на отдельном языке (CSS), возвращается в разметку, как Бэтмен в свой родной Готэм.

Вторая проблема — такой код становится трудно менять, потому что он… будем называть вещи своими именами: нарушает DRY. Если мы захотим увеличить скругления, придётся повторить это для каждого элемента, соответствующего карточке. Вы, конечно, скажете, что в реальной жизни никто не пишет теги карточек руками. Они берутся из шаблона, который инстанцируется на сервере (server-side rendering) или клиенте (Javascript), иногда — внутри компонентов. Но это никак не поможет вам, например, сделать две разные темы оформления, в одной из которых карточки будут круглыми, а в другой — квадратными. Если же вы поменяете закругление в .rounded-2, то сломаете что-нибудь другое.

Сейчас, конечно, темы оформления на сайтах большая редкость — кто такие эти юзеры, чтобы давать им выбор. (А помните тему «Погода» на стартовой странице «Яндекса»? Ради неё одной я открывал yandex.ru по десять раз в день). Однако, я, например, работаю над приложениями, некоторые из которых похожи на OpenShell (который меняет оформление Windows Explorer и меню Start в стиле Windows 7, Windows 10 или Windows 11), и без общей стилизации в едином файле CSS я бы замучился такое делать. Но и для традиционных веб-страниц стилизация, собранная в один файл, тоже полезна — например, при показе разных (работающих! не макетов!) вариантов начальству, инвесторам и фокус-группам. А ещё при версионировании UX, A/B-тестировании и т.п.

Третья проблема: утилиты изрядно захламляют собой код стилизации. Посмотрите на простыни из примера выше. Семейства классов-утилит, таких как .p-* и .rounded-*, могли быть… если пользоваться теминологией из ООП-языков, выражены одним-единственным обобщённым классом (дженериком), в который мы бы подставляли конкретные значения. Не говоря уж о том, что у нас обычно есть предопределённый список этих значений, или даже простая формула, по которой он генерируется (например, прибавление заданного шага — хотя конкретно отступы и округления чисто эстетически лучше увеличивать нелинейно).

Вот тут-то бы и пригодилось наследование и шаблонизация CSS-классов. Миксины в своей первой роли (построение иерархий) как раз это и делают:

// Миксины (базовые классы) маркированы скобками
.rounded-2()
{
	border-radius: 0.25rem !important;
}

.p-2()
{
	padding: 0.5rem !important;
}

// Настоящий CSS-класс
.card
{
	// «Наследуем» свойства card от rounded-2 и p-2
	.rounded-2();
	.p-2();

	// Добавляем к card специфические свойства.
	color: white;
}

Таким образом, функциональность, которую раньше выносили в отдельные классы-утилиты по принципу single responsibility, мы теперь оформляем как миксины — а настоящие классы «наследуем» от них. На выходе получится следующий CSS-код:

.card {
  border-radius: 0.25rem !important;
  padding: 0.5rem !important;
  color: white;
}

Обратите внимание на два момента.

Во-первых, мы написали генерирующий код, и смотрим, что препроцессор из него сгенерировал. Это типично при работе с LESS: постоянно переключаться в IDE между исходником и сгенерированным файлом и смотреть, что получилось, при задавании вопросов на форумах — описывать, что мы хотели получить, и что получили вместо этого и т.д.

Во-вторых, в результирующем коде нет никаких следов миксинов, туда попадают только настоящие CSS-классы.

Чего мы в итоге добились? Того, что разметка стала исключительно семантической (теперь <div> карточки может быть помечен только классом class="card" хоть в компоненте, хоть в HTML-коде страницы), но:
1) не ценой нарушения SOLID,
2) без копипасты, и
3) с поддержкой сквозной стилизации документа.

Классы-утилиты могут составлять что-то вроде словаря дизайнерских терминов. В качестве таковых, они могут приходить откуда угодно: из единого библиотечного CSS-файла, присланного корпоративным отделом дизайна («…используйте только следующие отступы и закругления…»), из CSS-фреймворка и даже быть сгенерированы другим препроцессором (настоящий индеец Зоркий Глаз, должно быть, уже заметил, что мои примеры утилит для использования в LESS-коде взяты из SASS'ного Bootstrap'а).

Поэтому для удобства в LESS вообще каждый класс считается миксином, от которого можно наследовать свойства.

// Классы-утилиты взяты откуда-то извне
…
.rounded-2
{
	border-radius: 0.25rem !important;
}
…
.p-2
{
	padding: 0.5rem !important;
}
…
.card
{
	// Наследуем свойства точно так же, как и раньше
	.rounded-2();
	.p-2; // И даже скобки опциональны!

	color: white;
}

При этом, само собой, из результирующего CSS базовые классы, в отличие от миксинов, выброшены не будут (для компилятора все классы одинаковы и неприкосновенны):

.rounded-2 {
  border-radius: 0.25rem !important;
}
.p-2 {
  padding: 0.5rem !important;
}
.card {
  border-radius: 0.25rem !important;
  padding: 0.5rem !important;
  color: white;
}

❯ Параметризованные миксины

Если же утилиты не пришли откуда-то извне, то нет смысла описывать их как раньше. Семейство классов можно сгенерировать из списка опорных значений или по формуле (об этом ниже), а можно использовать параметризованные миксины. Конечно, для .p-* и .rounded-* в параметризованных миксинах типа нижеприведённого нет никакого смысла — проще напрямую указать свойство border-radius/padding с нужным значением:

.rounded(@radius)
{
	border-radius: @radius !important;
}

.p(@padding)
{
	padding: @padding !important;
}

Смысл появляется тогда, когда миксин содержит не одно, а несколько связанных свойств. Например, поскольку «координатное» размещение элементов при помощи left/top требует абсолютного позиционирования, стоит объединить их в параметризованном миксине:

.pos(@l, @t)
{
	left: @l;
	top: @t;

	position: absolute;
}

А вот примеры его использования:

/* Заметка, размещённая поверх контейнера. */
img.note
{
	/* Размер в пикселях, потому что картинка не адаптивная
	(не зависит от выбранного размера шрифта у юзера). */
	.pos(0, 90px);
}

/* Рамка, составная часть контрола. */
.bezel
{
	.pos(0);
	.size(@control-size);
}

/* Блик. */
// & это «местоимение», замещающее селектор контейнера при вложении.
&::after
{
	.pos(10%, 5%);
	.size(100%);
	.circle();

	content: "";
	opacity: 0.2;
	transform: skewX(-20deg);
	background: @glare-radial-gradient;
}

Если вы подумали, не получится ли так, что из-за применения двух разных миксинов, включающих свойство position: absolute;, оно попадёт в результирующий класс дважды — нет, такого не будет. Компилятор за этим проследит. Что касается конфликтов, то есть ситуаций, когда одному свойству два миксина пытаются установить два разных значения, то, разумеется, на этот счёт есть какие-то правила приоритетов. Как и большинство правил приоритетов, эти я считаю малополезными и предлагаю в них не углубляться — всё равно ничего хорошего из того, что мы нарушаем атомарность миксина, не получится. Любой потенциальный конфликт говорит об ошибке проектирования, и решаться должен на более высоком уровне.

ℹ️ На заметку
Не забывайте про разницу между геометрическим позиционированием (left/top) и контекстным (inset-inline-start/inset-block-start). Подробнее см. раздел «Эра мультикультурализма» предыдущей статьи.

Лично для меня самое прикольное во всех этих миксинах и их параметризациях — возможность создать свой собственный DSL, внутренний квази-язык, на котором UI будет описываться в тех терминах, которые удобны мне: .pos(), .size(), .circle() и т.д. Не то, что бы я так хорошо придумывал языки, но оригинальный CSS (возможно, из-за многочисленных геологических напластований разных моделей и подходов, накопленных за долгие годы) слишком уж далёк от очевидности. Доказательством чему служат многочисленные шпаргалки по флексбоксам/гридам и прочему. За свою жизнь я написал сотни контейнеров, так что разбудите меня ночью и спросите, что делают align-items и justify-content, и я без запинки отвечу: «Слушай, дай поспать, а!». Также не советую спрашивать об этом на интервью (ни меня, ни других). Серьёзно, не знаю, кто придумывал эти свойства, но я первым делом поспешил обернуть их в миксины, названия которых способен не забыть, если на месяц погружаюсь в мир Rust. И, думаю, проблема с этим не у меня одного — недаром же в DevTools появилась визуальная подсказка для размещения дочерних элементов flex- и grid-контейнеров.

Вы, наверно, обратили внимание на несколько моментов. Во-первых, я использую два разных типа комментариев: традиционные CSS'ные /* comment */ и допустимые только в LESS // comment. Всё зависит от того, хочу ли я, чтобы комментарий попал в результирующий CSS, или был удалён при компиляции. Очевидно, комментарии, относящиеся к LESS-специфическим вещам, в итоговом файле смысла не имеют.

Во-вторых, я использую два одноимённых (.pos(@l, @t) и .pos(@offset)) параметризованных миксина с разным числом параметров (двумя и одним). Да, такие «перегрузки» возможны. Более того, удобно реализовывать один миксин через другой:

.pos(@l, @t)
{
	left: @l;
	top: @t;

	position: absolute;
}

.pos(@offset)
{
	.pos(@offset, @offset);
}

В-третьих, в составе селектора используется, как я его назвал, «местоимение» & — этот символ обозначает селектор контейнера, когда мы вкладываем классы друг в друга (ещё одна возможность, которую даёт нам LESS). Про вложенность и её роль в чисто семантическом подходе к стилизации мы поговорим отдельно, а пока оцените, насколько выразительно с нею выглядит описание псевдоэлементов:

.web-cam-control
{
	… свойства самого контрола веб-камеры …

	/* Блик. */
	&::after
	{
		… свойства псевдоэлемента .web-cam-control::after …
	}
}

❯ Листая старую тетрадь вполне живого Алан Кея

Однажды мне на глаза попалась мемуарная статья «История SmallTalk» за авторством его создателя Алана Кея (если что, это тот чувак, который придумал ООП). Я не ждал найти там какие-то откровения (просто люблю историю древних веков), однако же один момент произвёл на меня глубочайшее впечатление. Вот как он рассказывает о своём изобретении ООП:

За несколько дней до этого главный конструктор B5000 и профессор в Университете Юты Боб Бартон на одном из выступлений сказал так: «Базовый принцип рекурсивного проектирования состоит в том, что сущности на любом уровне вложенности должны обладать равными возможностями». Тогда я впервые примерил эту идею к компьютеру и понял, что совершенно зря его делят на более слабые концепции — структуры данных и процедуры. Почему бы не делить его на маленькие компьютеры, как в системах с разделением времени? Только не на десять, а сразу на тысячи, каждый из которых симулировал бы полезную структуру.

И это натолкнуло меня на мысль о том, как вообще можно проектировать новые крутые штуки: надо дать равные возможности чему-нибудь, что традиционно дискриминировалось, и сделать его т.н. «гражданином первого класса».

Допустим, у нас уже есть язык C, но никто не слышал про ООП. Почему мы можем упаковывать переменные в структуры, но не можем функции? Давайте уравняем их! И вот вам C++.

Допустим, у нас уже есть язык C, но никто не слышал про ФП (в нашей исторической реальности ФП появилось раньше, так что это просто пример). Почему мы можем передать как аргумент в функцию число, строку, но не in-place реализацию функции? И вот вам лямбды.

С тех пор, как я это осознал, я вижу этот принцип буквально повсюду.

❯ Рулсеты (rulesets)

Что же такого, в соответствии с этим принципом, эмансипировали в LESS?

В нём гражданами первого класса сделали рулсеты (rulesets), и это дало миксинам новые интересные возможности.

Что такое рулсеты? А это всем привычные тела классов. В CSS мы о них особо не думаем, поскольку там они не выделены в самостоятельную синтаксическую единицу. И селектор, и рулсет — в CSS всё это части одного монолита: класса.

div.error /* ← селектор */
{ /* ← начало рулсета */
	color: white;
	background-color: red;
} /* ← конец рулсета */

А вот в LESS мы можем оперировать гордыми и независимыми рулсетами, передавая их в миксины в качестве аргументов. Что позволяет произвольно объединять их с селекторами и порождать новые классы.

Начинаем применять на практике

❯ Я не прощу тебя, дорогой

Теперь, когда мы, худо-бедно, рассмотрели теоретические основы, я покажу миксин, полезный, например, для поддержки нескольких браузеров (или правильнее сказать: «обоих браузеров»?).

Этот миксин позволяет реализовать такую штуку, как прощающий парсинг селекторов.

Представьте себе, что вы делаете слайдер (input[type="range"]) кастомного вида.

Один из вариантов — собрать его из кусочков, т.е. из <div>'ов. Иногда, к сожалению, это действительно неизбежно (если желаемое поведение отличается от стандартного тонкими нюансами). В большинстве же случаев это плохая идея. Подумайте об accessibility. Вы готовы обеспечить вашим слепым пользователям работу со слайдером через скринридер и голосовой ввод не хуже обычного? А управление слайдером исключительно с клавиатуры для людей, которые не могут двигать мышью? Стилизация готового контрола снимает всю эту головную боль (и много других видов боли).

Со стилизацией, однако, есть одна проблема. Она состоит в том, что адресация составных частей контролов в браузерах не стандартизирована. В Chrome селектор ползунка выглядит так:

input[type="range"].joystick::-webkit-slider-thumb
{
	background-image: url(../images/joystick/thumb.png);
}

А в Firefox — так:

input[type="range"].joystick::-moz-range-thumb
{
	background-image: url(../images/joystick/thumb.png);
}

Что ж, думаете вы. Для поддержки сразу двух браузеров просто объединим селекторы:

input[type="range"].joystick::-webkit-slider-thumb,
input[type="range"].joystick::-moz-range-thumb
{
	background-image: url(../images/joystick/thumb.png);
}

И… всё перестаёт работать в обоих браузерах (ну, по крайней мере, они теперь действительно в равных условиях!). А всё дело в том, что обычный парсинг селекторов (тот, который через запятую) — как злой анонимус: ничего не прощает. Если хотя бы одна из частей селектора невалидная, невалидным является и весь селектор целиком. А для каждого из браузеров невалидной является та часть, которая касается другого браузера.

Что ж, думаете вы. Специально на такой случай у нас есть :is()! Он, согласно спецификации, является прощающим.

input[type="range"].joystick:is(::-webkit-slider-thumb, ::-moz-range-thumb)
{
	background-image: url(../images/joystick/thumb.png);
}

И… всё по-прежнему не работает. Потому, что согласно всё той же спецификации :is() не поддерживает псевдоэлементы. (А ::-webkit-slider-thumb для браузера является псевдоэлементом. Таков способ адресации составных частей контролов: как псевдоэлементов).

Вот тут нам на помощь и придёт миксин с прощающим парсингом селекторов .any():

// Provides *forgiving selector parsing*.
// Wrapping with quotes is mandatory for pseudo-elements and optional for regular selectors.
.any(@selector-list, @ruleset)
{
	each(@selector-list,
	{
		@selector: e(@value);

		@{selector}
		{
			@ruleset();
		}
	});
}

Давайте рассмотрим на его примере все теоретические механизмы, в которых мы разбирались ранее.

Это параметризованный миксин, который принимает на вход список селекторов (@selector-list) и независимый рулсет, не привязанный ни к какому классу (@ruleset). Он генерирует набор классов, комбинируя заданный рулсет с каждым из переданных селекторов. Отдельные селекторы могут быть невалидными, но это больше не проблема, поскольку на выходе у нас множество независимых классов. (Так сказать, генерируйте все, Господь браузер узнает своих!)

Для этого миксин перебирает список при помощи функции each(список, тело_цикла) и специальной переменной @value, и на каждой итерации распаковывает полученную строку (string escaping) при помощи функции e(), помещая результат в статическую переменную (переменную времени компиляции) @selector. Запаковка селекторов в строки с кавычками нужна нам, чтобы компилятор понимал, что селектор псевдоэлемента (например, &::-webkit-slider-thumb) — отдельный элемент списка. Для простых селекторов кавычки не нужны, но распаковка не помешает: функция e() просто вернёт исходную строку.

@{selector} — это синтаксически привычная нам по Javascript'у интерполяция переменной @selector, то есть, подстановка её значения. Если интерполяция стоит в месте, где по смыслу должен стоять селектор, а за ним следует блок { … }, происходит генерация класса для указанного селектора. Ну а внутри мы подставляем в генерируемый класс переданный рулсет при помощи скобок: { @ruleset(); }.

Как воспользоваться этим миксином для решения задачи со слайдером?

input[type="range"].joystick
{
	… свойства слайдера …

	// Вложенная стилизация непосредственно ползунка:
	.any(@range-thumb,
	{
		background-image: url(../images/joystick/thumb.png);
	});
}

Согласитесь, что эта запись достаточно похожа на :is()… по крайней мере, настолько, насколько я сумел приблизить: объявление и передача анонимного рулсета выглядит тут как обычное описание CSS-класса.

Список селекторов для псевдоэлемента ползунка в разных браузерах вынесен в глобальную переменную @range-thumb (а заодно в глобальную переменную вынесен и трек, по которому ползунок движется):

// https://developer.mozilla.org/en-US/docs/Web/CSS/::-webkit-slider-runnable-track
// https://developer.mozilla.org/en-US/docs/Web/CSS/::-moz-range-track
@range-track: '&::-webkit-slider-runnable-track', '&::-moz-range-track';

// https://developer.mozilla.org/en-US/docs/Web/CSS/::-webkit-slider-thumb
// https://developer.mozilla.org/en-US/docs/Web/CSS/::-moz-range-thumb
@range-thumb: '&::-webkit-slider-thumb', '&::-moz-range-thumb';

Нет никакого резона копипастить список селекторов каждый раз, когда в прикладном коде нам потребуется стилизовать слайдер. Подобным же образом следует поступить со всеми псевдоэлементами всех стандартных контролов. И, как вы видите, элементы списка взяты в кавычки, чтобы компилятор не пугался конструкции &::-webkit-slider-thumb и не пытался разбить её на токены (потому-то мы их потом и распаковываем при помощи функции e()). Зачем вообще добавлять & к каждому псевдоэлементу? Потому, что в реальной жизни мы всегда стилизуем сам слайдер (хотя бы, задаём ему размеры и положение) одновременно со стилизацией составных частей (ползунка и трека). А делать это удобно вложением стилизации составных частей внутрь класса слайдера input[type="range"].joystick, что мы и видим в примере выше.

Наконец, финальный штрих. В реальной жизни для стилизации составляющих контрола нам почти всегда нужно отключить родную стилизацию. Добиться этого можно вот таким вспомогательным миксином:

.reset-control()
{
	// Убрать стандартное оформление.
	appearance: none;

	// Убрать цвет фона, который иногда может появиться после того,
	// как убрали стандартное оформление.
	background-color: transparent;
}

И итоговое использование миксина примет следующий вид:

input[type="range"].joystick
{
	.reset-control();

	.any(@range-thumb,
	{
		.reset-control();

		background-image: url(../images/joystick/thumb.png);
	});
}

Обратите внимание, что миксин сброса .reset-control() мы применяем дважды: ко всему слайдеру целиком, и отдельно к ползунку. Этот код развернётся вот в такой CSS:

input[type="range"].joystick {
  appearance: none;
  background-color: transparent;
}
input[type="range"].joystick::-webkit-slider-thumb {
  appearance: none;
  background-color: transparent;
  background-image: url(../images/joystick/thumb.png);
}
input[type="range"].joystick::-moz-range-thumb {
  appearance: none;
  background-color: transparent;
  background-image: url(../images/joystick/thumb.png);
}

Наслаждаемся результатом:

(Код геометрических искажений ползунка и код отображения индикаторов я не стал включать в пример для простоты).

❯ Когда не хватает псевдоклассов

Переменные @range-track и @range-thumb показали нам, как можно формировать предопределённые списки селекторов. Что ещё это может давать на практике? Например, кастомные псевдоклассы. Ну, или, хотя бы, ароматизаторы, идентичные натуральным.

Взгляните на этот тикет, который для рабочей группы по CSS (CSSWG) открыл некто Keith Grant. Демократичненько, не правда ли? Любой Keith Grant в наши дни имеет полное право этак вот запросто прийти к CSSWG и попросить их запилить недостающий функционал. И любой из нас, имеющий учётку на Гитхабе, может поставить лайк (в сумме, их там штук 40). Ну а CSSWG, в свою очередь, имеет полное право этот тикет игнорировать. Ибо знайте: в феврале 2025-ого тикет отпраздновал свой седьмой день рождения (совсем взрослый, однако).

Чего же именно хотел внести в CSS Кит Грант? Довольно разумную, с моей точки зрения, штуку. Иногда бывает нужно выбрать все текстовые (как он их назвал, “text-ish”) контролы. Например, чтобы одинаково стилизовать у них рамочку:

.my-form *:text-input
{
	border: 2px solid blue;
}

Как видите, Кит предложил добавить псевдокласс :text-input. (И ещё парочку других псевдоклассов: :button и :toggle). Забавно, что пока он писал свой proposal, то редактировал его дважды — вот как легко ошибиться при ручном перечислении случаев!

Поскольку семь лет ожидания наводят на мысль, что жить в ту пору прекрасную, когда CSSWG реализует нужные псевдоклассы, уж не придётся ни мне, ни Киту, мы можем, не дожидаясь милостей от природы, реализовать функционал самостоятельно:

// https://github.com/w3c/csswg-drafts/issues/2296
@text-input: :is(
	input[type="text"],
	input[type="search"],
	input[type="tel"],
	input[type="url"],
	input[type="email"],
	input[type="password"],
	input[type="date"],
	input[type="month"],
	input[type="week"],
	input[type="time"],
	input[type="number"],
	input[type="color"],
	textarea
);

В вышеприведённом списке (пока?) нет псевдоэлементов, поэтому использовать его вместе с миксином .any() надобности нет. Мы можем просто перечислить селекторы внутри блока :is(). Соответственно, для его использования достаточно простой интерполяции:

.my-form .blue-text@{text-input}
{
	border: 2px solid blue;
}

И вот результирующий код (я специально добавил класс blue-text, чтобы показать, что конкатенация не проблема):

.my-form .blue-text:is(
	input[type="text"],
	input[type="search"],
	input[type="tel"],
	input[type="url"],
	input[type="email"],
	input[type="password"],
	input[type="date"],
	input[type="month"],
	input[type="week"],
	input[type="time"],
	input[type="number"],
	input[type="color"],
	textarea
) {
  border: 2px solid blue;
}

А вот (слегка упрощённый) пример из реального проекта:

/* Focused tabbable elements. */
:not(@{text-input}):not(.custom-focus-frame):focus-visible
{
	.focus-frame();
}

Если кто забыл, :focus-visible — это такое состояние контрола, когда пользователь перешёл на него при помощи клавиатурной навигации (кнопок Tab и Shift + Tab) и готов управлять этим контролом с клавиатуры (стрелками, пробелом, Enter'ом). Если бы Windows был написан на HTML, то вот как бы это было устроено:

Когда пользователь активировал контрол (а сделать активируемый клавиатурой контрол очень легко: достаточно к любому элементу дописать атрибут tabindex="0"), имеет смысл отображать унифицированную фокусную рамку а-ля Windows (дизайн, в конце концов, не должен далеко уходить от своих корней). Например, такую, как в этих двух примерах:

За неё и отвечает миксин .focus-frame(); (он реализован на базе свойства outline). Что касается текстообразных (“text-ish”) контролов, было принято решение пожертвовать единообразием и отображать их сфокусированное клавиатурой состояние изменением цвета рамки:

Вот поэтому к селектору и добавлено выражение :not(@{text-input}).

❯ Как создать семью? (спрашивает телезрительница из Пскова)

Вернёмся теперь к тому моменту, где я жаловался, что семейства утилит захламляют код стилизации, и называл их «простынями». Я обещал показать, как (в случае необходимости) можно сгенерировать разнообразные семейства. Начнём с семейства переменных. И в качестве примера возьмём всё ту же фокусную рамку.

В зависимости от контрола, фокусная рамка должна отстоять от него на разное расстояние (чем крупнее сам контрол, тем оно больше, но, в конечном итоге, всё зависит от его типа). А ещё, если для обычных контролов фокусная рамка должна располагаться снаружи (как в примерах выше), то для некоторых особенных — внутри. Например, для вот такого контрола, который отображает исходный код:

Чтобы в разных местах интерфейса продукта не было цыганщины, отступы рамок мы должны делать не как бог на душу положит, а выбирая из предопределённого списка. И, как видим, этот список должен быть продублирован с положительными и отрицательными значениями: рамки наружу и рамки внутрь.

(Если контролы создаются динамически, фокусная рамка и её тип задаются во время отображения документа, так что нам нужны списки именно браузерных, динамических переменных, а не препроцессорных).

Для начала создадим таблицу (map) «суффикс — отступ (по модулю)»:

@frame-offset-map:
{
	min: 1px; // Minimal possible offset.
	xxs: rp(3);
	xs: rp(4);
	sm: rp(5);
	md: rp(10);
	lg: rp(15);
};

С синтаксической точки зрения любая таблица вида «имя — значение» это тот же самый рулсет. А перебирать элементы рулсета можно точно так же, как и элементы списка — при помощи функции each().

Если с суффиксами всё должно быть понятно («от мала до велика»), на значениях, пожалуй, стоит остановиться. Список значений у меня смешанный: как в абсолютных единицах (пикселях), так и относительных (rem'ах). Минимально возможное значение всегда равно 1 пикселю. Остальные же, чтобы интерфейс был масштабируемым, а его масштаб привязан к базовому размеру шрифта, указаны в rem'ах (долях базового размера шрифта). «Макрос» (назовём его пока так) rp() служит для того, чтобы размеры в rem'ах можно было указывать не в виде нечитабельных десятичных дробей, а в виде пикселей при дефолтном размере шрифта (или, иными словами, при масштабе 100%, если механизм масштабирования привязан к шрифту).

Ранее я уже рассказывал про rp(), а далее расскажу и о других «макросах» (технически это LESS-плагины), которые позволят не только задавать размеры в «относительных пикселях», но и создавать уникальные имена, конкатенировать списки (чего LESS из коробки не умеет) и даже добавить к препроцессингу немного статической типизации (да, Карл! чтобы в случае несовпадения типов компиляция падала с ошибкой — пусть лучше девелопер узнает об этом от компилятора, чем услышит в подворотне… куда его выгонят из офиса в случае попадания ошибки в продакшен).

Ну что, теперь осталось отрендерить эту карту в два списка:

:root
{
…
	each(@frame-offset-map,
	{
		@minus-value: -@value;
		--focus-frame-@{key}-offset-out: @value;
		--focus-frame-@{key}-offset-in: @minus-value;
	});
…
}

Обратите внимание: когда мы перебираем элементы карты / рулсета, нам доступна не только переменная @value со значением, но и @key с именем (а ещё — @index с номером итерации).

И вот, пожалуйста, сгенерированный код и сдвоенный список в нём:

:root
{
…
	--focus-frame-min-offset-out: 1px;
	--focus-frame-min-offset-in: -1px;
	--focus-frame-xxs-offset-out: 0.1875rem;
	--focus-frame-xxs-offset-in: -0.1875rem;
	--focus-frame-xs-offset-out: 0.25rem;
	--focus-frame-xs-offset-in: -0.25rem;
	--focus-frame-sm-offset-out: 0.3125rem;
	--focus-frame-sm-offset-in: -0.3125rem;
	--focus-frame-md-offset-out: 0.625rem;
	--focus-frame-md-offset-in: -0.625rem;
	--focus-frame-lg-offset-out: 0.9375rem;
	--focus-frame-lg-offset-in: -0.9375rem;
…
}

Глядя на эти значения в rem'ах, вы, наверно, оценили наглядность их исходного представления в «относительных пикселях».

Абсолютно так же, при помощи интерполяции, можно сгенерировать не только список переменных, но и список утилит:

@outline-offset-map:
{
	min: 1px; // Minimal possible offset.
	xxs: rp(3);
	xs: rp(4);
	sm: rp(5);
	md: rp(10);
	lg: rp(15);
};

each(@outline-offset-map,
{
	@minus-value: -@value;

	.outline-offset-@{key}-out // Вариант: .outline-offset-out-@{index}
	{
		outline-offset: @value;
	}

	.outline-offset-@{key}-in // Вариант: .outline-offset-in-@{index}
	{
		outline-offset: @minus-value;
	}
});

На выходе мы получим вот такие два списка:

.outline-offset-min-out {
  outline-offset: 1px;
}
.outline-offset-min-in {
  outline-offset: -1px;
}
.outline-offset-xxs-out {
  outline-offset: 0.1875rem;
}
.outline-offset-xxs-in {
  outline-offset: -0.1875rem;
}
.outline-offset-xs-out {
  outline-offset: 0.25rem;
}
.outline-offset-xs-in {
  outline-offset: -0.25rem;
}
.outline-offset-sm-out {
  outline-offset: 0.3125rem;
}
.outline-offset-sm-in {
  outline-offset: -0.3125rem;
}
.outline-offset-md-out {
  outline-offset: 0.625rem;
}
.outline-offset-md-in {
  outline-offset: -0.625rem;
}
.outline-offset-lg-out {
  outline-offset: 0.9375rem;
}
.outline-offset-lg-in {
  outline-offset: -0.9375rem;
}

И, как говорилось ранее, если уж мы имеем дело с семейством утилит, их лучше использовать как миксины, а не пихать напрямую в разметку.

Благодаря такому подходу (генерации списков из карты), мы можем больше не переживать, что однажды, редактируя шаги с отступами, забудем вписать новое значение со знаком минуса и сделаем рамку вовнутрь среднего размера не симметричной рамке среднего размера наружу. А ещё, из одной карты можно сгенерировать не одно семейство, а несколько. Именно так устроен Bootstrap, где из одного списка получают и margin'ы, и padding'и.

❯ «Формула, где формула?!»

Пришло время показать, как семейство можно сгенерировать не из списка, а по формуле.

Допустим, мы хотим показать сетку объектов так, чтобы пользователь понял, что это сетка, но не показывать ему границы между объектами. Один из вариантов — при первом отображении запускать следующую анимацию:

Лично я, чтобы не возиться каждый раз с Javascript'ом, написал стандартную библиотечную функцию, которая при помощи visibility observer и руководствуясь условиями, заданными в разметке (например, атрибутом data-visible-part="0.3", устанавливающим порог площади, при котором элемент считается видимым, и классом autoplay-once, задающим повторное срабатывание), сама отслеживает события «первое отображение» и «повторное отображение» , а также добавляет/убирает к элементам класс start-autoplaying.

Сама по себе анимация простая: надо, как поёт группа Би-2, «зажигать и гасить». В смысле, по очереди зажигать и гасить строчки сверху вниз и столбцы слева направо. Строки и столбцы можно задать в разметке обычными <div>'ами, наложенными поверх картинки, чьи «хвостики» за её пределами уходят в прозрачность при помощи градиентной заливки. Элементы строк/столбцов надо будет вписать руками и разметить классами .horz.horz-i/.vert.vert-i, где i это индекс (про препроцессинг разметки, чтобы не приходилось вписывать и размечать элементы руками, поговорим как-нибудь в другой раз). «Зажигание и гашение» можно сделать простой раскадровкой, где непрозрачность быстро растёт до 30% и потом медленно падает до нуля:

&.start-autoplaying
{
	& > .vert,
	& > .horz
	{
		@keyframes blink-stripe_focus-grid
		{
			0%		{	opacity: 0;		}
			20%		{	opacity: 0.3;	}
			100%	{	opacity: 0;		}
		}

		animation: blink-stripe_focus-grid @fading-duration ease-out forwards;
	}
}

Необходимо лишь задать для строк и столбцов нарастающие паузы перед началом анимации (иными словами, CSS-свойство animation-delay должно линейно возрастать в зависимости от индекса строки/столбца). Для пущего эффекта анимация столбцов должна начинаться ещё до того, как полностью закончится анимация строк.

Какие для этого есть варианты?

Можно просто расписать таблицы задержек руками. И переписывать их каждый раз, когда потребуется изменить скорость анимации. Настраивать коэффициенты таким образом очень муторно и больно.

Можно сгенерировать строки и столбцы на клиенте из Javascript'а, задавая им animation-delay по формуле. И это создаст нам несколько проблем. Главная из них — мы разнесём код анимации по двум файлам, написанным на разных языках, что очень неудобно (см. выше про удобство упаковки стилизации в темы оформления). Но ещё мы потащим скрипты туда, где можно обойтись голым CSS, создадим трудности браузеру по планированию и оптимизации анимаций (одно дело, когда всё описано в CSS, и совсем другое, когда свойства анимации задаются динамически), наконец, не сможем удобно отлаживать их при помощи DevTools.

Сгенерировав таблицы задержек из препроцессора, мы сможем взять лучшее из обоих вариантов: для браузера у нас будет голый CSS, а для себя — формульная запись:

.focus-grid
{
	@iconset-src: '../images/iconset.png';
	@iconset-width: image-width(@iconset-src);
	@iconset-height: image-height(@iconset-src);

	// Ждём, когда распознать число элементов в LESS можно будет
	// во время компиляции при помощи AI. (Шутка юмора).
	@vert-stripes-count: 5;
	@horz-stripes-count: 8;

	// Размеры «хвостиков» за пределами картинки
	@vert-stick-out: 50px;
	@horz-stick-out: 50px;

	@vert-stripe-width: (@iconset-width / @vert-stripes-count);
	@horz-stripe-height: (@iconset-height / @horz-stripes-count);
	@step-duration: 0.1s;
	@fading-duration: 1s;

	// Столбцы
	& > .vert
	{
		.size(@vert-stripe-width, calc(100% + 2 * @vert-stick-out));

		top: (-1 * @vert-stick-out);
		background-image: linear-gradient(
			transparent,
			white @vert-stick-out,
			white (@vert-stick-out + @iconset-height),
			transparent 100%);

		each(range(@vert-stripes-count),
		{
			&.vert-@{value}
			{
				left: (@value - 1) * @vert-stripe-width;
				animation-delay: @value * @step-duration !important;
			}
		});
	}

	// Строки
	& > .horz
	{
		.size(calc(100% + 2 * @horz-stick-out), @horz-stripe-height);

		left: (-1 * @horz-stick-out);
		background-image: linear-gradient(
			to right,
			transparent,
			white @horz-stick-out,
			white (@horz-stick-out + @iconset-width),
			transparent 100%);

		each(range(@horz-stripes-count),
		{
			&.horz-@{value}
			{
				top: (@value - 1) * @horz-stripe-height;
				animation-delay: (@vert-stripes-count * @step-duration + @value * @step-duration) !important;
			}
		});
	}
}

Вся магия увеличения задержки анимации содержится в этих двух строках:

animation-delay: @value * @step-duration !important;
…
animation-delay: (@vert-stripes-count * @step-duration + @value * @step-duration) !important;

@value в данном случае — это индекс цикла. Помните, я говорил, что LESS бедноват синтаксически и уступает универсальным генераторам типа PHP? В отличие от него, в LESS нет императивных циклов вида for (@i = 0; @i < @vert-stripes-count; @i++), и приходится комбинировать функцию range() для создания списка индексов с функцией each() для его перебора.

Второе, на что следует обратить внимание, это вложение вида:

.focus-grid
{
	// Столбцы
	& > .vert
	{
		&.vert-@{value}
		{
			…
		}
	}

	// Строки
	& > .horz
	{
		&.horz-@{value}
		{
			…
		}
	}
}

Внешний & заменяется на имя контейнера (.focus-grid), так что мы описываем элементы .vert и .horz, напрямую вложенные в .focus-grid (из-за символа непосредственного вложения >).

А внутренний & заменяется уже на элементы .vert и .horz, которые мы уточняем, добавляя к ним классы .vert-1, .vert-2 и т.д. Так происходит из-за дописывания имени класса к & через точку.

На выходе это даёт следующий набор классов:

.focus-grid > .vert.vert-1 {
  left: 0px;
  animation-delay: 0.1s !important;
}
.focus-grid > .vert.vert-2 {
  left: 52px;
  animation-delay: 0.2s !important;
}
…
.focus-grid > .vert.vert-5 {
  left: 208px;
  animation-delay: 0.5s !important;
}
.focus-grid > .horz.horz-1 {
  top: 0px;
  animation-delay: 0.6s !important;
}
.focus-grid > .horz.horz-2 {
  top: 52px;
  animation-delay: 0.7s !important;
}
…
.focus-grid > .horz.horz-8 {
  top: 364px;
  animation-delay: 1.3s !important;
}

И никакого Javascript'а.


В следующих частях мы поговорим обо всём, что было обещано в этой части, но так и не раскрыто, а ещё — как генерировать контролы из простой картинки, как проверять переданные художником изображения, используемые в вёрстке, по разным критериям и останавливать компиляцию, если изображения им не соответствуют, про миксины в роли функций (а не классов) и многом другом.

Если вам понравился материал — «ставьте лайк, подписывайтесь на канал», чтобы я знал, насколько кому-то ещё интересна та или иная тема про UI, которые я тут периодически затрагиваю.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud - в нашем Telegram-канале

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.

Комментарии (1)


  1. johnfound
    14.07.2025 09:55

    Я лично использую clessc и доволен. Есть какие-то несоответствия с «стандартом», но незначительные.