Files
small-packages/luci-theme-footstrap/docs/luci-app-styling-guide_ru.md
T
2026-09-20 01:22:33 +08:00

30 KiB
Raw Blame History

Как стилизовать приложение LuCI, чтобы оно работало под любой темой

English version: luci-app-styling-guide.md. Это единственная страница docs/, у которой есть русское зеркало: остальная документация — на английском.

Написано для авторов пакетов luci-app-*. Это не советы «под footstrap»: каждое правило ниже взято из паттерна, найденного в реальном популярном приложении, и каждое из них — баг на какой-нибудь теме, обычно на той, против которой автор не проверял. Где footstrap делает что-то своё, это оговорено отдельно.

Основание: 20 приложений прочитаны построчно (OpenClash, passwall/passwall2, ssr-plus, mosdns, vssr, ssclash, podkop, justclash, homeproxy, nikki, alist, dockerman, diskman, istore, AdGuardHome, netspeedtest, partexp, taskplan, плюс стоковые openwrt/luci), а самые звёздные — отрисованы на живом роутере. Фактура — в third-party-apps.md.


1. Время жизни CSS: куда положен <style>, тот и решает, кого он заденет

Вьюха LuCI рисуется на клиенте и подменяется внутри #view. Современная тема может быть SPA — она не перезагружает документ. Поэтому у вопроса «когда мой CSS умрёт?» три разных ответа, и безопасен только один.

НАДО — класть <style> в то дерево, которое вы возвращаете. Он умрёт вместе с вьюхой, ровно как остальная ваша разметка. Стоковый LuCI так и делает в package-manager.js, nftables.js, aria2/log.js.

return E([], [
    E('style', [ css ]),           // по построению живёт ровно столько, сколько вьюха
    E('div', { 'class': 'myapp-root' }, …)
]);

НЕЛЬЗЯ — document.head.appendChild(style). На полной загрузке выглядит безобидно: документ всё равно выбрасывается на следующем клике. Под SPA-темой он остаётся навсегда и перекрашивает каждую страницу, куда пользователь зайдёт дальше. luci-app-filemanager именно так прячет стоковые Save/Apply/Reset (.cbi-button-save { display: none !important }) — и все config-страницы за сессию становятся несохраняемыми. luci-app-banip и luci-app-adblock делают то же через <link>.

НЕЛЬЗЯ — инжектить на eval модуля. <style>, добавленный на верхнем уровне модуля (или библиотекой, которую вы грузите, — так делает ACE со своим 14-килобайтным ace_editor.css), создаётся один раз на документ и не может быть создан заново: модуль закэширован. Тема, которая за вами приберёт, вернуть его не сможет, и ваш виджет отрисуется без структуры. Если иначе никак — хотя бы будьте идемпотентны и рассчитывайте, что ваш лист проживёт всю жизнь страницы.

Как реагирует footstrap (чтобы это можно было предсказать). Не удаляется ничего никогда. Лист в <head> остаётся нетронутым — и SPA-навигация продолжает работать, — пока он не может покрасить чужую страницу:

  • он называет только ваши классы (.ace_*, .hexview, .myapp-card), или
  • он называет стоковый виджет, но прибивает правило к вашей разметке собственным именем: #cbi-myapp-section > .cbi-section-remove и .myapp-table th.sortable.active оба в порядке, потому что без вашей секции или вашей таблицы они не матчат ничего. (Аргумент :not() пином не считается: .cbi-button-save:not(.my-save-button) по-прежнему матчит каждую стоковую кнопку сохранения на каждой странице.) Или
  • это голый селектор, объявляющий только ваши собственные кастомные свойства: :root { --myapp-accent: … } инертен, а :root { color-scheme: light dark } — нет.

Всё остальное — .cbi-button-save { display: none }, .error { … }, h4 { … }, div > label + select { … }, svg text { … } — делает документ непригодным для SPA: следующая навигация уходит в полную перезагрузку страницы. Приложение продолжает работать; пользователь платит одну обычную загрузку. Все пять примеров настоящие, из приложений первой тридцатки по звёздам.


2. Именуйте всё, что объявляете

