30 KiB
Как стилизовать приложение 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— ПРАВИЛЬНОЕ имя. У podkopvar(--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) всё равно выглядят неправильно на тёмной странице, и снаружи этого не починит ни одна тема.