Вы гость в документе, который делите с темой и со всеми остальными приложениями.

  • Классы и id: префикс — имя вашего пакета: .myapp-card, #cbi-myapp-…. Не занимайте словарные слова. Встречено в дикой природе, и каждое — живая коллизия: .hidden, .label, .description, .centered, .skeleton, .toast, .dialog-title, .flex-row, .log-line, а также [hidden] { display: none !important } у luci-lib-taskd.
  • Кастомные свойства: объявляйте их на своём корневом элементе, а не на :root. :root — общая глобальная область: hex-редактор из luci-app-filemanager кладёт туда --clr-background, --clr-border, --clr-highlight, а сам файл-менеджер пишет туда color-scheme. Тема, у которой токен назван так же, молча перекрашивается. (OpenClash здесь прав: его ~90 свойств заскоуплены на .oc.)
  • Никогда не везите глобальный ресет. * { margin: 0; padding: 0 } (OpenClash, дважды) расплющивает собственный хром темы. То же с CSS-фреймворком через @import (pure.css в vssr) — не вам ставить его на весь документ.
  • Никогда не перестилизуйте стоковые селекторы LuCI за пределами своей страницы. .cbi-button-up, .cbi-value, .cbi-section-table-titles, ul.dropdown, table th — ими владеет тема, а ваше правило вне слоёв, то есть бьёт любой каскадный слой темы. Внутри своей страницы — заскоупьте: .myapp-root .cbi-value { … }. (Тот же факт работает и в вашу пользу — см. §3.2: внутри своего поддерева вы выигрываете у темы автоматически и !important вам не нужен никогда.)

3. Цвет: берите экспортный ярус, а не литералы

Каждая тема LuCI отдаёт наружу один и тот же контракт кастомных свойств. Читайте его — с литеральным фолбэком:

color:            var(--text-color-high, #333);
background-color: var(--background-color-low, #f5f5f5);
border-color:     var(--border-color-medium, #ddd);
/* статусные цвета: primary / success / warn / error, каждый в high / medium / low */
color:            var(--error-color-medium, #f44336);
/* у текста ПОВЕРХ заливки свои чернила: */
background-color: var(--success-color-high, #2e7d32);
color:            var(--on-success-color, #fff);

3.1. Экспортная поверхность — ровно 26 имён, и она только про цвет

Это весь контракт. Больше контрактом не является ничто.

Семейство Имена Замечания
Поверхности --background-color-high / -medium / -low ось высоты: high — приподнятое (карточка), low — утопленное (страница)
Линии --border-color-high / -medium / -low high — видимая линия, low — волосяная
Текст --text-color-highest / -high / -medium / -low четыре уровня, не три
Акцент --primary-color-high / -medium / -low + --on-primary-color
Успех --success-color-high / -medium / -low + --on-success-color
Предупреждение --warn-color-high / -medium / -low + --on-warn-color семейство называется warn, не warning
Ошибка --error-color-high / -medium / -low + --on-error-color

Имена, которых не существует нигде и которые поэтому всегда проваливаются в ваш литерал: --warning-color-*, --text-color (без уровня), --secondary-color-*, --font-sans, --font-mono, --disabled-opacity. Если вы читаете одно из них — вы не на ярусе: у вас захардкоженный цвет, только длиннее.

  • --warn-color-medium — ПРАВИЛЬНОЕ имя. У podkop var(--warn-color-medium, orange) (7 использований) резолвится на footstrap и красит тематической охрой. Не «чините» его на --warning-color-medium: такое имя не определено ничем, и переименование молча уронило бы все семь объявлений в фолбэк orange. (Ранняя редакция этого гайда утверждала обратное. Это была ошибка.)
  • --text-color (justclash, 6 использований) действительно не существует. Правильное — --text-color-high.

3.2. --fs-* — ПРИВАТНЫЙ ярус footstrap, не читайте его

Откройте cascade.css — там найдётся больше сотни кастомных свойств --fs-* с именами куда приятнее экспортных: --fs-accent, --fs-panel2, --fs-radius, --fs-dim, вся шкала z-index. Они не ваши. Они внутренние, тема переименовывает и перевыводит их когда захочет, и ни у одной другой темы LuCI их нет вообще. Контракт — только имена --*-color-* выше.

Разделение на два яруса — не аккуратизм, а починка измеренного бага, причём того же самого, о котором §2 предупреждает с другой стороны. :root — общая глобальная область, а таблица стилей приложения вне слоёв, то есть бьёт любой каскадный слой темы. Одно приложение, написавшее :root { --accent: … } — или --text-color-high, которое является конвенцией LuCI и потому объявляется приложением охотнее, — перекрашивало саму тему: под одним враждебным блоком :root меняли цвет 312 элементов из 336. Поэтому footstrap теперь читает внутри себя только приватные --fs-*, а экспортные имена выводит из них. Тот же замер после разделения: 0 элементов. (styles/02-tokens.css; CI-гейт валит сборку, если хоть одно правило темы прочитает экспортное имя.)

Практическое следствие для вас — хорошая половина: раз тему больше нельзя перекрасить вашим :root, вы можете переопределить внутри собственного поддерева всё, что рисует тема, — и !important для этого не нужен никогда. Внеслойное бьёт любой слой безусловно. Если в CSS вашего приложения есть !important, чтобы победить тему, — удалите его: побеждал не он.

3.3. Что ярус гарантирует (и что с этой гарантией делать)

footstrap доказывает всё это в CI (tools/export-tier.mjs) по полной матрице {светлая, тёмная} × {дефолт, hicontrast, bootstrap, 2020, forum} × семь значений тинта — 70 сочетаний:

  • Каждый уровень text / primary / success / warn / error проходит WCAG AA как ТЕКСТ на всех трёх поверхностях --background-color-*. То есть любой из них можно ставить в color: и не проверять.
  • Парный --on-*-color проходит AA поверх своего цвета, использованного заливкой. Отсюда: чтобы положить текст на цветную заливку, берите чернила из --on-*-color. Не выдумывайте свои. Захардкоженный #fff на залитом бейдже — классический провал: до того, как footstrap развёл чернила по палитрам и режимам, он падал до 1.69:1 на семи из восьми тёмных заливок.
  • high / medium / low — три по-настоящему разных цвета, а не три алиаса. Если вы красите «нет данных» в --primary-color-low, оно действительно прочитается тише живого значения. (Две оговорки: у --background-color-* ось идёт наоборот, high — приподнятое; и -low — это и есть самая бледная ступень, которую палитра может себе позволить: во всех раскладках она держится выше порога AA, так что текст в ней читается, но бледнее уже не будет.)

3.4. Оси палитры и тинта достаются даром — но только на ярусе

footstrap — это не просто светлая и тёмная. :root[data-palette="hicontrast"] подменяет весь набор цветов, а слайдер Tint перекрашивает оттенок всех поверхностей (:root[data-tint] с --fs-tint-h на любом оттенке 1–360°). Обе оси тянут экспортный ярус за собой — потому матрица CI выше и метёт именно эти оси.

Приложение на ярусе следует за всем этим бесплатно и остаётся читаемым. Захардкоженный «примерно такой же» цвет не двигается: карточка #f7f7f7, подобранная под дефолтную панель, становится неверной в ту же секунду, как пользователь выбрал hicontrast, а вручную подобранная охра — как только он сдвинул тинт на 40°. Это и есть настоящий аргумент всего раздела: дело не в том, что литералы некрасивы, а в том, что они верны ровно на одной конфигурации — той, на которую вы смотрели.

3.5. Остальное

  • Никогда не хардкодьте поверхность. background: white на position: fixed-модалке (passwall) — это белая коробка на тёмной странице. #1e1e1e на терминале логов (justclash) — чёрная коробка на светлой.
  • Никогда не кладите текст цвета C на полупрозрачный тинт того же C. Тинт тянет фон к цвету текста и съедает собственный контраст, а будучи полупрозрачным — зависит от того, что под ним, так что безопасного процента не существует. Дайте чипу непрозрачную поверхность, а цвет пусть несёт рамка.
  • <font color="green"> (dockerman, ssr-plus, OpenClash) — это индикатор статуса, исчезающий на половине палитр. Используйте --success-color-medium.

4. Тёмная тема: prefers-color-scheme — не тот вопрос

@media (prefers-color-scheme: dark) сообщает о операционной системе, а не о теме. У любой современной темы LuCI есть свой переключатель светлой и тёмной, и пользователь, включивший тёмную на светлой ОС, получит вашу светлую карточку на своей тёмной странице. У netspeedtest, vssr, passwall и стокового ustreamer этот баг есть.

Спрашивайте тему, в таком порядке:

function isDark() {
    const root = document.documentElement;
    if (root.dataset.darkmode) return root.dataset.darkmode === 'true';  // так делают OpenClash и luci-app-internet-detector
    if (root.dataset.theme)    return root.dataset.theme === 'dark';     // footstrap, конвенция justclash
    if (root.dataset.bsTheme)  return root.dataset.bsTheme === 'dark';   // конвенция Bootstrap 5
    const m = getComputedStyle(document.body).backgroundColor.match(/\d+/g);   // работает на любой теме
    return m && m.length >= 3
        ? (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255 < 0.5
        : false;
}

В дикой природе четыре диалекта, и footstrap штампует три из них — data-darkmode, data-theme, data-bs-theme — на :root до первой отрисовки, так что сработает любая из этих проверок. luci-app-internet-detector вешает свой тёмный блок на :root[data-darkmode="true"] и потому выглядит правильно вообще без единой правки.

В CSS то же самое:

.myapp-card { background: var(--background-color-low, #fff); }         /* уже следует за темой */
:root[data-theme="dark"] .myapp-card { /* только если вам правда нужен тёмно-специфичный оверрайд */ }
  • footstrap штампует data-darkmode, data-theme и data-bs-theme на :root до первой отрисовки — именно затем, чтобы работали все три диалекта.
  • Фолбэк по яркости работает везде, но лишь потому, что тема держит фон body непрозрачным. Не считайте, что какой-то один атрибут существует.
  • Лучше всего — не детектить вообще. Если каждый ваш цвет взят с экспортного яруса, тёмная тема уже обработана за вас — ярус ровно для этого и существует. luci-app-nikki не читает ни бита состояния темы и выглядит правильно везде.

5. Редакторы и прочие тяжёлые виджеты

  • Не хардкодьте цветовую тему редактора. mosdns и AdGuardHome прибивают CodeMirror к dracula, ssclash прибивает ACE к tomorrow_night_bright — вечно чёрный редактор на светлой странице. Выбирайте тему редактора по isDark() выше (OpenClash делает это правильно: themeExtension(isDark)).
  • Не ставьте общую библиотеку в общий путь. AdGuardHome везёт свой CodeMirror в /luci-static/resources/codemirror/; два приложения с разными версиями перезапишут друг друга. Кладите под /luci-static/resources/view/<yourapp>/.
  • Iframe: эталон — стоковый luci-app-ttyd: width: 100%; min-height: …; resize: vertical, порт из UCI, протокол зеркалит протокол страницы. Не хардкодьте порт (dockerman: :7682) и не красьте iframe в background: #fff (netspeedtest) — встроенная страница решает свои цвета сама.
  • z-index: держитесь вменяемого диапазона. Дропдаун passwall сидит на 2147483647, полноэкранный редактор OpenClash — на 999999 !important; собственные оверлеи и выпадашки темы с этим сосуществовать не могут. Ничему из того, что вы рисуете, не нужно быть выше всего на свете.
  • Никогда body { pointer-events: none } (passwall, пока открыта его модалка): если при включённом классе что-нибудь навигирует, весь интерфейс мёртв.

6. Раскладка: вьюпорт — это не ваша колонка

Тема ставит сайдбар рядом с вашей страницей. У footstrap это 224px, и на экране 1024px ваша контентная колонка шириной ~740px, тогда как @media (max-width: 768px) всё ещё говорит «десктоп». Каждое приложение из обзора, складывающее карточки по вьюпортному запросу (justclash 56rem, podkop 900px, netspeedtest 768px), ломается ровно в этой полосе.

  • Предпочитайте @container-запросы — или меряйте свой контейнер сами и ставьте класс.
  • Не навязывайте table-layout: fixed плюс min-width на каждую ячейку (filemanager) — это убивает любое адаптивное сворачивание таблицы в карточки, которое делает тема.
  • Избегайте фиксированных ширин в px у контейнеров; width: 100%; max-width: … — это то, что вы имели в виду.

7. Дружите с SPA (на классической теме это ничего не стоит)

Современная тема может переинстанцировать вашу вьюху, ни разу не перезагрузив документ.

  • window.onload и DOMContentLoaded срабатывают один раз на документ. Обработчик, повешенный там (diskman, optimize_cbi_ui.htm у passwall), молча перестаёт работать после первой навигации. Делайте работу в render().
  • Гасите свои setInterval и поллеры, когда вьюха уходит; регистрируйте поллеры через L.Poll — и LuCI сделает это за вас.
  • Не добавляйте долгоживущие узлы в document.body (контейнер тостов podkop, диалог taskd): они переживают подмену вьюхи. Кладите их в дерево вьюхи.
  • Не вешайте документный обработчик popstate, который навигирует (istore), — вы обгоните роутер темы и выиграете, причём неправильно.
  • Не прячьте стоковые кнопки через CSS. Ставьте handleSave, handleSaveApply и handleReset в null в своей вьюхе (как делают luci-app-partexp и luci-app-ttyd). Это штатный способ, ему не нужен !important, и он не протекает на следующую страницу.

8. Чек-лист

Отметьте каждый пункт перед публикацией:

  • <style> возвращается внутри дерева вьюхи, а не добавляется в <head>
  • Каждый класс, id и кастомное свойство с префиксом имени приложения
  • Ни :root { … }, ни * { … }, ни импортированного CSS-фреймворка
  • Ни один стоковый .cbi-* / table / pre не стилизуется вне своего поддерева
  • Все цвета — из 26 экспортных имён --*-color-* с литеральным фолбэком; warn, не warning
  • Нигде не читается --fs-* — это приватный ярус footstrap, а не контракт
  • Текст на цветной заливке берёт чернила из --on-*-color, а не захардкоженный #fff
  • !important не используется, чтобы победить тему: ваш CSS вне слоёв и уже бьёт любой слой
  • Тёмная тема читается из data-theme / data-bs-theme / яркости body, а не из prefers-color-scheme
  • Тема редактора выбирается по режиму страницы, а не захардкожена
  • Раскладка завязана на контейнер, а не на вьюпорт
  • handleSaveApply = null вместо прятания стоковых кнопок
  • Ни window.onload, ни остатков на уровне body, ни перехвата popstate, ни z-index: 2147483647

Приложения, которые стоит прочесть как образцы: luci-app-nikki (ноль инжектнутого CSS, ноль цветовых литералов, только стоковые формы), стоковый luci-app-ttyd (правильно сделанный iframe), luci-app-smartdns (его <style> возвращается внутри той .cbi-map, которой он принадлежит) и luci-app-internet-detector (кастомные свойства в своём неймспейсе :root { --app-id-* } и тёмный блок на атрибуте темы).


9. Насколько всё это сломано на практике

Два круга обзора, 30 приложений, отрисованных на живом роутере под этой темой:

  • Большинство приложений в порядке. Из десяти следующих по звёздам (quickfile, tailscale-community, smartdns, internet-detector, 3ginfo-lite, cloudflarespeedtest, aliddns, koolproxy, ech-workers, sms-tool) все десять сохраняют SPA-навигацию, и ни одно не инжектит ничего, чем можно покрасить чужую страницу.
  • Поломки сосредоточены в больших и старых, и это почти всегда одни и те же две ошибки: CSS положен в <head> вместо дерева вьюхи, и цвет захардкожен вместо взятого с экспортного яруса.
  • Легаси-приложения на Lua/CBI (OpenClash, passwall, dockerman, diskman, AdGuardHome, koolproxy, sms-tool) рендерятся сервером, поэтому их <style> живёт и умирает вместе с их собственной страницей — но их захардкоженные светлые поверхности (карточки #f7f7f7, диалоги background: white) всё равно выглядят неправильно на тёмной странице, и снаружи этого не починит ни одна тема.