🌴 Sync 2026-08-05 03:17:09

This commit is contained in:
github-actions[bot] committed 2026-08-05 03:17:09 +08:00
1 parent 2f3ffbb7d9
commit fffafa4359
91 files changed
+18955 -44

No files matched your search

+1
View File
@@ -4,4 +4,5 @@ config airconnect 'config'
option 'interface' 'br-lan'
option 'aircast' '1'
option 'airupnp' '1'
option 'stream_type' 'broadcast'
+2
View File
@@ -10,6 +10,7 @@ get_config() {
config_get interface $1 interface "br-lan"
config_get aircast $1 aircast "1"
config_get airupnp $1 airupnp "1"
config_get stream_type $1 stream_type "broadcast"
}
start_service() {
@@ -24,6 +25,7 @@ start_service() {
procd_append_param command -l 1000:2000
procd_append_param command -Z
procd_append_param command -b $interface
procd_append_param command -S $stream_type
procd_set_param stdout 0
procd_set_param stderr 0
procd_set_param respawn
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-airconnect
PKG_VERSION:=1.0.2
PKG_RELEASE:=1
PKG_VERSION:=1.0.3
PKG_RELEASE:=2
LUCI_TITLE:=LuCI support for AirConnect
LUCI_DEPENDS:=+airconnect
@@ -6,7 +6,7 @@
'require view';
'require tools.widgets as widgets';
var callServiceList = rpc.declare({
const callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: ['name'],
@@ -14,8 +14,8 @@ var callServiceList = rpc.declare({
});
function getServiceStatus() {
return L.resolveDefault(callServiceList('airconnect'), {}).then(function (res) {
var isRunning = false;
return L.resolveDefault(callServiceList('airconnect'), {}).then(res => {
let isRunning = false;
try {
isRunning = res['airconnect']['instances']['airupnp']['running'] || res['airconnect']['instances']['aircast']['running'];
} catch (e) { }
@@ -24,8 +24,8 @@ function getServiceStatus() {
}
function renderStatus(isRunning) {
var spanTemp = '<em><span style="color:%s"><strong>%s %s</strong></span></em>';
var renderHTML;
const spanTemp = '<em><span style="color:%s"><strong>%s %s</strong></span></em>';
let renderHTML;
if (isRunning) {
renderHTML = spanTemp.format('green', 'AirConnect', _('RUNNING'));
} else {
@@ -36,8 +36,8 @@ function renderStatus(isRunning) {
}
return view.extend({
render: function() {
var m, s, o;
render() {
let m, s, o;
m = new form.Map('airconnect', _('AirConnect'),
_('Send audio to UPnP/Sonos/Chromecast players using AirPlay.'));
@@ -45,17 +45,19 @@ return view.extend({
s = m.section(form.TypedSection);
s.anonymous = true;
s.render = function () {
poll.add(function () {
return L.resolveDefault(getServiceStatus()).then(function (res) {
var view = document.getElementById('service_status');
view.innerHTML = renderStatus(res);
poll.add(() => {
return L.resolveDefault(getServiceStatus()).then(res => {
const view = document.getElementById('service_status');
if (view) {
view.innerHTML = renderStatus(res);
}
});
});
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
E('p', { id: 'service_status' }, _('Collecting data...'))
E('p', { id: 'service_status' }, _('Collecting data...'))
]);
}
};
s = m.section(form.NamedSection, 'config', 'airconnect');
@@ -63,19 +65,26 @@ return view.extend({
o.default = o.disabled;
o.rmempty = false;
o = s.option(widgets.DeviceSelect, 'interface',
_('Bind interface'));
o.filter = function(section_id, value) {
var dev = this.devices.filter(function(dev) { return dev.getName() == value })[0];
var excludeDevice = ['docker', 'dummy', 'radio', 'sit', 'teql', 'veth', 'ztly'];
return (dev && dev.getName() != null && !excludeDevice.some(prefix => dev.getName().startsWith(prefix)));
}
o = s.option(widgets.DeviceSelect, 'interface', _('Bind interface'));
o.filter = function (section_id, value) {
const dev = this.devices.find(d => d.getName() === value);
const excludeDevice = ['docker', 'dummy', 'radio', 'sit', 'teql', 'veth', 'ztly'];
return dev && dev.getName() != null && !excludeDevice.some(prefix => dev.getName().startsWith(prefix));
};
o.rmempty = false;
o = s.option(form.Flag, 'airupnp', _('UPnP/Sonos'), _('Enable UPnP/Sonos Device Support'));
o.default = o.disabled;
o.rmempty = false;
o = s.option(form.ListValue, 'stream_type', _('Sonos stream mode'), _('How Sonos should treat the stream (affects buffering/behavior)'));
o.value('broadcast', _('broadcast'));
o.value('track', _('track'));
o.value('radio', _('radio'));
o.default = 'broadcast';
o.depends('airupnp', '1');
o.rmempty = false;
o = s.option(form.Flag, 'aircast', _('Chromecast'), _('Enable Chromecast Device Support'));
o.default = o.disabled;
o.rmempty = false;
@@ -0,0 +1,71 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:42
#: luci-app-airconnect/root/usr/share/luci/menu.d/luci-app-airconnect.json:3
msgid "AirConnect"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:68
msgid "Bind interface"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88
msgid "Chromecast"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:58
msgid "Collecting data..."
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88
msgid "Enable Chromecast Device Support"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76
msgid "Enable UPnP/Sonos Device Support"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:64
msgid "Enabled"
msgstr ""
#: luci-app-airconnect/root/usr/share/rpcd/acl.d/luci-app-airconnect.json:3
msgid "Grant UCI access for luci-app-airconnect"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80
msgid "How Sonos should treat the stream (affects buffering/behavior)"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:32
msgid "NOT RUNNING"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:30
msgid "RUNNING"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:43
msgid "Send audio to UPnP/Sonos/Chromecast players using AirPlay."
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80
msgid "Sonos stream mode"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76
msgid "UPnP/Sonos"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:81
msgid "broadcast"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:83
msgid "radio"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:82
msgid "track"
msgstr ""
+60 -18
View File
@@ -1,36 +1,78 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Project-Id-Version: PACKAGE VERSION\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: zh_Hans\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:42
#: luci-app-airconnect/root/usr/share/luci/menu.d/luci-app-airconnect.json:3
msgid "AirConnect"
msgstr "隔空播放"
msgid "Send audio to UPnP/Sonos/Chromecast players using AirPlay."
msgstr "通过 AirPlay 将音频流传输到 UPnP/Sonos 和 Chromecast 设备。"
msgid "RUNNING"
msgstr "运行中"
msgid "NOT RUNNING"
msgstr "未运行"
msgid "Collecting data..."
msgstr "获取数据中..."
msgid "Enabled"
msgstr "启用"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:68
msgid "Bind interface"
msgstr "绑定接口"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88
msgid "Chromecast"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:58
msgid "Collecting data..."
msgstr "获取数据中..."
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88
msgid "Enable Chromecast Device Support"
msgstr "启用 Chromecast 设备支持"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76
msgid "Enable UPnP/Sonos Device Support"
msgstr "启用 UPnP/Sonos 设备支持"
msgid "Enable Chromecast Device Support"
msgstr "启用 Chromecast 设备支持"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:64
msgid "Enabled"
msgstr "启用"
#: luci-app-airconnect/root/usr/share/rpcd/acl.d/luci-app-airconnect.json:3
msgid "Grant UCI access for luci-app-airconnect"
msgstr "授予 luci-app-airconnect UCI 访问权限"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80
msgid "How Sonos should treat the stream (affects buffering/behavior)"
msgstr "指定 Sonos 对流的处理方式(会影响缓冲和播放行为)"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:32
msgid "NOT RUNNING"
msgstr "未运行"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:30
msgid "RUNNING"
msgstr "运行中"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:43
msgid "Send audio to UPnP/Sonos/Chromecast players using AirPlay."
msgstr "通过 AirPlay 将音频流传输到 UPnP/Sonos 和 Chromecast 设备。"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80
msgid "Sonos stream mode"
msgstr "Sonos 流模式"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76
msgid "UPnP/Sonos"
msgstr ""
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:81
msgid "broadcast"
msgstr "广播"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:83
msgid "radio"
msgstr "电台"
#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:82
msgid "track"
msgstr "曲目"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+221
View File
@@ -0,0 +1,221 @@
#
# Copyright (C) 2026
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-footstrap
# luci.mk keys the Build/Prepare hook name on LUCI_NAME, which defaults to the checkout's
# directory name — a differently-named checkout symlinked into the feed would silently skip
# the CSS build. Pin it.
LUCI_NAME:=luci-theme-footstrap
# Version is git-derived; never hardcode PKG_VERSION. CI injects FOOTSTRAP_VERSION from the
# tag (an SDK build has no .git, so it cannot derive one).
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=1
endif
LUCI_TITLE:=Footstrap Theme
# +luci-base is the WHOLE list. The self-updater used to hard-require `curl`, which is NOT in
# OpenWrt's default package set (the base image ships uclient-fetch), so the update button
# failed on a stock router; it falls back to uclient-fetch rather than pull curl onto an 8 MB
# device for one API call. jsonfilter and sha256sum ARE in the base image. Do not add a
# runtime dep for a convenience tool — fall back instead.
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
# Else luci.mk defaults these to LuCI's own, and the package claims MAINTAINER "OpenWrt LuCI
# community" / URL openwrt/luci.
LUCI_MAINTAINER:=VizzleTF <vizzletf47@gmail.com>
LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap
# CSS: ship verbatim. luci.mk would run csstidy over it, which is old enough to mangle
# :has()/color-mix()/nested calc() and break the layout. build-css.sh minifies instead.
LUCI_MINIFY_CSS:=0
#
# JS: TWO minify paths, chosen by who is building.
#
# CI (the released packages) pre-minifies with terser BEFORE the SDK build (tools/minify-js.mjs:
# mangles top-level identifiers too, which jsmin cannot — ~41 KB against jsmin's ~57 KB, −27%)
# and signals it with FOOTSTRAP_PREMIN=1. jsmin must then stay AWAY from the already-minified
# output: terser legitimately emits `return/^v/.test(s)`, the exact one-character-lookback trap
# below, so LUCI_MINIFY_JS goes to 0 for that build.
#
# Everyone else (an SDK user, the buildbot — no node) builds the untouched source and KEEPS
# luci.mk's default jsmin (LUCI_MINIFY_JS=1): ~105 KB of this tree's JS is comments, and uhttpd
# serves /www with NO compression, so those are wire AND flash bytes either way.
#
# The catch on the jsmin path, and why the gates exist: jsmin decides `/` = regex-or-division
# from a ONE-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its
# allow-list — so `return /re/` makes it eat the rest of the file AND EXIT 0
# (openwrt/luci#8299). eslint's `wrap-regex` bans the shape in the SOURCE; tools/jsmin-verify.mjs
# proves jsmin's output is TOKEN-IDENTICAL to it. Both keep guarding the non-CI path.
FOOTSTRAP_PREMIN?=
ifneq ($(FOOTSTRAP_PREMIN),)
LUCI_MINIFY_JS:=0
endif
# Apache-2.0, and not a free choice: styles/base began as a fork of luci-theme-bootstrap's
# cascade.css, the ucode templates derive from LuCI's own, and a few JS helpers are verbatim
# copies. The notices travel with it.
#
# THE OFL-1.1 HALF IS GONE WITH THE WEBFONTS. The theme carried subsetted Manrope and JetBrains
# Mono and had to ship the OFL text beside them (§2 requires the notice and licence with every
# copy of the Font Software). It ships no Font Software now, so declaring OFL would be a false
# statement about the package's contents.
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE
# /etc/config/footstrap is SHIPPED as an empty stub and WRITTEN AT RUNTIME: Appearance -> "Save as
# default" has rpcd uci-set the router-wide axes into that very file (fs-prefs.js saveAsDefault()).
# Without this define the package manager owns it as an ordinary file and REPLACES it on upgrade —
# so the admin's saved defaults were wiped by the theme's own one-click Update, silently, reported
# as success. Measured on the dev router: eight options in the live file, package-owned, no
# .conffiles entry beside base-files'/dnsmasq's. OpenWrt honours this for BOTH formats
# (include/package-pack.mk: KEEP_$(1) -> apk .conffiles, ipk CONTROL/conffiles), so one define
# covers 24.10's opkg and 25.12's apk alike. Any future root/etc/config/* must be listed here too.
define Package/luci-theme-footstrap/conffiles
/etc/config/footstrap
endef
# RELOAD, NOT RESTART: rpcd holds sessions in memory, so `restart` logs out every LuCI user —
# including the admin who just clicked Update. `reload` sends SIGHUP, which re-reads
# /usr/share/rpcd/acl.d/*, and that ACL refresh is the only thing this package needs from rpcd.
# Verified on a live router: a session survives `reload` and dies across `restart`; deleting our
# acl.d file + `reload` flips `session access` for the self-update script from true to false.
define Package/luci-theme-footstrap/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
# uci-defaults registers the theme; refresh rpcd + drop caches so LuCI sees it without a
# reboot. Calling it ourselves is belt-and-braces: OpenWrt's default_postinst also runs
# (then deletes) every /etc/uci-defaults/* we ship, so it executes twice per install. It is
# idempotent, and its fresh-vs-upgrade marker is written at the end of the first pass, so
# that pass still sees a fresh install.
[ -f /etc/uci-defaults/30_luci-theme-footstrap ] && \
sh /etc/uci-defaults/30_luci-theme-footstrap >/dev/null 2>&1 || true
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true
/etc/init.d/rpcd reload >/dev/null 2>&1 || true
}
exit 0
endef
define Package/luci-theme-footstrap/postrm
#!/bin/sh
# opkg runs the OLD package's postrm with arg "upgrade" during a version upgrade (proven on 24.10:
# `prerm upgrade`, then `postrm upgrade`, then the new `postinst configure`) and "remove" on a real
# removal. On upgrade this script MUST change nothing: reverting mediaurlbase to bootstrap, wiping
# the theme registration and deleting the .installed marker here is what flipped every updating
# 24.10 user back to bootstrap — and it defeated uci-defaults' own upgrade guard, because with the
# marker gone uci-defaults treated the run as "fresh", yet postrm had already set mediaurlbase to
# bootstrap, which no "fresh" branch re-activates. apk never runs this on upgrade (it uses the new
# package's pre/post-upgrade), so guarding on the arg is correct for both managers; real removal
# passes "remove" (opkg) or no arg and proceeds below.
case "$$1" in *upgrade*) exit 0 ;; esac
[ -n "$${IPKG_INSTROOT}" ] || {
# Every theme name this package has ever registered. postrm runs when uci-defaults is
# already deleted, so it cannot source the list: it is duplicated by necessity and
# mirror-pinned so it cannot drift. Only the assignment is byte-identical — the `$$` is
# make's escaping.
# @mirror theme/legacy-names
LEGACY_THEMES="Footstrap FootstrapDark FootstrapLight FootstrapTop FootstrapTopDark FootstrapTopLight FootstrapSidebar FootstrapOnTop"
# @endmirror
for t in $$LEGACY_THEMES; do
uci -q delete "luci.themes.$$t"
done
# The router's DEFAULT layout, set by uci-defaults when migrating an old top-nav install.
# It means nothing to any other theme, and left behind it would silently re-apply the top
# bar on a later reinstall.
uci -q delete luci.main.footstrap_layout
# Don't leave the active theme pointing at the media dir we just removed. The same TWO-part
# check uci-defaults uses: a theme needs its media dir AND its ucode template to render, so
# a one-part check could hand the UI to a half-removed bootstrap — the blank page this
# branch exists to avoid.
case "$$(uci -q get luci.main.mediaurlbase)" in
/luci-static/footstrap*)
[ -d /www/luci-static/bootstrap ] && \
[ -f /usr/share/ucode/luci/template/themes/bootstrap/header.ut ] && \
uci set luci.main.mediaurlbase=/luci-static/bootstrap ;;
esac
uci commit luci
# The "installed before" marker uci-defaults writes to tell fresh from upgrade. Removing it
# makes uninstall-then-reinstall count as fresh again.
rm -rf /usr/share/luci-theme-footstrap >/dev/null 2>&1 || true
# The admin-uploaded login background (kept out of the package on purpose, so it survives an
# upgrade). A real removal is the one time it should go — this branch never runs on upgrade.
rm -rf /etc/footstrap >/dev/null 2>&1 || true
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true
/etc/init.d/rpcd reload >/dev/null 2>&1 || true # see the postinst note: reload keeps sessions
}
endef
# Runs right after luci.mk copies luasrc/ucode/htdocs/root/src into PKG_BUILD_DIR, so it
# rewrites the build copy, never the source tree.
#
# 1. cascade.css is generated, not committed — and styles/ is not in luci.mk's copy list, so
# build-css.sh reads it from $(CURDIR) and writes into the build tree. cat/awk only, no host
# toolchain: that is why it runs on an OpenWrt buildbot.
# 2. Stamp the git-derived version into the theme JS (fs-version.js — the string the Appearance tab
# shows). Skipped when PKG_VERSION is empty, leaving '0.0.0-dev'.
#
# THE CATALOGUE IS luci.mk's BUSINESS, and nothing here compiles it. `po/` is the directory
# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-<lang>` package per language the
# way it does for every luci-app — which is what a package IN THE LUCI TREE has to look like, and
# what Weblate can see (it translates `po/`, and only `po/`).
#
# It was `i18n/` here for a while, with a po2lmo loop in this hook bundling the catalogue into the
# theme package, because a multi-package release was mis-picked by a self-updater that resolved the
# theme by name and took `head -1` (issue #6). That updater is retired and the release is built by
# owfeed, which packages this theme as exactly one artifact per format regardless of what luci.mk
# would do — so the constraint that bought the rename is gone, and the cost of keeping it (a
# catalogue invisible to the project's own translation platform) is not.
define Build/Prepare/luci-theme-footstrap
# luci.mk's Build/Prepare copies only src/ luasrc/ htdocs/ root/ ucode/ into
# $(PKG_BUILD_DIR), and PKG_LICENSE_FILES resolves against THAT — so the Apache text has to
# be put there by hand or the declaration points at nothing.
$(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/
$(SHELL) $(CURDIR)/build-css.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css
# The private `--fs-*` names are 16% of the sheet and mean nothing to a browser — the same trade
# terser makes for the JS. BEFORE strip-templates.sh on purpose: the reserved set is derived by
# reading the JS and the templates, so it must see them whole. Over-reserving costs bytes,
# under-reserving breaks the theme silently.
# The reserved set is read from $(CURDIR), the SOURCE, never from $(PKG_BUILD_DIR): in CI the
# build tree's JS has already been through terser, so its comments are gone and five names that
# are only MENTIONED in a comment stop being reserved. That made the shipped sheet depend on WHO
# built it — 10 reserved names via CI, 15 via a plain SDK build, same source. Reading the source
# over-reserves by ~1 KB and is the safe direction.
$(SHELL) $(CURDIR)/mangle-tokens.sh \
$(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \
$(CURDIR)/htdocs/luci-static/resources $(CURDIR)/ucode
# Templates are 58% comments and shipped them all. jsmin/terser strip the JS, build-css.sh the
# CSS; the .ut files were simply never included in that trade. Only `{# … #}` goes — see the
# script for why the ucode-code `/* … */` deliberately stays. -16 KB of 39, git keeps every word,
# and dev-sync.sh does NOT run this: a router you are debugging on keeps its comments.
$(SHELL) $(CURDIR)/strip-templates.sh $(PKG_BUILD_DIR)/ucode
# …and the same for the shell under root/ (71% and 95% comment lines). Whole-line `#` only.
$(SHELL) $(CURDIR)/strip-shell.sh $(PKG_BUILD_DIR)/root
$(SED) "s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$(if $(PKG_VERSION),$(PKG_VERSION),$(PKG_SRC_VERSION))'#" \
$(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js
endef
# Absolute, not a relative path: CI rsyncs this package into package/, not into the feed.
include $(TOPDIR)/feeds/luci/luci.mk
# THE LINE BELOW IS LOAD-BEARING AND IT IS NOT A SIGNATURE — do not delete it as boilerplate.
#
# include/scan.mk builds the package list by GREPPING the Makefiles, not by parsing them:
# find -L package -name Makefile | xargs grep -aHE 'call (Build/DefaultTargets|BuildPackage|KernelPackage)'
# A Makefile that does not match is not in the list, so it is never dumped, no
# CONFIG_PACKAGE_* symbol is emitted for it, and `make package/<name>/compile` answers
# "No rule to make target" — with no error naming the package anywhere in the build.
#
# This theme never calls BuildPackage itself; luci.mk does, at the include above. The grep
# cannot see that. So the only thing that puts this package into the SDK's list is the literal
# text on the next line — which is why every luci-* Makefile carries it.
# Deleting it as a useless comment is exactly what broke the build (both formats, silently).
# tools/scan-marker.sh re-derives the grep from scan.mk and fails the build if it goes missing.
# call BuildPackage - OpenWrt buildroot signature
+67
View File
@@ -0,0 +1,67 @@
# luci-theme-footstrap (the package)
A LuCI theme for OpenWrt **24.10 and newer** (ucode templates). Installing and using it:
[the repository README](../README.md). Developer documentation: [`../docs/`](../docs/README.md).
Internal name: `footstrap`. Media path: `/luci-static/footstrap`.
## One theme, one entry
`luci.themes` carries **exactly one** entry, `Footstrap` → `/luci-static/footstrap`. Layout (top bar
by default, or the side menu), mode, palette, density, wallpaper, rounding and every colour — accent,
the three status colours, the four surfaces — are **client** axes on the Footstrap tab of
System → System: `localStorage` plus attributes and inline properties on `:root`, and nothing is
written to the router until "Save as default" is pressed.
There are no per-layout or per-mode themes; every legacy name (`FootstrapSidebar`, `FootstrapOnTop`,
`…-dark`/`…-light`) is deleted by `root/etc/uci-defaults/30_luci-theme-footstrap`.
There is one menu renderer too, `menu-footstrap.js`. The top bar is its own markup, morphed by CSS
through `:root[data-layout]`. No second template, no second renderer, no `footstrap-top` symlink.
**The theme's boundary:** it supplies the chrome and the design language; page content is drawn by
the view JS of `luci-mod-*`. The one exception is `fs-overview.js`, which draws no content of its own
and only re-arranges the stock overview sections.
## Layout
```
Makefile luci.mk; LUCI_MINIFY_CSS:=0; Build/Prepare (CSS, mangle, strip,
version, po2lmo)
styles/ CSS SOURCE: layers tokens / base / theme / pages
build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css
mangle-tokens.sh shorten the private --fs-* names in a built sheet
strip-templates.sh strip-shell.sh drop comments from .ut and from root/**.sh
po/ translation catalogue (Weblate translates this)
ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/
htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfonts, and no
wallpaper: the Pattern is one the admin uploads, and a font
is one the admin installs (repo: fonts/set-font.sh)
htdocs/luci-static/resources/ menu-footstrap.js (renderer), menu-footstrap-common.js,
fs-{fit,menutree,prefs,widgets,chrome,router,sheets,search,
select,appearance,version,overview}.js
root/etc/uci-defaults/ registration and legacy-name migration
root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background upload
```
There is no update checker: the theme is installed from the package feed (`install.sh` adds it), so
`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The Footstrap
tab shows the version it is running and makes no network call to do it.
**Do not edit `cascade.css`** — it is generated by `build-css.sh` from `styles/` and is gitignored.
Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`.
## Working on a router
The normal dev stand is four containers driven by `owlab` from `../owlab.yaml` — see
[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over
ssh:
```sh
./dev-sync.sh # push to `ssh router` (registers the theme but does NOT activate it)
ssh router 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit luci; rm -f /tmp/luci-indexcache*'
ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back
```
Before pushing, run `npm run check` from the repository root. The rules and the traps are in
[`../docs/conventions.md`](../docs/conventions.md); packaging is in
[`../docs/package.md`](../docs/package.md).
+117
View File
@@ -0,0 +1,117 @@
#!/bin/sh
# Build luci-theme-footstrap as an OpenWrt .apk via the SDK.
# The theme is noarch (CSS/JS/templates/fonts only), so the package installs on any
# router of that release whatever its CPU architecture.
#
# ./build-apk.sh # download SDK if needed, then build
# BUILD_DIR=~/x ./build-apk.sh
#
# THIS IS NOT HOW THE RELEASE IS BUILT any more — `./tools/stage.sh && owfeed build` is, and
# it produces both formats in seconds without a toolchain (see owfeed.yml). What this script
# exercises is the OTHER path: the Makefile, luci.mk, jsmin, and the SDK's own packaging. That
# path still has to work — the theme should stay buildable in an SDK or a feed by someone who
# has never heard of owfeed — and this is the only way to find out that it does.
set -e
REL="${OPENWRT_RELEASE:-25.12.2}"
SDK_BASE="https://downloads.openwrt.org/releases/${REL}/targets/mediatek/filogic"
SDK_FILE="openwrt-sdk-${REL}-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst"
SDK_URL="$SDK_BASE/$SDK_FILE"
# MUST be a case-sensitive fs (ext4/…), NOT an NTFS/9p Windows mount.
BUILD_DIR="${BUILD_DIR:-/tmp/ow-footstrap-build}"
# FORCE=1 overrides buildroot's host-prereq bail-outs (see step 4).
export FORCE=1
THEME_DIR="$(cd "$(dirname "$0")" && pwd)" # this package
REPO="$(cd "$THEME_DIR/.." && pwd)" # repo root (holds tools/, .github/)
SDK_DIR="$BUILD_DIR/sdk"
# The SDK channel (major.minor) the release SDK is signed under — derived from REL, never a
# second copy: 25.12.2 -> 25.12.
CHANNEL="${REL%.*}"
mkdir -p "$BUILD_DIR"
cd "$BUILD_DIR"
# 1. SDK
if [ ! -d "$SDK_DIR" ]; then
echo ">> downloading SDK $REL ..."
# --https-only: GNU wget follows https -> http redirects, and this tarball is a toolchain
# that will build a package a maintainer may hand to someone.
wget -q --https-only -O sdk.tar.zst "$SDK_URL"
# Verify the SDK: signature first, checksum under it. This is the least verified input in this
# repo and the only one that ends up INSIDE the built package (jsmin.c and i18n-scan.pl are
# LINTERS pinned by commit and sha256; the toolchain that compiles the artifact arrives on TLS).
# `sha256sums` alone is NOT a verification — it is served by the same host from the same
# directory, unsigned, so whoever can replace the tarball replaces the checksum beside it (see
# docs/conventions.md on GitHub's asset digest). What makes it one is the ed25519 signature over that file,
# checked with a key pinned from a DIFFERENT host (github.com/openwrt/keyring). Fails CLOSED.
#
# Every pin comes from luci-upstream.pin — the keyring commit, usign, and the branch's own
# signing key. The keys used to live in .github/workflows/build.yml's matrix and were read out
# of it here; that matrix went with the SDK build when the release moved to owfeed, so they now
# have one home and this script has one source.
. "$THEME_DIR/luci-upstream.pin"
[ -n "${OPENWRT_KEYRING_PIN:-}" ] || { echo "OPENWRT_KEYRING_PIN missing from luci-upstream.pin" >&2; exit 1; }
# sdk_key / sdk_key_sha256 for CHANNEL: 25.12 -> OPENWRT_SDK_KEY_2512. Indirection through
# eval because the pin file is plain shell assignments and /bin/sh has no associative arrays.
CH_VAR="$(printf '%s' "$CHANNEL" | tr -d .)"
eval "SDK_KEY=\${OPENWRT_SDK_KEY_$CH_VAR:-}"
eval "SDK_KEY_SHA256=\${OPENWRT_SDK_KEY_${CH_VAR}_SHA256:-}"
[ -n "$SDK_KEY" ] && [ -n "$SDK_KEY_SHA256" ] \
|| { echo "no SDK signing key for channel $CHANNEL in luci-upstream.pin" >&2; exit 1; }
echo ">> verifying SDK signature (key $SDK_KEY, channel $CHANNEL) ..."
rm -rf "$BUILD_DIR/usign"
U="$("$REPO/tools/build-usign.sh" "$BUILD_DIR/usign")"
wget -q --https-only -O sha256sums "$SDK_BASE/sha256sums"
wget -q --https-only -O sha256sums.sig "$SDK_BASE/sha256sums.sig"
wget -q --https-only -O openwrt.pub \
"https://raw.githubusercontent.com/openwrt/keyring/$OPENWRT_KEYRING_PIN/usign/$SDK_KEY"
echo "$SDK_KEY_SHA256 openwrt.pub" | sha256sum -c - >/dev/null \
|| { echo "OpenWrt's $CHANNEL key does not match its pin — refusing to verify with it" >&2; exit 1; }
"$U" -V -m sha256sums -p openwrt.pub -x sha256sums.sig \
|| { echo "BAD SIGNATURE on OpenWrt's sha256sums — refusing to build with this SDK." >&2; exit 1; }
echo ">> signature verified; now the checksum means something."
WANT="$(grep -F " *$SDK_FILE" sha256sums | cut -d' ' -f1)"
[ -n "$WANT" ] || { echo "no sha256 published for $SDK_FILE" >&2; exit 1; }
GOT="$(sha256sum sdk.tar.zst | cut -d' ' -f1)"
[ "$WANT" = "$GOT" ] || { echo "SDK checksum mismatch: want $WANT, got $GOT" >&2; exit 1; }
rm -f sha256sums sha256sums.sig openwrt.pub
echo ">> SDK verified."
echo ">> extracting ..."
mkdir -p "$SDK_DIR"
tar --zstd -xf sdk.tar.zst -C "$SDK_DIR" --strip-components=1
rm -f sdk.tar.zst
fi
cd "$SDK_DIR"
# 2. feeds (need luci for luci.mk + BuildPackage macros)
if [ ! -f feeds/luci.index ] && [ ! -d feeds/luci ]; then
./scripts/feeds update base luci
fi
./scripts/feeds install -a -p luci >/dev/null 2>&1 || true
# 3. drop our theme into the luci themes feed (fresh copy)
DEST="feeds/luci/themes/luci-theme-footstrap"
rm -rf "$DEST"
cp -a "$THEME_DIR" "$DEST"
rm -rf "$DEST/build-apk.sh" "$DEST/dev-sync.sh" "$DEST/.git" 2>/dev/null || true
./scripts/feeds update -i luci
./scripts/feeds install luci-theme-footstrap
# 4. build. ncurses is only needed for interactive menuconfig, not for a noarch
# theme, so satisfy the host prereq stamp to skip that check.
mkdir -p staging_dir/host
touch staging_dir/host/.prereq-build
make defconfig FORCE=1
make package/luci-theme-footstrap/clean FORCE=1 V=s >/dev/null 2>&1 || true
make package/luci-theme-footstrap/compile FORCE=1 V=s
# 5. locate the artifact
echo
echo ">> built packages:"
find bin -name 'luci-theme-footstrap*' \( -name '*.apk' -o -name '*.ipk' \) -print
+255
View File
@@ -0,0 +1,255 @@
#!/bin/sh
# Concatenate the styles/ tree into a single cascade.css.
#
# ./build-css.sh [outfile] [--dev]
#
# Dir order = layer order (styles/, base/, theme/, pages/); filename order = source
# order within a dir. A later layer beats an earlier one whatever the specificity, so
# nothing needs !important to override base (declared in styles/00-header.css).
#
# Minified unless --dev: ~287 KB of source -> ~111 KB (-61%). uhttpd serves CSS with NO
# gzip, so every byte is a wire byte. Comments and whitespace go; a selector or a
# declaration is never rewritten, which is why LuCI's csstidy stays off (it mangles
# :has()/color-mix()). Needs only cat/awk, so an OpenWrt buildbot can run it.
set -e
D="$(cd "$(dirname "$0")" && pwd)"
OUT=""
DEV=0
for a in "$@"; do
case "$a" in
--dev) DEV=1 ;;
# An unknown option used to fall through to `OUT="$a"`: a typo like `--devv`
# wrote the stylesheet to a file named "--devv".
-*) echo "build-css: unknown option: $a" >&2; exit 1 ;;
*) OUT="$a" ;;
esac
done
[ -n "$OUT" ] || OUT="$D/htdocs/luci-static/footstrap/cascade.css"
for d in styles styles/base styles/theme styles/pages; do
[ -d "$D/$d" ] || { echo "build-css: $D/$d missing" >&2; exit 1; }
done
TMP="$OUT.tmp.$$"
# $TMP.min too: an awk failure used to leave it behind next to the real output.
trap 'rm -f "$TMP" "$TMP.min"' EXIT
mkdir -p "$(dirname "$OUT")"
# glob expands in filename order
cat "$D"/styles/*.css \
"$D"/styles/base/*.css \
"$D"/styles/theme/*.css \
"$D"/styles/pages/*.css > "$TMP"
# Strip /* ... */, keep /*! ... */ (the licence banner), drop indentation/blank lines.
#
# STRING-AWARE: the old scanner just hunted for the next "/*", so `content: "/*"` would
# open a comment and eat every rule up to the next "*/". Its only guard was the brace
# count below, and two such literals balance each other — rules could vanish in silence.
# Quoted data-URIs run through here on every build.
strip_comments() {
awk '
BEGIN { inc = 0; q = "" }
{
line = $0; out = ""; i = 1; n = length(line)
while (i <= n) {
c = substr(line, i, 1)
if (inc) { # inside /* ... */
if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue }
i++; continue
}
if (q != "") { # inside a "..." or '"'"'...'"'"' string
out = out c
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
if (c == q) q = ""
i++; continue
}
if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue }
if (c == "/" && substr(line, i + 1, 1) == "*") {
# the banner: keep it, and everything after it on this line
if (substr(line, i + 2, 1) == "!") { out = out substr(line, i); break }
inc = 1; i += 2; continue
}
out = out c; i++
}
sub(/^[ \t]+/, "", out)
sub(/[ \t]+$/, "", out)
if (length(out)) print out
}
' "$1"
}
# Squeeze the whitespace CSS ignores — wire AND flash bytes, since uhttpd does not compress.
#
# REMOVED (~9.5 KB): the space after `:`, the spaces around `{ } ; ,`, the last `;` of a block,
# the newline after every declaration (one line per RULE). lightningcss would save ~13 KB, but
# its extra 3.5 KB comes from rewriting colours and merging rules — transforms that can change
# behaviour. These cannot.
#
# LEFT ALONE, each for a reason:
# - the single space between selectors: `.a .b` is a DESCENDANT combinator, `.a.b` is not.
# Whitespace runs collapse to one space; that one stays.
# - spaces inside calc(): required around `*` `/` and the `-` of `calc(100% - 8px)`.
# - the LINE BREAK inside a declaration — whitespace too. This line-oriented scanner used to
# join lines with nothing between them, so a wrapped calc() came out `…))- .004 …`; a `-`
# with no space BEFORE it is a parse error, so the declaration dropped, --fs-tint-c went
# undefined, --fs-bg became invalid at computed-value time and the canvas fell back to
# white — silently (export-tier.mjs caught it: contrast 1.5:1). A newline is now treated
# exactly like a space run.
# - `>` `+` `~` spaces: stripping them is safe but buys only ~200 bytes.
# - anything inside a string: every data-URI here is quoted and full of `:` `;` and spaces.
# - one newline after `}`, so the shipped file stays greppable.
squeeze() {
awk '
BEGIN { q = ""; ban = 0; lastc = ""; buf = ""; lastreal = "" }
{
line = $0
# The /*! banner is an Apache-2.0 attribution, not formatting: it must survive
# BYTE FOR BYTE. Squeezing it made "Twitter, Inc" into "Twitter,Inc" and glued its
# lines together. Copy it out untouched.
if (ban) { print line; lastc = ""; lastreal = ""; if (index(line, "*/")) ban = 0; next }
if (substr(line, 1, 3) == "/*!") {
print line; lastc = ""; lastreal = ""
if (!index(substr(line, 4), "*/")) ban = 1
next
}
# The line BREAK we are about to swallow is whitespace, and a declaration may be
# wrapped across it. Feed it to the whitespace-run logic below as a leading space:
# it survives only where a space would (between two tokens) and is dropped next to
# { } ; , : — lastc == "" means output is already at the start of a line, with
# nothing to glue to.
if (lastc != "" && q == "") line = " " line
out = ""; i = 1; n = length(line)
while (i <= n) {
c = substr(line, i, 1)
if (q != "") { # inside a string: copy verbatim
out = out c
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
if (c == q) q = ""
lastreal = "" # a char inside a string is not structure
i++; continue
}
if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue }
if (c == " " || c == "\t") { # collapse a run of whitespace to one space
while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++
# the last char EMITTED, which on a continuation line lives on the
# previous output line — hence lastc, not just `out`.
prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "")
# drop it entirely next to a delimiter; otherwise it may be a combinator
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":")
continue
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "")
continue
out = out " "; lastreal = " "
continue
}
# THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e.
# INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted onto
# the awk output, and sed cannot see strings: `content: ";}"` came out as
# `content: "}"`, and a data-URI containing `;}` was corrupted the same way (both
# reproduced). Nothing in the tree holds that byte pair today — which is how such
# a bug waits for whoever adds the first one.
#
# The `;` may already sit in the previous line output, so text is held in `buf`
# until the rule closes: a `;` already printed cannot be taken back.
if (c == "}") {
if (length(out) && substr(out, length(out), 1) == ";")
out = substr(out, 1, length(out) - 1)
else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";")
buf = substr(buf, 1, length(buf) - 1)
}
out = out c; lastreal = c; i++
}
buf = buf out
if (length(out)) lastc = substr(out, length(out), 1)
# newline only after a closing brace — one rule per line. lastreal, not lastc: a
# line ending in a QUOTED `}` (content: "}") is not the end of a rule, and flushing
# there would split the rule and lose the space before its next token.
if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" }
}
END { if (length(buf)) print buf; else printf "\n" }
' "$1"
}
# Fail loudly rather than let an unbalanced block ship.
#
# STRING-AWARE, for the same reason the comment stripper is: a brace inside a CSS STRING is not a
# block. The counter used to gsub() over the raw line, so a perfectly valid rule made the build
# REFUSE — measured, all three shapes: `content: ";}"`, `content: "{"`, and a data-URI carrying
# `;}` (an inline <style> inside an SVG icon carries braces by construction). It fails closed, so
# nothing was ever corrupted; what it costs is an afternoon spent looking for an imbalance that is
# not there, on a message that names the file as broken. Nothing in the tree contains such a byte
# today, which is exactly how this waited for whoever adds the first one — the same sentence
# already written above the comment stripper, which was fixed and left this one behind.
brace_count() {
awk '
BEGIN { q = "" }
{
line = $0; n = length(line); i = 1
while (i <= n) {
ch = substr(line, i, 1)
if (q != "") { # inside a string
if (ch == "\\") { i += 2; continue } # escape: skip the pair
if (ch == q) q = ""
i++; continue
}
if (ch == "\"" || ch == "'"'"'") { q = ch; i++; continue }
if (ch == "{") o++
else if (ch == "}") c++
i++
}
}
END {
if (o != c) { printf "build-css: %s: unbalanced braces (%d { vs %d })\n", FILENAME, o, c > "/dev/stderr"; exit 1 }
if (o < 100) { printf "build-css: %s: suspiciously few rules (%d)\n", FILENAME, o > "/dev/stderr"; exit 1 }
print o
}' "$1"
}
# Always brace-check a COMMENT-STRIPPED copy, --dev included: counting braces in the raw
# file made a stray "{" in prose fail the build on perfectly valid CSS.
strip_comments "$TMP" > "$TMP.min"
RULES_BEFORE=$(brace_count "$TMP.min") || exit 1
if [ "$DEV" -eq 0 ]; then
# comments gone; now squeeze the whitespace CSS ignores
squeeze "$TMP.min" > "$TMP"
rm -f "$TMP.min"
# AND AGAIN, on what actually ships: the check above only saw the squeeze's INPUT, yet
# the squeeze is the pass most able to corrupt the sheet — it tracks strings, joins lines
# and deletes the `;` before a `}`. An unchanged rule count is what says it did not.
RULES_AFTER=$(brace_count "$TMP") || exit 1
if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then
echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2
exit 1
fi
else
# --dev keeps comments AND formatting: this output is for reading, not shipping
rm -f "$TMP.min"
fi
# BROKEN-BUILD FLOOR (no upper size budget — removed). With only an upper bound, every way of
# producing a SHORT file — a truncated write, a full disk, a squeeze that ate the tail — passed
# and shipped a stylesheet with its second half missing. The floor catches that; it is a
# correctness guard, not a size limit.
#
# Measured on $TMP and BEFORE the mv, with the EXIT trap still armed — like the brace checks above,
# which had this order right. It used to run after, so the guard's own failure path LEFT the mangled
# sheet at $OUT with nothing to clean it up: make aborts in Build/Prepare, fine, but dev-sync.sh
# writes straight into htdocs/luci-static/footstrap/cascade.css, so a truncated sheet stayed in the
# working tree and the CSS-only iterate loop (scp that same file) would ship it.
SIZE=$(wc -c < "$TMP" | tr -d ' ')
FLOOR=${FS_CSS_FLOOR:-81920} # 80 KB — well under the real sheet; only a mangled build lands here
if [ "$DEV" -eq 0 ] && [ "$SIZE" -lt "$FLOOR" ]; then
echo "build-css: cascade.css would be only $SIZE bytes, under the $FLOOR-byte floor." >&2
echo "build-css: that is not a smaller stylesheet, that is a broken one. $OUT left untouched." >&2
exit 1
fi
mv "$TMP" "$OUT"
trap - EXIT
echo "build-css: $SIZE bytes -> $OUT"
+125
View File
@@ -0,0 +1,125 @@
#!/bin/sh
# Push the footstrap theme to a HARDWARE router over ssh (containers use owlab).
# ONE theme, one luci.themes entry; layout (top/sidebar) is a client axis on the Footstrap tab of
# System -> System, not a theme entry. This registers the theme; it does NOT activate it.
set -e
R="${1:-router2512}"
N=footstrap
D="$(cd "$(dirname "$0")" && pwd)"
# cascade.css is generated from styles/ and is not in git. --dev keeps the comments so the
# file on the router still reads like the source.
"$D"/build-css.sh "$D/htdocs/luci-static/$N/cascade.css" --dev
ssh "$R" "mkdir -p /usr/share/ucode/luci/template/themes/$N \
/www/luci-static/$N \
"
# the ONE template (+ partials/). Sidebar and top bar are the same markup, morphed by
# :root[data-layout] — there is no second theme dir to copy.
scp -q "$D"/ucode/template/themes/$N/*.ut "$R":/usr/share/ucode/luci/template/themes/$N/
ssh "$R" "mkdir -p /usr/share/ucode/luci/template/themes/$N/partials"
scp -q "$D"/ucode/template/themes/$N/partials/*.ut "$R":/usr/share/ucode/luci/template/themes/$N/partials/
# shared static (cascade.css, fonts, logo). The doodle wallpapers are NOT here and not in the
# package: they are downloaded on demand from GitHub (fs-prefs.js installWallpaper), so the dev
# router deliberately starts without them — that is the state to test the download dialog in.
scp -qr "$D"/htdocs/luci-static/$N/* "$R":/www/luci-static/$N/
# EVERY resource JS, by GLOB — never by name. This used to list the four files
# individually, so a FIFTH would be shipped by the package (luci.mk copies htdocs/
# wholesale) yet silently never reach the dev router — first tested after a release.
#
# A TAR OF THE TREE, not `scp *.js`: the flat glob does not descend, so anything a subdirectory
# holds shipped in the package (luci.mk copies htdocs/ wholesale) and reached the dev router never —
# the same class of miss the glob above was written to end, one directory level down. Every path
# under resources/ is ours to place.
tar -C "$D/htdocs/luci-static/resources" -cf - . | ssh "$R" "mkdir -p /www/luci-static/resources && tar -C /www/luci-static/resources -xf -"
# stamp the git-derived version into the deployed fs-version.js (the package does the same in
# Build/Prepare) so the Footstrap tab shows a real version instead of "(dev)". The FILE NAME is part
# of the contract: FS_VERSION lives in fs-version.js and moving it means changing every sed.
FS_V="$(git -C "$D" describe --tags --always 2>/dev/null | sed 's/^v//')"
# if-form, not `[ -n ] && ssh`: under set -e a failed &&-list aborts the whole sync when git
# describe yields nothing (a copied tree without .git). expr refuses a tag with
# sed/shell-special characters rather than interpolating it.
if [ -n "$FS_V" ] && expr "$FS_V" : '[0-9A-Za-z._-]*$' >/dev/null; then
ssh "$R" "sed -i \"s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$FS_V'#\" /www/luci-static/resources/fs-version.js"
fi
# The catalogue, which the PACKAGE compiles in Build/Prepare. po2lmo is a luci-base host tool,
# absent from a dev machine by default, so this is best-effort: without it the strings stay
# English and the sync still succeeds. (Build it once from the pinned luci commit: cc -o po2lmo
# po2lmo.c lib/lmo.c lib/plural_formula.c.) Same basename as the package — see the Makefile.
if command -v po2lmo >/dev/null 2>&1; then
ssh "$R" "mkdir -p /usr/lib/lua/luci/i18n"
for po in "$D"/po/*/*.po; do
[ -e "$po" ] || continue
lang="$(basename "$(dirname "$po")")"
po2lmo "$po" "/tmp/footstrap-theme.$lang.lmo"
scp -q "/tmp/footstrap-theme.$lang.lmo" "$R":/usr/lib/lua/luci/i18n/
rm -f "/tmp/footstrap-theme.$lang.lmo"
done
else
echo " (po2lmo not found — skipping the translation catalogue; strings stay English)"
fi
# root/ -> / as a TREE, never as a list of names. luci.mk installs root/ wholesale, so a file named
# here one-by-one is a file that ships in the package and silently never reaches the dev router —
# first noticed after a release. The top-level dirs come from a GLOB for the same reason: this used
# to tar `usr` literally, which is a hand-written list of one, and root/etc/ was the counterexample
# already sitting in the tree — root/etc/config/footstrap shipped in the package and never reached
# the router. tar, not `scp -r`, because scp's merge semantics on an existing /usr are ambiguous.
#
# Two subtrees have semantics a blind tar would break, so each is excluded and handled explicitly
# below. Both exclusions are about BEHAVIOUR, not about naming files: a new file under either dir
# still arrives for free.
# - etc/uci-defaults: deliberately run from /tmp (see below). Landing it in /etc/uci-defaults
# would make the router's next boot execute it and then DELETE it.
# - etc/config: conffiles. The package manager does not overwrite them on upgrade (Makefile's
# conffiles define) and neither may dev-sync — the live file holds the admin's "Save as
# default", and clobbering it here would be the very wipe that define exists to prevent.
set --
for _d in "$D"/root/*/; do set -- "$@" "$(basename "$_d")"; done
tar -C "$D/root" --exclude=etc/uci-defaults --exclude=etc/config -cf - "$@" | ssh "$R" "tar -C / -xf -"
# conffiles: install only when ABSENT, which is exactly what the package manager does with them.
for _f in "$D"/root/etc/config/*; do
[ -f "$_f" ] || continue
_b=$(basename "$_f")
scp -q "$_f" "$R":"/tmp/.fs-conf-$_b"
ssh "$R" "[ -f /etc/config/$_b ] || { mv /tmp/.fs-conf-$_b /etc/config/$_b; echo ' installed /etc/config/$_b (was absent)'; }; rm -f /tmp/.fs-conf-$_b"
done
# There is no self-update backend to deploy any more: the theme upgrades through the package feed
# the installer adds, which is what a package manager is for.
ssh "$R" "/etc/init.d/rpcd reload 2>/dev/null; rm -f /tmp/luci-indexcache*"
ssh "$R" "
# Sweep every pre-consolidation name, INCLUDING $N-top: the top bar is not a theme any more,
# so a stale mediaurlbase pointing at its media dir would still render a theme LuCI no longer
# lists.
#
# rm -rf, not rm -f: these are DIRECTORIES (and $N-top a SYMLINK to one, which rm -rf removes
# as the link, not its target). \`rm -f\` refuses a directory, 2>/dev/null swallowed the error
# and this block runs without set -e — so the sweep silently did nothing. Every path is a
# literal built from \$N: no glob, no user input.
cd /www/luci-static
rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light
cd /usr/share/ucode/luci/template/themes
rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light
# The overview layout used to install into luci-mod-status's GLOBAL include dir, where LuCI loads
# every *.js under any theme. It is a chrome module (fs-overview.js) now. A package upgrade drops
# the old file; dev-sync copies rather than installs, so it has to be swept by name — otherwise the
# dev router keeps running BOTH copies and the grid is arranged twice.
rm -f /www/luci-static/resources/view/status/include/05_footstrap_overview_layout.js
for db in /lib/apk/db/installed /usr/lib/opkg/status; do [ -f \"\$db\" ] && touch \"\$db\"; done
rm -f /tmp/luci-indexcache*"
# Register by running the package's own uci-defaults script — the single source of truth
# (ONE entry, luci.themes.Footstrap; layout, mode and palette are client-side toggles).
# PKG_UPGRADE=1 forces its upgrade branch, so the dev router's active theme is left alone.
scp -q "$D"/root/etc/uci-defaults/30_luci-theme-footstrap "$R":/tmp/30_luci-theme-footstrap
ssh "$R" "PKG_UPGRADE=1 sh /tmp/30_luci-theme-footstrap; rm -f /tmp/30_luci-theme-footstrap /tmp/luci-indexcache*"
echo "synced to $R (single Footstrap theme registered, active theme unchanged)"
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 -1 100 100">
<!-- The OpenWrt mark, and it stays OpenWrt's on purpose: the tab icon says which DEVICE this
is, not which theme is painting it. What it does not need is the flat cyan tile the mark
used to be pasted on — that tile was the "outdated" part (issue #7), and it fought every
browser's tab strip. Transparent now, so the icon sits in whatever the browser draws.
Colours are the mark's own: cyan arcs, dark-blue ring. On a DARK tab strip that ring is
nearly invisible, so it lightens under prefers-color-scheme — an SVG favicon is a live
document and both Chrome and Firefox honour the query. logo_48.png cannot: it is the
fallback for browsers with no SVG favicon support, and it carries the light variant. -->
<style>
@media (prefers-color-scheme: dark) { .ring { fill: #e6f4fb } }
</style>
<path d="m40.5 50.8a6.6 6.6 0 1 0 0 13.2 6.6 6.6 0 0 0 0-13.2m-40.5-33.9 7 6.8a47.5 47.5 0 0 1 67.2 0l7-6.8a57.2 57.2 0 0 0-81.2 0" fill="#00b5e2"/>
<path d="m12.5 29.2 6.8 7a30 30 0 0 1 42.6 0l6.8-7a39.7 39.7 0 0 0-56.3 0" fill="#00b5e2"/>
<path d="m24.8 41.6 6.8 6.9a12.6 12.6 0 0 1 18 0l6.8-6.9a22.3 22.3 0 0 0-31.6 0" fill="#00b5e2"/>
<path class="ring" d="m64.9 39.7a30.2 30.2 0 1 1-48.7 0l-6.9-7a39.9 39.9 0 1 0 62.5 0z" fill="#002b49"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1,781 @@
'use strict';
'require baseclass';
'require ui';
'require dom';
'require fs-prefs as prefs';
'require fs-widgets as widgets';
'require fs-version as ver';
/* The Appearance CONTROLS: the DOM that presents the axes. It owns no preference — fs-prefs.js
* holds the axes, fs-version.js the version string; this file is the form they are shown in.
*
* WHERE IT LIVES. It used to be a popover hanging off a button in the chrome; it is now a TAB on
* System -> System (admin/system/system), beside General Settings / Logging / Time Synchronization
* / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — eighteen of them, nine
* carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab,
* place itself against a viewport edge and stay inside a 320px column is the wrong container for
* that. Keeping BOTH would have meant every axis rendered twice, which is the failure this file's
* own history is made of.
*
* It is appended by a MutationObserver rather than by a route of its own, and that is the same
* boundary fs-overview.js sits on: a THEME may not own a dispatcher node, because a node outlives
* the theme that registered it — switch to another theme and the menu keeps an entry whose view is
* gone. So the theme owns no menu.d and no view; it watches for the stock page and adds one
* section to it, additively, and removes nothing. Off the footstrap theme, or on any other page,
* nothing runs at all.
*
* THE VERSION LINE MAKES NO REQUEST and must not grow one. Which version is INSTALLED is what this
* page answers; which version is available is the package manager's question, and a theme polling a
* release API to re-answer it from a settings page is the wrong shape twice over — it reaches the
* network from a page that has no business doing so, and it reimplements `apk upgrade`. */
/* THE COLOUR PRESETS WERE HERE — eight chips that wrote the Accent axis, each painted in the
* colour it would set. They are gone, and what they were for is not: the request behind them
* (#20, "the blue theme is cool but sometimes you want grey or black") is answered by the Accent
* row itself, which takes any #rrggbb and sits three rows below where the chips used to be. A
* preset only ever wrote that one axis, so the chips were a second, prettier way to do the thing
* the field already does — and they were the one control on this page that looked like nothing
* else in LuCI: a bare row of coloured pills starting at the card's edge rather than at the field
* column, which is what made the tab read as ragged. */
/* Build the whole form. Returns a promise for one element wire() appends to the stock page.
*
* Everything applies IMMEDIATELY — there is no Save button for the axes themselves, because there
* is nothing to save: every axis is this browser's, in localStorage, and the page repaints under
* the control as it moves. The one button that writes anything is "Save as default", which pushes
* the current look to the ROUTER for other browsers. That distinction is the whole model
* (docs/design-system.md) and it is why this page has no Save/Reset footer of LuCI's own. */
function render() {
/* still a promise: the view awaits it, and keeping the shape means a future asynchronous step
* (or the caller) needs no change. */
return Promise.resolve(build());
}
function build() {
/* every saved axis re-checks the Save button after it applies, so the button greys the moment
* this browser matches the saved default again and un-greys the moment it diverges. Wrapped
* around the appliers because the seg/slider/colour controls call them directly and have no
* other seam back to here. refreshSave is a hoisted function declaration; saveBtn it reads is
* assigned below, before any of these fire (all are user events). */
const bump = (fn) => (v) => { fn(v); refreshSave(); };
/* Every colour control mirrors something it does not own — the PALETTE's colour, while its own
* axis is off, and the contrast that colour lands at. A palette switch, a dark-mode flip or a
* preset changes all of that under controls nobody touched, so they are refreshed together
* rather than each listening for what might have moved. */
const colourCtls = [];
const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh());
/* wrap an applier so the colour readouts follow it: mode and palette change what every axis is
* measured against, and a preset changes the axes themselves */
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
/* One captioned row, in LuCI's OWN row shape: `.cbi-value` > `label.cbi-value-title` +
* `.cbi-value-field`. This tab used to draw its own two-column grid of stacked cards — an
* uppercase eyebrow above each control — which made the theme's settings the one page in LuCI
* that did not look like LuCI. Sitting beside General Settings / Logging / Time Synchronization /
* Language and Style, the odd one out was ours.
*
* Nothing here styles those class names: `.cbi-value` is stock, base/30-forms.css and
* theme/60-inputs.css already lay it out (title column, field column, hairline under each row),
* so this page now inherits every future fix to the form layout instead of keeping a private
* copy of it. That also means the row lives on a SHARED surface — Zone 2, where a third-party
* app is entitled to win on specificity — which is exactly right for a page rendered inside
* #view rather than for chrome.
*
* `make` is handed the SAME label string the caption renders, because every control in here
* needs it a second time as its aria-label (segControl/sliderControl/colorControl take it as
* their last argument) — and stating it twice is how the visible caption and what a screen
* reader announces drift apart. One literal per axis, used by both, with nothing to keep in
* sync. `extra` is for the rows that carry more than a control (the Save row's error line),
* `opts.cls` for the rows CSS has to be able to single out. */
const group = (label, make, opts) => {
const o = opts || {};
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
E('label', { 'class': 'cbi-value-title' }, [ label ]),
E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || []))
]);
};
/* ---- the CONTROLS are LuCI's own, not this theme's ------------------------------------------
*
* Every enum axis is a `ui.Select` and every number is a `ui.RangeSlider` — the same widgets the
* form on the other tabs is built from, so a dropdown here is the dropdown an admin already knows
* and the theme's own stylesheet already dresses (`select` in base/30-forms.css,
* `.cbi-range-slider` in theme/60-inputs.css). Both classes exist on the whole range this theme
* supports — checked against luci's own openwrt-24.10 branch, not only against master.
*
* What this replaces is two primitives of ours: a segmented radiogroup with a roving tabindex and
* a range wrapper with a live readout. They were written when this page was a floating popover
* and a `<select>` inside it read as a hole in the card. On a page there is no such argument, and
* a control LuCI maintains is one this theme cannot get wrong on its own.
*
* THE EVENT IS `widget-change`, dispatched by UIElement, not a listener on the inner element —
* that is the seam the widget publishes, and reaching past it to the `<select>` would tie us to
* how it happens to be built today. RangeSlider also emits `widget-update` while the handle
* moves, which is what makes the tile resize UNDER the drag rather than on release; both are
* wired, and the appliers are idempotent so the pair costs nothing. */
const selectCtl = (current, choices, apply, label) => {
const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) });
const node = w.render();
node.setAttribute('aria-label', label);
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
const sliderCtl = (current, min, max, apply, label, opts) => {
const o = opts || {};
const w = new ui.RangeSlider(String(current), {
min: min, max: max, step: o.step || 1, calcunits: o.unit || null
});
const node = w.render();
node.setAttribute('aria-label', label);
const push = () => apply(parseInt(w.getValue(), 10));
node.addEventListener('widget-update', push);
node.addEventListener('widget-change', push);
return node;
};
/* one colour axis: the shared shape of the rows below. `probe` is the live token the control
* reads the effective colour back from, `contrast` the pair it reports. */
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
const ctl = widgets.colorControl(axis.current(), bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast,
cls: (opts && opts.cls) || ''
});
colourCtls.push(ctl);
return ctl;
}, opts);
/* EVERY LABEL IN HERE CARRIES THE 'footstrap' CONTEXT (`_(str, ctx)`, key `ctx\1str`). LuCI
* serves ONE MERGED catalogue — load_catalog() loads every *.<lang>.lmo in
* /usr/lib/lua/luci/i18n and a lookup returns the first archive holding the hash — so a msgid is
* a GLOBAL name shared with every luci-app, and readdir order picks the winner: the layout
* toggle rendered "Максимум" on a Russian router (issue #6), because another catalogue
* translates the msgid "Top" as "maximum". Contexting cannot be selective — whatever we leave
* bare is a name anyone may take. The chrome and the login/notice sentences are deliberately
* bare (inheriting luci-base's translation is a feature in the ~40 languages we have no
* catalogue for), as are System/Memory/Storage in fs-overview.js, which MATCH the stock
* headings. */
/* ---- section 1: the shell ---- */
const shell = [
group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), {
sidebar: _('Sidebar', 'footstrap'),
top: _('Top', 'footstrap')
}, bump(prefs.applyLayout), label)),
group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), {
auto: _('Auto', 'footstrap'),
light: _('Light', 'footstrap'),
dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(prefs.currentPalette(), {
footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
* so it is a proper noun and stays untranslated, like the two above it */
bootstrap: 'Bootstrap'
}, bump(repaint(prefs.applyPalette)), label)),
/* Density: how much air the UI uses. Pure token axis — 02-tokens.css multiplies the type and
* space ladders, so every size, gap and padding in the theme follows at once. */
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'),
normal: _('Normal', 'footstrap'),
large: _('Large', 'footstrap')
}, bump(prefs.applyDensity), label)),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label, { unit: 'px' })),
/* The top layout has no accordion (its sections are hover dropdowns, already exclusive), so
* this switch is meaningless there. ALWAYS BUILT, HIDDEN BY CSS (:root[data-layout="top"]
* .fs-ap-submenus). Do NOT put an `if (currentLayout() !== 'top')` around it: the page is
* built once, so the branch would freeze the control to the layout the page LOADED in — it
* would stay on screen after a switch to the bar and never appear after a switch away from
* it. Toggling the layout re-renders nothing; CSS morphs the chrome. */
group(_('Submenus', 'footstrap'), (label) => selectCtl(
prefs.currentAutoCollapse() ? 'on' : 'off', {
off: _('Keep open', 'footstrap'),
on: _('Auto-collapse', 'footstrap')
}, bump(prefs.applyAutoCollapse), label),
{ cls: 'fs-ap-submenus' })
];
/* ---- section 2: colours ---- */
const colours = [
/* the caption says what the axis is FOR: "Tint" alone reads as decoration and nobody would
* look for the router-identity cue under it. */
colourGroup(_('Tint (router identification)', 'footstrap'), {
current: prefs.currentTint, apply: prefs.applyTint
}, 'var(--fs-bg)', {
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette token
* this axis must not move, so the ratio is reported instead of corrected */
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
}, { cls: 'fs-ap-tint' }),
/* the STRENGTH half of the Tint — how strong the hue reads. Only meaningful in hue mode: a
* hex canvas IS the colour asked for, with no chroma of ours to scale. CSS hides it in the
* other two states (no tint at all, or a hex one). */
/* NOT "Density": that is the UI-density segment above, and this string is both the visible
* caption AND the control's aria-label, so two rows would read "Density" and a screen reader
* would announce "Density, radio group" and "Density, slider" with nothing to tell them
* apart. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
step: 5,
fmt: (v) => v + '%'
}), { cls: 'fs-ap-tint fs-ap-density' }),
/* recolours the accented CONTROLS (buttons/toggles/sliders/focus rings), not the canvas the
* way Tint does. Measured as TEXT on a card, which is the use that fails first: as a fill it
* carries derived ink, as a link or a status label it carries only itself. */
colourGroup(_('Accent', 'footstrap'), {
current: prefs.currentAccent, apply: prefs.applyAccent
}, 'var(--fs-accent)', {
fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Good', 'footstrap'), {
current: prefs.currentGood, apply: prefs.applyGood
}, 'var(--fs-good)', {
fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Warning', 'footstrap'), {
current: prefs.currentWarn, apply: prefs.applyWarn
}, 'var(--fs-warn)', {
fg: 'var(--fs-warn)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Danger', 'footstrap'), {
current: prefs.currentDanger, apply: prefs.applyDanger
}, 'var(--fs-danger)', {
fg: 'var(--fs-danger)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
})
];
/* ---- the SURFACES: the sheet the UI is drawn on ----
* Cards, inset controls, the chrome bar and the hairlines between them. Every one of these is a
* surface that body text is read ON, so what each reports is --fs-text against itself — the one
* measurement that says whether the page is still readable. There is no ink to derive here and
* none is: --fs-text is the palette's, and an axis that silently moved it would be recolouring
* the very thing it is being measured against.
*
* The hairline is the exception and takes the 3:1 UI-component threshold rather than the text
* one, which is what its readout comparing --fs-border to --fs-panel means: a border is a shape,
* not a label, and AA asks 3:1 of it. Below that it is decoration — which a hairline is entitled
* to be, so the readout says the number and leaves the call to the admin. */
const surfaces = [
colourGroup(_('Cards', 'footstrap'), {
current: prefs.currentCard, apply: prefs.applyCard
}, 'var(--fs-panel)', {
fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Controls', 'footstrap'), {
current: prefs.currentControl, apply: prefs.applyControl
}, 'var(--fs-panel2)', {
fg: 'var(--fs-text)', bg: 'var(--fs-panel2)', label: _('on a control', 'footstrap')
}),
colourGroup(_('Sidebar and bar', 'footstrap'), {
current: prefs.currentBar, apply: prefs.applyBar
}, 'var(--fs-bar-bg)', {
fg: 'var(--fs-text)', bg: 'var(--fs-bar-bg)', label: _('in the sidebar', 'footstrap')
}),
colourGroup(_('Borders', 'footstrap'), {
current: prefs.currentLine, apply: prefs.applyLine
}, 'var(--fs-border)', {
fg: 'var(--fs-border)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap'), kind: 'shape'
})
];
/* ---- section 3: the wallpaper and everything that depends on which one is picked ----
*
* Wallpaper is THREE-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
* uploaded photo). Each value brings rows with it — the SVG plus Scale/Strength/Colours, or the
* photo plus Dim — and those rows are SIBLINGS of the Wallpaper row, not children of its field.
*
* That is the whole point of this shape. They were nested inside the field at first, which put a
* second `.cbi-value` inside a `.cbi-value-field` and therefore a second 180px caption column
* inside the first: measured on the router, Scale and Strength started 216px right of every
* other control on the page. LuCI has no such construct anywhere, and the eye reads it as two
* forms interleaved. Flat rows, hidden as a group, is what the stock pages do with a dependent
* field — one caption column, one field column, top to bottom.
*
* The select is the per-browser switch that decides whether to paint an image, so it is what
* keeps the Save button honest (refreshSave); Choose/Remove only swap the picture behind
* whoever is in that mode and never touch the axis. Both native file inputs stay hidden — the
* styled buttons trigger them. */
const wallpaper = (() => {
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
/* display:none, not the `hidden` attribute — a bare `hidden=""` still rendered the native
* "Choose File / No file chosen" control; only the styled button below should be visible. */
const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' });
const chooseLabel = _('Choose image', 'footstrap');
const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]);
const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' });
const patChooseLabel = _('Choose SVG', 'footstrap');
const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]);
const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
/* Dim: the scrim opacity over the photo. An ORDINARY per-browser axis — it is in AXIS_KEYS
* and in snapshotAxes(), so it moves this browser toward or away from the router default and
* must therefore be bump()-ed like every other saved axis. It was not, on the strength of a
* comment that said it wrote straight to uci: true until "keep every axis per-browser until
* Save as default" made it a propAxis and did not reach this file. The symptom is the one
* thing the Save button IS — its own status. Separate from the Tint's strength above. */
const dimLabel = _('Dim', 'footstrap');
const scaleLabel = _('Scale', 'footstrap');
const strengthLabel = _('Strength', 'footstrap');
const inkLabel = _('Colours', 'footstrap');
/* The rows the PATTERN brings. Scale and Strength are live: the appliers write a custom
* property, so the tile behind the page resizes and fades under the drag with nothing to
* reload. Colours decides whether the file's own palette is kept or thrown away for the
* theme's — a mask uses the alpha only, which is right for line art and wrong for artwork
* that carries its own colours, and only whoever picked the file knows which it is. */
const patRows = [
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600,
bump(prefs.applyPatternSize), lbl, { step: 20, unit: 'px' })),
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100,
bump(prefs.applyPatternStrength), lbl, { step: 5, unit: '%' })),
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
}, bump(prefs.applyPatternInk), lbl))
];
/* …and the rows the FILE photo brings. */
const fileRows = [
group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(prefs.currentPhotoDim(), 0, 100,
bump(prefs.applyPhotoDim), lbl, { step: 5, unit: '%' }))
];
function reflect(tok) {
if (tok) { preview.src = prefs.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
}
function reflectPattern(tok) {
if (tok) { patPreview.src = prefs.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
}
/* `hidden` on the ROW, which is why 80-appearance.css restates it at a specificity that beats
* `.cbi-value`'s own display — the UA's bare `[hidden]` rule loses to it. Hidden and not
* removed: the rows are built once and each holds a live control whose value is this
* browser's, so rebuilding them on every switch would be the popover's old bug (a control
* frozen to the state it was constructed in) in a new place. */
function togglePanel(v) {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(prefs.currentLoginBg());
reflectPattern(prefs.currentPattern());
togglePanel(prefs.currentWallpaper());
const setWallpaper = (v) => { prefs.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
patChoose.addEventListener('click', () => { patErr.hidden = true; patInput.click(); });
patInput.addEventListener('change', () => {
const f = patInput.files && patInput.files[0];
patInput.value = ''; /* so re-picking the same file fires change again */
if (!f) return;
patErr.hidden = true; patChoose.disabled = true;
patChoose.textContent = _('Uploading…', 'footstrap');
prefs.uploadPattern(f)
.then((tok) => {
reflectPattern(tok);
/* uploadPattern already switched THIS browser onto the pattern, so the control has
* to catch up or the page paints the tile while the dropdown still reads Off.
* `dom.callClassMethod` is how LuCI moves one of its own widgets from the outside;
* setWallpaper is then called directly, because a programmatic setValue does NOT
* emit `widget-change` — the event is the user's, and relying on it here would
* leave the rows and the Save button behind. */
dom.callClassMethod(seg, 'setValue', 'pattern');
setWallpaper('pattern');
})
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
.finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; });
});
patRemove.addEventListener('click', () => {
patErr.hidden = true; patRemove.disabled = true;
prefs.removePattern()
.then(() => reflectPattern(''))
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
.finally(() => { patRemove.disabled = false; });
});
chooseBtn.addEventListener('click', () => { err.hidden = true; fileInput.click(); });
fileInput.addEventListener('change', () => {
const f = fileInput.files && fileInput.files[0];
fileInput.value = ''; /* so re-picking the same file fires change again */
if (!f) return;
err.hidden = true; chooseBtn.disabled = true;
chooseBtn.textContent = _('Uploading…', 'footstrap');
prefs.uploadLoginBg(f)
.then(reflect)
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
.finally(() => { chooseBtn.disabled = false; chooseBtn.textContent = chooseLabel; });
});
removeBtn.addEventListener('click', () => {
err.hidden = true; removeBtn.disabled = true;
prefs.removeLoginBg()
.then(() => reflect(''))
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
.finally(() => { removeBtn.disabled = false; });
});
let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(prefs.currentWallpaper(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
}, setWallpaper, label);
return seg;
});
return [ wallRow ].concat(patRows, fileRows);
})();
/* ---- section 4: the router default and the version ---- */
/* the version line: read from fs-version.js, which the Makefile stamps at package time. No
* request, no check — `apk upgrade` is what tells this router about a new one. */
/* Save the current look as the ROUTER-WIDE default (fs-prefs writes it to /etc/config/footstrap
* via the scoped uci ACL). It does NOT change this browser — localStorage keeps overriding, so
* the saved default only shows on a fresh browser/device. "Reset" is the escape hatch: it clears
* this browser's overrides and reloads onto the saved default (a two-click confirm, since it
* discards local tweaks).
*
* No status text — the Save BUTTON itself is the status: enabled "Save as default" when this
* browser diverges from the saved default, disabled "Saved as default" when it already matches
* (nothing to save). refreshSave() below drives that from prefs.matchesSavedDefault(). */
const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ _('Save as default', 'footstrap') ]);
/* TWO resets, because there are two things underneath a browser's tweaks (fs-prefs.js):
* "Reset to saved" clears them and lets every axis fall back through the layers — to whatever
* Save as default put on the ROUTER; "Reset to default" writes the THEME's own built-ins
* explicitly, which is the only way to say "as the theme ships" on a router that has a saved
* default of its own. Neither touches /etc/config/footstrap. */
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ _('Reset to saved', 'footstrap') ]);
/* The stock destructive class, so the button that throws away every local tweak is the red one
* on the page — LuCI paints .cbi-button-negative/.cbi-button-remove from --fs-danger
* (theme/55-buttons.css). "Reset to saved" stays neutral on purpose: it drops this browser back
* onto whatever the router says, which is a step BACK to a shared state rather than a discard. */
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to default', 'footstrap') ]);
/* Save's only visible failure surface. saveAsDefault() writes /etc/config/footstrap over the
* scoped uci ACL; the realistic failure is the rpc REJECTING — an expired session (403), a
* missing ACL, ubus down — which the old code buried in a title tooltip nobody sees. (A DELETED
* config is NOT caught here: rpcd stages the set in the session and commit then silently no-ops
* without writing the file, returning success — measured on the router. The package owns that
* file and the read side falls back to built-in defaults, so that edge is left to the package.) */
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
/* the Save button IS the status: match -> disabled "Saved as default", diverged -> enabled
* "Save as default". Called after every axis change (via bump). */
function refreshSave() {
const saved = prefs.matchesSavedDefault();
saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
}
saveBtn.addEventListener('click', () => {
saveBtn.disabled = true;
saveErr.hidden = true;
prefs.saveAsDefault()
.then(() => { saveErr.hidden = true; })
/* On failure re-enable (refreshSave, below) so the user can retry. The usual cause is a
* stale session, which a reload fixes — so say that. The raw rpc error — the one string
* here neither the theme nor LuCI composed — stays in a title tooltip for debugging. */
.catch((e) => {
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
saveErr.title = String((e && e.message) || e);
saveErr.hidden = false;
})
.finally(refreshSave);
});
/* two-click confirm on BOTH: the first click arms, the second resets — discarding this browser's
* tweaks is destructive of local work, and a native confirm() is banned in this UI. Arming one
* disarms the other, so a primed button can never be fired by a click meant for its neighbour.
*
* Each reload lands on this tab rather than back on General Settings: a reset is a change to
* what is on THIS tab, and being thrown to the top of the page to find it again is the kind of
* small rudeness that makes a setting feel unfinished. See armReturn() / the mount() flag. */
const armed = new Map();
function disarm(btn, label) {
armed.delete(btn);
btn.textContent = label;
btn.classList.remove('fs-ap-armed');
}
function twoClick(btn, label, run) {
btn.addEventListener('click', () => {
if (!armed.has(btn)) {
[ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other)));
armed.set(btn, label);
btn.textContent = _('Confirm reset', 'footstrap');
btn.classList.add('fs-ap-armed');
return;
}
disarm(btn, label);
run();
armReturn();
location.reload();
});
}
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved);
twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin);
refreshSave(); /* correct label/enabled state before the first paint */
const versionLink = E('a', {
'class': 'fs-ap-version',
'href': ver.REPO_URL,
'target': '_blank',
'rel': 'noopener noreferrer'
}, [ ver.label() ]);
const defaults = [
/* the one row whose "control" is a pair of buttons, each already named by its own text — so
* the caption is not re-used as an aria-label here and `make` ignores it */
group(_('Router default', 'footstrap'),
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, resetSavedBtn, resetBtn ]),
{ extra: saveErr })
];
defaults.push(E('div', { 'class': 'fs-ap-footer' }, [
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ])
]));
/* NOT .cbi-section: inside a tab pane that class is a card drawn within a card, and the stock
* tabs (General Settings, Logging, …) put their rows straight into the pane. These are grouping
* headings within one pane, so they are the theme's own class and take their rule from
* styles/pages/80-appearance.css. */
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ])
].concat(rows));
/* ---- the folded groups ------------------------------------------------------------------
* Recolouring is a thing most admins never do, and these are the widest rows on the page —
* nine colour fields and an uploader, which used to sit permanently open in front of someone
* who came here to change the layout. Each is a DISCLOSURE now: the heading is the control,
* and both start closed.
*
* A disclosure and not a switch, which is what these were first. A switch answers "is this
* feature on", and that is the wrong question — turning it off would either revert nine colours
* (destructive, from a control that looks like a disclosure) or change nothing at all, which is
* a switch that lies. Folding answers the question that is actually being asked: am I looking
* at this right now. Nothing is applied, un-applied or disabled by opening or closing one.
*
* It is the W3C APG disclosure pattern, the same one the menu's sections use: a <button> owning
* the region it shows, `aria-expanded` on the button and `aria-controls` pointing at the panel.
* `hidden` on the panel rather than a class, so a closed group is out of the tab order and out
* of the accessibility tree for free.
*
* The open/closed state is remembered per browser but is NOT an axis: it changes nothing about
* how the page looks, so it is absent from AXIS_KEYS, from snapshotAxes() and from the
* pre-paint. Closed is the default, including on a router that already has colours set — the
* fold says where things are, not whether they are in use. */
let foldSeq = 0;
function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq);
let open = (prefs.lsGet(key) === 'on');
const body = E('div', { 'class': 'fs-ap-body', 'id': id }, rows);
const btn = E('button', {
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
}, [
E('h4', {}, [ title ]),
/* The chevron is the affordance, and it is the SAME one the overview's card toggles
* draw: an empty box whose ::after is two borders rotated 45° (styles/pages/
* 20-overview.css). Not an <svg> — this theme has one chevron for "this panel opens",
* and a second drawing of it would be a second thing to keep looking like the first.
* Empty and aria-hidden: the STATE is on the button's aria-expanded, which is also what
* CSS rotates it off, so what a screen reader is told and what the eye sees cannot
* disagree. */
E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' })
]);
const paint = () => {
body.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
};
btn.addEventListener('click', () => {
open = !open;
prefs.lsSet(key, open ? 'on' : 'off');
paint();
/* the colour controls read the COMPUTED cascade, and a hidden element computes nothing
* useful — so the readouts are filled when the group becomes visible, not before */
if (open) refreshColours();
});
paint();
return E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ btn ]), body
]);
}
/* Colours and Surfaces are ONE fold: they are the same job — "make this router a different
* colour" — split into two headings only because a figure and the sheet it sits on are read
* differently. Two folds for one decision would be two things to open. */
const page = E('div', { 'class': 'fs-ap' }, [
section(_('Interface', 'footstrap'), shell),
foldable(_('Colours', 'footstrap'),
colours.concat([ E('div', { 'class': 'fs-ap-head fs-ap-sub' }, [ E('h4', {}, [ _('Surfaces', 'footstrap') ]) ]) ], surfaces),
'fs-ui-colours'),
foldable(_('Background', 'footstrap'), wallpaper, 'fs-ui-background'),
section(_('Defaults', 'footstrap'), defaults)
]);
/* The colour controls read the COMPUTED cascade, which needs them in the document — so the first
* fill cannot happen while the tree is still being assembled above. The view appends this
* element synchronously on return, so a microtask is late enough and early enough: late enough
* that the probe resolves, early enough that nothing has painted a blank field. */
Promise.resolve().then(refreshColours);
return page;
}
/* ---- mounting it on the stock System page ---------------------------------------------------
*
* The same shape as fs-overview.js's, and for the same reason: a chrome module is instantiated once
* per PAGE LOAD, so it has to notice SPA navigation itself. `body[data-page]` is the signal — both
* the server template and fs-router stamp it with the dispatch path — so one attribute observer
* covers arriving at System, leaving it, and coming back. */
const PAGE = 'admin-system-system';
/* A reset reloads the page, and a reload opens the stock page on the tab LuCI remembers — which is
* never this one, because ui.tabs only knows the tabs it built itself. So the reset says where it
* came from and mount() puts the user back. sessionStorage and not a URL fragment: the fragment is
* the stock page's own business, and a stale one would keep re-opening this tab on every later
* visit. The key is read once and removed, so it survives exactly one reload. */
const RETURN_KEY = 'fs-ap-return';
function armReturn() { try { sessionStorage.setItem(RETURN_KEY, '1'); } catch (e) {} }
function takeReturn() {
try {
if (sessionStorage.getItem(RETURN_KEY) === null) return false;
sessionStorage.removeItem(RETURN_KEY);
return true;
} catch (e) { return false; }
}
const MARK = 'fs-ap'; /* the built form's own class; also how mount() knows it is already there */
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
let _routeObserver = null, _viewObserver = null, _observedView = null, _building = false;
function onPage() { return (document.body.getAttribute('data-page') || '') === PAGE; }
function stopWatch() {
if (_viewObserver) _viewObserver.disconnect();
_viewObserver = null;
_observedView = null;
}
/* Append the form once the stock view has rendered. LuCI's system.js resolves its own promises
* before it puts anything in #view, so there is nothing to hook but the DOM — hence the observer,
* which also covers the view being re-rendered under us (a Save & Apply redraws the map).
*
* Idempotent through the marker: an observer fires for every mutation, and the form's own
* construction is a mutation. Without the check it would append itself for as long as it kept
* noticing itself. */
/* The stock tab GROUP: the element whose children are the panes, which ui.tabs marks
* data-initialized when it builds the menu — and the menu it inserted is that element's previous
* sibling. Both are read from the DOM rather than assumed, because a group that is not initialised
* yet is a page still rendering, not a page without tabs.
*
* The flag and the sibling are the whole test, deliberately: the panes themselves are NOT required
* to be found here. On the stock System page they are not direct children of the marked element at
* all — the map nests them a level deeper — so a `:scope > .cbi-tabcontainer` check found none and
* the tab was never added, silently, on a page that plainly has tabs. */
function tabGroup(view) {
for (const g of view.querySelectorAll('[data-initialized="true"]')) {
const menu = g.previousElementSibling;
if (menu?.classList.contains('cbi-tabmenu'))
return { group: g, menu };
}
return null;
}
/* Append the pane and its tab once the stock view has rendered. LuCI's system.js resolves its own
* promises before it puts anything in #view, so there is nothing to hook but the DOM — hence the
* observer, which also covers the view being re-rendered under us (a Save & Apply redraws the map).
*
* The tab is added BY HAND rather than by calling ui.tabs.initTabGroup again: that function returns
* immediately when the group carries data-initialized, and clearing the flag to re-run it would
* build a SECOND menu beside the first (it inserts one unconditionally) and drop the stock tabs'
* own click bindings. One <li>, the same click handler ui.tabs binds to every other tab, and the
* pane the handler expects to find.
*
* Idempotent through the marker: an observer fires for every mutation, and the form's own
* construction is a mutation. Without the check it would append itself for as long as it kept
* noticing itself. */
function mount() {
const view = document.getElementById('view');
if (!view || view.querySelector('.' + MARK) || _building) return;
const tabs = tabGroup(view);
if (!tabs) return;
_building = true;
render()
.then((form) => {
/* re-check: render() resolves on a microtask and the view can have been replaced, or
* navigated away from, in the meantime */
const v = document.getElementById('view');
if (!onPage() || !v || v.querySelector('.' + MARK)) return;
const t = tabGroup(v);
if (!t) return;
/* The tab is named after the THEME, not after what it does: it sits between four stock
* tabs that are all "what this page configures" (General Settings, Logging, …), and a
* fifth called Appearance reads as another facet of the router rather than as one
* package's settings. "Footstrap" says whose these are — and it is a proper noun, so it
* is deliberately NOT translated, like the palette name in the form below. */
const title = 'Footstrap';
/* data-tab-active is deliberately absent: the stock page opens on whichever tab it
* opened on before, and a theme has no business taking that over. */
t.group.appendChild(E('div', {
'class': 'cbi-tabcontainer',
'data-tab': TAB,
'data-tab-title': title
}, [ form ]));
const link = E('a', { 'href': '#' }, [ title ]);
link.addEventListener('click', ui.tabs.switchTab.bind(ui.tabs));
t.menu.appendChild(E('li', { 'class': 'cbi-tab-disabled', 'data-tab': TAB }, [ link ]));
/* …and if this load is the one a reset asked for, open on it. Clicking the link we just
* built goes through ui.tabs' own switchTab, so the stock panes are hidden exactly the
* way they are for any other tab — nothing here reimplements the switch. */
if (takeReturn()) link.click();
})
.catch((e) => console.error('footstrap: the Appearance tab failed to build', e))
.finally(() => { _building = false; });
}
function watch() {
const view = document.getElementById('view');
if (_viewObserver && _observedView !== view) stopWatch();
if (_viewObserver || !view || !onPage()) return;
_observedView = view;
_viewObserver = new MutationObserver(mount);
_viewObserver.observe(view, { childList: true, subtree: true });
mount();
}
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the data-page
* observer inside. */
function wire() {
if (_routeObserver || !document.body) return;
_routeObserver = new MutationObserver(() => (onPage() ? watch() : stopWatch()));
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
if (onPage()) watch();
}
return baseclass.extend({
wire,
render
});
@@ -0,0 +1,429 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
/* The chrome AROUND the content: the mode menu, the section tabs, the rail toggle, and the
* measurements that decide how much room any of it gets. The MAIN menu is not here — it is injected
* by menu-footstrap.js as a callback (renderMainMenu), because LuCI instantiates every required
* module into a singleton and so a renderer cannot be a subclass of the chrome (docs/conventions.md). */
/* the injected main-menu renderer; handed over once by the theme's init() */
let _renderMain = null;
function setRenderMain(fn) {
_renderMain = fn;
}
/* section tabs -> #tabmenu (horizontal) */
function renderTabMenu(node, url, level) {
const container = document.querySelector('#tabmenu');
/* a template without the container must not reject: an unhandled rejection here kills the
* whole ui.menu.load() chain, i.e. every menu */
if (!container)
return E([]);
const ul = E('ul', { 'class': 'tabs' });
const children = ui.menu.getChildren(node);
let activeNode = null;
children.forEach((child) => {
const isActive = (L.env.dispatchpath[3 + (level || 0)] === child.name);
/* aria-current="page", not just the `active` class: the class is paint, which a screen
* reader cannot see. E() drops a null attribute value, so inactive tabs carry nothing. */
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s %s'.format(child.name, isActive ? 'active' : '') }, [
E('a', { 'href': L.url(url, child.name), 'aria-current': isActive ? 'page' : null }, [ _(child.title) ])
]));
if (isActive)
activeNode = child;
});
if (ul.children.length === 0)
return E([]);
container.appendChild(ul);
container.style.display = '';
if (activeNode)
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
return ul;
}
/* ---- tab-strip auto-fit ----
* A tab strip (#tabmenu, or a view's own .cbi-tabmenu) can carry ~11 pills (luci-app-justclash)
* that overflow one row. Rather than wrap, shrink: two density classes (styles/theme/40-tabs.css)
* trim padding, then gap+font. Floored so a pill never gets tighter than its label — past the
* floor the strip is allowed to wrap. */
function stripFitsOneRow(ul) {
/* Only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
* read as "one row" while the strip had in fact wrapped, and the density fit never fired. */
const items = [...ul.children].filter((el) => el.getClientRects().length > 0);
const first = items[0], last = items[items.length - 1];
/* one row iff first and last item share a top edge */
return !first || !last || first.offsetTop === last.offsetTop;
}
function fitTabStrips() {
/* `.fs-sidebar > ul.nav` is the main menu in EVERY layout — the same list — so the
* flexDirection check below is what tells a bar (row) from a vertical sidebar (column),
* where a one-row measure is meaningless. */
document.querySelectorAll('.tabs, .cbi-tabmenu, .fs-sidebar > ul.nav').forEach((ul) => {
if (ul.children.length < 2) return;
if (ul.matches('.fs-sidebar > ul.nav') && getComputedStyle(ul).flexDirection !== 'row') {
/* vertical list: the measure would floor it at fs-dense2 forever. Clear and skip. */
if (ul.classList.contains('fs-dense1') || ul.classList.contains('fs-dense2'))
ul.classList.remove('fs-dense1', 'fs-dense2');
return;
}
/* steady state (poll tick on an already-fitting strip): one measure, no class writes —
* the write-measure-write dance below forces a reflow per strip, every second. */
if (!ul.classList.contains('fs-dense1') && !ul.classList.contains('fs-dense2') && stripFitsOneRow(ul))
return;
ul.classList.remove('fs-dense1', 'fs-dense2');
if (stripFitsOneRow(ul)) return;
ul.classList.add('fs-dense1');
if (stripFitsOneRow(ul)) return;
ul.classList.remove('fs-dense1');
ul.classList.add('fs-dense2'); /* floor: leave wrapped if it still overflows */
});
}
/* ---- does the CONTENT column still have room, once the sidebar has taken its cut? ----
*
* The sidebar gives way to the bar when what is LEFT for the content would be too narrow to read.
* A viewport breakpoint (`@media (max-width: 767px)`) cannot say that: the cut is not a constant —
* 224px expanded, 68px collapsed to the rail — so one breakpoint gave both states the same answer,
* and the rail folded away at the same width as the full sidebar, the ~156px it had just freed
* buying the user nothing. Do NOT measure the RENDERED sidebar either: the answer would depend on
* the state it is deciding (once it is a bar there is no cut, so the content "fits", so it
* un-narrows, so it cuts again) — oscillation.
*
* The widths come from the STYLESHEET (02-tokens.css), which is what lays the sidebar out; never
* restate them here, or narrowing the rail in CSS leaves this subtracting the old width with no gate
* able to see it. Memoised because fitShell runs on every resize and mutation and getComputedStyle
* forces a style recalc; the fallbacks stop an empty custom property making the measurement NaN
* (`NaN < NaN` is false, so the sidebar would simply never yield). */
/* A CUSTOM PROPERTY IS UNTYPED, so its computed value is a token stream and not a length.
* `parseFloat(getComputedStyle(root).getPropertyValue('--fs-sidebar-w'))` therefore reads
* `calc(224px * 1)` — a string starting with `c` — and returns NaN, which the fallbacks below turn
* straight back into the literals this function exists to stop restating. It read correctly until
* the Density axis wrapped three of the four tokens in `calc(… * var(--fs-density-box))`; only
* `--fs-content-min`, still a bare `500px`, kept working, which is why the failure was one-sided
* and silent. Measured on the router: content-min 500, and sidebar/rail/pad all NaN.
*
* Resolve them the way the platform actually offers without registering the property: assign the
* token to a REAL length property on a throwaway element and read the used value back. */
let _probe = null;
/* THE PROBE IS A PLAIN <div> IN THE SHARED DOCUMENT, so a third-party app's CSS can style it, and
* every one of its declarations is !important for that reason alone. It carries no chrome mark, so
* `fs-sheets`'s fence deliberately does not spare it (the fence protects the chrome's own elements),
* and even a fenced sheet still matches an unmarked div. Issue #19: an app whose stylesheet carried
* `div { min-width: 500px !important }` won every read — all four tokens came back 500 — so the cut
* the sidebar was said to take became 500 + 2x500 and `fitShell` folded the sidebar into a bar on a
* 1857px desktop. The threshold that produces is exactly 500 + 500 + 1000 = 2000 CSS px, which is
* why it was reported as a ZOOM bug: Chrome at 90% gives 2063 CSS px and passed, 100% gives 1857 and
* failed. Inline !important is what answers it — a style-attribute declaration outranks any author
* rule at the same importance, so there is nothing left for the app to out-rank.
* box-sizing is stated for the same reason: getComputedStyle().width is the CONTENT box, so a
* foreign `border-box` plus padding would shave the reading (the padding/border resets below are
* important, but only a stated box-sizing makes them provably irrelevant). */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
_probe.setAttribute('aria-hidden', 'true');
/* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
_probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
'pointer-events:none!important;height:0!important;box-sizing:content-box!important;' +
'min-width:0!important;max-width:none!important;border:0!important;' +
'padding:0!important;margin:0!important;';
document.body.appendChild(_probe);
}
_probe.style.setProperty('width', 'var(' + token + ')', 'important');
const v = parseFloat(getComputedStyle(_probe).width);
return Number.isFinite(v) ? v : dflt;
}
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc —
* but keyed on the DENSITY, because that is the one thing that changes these widths at runtime
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() precisely so they
* are re-measured). Reading one attribute is free; the memo without the key meant a density change
* re-measured against the widths of the density before it. */
/* The last resort, stated ONCE so the fallbacks and the sanity net below cannot restate the
* stylesheet's widths in two different places. Reaching for these means the measurement failed. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
let _geom = null, _geomDensity = null;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
if (_geom && _geomDensity === density) return _geom;
_geomDensity = density;
const px = (name, dflt) => resolveLen(name, dflt);
const g = {
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: px('--fs-rail-w', GEOM_DFLT.railW),
/* the token is ONE side's padding; the column loses it twice */
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2
};
/* Plausibility, and it costs one comparison: the rail IS the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known ways this measurement fails destroy
* that — a hijacked probe reports ONE foreign width for all four (issue #19), a renamed or
* absent token reports 0 for all four (an abs-positioned empty div with `width:auto` shrinks to
* 0, and 0 is finite, so the per-read fallback above never fires). Neither can be seen in the
* numbers one at a time; the RELATION between them is what gives it away. */
_geom = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0) ? g : Object.assign({}, GEOM_DFLT);
return _geom;
}
function fitShell() {
const root = document.documentElement;
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
root.removeAttribute('data-narrow');
return;
}
const g = shellGeometry();
const cut = prefs.currentRail() ? g.railW : g.sidebarW;
/* clientWidth, not innerWidth: the column the content actually gets excludes a classic
* scrollbar, and innerWidth includes it — 15-17px of phantom room on Linux/Windows, i.e. the
* 500px floor really fired at ~484. */
const content = document.documentElement.clientWidth - cut - g.contentPad;
/* toggleAttribute, NOT setAttribute: a same-value setAttribute still QUEUES a mutation record
* (measured in Chromium: 5 identical setAttribute('data-narrow','') -> 5 records; toggleAttribute
* on an already-present attribute -> 0). fitShell runs from fitChrome, which fs-fit calls on every
* mutation batch inside #view — i.e. once a second on any polled page. menu-footstrap observes
* data-narrow and treats each record as a mode CHANGE, so on a phone (390 - 224 - 56 = 110 < 500,
* so the attribute is permanently set) every poll tick re-fired closeFlyouts() and the section the
* user had just tapped open snapped shut, forever. The bug was one-sided and therefore invisible
* on a desktop: the else-branch removeAttribute on an absent attribute already fires 0 records. */
root.toggleAttribute('data-narrow', content < g.contentMin);
}
function fitChrome() {
fitShell();
const bar = document.querySelector('.fs-sidebar');
const menu = document.getElementById('topmenu');
/* The top bar is MEASURED at every width — no 768 floor. It used to bail below 768 and hand
* the job to a phone-bar media query, which left the sub-768 bar pinning its dropdowns to the
* left edge and never collapsing "Refreshing"; the shrink/compact/stack escalation below now
* runs at any width for the top layout. (The SIDEBAR layout still has its own phone bar,
* decided by fitShell's data-narrow, and is untouched here.) */
const topBar = !!bar && !!menu && prefs.isTopLayout();
if (bar) bar.classList.remove('fs-bar-stack', 'fs-ind-compact', 'fs-bar-actrow');
fitTabStrips();
/* ---- does the main menu fit on the brand's row? ----
* Whether it fits depends on how many sections THIS router has (stock 5, a loaded box 11), not
* on the viewport — so it is measured, not a breakpoint. `@media (max-width: 1199px)` stacked
* it on every laptop: a stock bar's contents come to ~683px, i.e. one row fits down to ~723px.
* Measured UNSTACKED (the remove above): a stacked menu owns a whole row and would "fit",
* flipping straight back — oscillation.
*
* The menu's own pills wrapping IS the "does not fit" signal, but only because the unstacked
* top bar is flex-wrap: nowrap (50-toplayout.css); otherwise the BAR wraps, hands the menu
* a whole row, and it always "fits". Do NOT measure the bar's children by offsetTop instead:
* the bar is align-items:center with children of differing heights, so their offsetTop differs
* even on one row (that read as "wrapped" for a 5-section menu). */
if (topBar && !stripFitsOneRow(menu)) {
/* First step before stacking: collapse the poll pill ("Refreshing", ~90px) to an icon
* square and re-measure — that width alone is often enough to keep the menu on the
* brand's row and skip the second row entirely (styles/theme/50-toplayout.css). */
bar.classList.add('fs-ind-compact');
fitTabStrips();
if (!stripFitsOneRow(menu)) {
bar.classList.add('fs-bar-stack');
fitTabStrips();
}
}
/* The cluster's own escalation, for EVERY bar — the top layout at any width, and the sidebar
* layout once fitShell has stamped data-narrow. It runs after the menu's, because on the top
* layout .fs-bar-stack has by then given the menu its own row and taken it out of the sum. */
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
}
/* ---- does the right-hand cluster still fit beside the brand? ----
*
* The same question as the menu's, one row up, and it cannot be asked the same way. The cluster is
* four SIBLINGS — the indicators, Search, Appearance, Log out — so flexbox wraps them ONE AT A
* TIME: measured on a 380px phone bar, Log out alone dropped onto a second row and sat at its LEFT
* edge under the hostname, while the other three stayed up top. Either the whole cluster shares the
* brand's row or it takes a row of its own, right-aligned; there is no useful state in between.
*
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose on a bar showing
* both), and only if that still overflows give the cluster a row. .fs-ind-compact may already be
* set by the menu's escalation above — adding it twice is free, and it must NOT be cleared here:
* whoever asked for it still needs it. */
function fitCluster(bar, menu) {
bar.classList.remove('fs-bar-actrow');
if (clusterFitsBrandRow(bar, menu))
return;
bar.classList.add('fs-ind-compact');
if (clusterFitsBrandRow(bar, menu))
return;
bar.classList.add('fs-bar-actrow');
}
/* Add the widths up rather than read positions: the bar is align-items:center over children of
* differing heights, so offsetTop differs even on ONE row — the same trap the menu measurement
* documents above. The menu is excluded because it owns a full-width row of its own in every bar
* (`ul.nav { flex: 1 1 100% }`), so it is never what the cluster competes with. */
function clusterFitsBrandRow(bar, menu) {
const cs = getComputedStyle(bar);
const gap = parseFloat(cs.columnGap) || 0;
const room = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
let need = -gap;
for (const el of bar.children) {
/* offsetParent is null for a display:none child, which is most of them in a bar:
* .fs-navlabel, .fs-spacer and #modemenu never show there, and #indicators is empty
* (and so hidden) until the first poll pill lands. */
if (el === menu || el.offsetParent === null)
continue;
need += el.offsetWidth + gap;
}
return need <= room;
}
/* No observer and no resize listener of our own: fs-fit owns both, and this file used to grow the
* second one docs/conventions.md warns against. A view renders its .cbi-tabmenu into #view, which fs-fit's
* MutationObserver already watches — and it re-fits SYNCHRONOUSLY (rule 2), where the copy here
* deferred through fit.schedule(), i.e. the duplicate was strictly the slower path into the same
* work. #tabmenu is a sibling of #view rather than inside it, but nothing writes it except
* renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view (with a
* window-resize fallback where there is no RO). */
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
const ul = document.querySelector('#modemenu');
const children = ui.menu.getChildren(node);
children.forEach((child, index) => {
const isActive = L.env.requestpath.length
? child.name === L.env.requestpath[0]
: index === 0;
/* the main menu must render even if a template has no #modemenu — only the mode
* list itself is skippable chrome */
if (ul)
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
if (isActive)
renderMainMenu(child, child.name);
});
if (!ul)
return;
if (children.length <= 1)
ul.classList.add('single');
if (ul.children.length > 1)
ul.style.display = '';
}
/* rebuild mode menu + main menu + section tabs from the current L.env; on first load and after
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
function renderChrome() {
const root = tree.tree();
const modemenu = document.querySelector('#modemenu');
const topmenu = document.querySelector('#topmenu');
const tabmenu = document.querySelector('#tabmenu');
if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
if (topmenu) topmenu.innerHTML = '';
if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
renderModeMenu(root, _renderMain);
if (L.env.dispatchpath.length >= 3) {
let node = root, url = '';
/* `node.children &&`, exactly as fs-menutree's nodeForSegs() walks it: a node without
* children is an ordinary leaf, and reading `.children[…]` off one is a TypeError that
* escapes renderChrome() — i.e. it takes out the mode menu, the tabs and, on the init path,
* everything menu-footstrap-common wires after it. The walk already tests `node` on each
* step; testing only half of what it dereferences is what left the hole. */
for (let i = 0; i < 3 && node; i++) {
node = node.children && node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
}
if (node)
renderTabMenu(node, url);
}
fit.schedule();
}
/* Sidebar rail toggle: collapse the sidebar to an icon-only strip. The state lives on
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else —
* flyout submenus, hidden labels — is CSS keyed off that attribute. */
function wireRail() {
const btn = document.getElementById('fs-rail-toggle');
if (!btn) return;
function sync() {
const on = prefs.currentRail();
btn.setAttribute('aria-expanded', on ? 'false' : 'true');
const label = on ? _('Expand menu') : _('Collapse menu');
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
btn.addEventListener('click', () => {
prefs.applyRail(!prefs.currentRail());
sync();
/* the sidebar's cut just changed by ~156px, so the content column may now clear (or fall
* below) --fs-content-min: re-measure rather than wait for a resize that is not coming */
fit.schedule();
});
sync();
}
/* An indicator pill carries its whole meaning as prose — LuCI writes "Unsaved Changes: 2" — and the
* collapsed rail is 68px wide. Measured on the router: the pill wants 86px, wraps onto three lines
* and hangs 34px past the rail's edge over the content (issue #14). The rail is an icon strip, so
* CSS squares the pill there and draws this attribute instead of the label; a text node cannot be
* reached by a selector, which is why the badge has to be lifted into one here.
*
* The COUNT is what it shows — the only part that changes and the only part worth reading at that
* size. A pill with no trailing number (a third-party app's "Backup pending") falls back to a
* neutral dot: clipping the prose was tried and rendered "up pen", because a centred pill gives an
* ellipsis no start to anchor to. Choosing between the two is a decision, so it lives here rather
* than as a second CSS rule. The full prose stays in the label either way — a screen reader still
* reads it, and `title` keeps it reachable by pointer. */
const IND_DOT = '•';
function wireIndicatorCounts() {
const box = document.getElementById('indicators');
if (!box) return;
function stamp() {
box.querySelectorAll('[data-indicator]').forEach((el) => {
const txt = el.textContent || '';
const m = txt.match(/(\d+)\s*$/);
el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
el.setAttribute('title', txt);
});
}
/* ui.showIndicator REPLACES the label's text node on an update ("Unsaved Changes: 1" -> ": 2")
* and appends the span on the first change of the session, so both matter — and characterData
* too, for the in-place rewrite. Our own attribute writes do not re-enter: attributes are not
* observed. */
new MutationObserver(stamp).observe(box, { childList: true, subtree: true, characterData: true });
stamp();
}
return baseclass.extend({
setRenderMain,
renderChrome,
wireIndicatorCounts,
/* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
* measurement rides the same engine as the data tables' */
fitChrome,
wireRail
});
@@ -0,0 +1,153 @@
'use strict';
'require baseclass';
/* fs-fit — the theme's ONE "does it still fit?" engine; add fit logic here, do not grow a second
* observer. No CSS query can ask what the CONTENT needs (media = viewport, container =
* container): does the menu fit beside the brand, is a table still readable? Both were once
* breakpoints (one @media, five @container thresholds) — guessed numbers that real routers got
* wrong, useless for a third-party luci-app-* table of unknown column count.
*
* THREE RULES, each a bug that was hit:
* 1. MEASURE UNCOLLAPSED — a collapsed thing always "fits" (a stacked table is a pile of flex
* rows): read it as it stands and it un-collapses, next frame re-collapses. Oscillation.
* 2. RE-FIT SYNCHRONOUSLY ON A MUTATION — the poll re-renders content once a second and the
* fresh element has lost our class. A MutationObserver callback is a microtask (pre-paint),
* rAF runs AT paint: deferring there painted a stacked table one frame at full width —
* 19-109px of overflow, once a second, on Firewall/DHCP/Wireless.
* 3. COALESCE ON RESIZE — every fit forces a synchronous layout.
*
* ResizeObserver, not onresize: a rail collapse and a layout toggle change the content width
* without resizing the window. */
const _fitters = [];
let _rafPending = false;
let _ro = null, _mo = null;
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
* relevant mutation. */
function run() {
for (const fit of _fitters) {
try { fit(); }
/* one broken fitter must take neither the others nor the poll's MutationObserver
* callback with it — that would silently stop ALL re-fitting */
catch (e) { console.error('fs-fit: a fitter threw', e); }
}
}
/* Next frame, at most once per frame (rule 3). */
function schedule() {
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => { _rafPending = false; run(); });
}
/* Watch an element's size. Any change re-fits everything — the fitters are cheap and few. */
function watch(el) {
if (!el) return;
if (!_ro) {
if (!window.ResizeObserver) { /* no RO: fall back to the window */
window.addEventListener('resize', schedule);
return;
}
_ro = new ResizeObserver(schedule);
}
_ro.observe(el);
}
/* Rule 2's mutation side. Deliberately NOT filtered by node type: a filter is a second place to
* get wrong (the table fitter's own once said `table.table`, and LuCI renders most of its tables
* as DIVs — so the poll never re-measured at all), and run() is a handful of measurements. */
function observeContent() {
if (_mo) return;
const host = document.getElementById('view') || document.body;
_mo = new MutationObserver(run);
_mo.observe(host, { childList: true, subtree: true });
watch(host);
}
return baseclass.extend({
/* Register a fitter and run it once. A fitter selects its own elements, strips its class
* (rule 1), measures, re-applies. */
add(fit) {
if (typeof fit !== 'function') return;
_fitters.push(fit);
observeContent();
fit();
},
/* Re-fit on the next frame, coalesced. (There is no exported `run`: everything that changes
* the available room — the layout toggle, the rail collapse — schedules. Only the mutation
* observer re-fits synchronously, and that is rule 2's whole point.) */
schedule,
/* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs
* EVERY fitter, so a caller wanting only its own work batched cannot use it — three had
* hand-rolled the identical five lines. NOT for the per-element case: menu-footstrap.js's
* clamp keeps a rAF handle per <li> so it can CANCEL a pending measure, which a one-flag
* coalescer cannot express. */
frame(fn) {
let pending = false;
return () => {
if (pending) return;
pending = true;
requestAnimationFrame(() => { pending = false; fn(); });
};
},
/* Did this batch add anything matching `sel`? The poll rewrites content once a second, so a
* MutationObserver here needs that cheap question before its document-wide queries. */
touches(mutations, sel) {
for (const m of mutations)
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches(sel) || n.querySelector(sel)) return true;
}
return false;
},
/* Room for `el` = its PARENT's content box. Measuring against ITSELF does not work: a
* `display: table` box with width:100% still grows past it when min-content needs more (auto
* layout beats the declared width), so scrollWidth and clientWidth grow together and the
* overflow is invisible. The parent is an ordinary block and does not grow. */
roomFor(el) {
const p = el && el.parentElement;
if (!p) return Infinity;
const cs = getComputedStyle(p);
return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
},
/* Does `el` need more width than it has been given? */
overflows(el) {
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
},
/* How many LINE BOXES of text does `el` render? The fact behind "this column has been
* squeezed into a tower" — a cell that has to break its own words is a column that has run
* out of width, which is a thing no viewport query can ask.
*
* NOT height / line-height: the assoclist's first cell is an `.ifacebadge`, a flex COLUMN
* with a 32px icon over the text, so a third of that height is not text at all. Ranges over
* the TEXT NODES only, so an <img> cannot be counted as a line.
*
* Cluster by the rect's TOP, not by vertical overlap: consecutive lines OVERLAP. Measured on
* the router — tops 15-16px apart while each rect is 17-18px tall (the font's box is taller
* than the line advance), so an overlap test merged an 8-line tower into ONE line and the
* whole check silently never fired. Half a line of tolerance also merges what belongs on one
* line: a `<small>` shares the baseline but sits a few px lower, and is not a new line. */
textLines(el) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const range = document.createRange();
const rects = [];
for (let n; (n = walker.nextNode());) {
if (!n.nodeValue.trim()) continue;
range.selectNodeContents(n);
for (const r of range.getClientRects())
if (r.width > 0.5 && r.height > 0.5) rects.push(r);
}
rects.sort((a, b) => a.top - b.top);
let lines = 0, top = -Infinity;
for (const r of rects)
if (r.top - top > r.height * 0.5) { lines++; top = r.top; }
return lines;
}
});
@@ -0,0 +1,166 @@
'use strict';
'require baseclass';
/* The menu tree as a ROUTING TABLE: path <-> node, with LuCI's alias/firstchild resolution.
*
* Pure lookup — it renders nothing and touches no DOM. Both the chrome (which walks the tree to
* draw the mode menu and the section tabs) and the SPA router (which asks "what view does this URL
* open?") need it, and a module they both require is the only way to give them one copy without a
* cycle: LuCI's require() raises DependencyError on a dependency loop, so the shared half has to
* come OUT rather than be reached across. */
/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
let _tree = null;
function setTree(tree) {
_tree = tree;
}
/* /cgi-bin/luci/admin/status/overview -> ['admin','status','overview'].
* The bare base (what build_url() emits for the brand wordmark) yields an EMPTY seg list, NOT null:
* the dispatcher's root node is itself a `firstchild`, so resolveSegs([]) walks to the overview
* exactly as the server does — returning null made the wordmark un-routable and full-reload. null
* stays reserved for a path outside LuCI's scriptname. */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && pathname.indexOf(base) !== 0)
return null;
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
return rest.length ? rest.split('/') : [];
}
/* walk the (scrubbed, ACL-filtered) menu tree to the node for a path */
function nodeForSegs(segs) {
let node = _tree;
for (let i = 0; i < segs.length; i++) {
node = node && node.children && node.children[segs[i]];
if (!node) return null;
}
return node;
}
/* ---- alias / firstchild resolution ----
*
* 7 of the 27 menu links are redirects, not pages: 4 `alias` (Firewall, System Log, Realtime
* Graphs) and 3 `firstchild` (Administration, Terminal, Attended Sysupgrade) — i.e. the
* most-clicked entries were the ones still doing a full load.
*
* The server does not redirect them: a full GET of /admin/status/logs answers 200 at that URL and
* stamps the RESOLVED leaf into requestpath/dispatchpath/nodespec, keeping `pathinfo` as requested.
* The client must resolve EXACTLY as dispatcher.uc does, or a click and an F5 on the same URL would
* open different pages — nodeWeight() and firstChildOf() are ports, not approximations. Only the
* ACL check is skipped: the tree from /admin/menu is already ACL-filtered for this session.
*
* `rewrite` is deliberately NOT followed: the tree has none, and a wrong guess at its splice
* semantics would silently open the WRONG page — worse than the full load it falls back to. */
/* node_weight() from dispatcher.uc: lower wins; a login node sorts last. */
function nodeWeight(node) {
return Math.min(node.order ?? 9999, 9999) + (node.auth && node.auth.login ? 10000 : 0);
}
/* resolve_firstchild() from dispatcher.uc: the eligible child of lowest weight. Ties go to tree
* order (the comparison is strict, as upstream's is, and JSON.parse preserves key order). A
* `firstchild` child is eligible only if it resolves to something itself — recursively. */
function firstChildOf(node) {
let bestName = null, best = null;
const kids = node.children || {};
for (const name in kids) {
const child = kids[name];
if (!child.satisfied || !child.title || !child.action || typeof child.action !== 'object')
continue;
if (child.action.type === 'firstchild') {
if ((!best || nodeWeight(best) > nodeWeight(child)) && firstChildOf(child)) {
best = child; bestName = name;
}
} else if (!child.firstchild_ineligible) {
if (!best || nodeWeight(best) > nodeWeight(child)) {
best = child; bestName = name;
}
}
}
return best ? { name: bestName, node: best } : null;
}
/* Follow alias/firstchild to the real page: {segs, node} of the leaf the dispatcher would have
* rendered, or null when nothing resolves (the server would 404 — let it). The hop cap is a cycle
* guard: an alias loop in some app's menu.d must not hang the UI. */
function resolveSegs(segs) {
let node = nodeForSegs(segs);
for (let hops = 0; node && node.action && hops < 8; hops++) {
const type = node.action.type;
if (type === 'alias') {
segs = String(node.action.path).split('/');
node = nodeForSegs(segs);
} else if (type === 'firstchild') {
const pick = firstChildOf(node);
if (!pick) return null;
segs = segs.concat([ pick.name ]);
node = pick.node;
} else {
return { segs, node };
}
}
return null;
}
/* ---- readonly is a property of the PATH, not of the leaf ----
*
* The dispatcher decides it twice over, from two different inputs. apply_tree_acls()
* (dispatcher.uc:442) walks the menu JSON handed to the client and marks a NODE readonly when its
* OWN `depends.acl` resolves to read-without-write. The request path instead accumulates every
* ancestor's acls into ctx.acls and stamps the leaf from the accumulation
* (`resolved.node.readonly = !perm`, :1003). So a leaf that declares no acl of its own still comes
* back readonly from a real GET, while its node in the tree carries nothing at all.
*
* Reading the leaf alone therefore loses it, and did: measured on the stand, a full load of
* admin/status/logs/syslog reports nodespec.readonly true — the flag sits on `logs`, two levels up —
* against false on an SPA arrival, and the same for dmesg and all four realtime graphs. luci.js
* implements hasViewPermission() as `!env.nodespec.readonly`, which is what views and luci.js's own
* Save/Apply footer key their disabled state off, so on a session with narrower ACLs than root's
* this is any page whose SECTION is read-only.
*
* OR down the path, exactly as check_acl_depends() folds the accumulated list: one read-only acl
* anywhere on it is enough. Feed it the RESOLVED segments, since that is the path the dispatcher
* accumulates over. */
function readonlyForSegs(segs) {
let node = _tree;
if (node && node.readonly === true)
return true;
for (let i = 0; i < segs.length; i++) {
node = node && node.children && node.children[segs[i]];
if (!node)
return false;
if (node.readonly === true)
return true;
}
return false;
}
/* The view class a menu node instantiates, or null if the node isn't SPA-able. The Status→Overview
* `template` node maps to view.status.index (its server template just instantiates that — see
* ensureOverviewHelpers in fs-router.js). Shared by navigate() and the hover prefetch. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
if (node.action.type === 'view')
return 'view.' + String(node.action.path).replace(/\//g, '.');
if (node.action.type === 'template' && node.action.path === 'admin_status/index')
return 'view.status.index';
return null;
}
/* The node the CURRENT full-load landed on, i.e. what L.env.dispatchpath points at. */
function currentNode() {
return nodeForSegs(L.env.dispatchpath || []);
}
return baseclass.extend({
setTree,
tree: () => _tree,
segsFromPath,
currentNode,
resolveSegs,
readonlyForSegs,
viewClassFor
});
@@ -0,0 +1,334 @@
'use strict';
'require baseclass';
'require dom';
'require network';
'require fs-fit as fit';
/* Footstrap overview LAYOUT-only module: renders NOTHING of its own, only re-arranges the STOCK
* sections — wrapping System / Memory / Storage in a grid so Memory and Storage sit in a right
* column beside System. Content, data and styling stay luci-mod-status's. (Do not go back to the
* old 05_footstrap_dashboard.js: re-rendering a custom tree every poll flickered and reset mobile
* scroll.) The stock poll updates each section IN PLACE via dom.content() and never rebuilds the
* .cbi-section wrapper, so once moved into our grid the wrappers stay put across polls.
*
* IT USED TO LIVE IN LuCI'S GLOBAL INCLUDE DIR (view/status/include/05_footstrap_overview_layout.js)
* and that was a real defect, not a filing preference: luci-mod-status loads EVERY *.js in that
* directory, so this file was fetched, parsed and evaluated on the overview of every router running
* a DIFFERENT theme. Measured with a headless browser against the dev container with `bootstrap`
* active: the request is right there beside 10_system.js and 20_memory.js. The `L.env.media` gate
* silenced it, but only after it had already been downloaded and run — a theme package reaching
* into another module's namespace, which is exactly what this project refuses to do to third-party
* apps (docs/conventions.md, the three zones). It is a chrome module now, loaded by the chrome, so a router on
* another theme never sees it at all.
*
* WHAT THAT COST, and why the code below looks the way it does: the old location bought two timing
* guarantees for free, because LuCI evaluated the file INSIDE index.load(). Both had to be paid for
* explicitly — see ensureOverviewHelpers() and patchOverview() at the bottom. */
function isFootstrapTheme() {
return String(L.env.media || '').indexOf('footstrap') >= 0;
}
/* section title -> grid role. _() with NO msgctxt on purpose: these must resolve to exactly what
* luci-mod-status resolves to, or the titles stop matching. Built once — it used to cost an
* allocation plus three _() lookups per poll tick. */
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
function sectionTitle(sec) {
/* TWO title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10
* emits a bare `<h3>` as the section's first child. Matching only the wrapped one meant the
* grid never applied on 24.10 at all — measured on the dev container: every section tagged
* `none`, `.fs-ovl` never built, silently. */
const h = sec.querySelector('.cbi-title h3, :scope > h3');
return (h && h.firstChild) ? String(h.firstChild.nodeValue || '').trim() : '';
}
/* the wrapper we built, so the poll-tick fast path costs one property read */
let _wrapEl = null;
function arrange() {
/* the SPA nav can leave this _observer wired while another page renders into #view — detach as
* soon as the route stops being the overview. Both the server template and the SPA router
* stamp body[data-page] with the DISPATCH path, so /admin/status (firstchild -> overview)
* matches too. */
if ((document.body.getAttribute('data-page') || '') !== 'admin-status-overview') {
stopWatch();
return;
}
const view = document.getElementById('view');
if (!view) return;
/* Fast path — the poll lands here once a second, forever. The stock poll never rebuilds the
* .cbi-section wrappers, so the grid survives and there is nothing to do; proving that used
* to cost a querySelectorAll over #view plus a sectionTitle() dig per section, every tick.
* Deliberately NOT a disconnect(): if a future luci-mod-status ever DOES rebuild a section,
* the wrapper loses its children and the slow path below rebuilds the grid — self-healing. */
if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
return;
const found = {};
view.querySelectorAll(':scope > .cbi-section').forEach((sec) => {
const r = ROLES[sectionTitle(sec)];
if (r && !found[r]) found[r] = sec;
});
/* wait until all three stock sections exist */
if (!(found.sys && found.mem && found.sto)) return;
/* already wrapped? (first tick after a rebuild re-finds the existing grid) */
if (found.sys.parentElement && found.sys.parentElement.classList.contains('fs-ovl')) {
_wrapEl = found.sys.parentElement;
return;
}
const wrap = document.createElement('div');
wrap.className = 'fs-ovl';
found.sys.parentNode.insertBefore(wrap, found.sys);
found.sys.classList.add('fs-ovl-sys'); wrap.appendChild(found.sys);
found.mem.classList.add('fs-ovl-mem'); wrap.appendChild(found.mem);
found.sto.classList.add('fs-ovl-sto'); wrap.appendChild(found.sto);
_wrapEl = wrap;
}
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange()
* (coalesced, ONE _observer per #view node — a per-poll _observer leak would slow the page down).
* The SPA router may REPLACE the #view element between visits, so re-attach when the node we
* observed is no longer the current one: a singleton bound to the first #view would silently
* watch a detached tree and the grid would never apply on a later SPA visit. */
let _observer = null, _observedView = null, _routeObserver = null;
function stopWatch() {
if (_observer) _observer.disconnect();
_observer = null;
_observedView = null;
_wrapEl = null; /* the grid belongs to the #view we are leaving */
}
function watch() {
const view = document.getElementById('view');
if (_observer && _observedView !== view)
stopWatch();
arrange();
/* The route check is what the stock include's render() used to provide implicitly — it only ran
* when luci-mod-status rendered THIS page. A chrome module is alive on every page, so without
* this an observer would be attached to #view on, say, the firewall page and re-run arrange()
* for every mutation of a table it has no business watching. */
if (_observer || !view ||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
return;
_observedView = view;
/* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
* theme's shared coalescer, fs-fit.js) */
_observer = new MutationObserver(fit.frame(arrange));
_observer.observe(view, { childList: true, subtree: true });
}
/* WHAT REPLACED render(). The include was instantiated by luci-mod-status once per overview render,
* which is how it knew a visit had happened; a chrome module is instantiated once per PAGE LOAD and
* then has to notice SPA navigation itself. `body[data-page]` is the signal — both the server
* template and fs-router stamp it with the dispatch path — so one attribute observer covers arriving
* at the overview, leaving it, and coming back.
*
* The empty `.cbi-section` wrapper stock used to build around this include is gone with it, and so
* is the `.fs-ovl-marker` element that existed only to let CSS hide that wrapper. */
function wire() {
if (_routeObserver || !isFootstrapTheme() || !document.body)
return;
_routeObserver = new MutationObserver(() => {
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
onOverview();
else
stopWatch();
});
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
onOverview();
}
/* Everything that must happen on ARRIVAL at the overview, from either direction: a full page load
* that lands here, or an SPA navigation that restamps data-page. patchOverview() is idempotent
* (the __fsProgressive flag), so the two paths cannot double-patch. */
function onOverview() {
patchOverview();
watch();
}
/* ---- progressive paint -----------------------------------------------------
*
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over EVERY
* include's load(), and render() does not return the tree until it resolves — so #view stays
* EMPTY for as long as the slowest include takes. Measured on the dev router (warm SPA nav):
* 182 ms of blank page, of which System/CPU/Memory/Storage/DHCP/Network were ready at 88 ms and
* were simply waiting on 29_ports and 60_wifi (180 ms each).
*
* Replacing poll_status does two things:
* 1. Each section paints when ITS OWN data lands: first content halves, 182 -> ~90 ms. Nothing
* jumps — the frames are already in the DOM (built before poll_status is called), a section
* just goes hidden -> filled, exactly as on a stock poll tick.
* 2. Kills the redundant re-fetch: stock adds the poller only after the first load completes
* and Poll.add() steps at once, so the overview re-fetched EVERYTHING (~250 ms of ubus)
* right after the first paint. The in-flight guard joins that to the run already going.
*
* NOT a re-implementation — frames, toggles, includes and their render() stay upstream's.
* fillSection() transcribes stock's own loop in the same order so it can be diffed against
* index.js when luci-mod-status changes; if that shape is gone, the patch is skipped and the
* page runs stock. */
function fillSection(inc, container, res) {
if (inc.failed)
return;
let content = null;
if (typeof inc.render === 'function')
content = inc.render(res);
else if (inc.content != null)
content = inc.content;
if (typeof inc.oneshot === 'function') {
inc.oneshot(res);
inc.oneshot = null;
}
if (content != null) {
container.parentNode.style.display = '';
container.parentNode.classList.add('fade-in');
if (!inc.hide)
dom.content(container, content);
}
}
let _inflight = null;
function pollProgressive(includes, containers, first_load) {
/* A run is already fetching exactly this data — join it instead of starting a second
* stampede of the same RPCs. This is what kills the duplicate load. */
if (_inflight)
return first_load ? Promise.resolve() : _inflight;
const run = network.flushCache().then(() => Promise.all(
includes.map((inc, i) => {
if (inc.hide && !first_load)
return null;
const loaded = (typeof inc.load === 'function')
? Promise.resolve(inc.load()).catch(() => { inc.failed = true; })
: Promise.resolve(null);
/* the point of the patch: fill THIS section the moment ITS data is here,
* not at the end of a Promise.all over all of them */
return loaded.then((res) => {
try { fillSection(inc, containers[i], res); }
catch (e) { console.error('footstrap: overview section failed', e); }
});
}).filter(Boolean)
)).then(() => {
const ssi = document.querySelector('div.includes');
if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
});
_inflight = run.finally(() => { _inflight = null; });
/* NOBODY AWAITS THIS ON THE FIRST LOAD — the line below hands the caller a fresh
* Promise.resolve() so index.render() can return at once — so a rejection here has no handler
* and surfaces as an unhandled rejection in the console. `run` rejects for one ordinary reason:
* network.flushCache() failing on an expired session, i.e. exactly when the user is already
* being redirected to the login page and the noise is least useful. The sections' own failures
* cannot reach it (fillSection is called inside a try/catch and inc.load() has its own .catch),
* so there is nothing to report that the page has not reported already. */
_inflight.catch(() => {});
/* First load: resolve NOW so index.render() returns its tree and the frames reach #view
* immediately; the sections fill themselves. A poll tick resolves when the data is in —
* that is what the poller expects. */
return first_load ? Promise.resolve() : _inflight;
}
/* Patch the stock overview view: replace poll_status so each section paints when its own data lands.
*
* TIMING, AND WHAT MOVING THE FILE COST. As an include this ran at module eval — which LuCI performs
* inside index.load(), i.e. after the view instance exists and before render() calls poll_status:
* the exact window the patch needs, for free, on both a full load and an SPA nav.
*
* A chrome module evaluates much earlier, so that window has to be aimed at rather than inherited,
* and it is called from the ROUTE (see wire()) instead of at eval. Two reasons it is not called at
* module eval any more: `L.require('view.status.index')` would pull the whole stock overview view
* into memory on every page, including pages that are not the overview; and on a full page load the
* require would race index.load() — which is why the patch is idempotent, guarded by the
* `__fsProgressive` flag on the prototype, and why failing to land is harmless. If it misses, the
* page simply renders the stock way: one Promise.all, ~90 ms later. Never broken, sometimes slower.
*/
function patchOverview() {
/* `window.L`, NEVER the bare `L` this factory was handed — and the reason is NOT this module's
* own convenience, it is what the STOCK view ends up holding. `require()` passes the object it
* was called on into the loaded module's factory (`const L = this` in luci.js), and
* `view/status/index.js` then loads its own includes with that same `L` inside `load()`
* (`L.require('view.status.include.' + …)`), so whichever `L` reaches index.js reaches
* 30_network.js too — which calls `L.itemlist(...)` directly. `ui` hangs itemlist/showModal/…
* on the RUNTIME INSTANCE that the dispatcher builds (`window.L = new LuCI()`), and a chrome
* module like this one is loaded as a dependency, i.e. through `LuCI.prototype.require`, so our
* `L` is the PROTOTYPE and has none of them.
*
* `require()` caches by class name, so the FIRST caller decides this for everybody: patching
* through the bare `L` cached view.status.index bound to the prototype, and the overview then
* died mid-render on "L.itemlist is not a function" with the page stuck on "Loading view…"
* (issue #22 follow-up). It only bit on an SPA arrival — a full page load has the dispatcher
* require the view through `window.L` before this observer ever fires — and only when this
* patch won the race against fs-router's own `RT.require`, which is exactly the "sometimes,
* coming from another page" the report described. Same trap as the itemlist calls below
* (docs/spa-router.md), reached from the other side. */
window.L.require('view.status.index').then((idx) => {
const proto = idx ? Object.getPrototypeOf(idx) : null;
if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
return;
proto.__fsProgressive = true;
proto.poll_status = function(includes, containers, first_load) {
return pollProgressive(includes, containers, first_load);
};
}).catch((e) => console.error('footstrap: overview progressive paint not applied', e));
}
/* Status→Overview is a `template` node whose server template (admin_status/index.ut) defines 3
* globals the stock status includes use (18_cpu/20_memory/25_storage/…) and then instantiates
* view.status.index. Arriving by the theme's SPA router never runs that inline <script>, so define
* them here, guarded, so a full load's copies (any theme) are not clobbered.
*
* This is the OTHER thing the include location used to give for free, and the one that survived the
* move intact: the definitions had to exist before any include renders, which module eval inside
* index.load() guaranteed. A chrome module evaluates once, at chrome init — i.e. before any SPA
* navigation can possibly happen — so the guarantee is now stronger rather than weaker. On a full
* load of the overview the template's own copies win the race and these are no-ops, as before.
*
* Bodies are verbatim from upstream except L.itemlist → window.L.itemlist (the two-L trap,
* docs/spa-router.md). */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copied VERBATIM from LuCI's
admin_status/index.ut so they can be diffed against upstream when it changes.
Modernising the `var`s would silently break that property, which is the whole
reason the copies are safe to carry. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn);
if (pg) {
pg.firstElementChild.style.width = pc + '%';
pg.setAttribute('title', '%s / %s (%d%%)'.format(fv, fm, pc));
}
};
if (typeof window.renderBox !== 'function')
window.renderBox = function(title, active, childs) {
childs = childs || [];
childs.unshift(window.L.itemlist(E('span'), [].slice.call(arguments, 3)));
return E('div', { class: 'ifacebox' }, [
E('div', { class: 'ifacebox-head center ' + (active ? 'active' : '') },
E('strong', title)),
E('div', { class: 'ifacebox-body left' }, childs)
]);
};
if (typeof window.renderBadge !== 'function')
window.renderBadge = function(icon, title) {
return E('span', { class: 'ifacebadge' }, [
E('img', { src: icon, title: title || '' }),
window.L.itemlist(E('span'), [].slice.call(arguments, 2))
]);
};
/* eslint-enable no-var */
}
/* Unconditional: the typeof guards make it a no-op wherever the real definitions already exist. */
ensureOverviewHelpers();
return baseclass.extend({
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the
* data-page observer inside. */
wire,
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,836 @@
'use strict';
'require baseclass';
'require ui';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-sheets as sheets';
/* ---- SPA client router ----
*
* Kills the full page reload for `view`-type menu nodes — 54 of 74 menu leaves (~73%) on the dev
* router; the rest are call/function/template. LuCI already renders every page client-side into
* #view; only NAVIGATION is server-dispatched. So intercept link clicks and re-instantiate the
* target view in place — what the dispatcher's view.ut does via ui.instantiateView(), minus the
* reload. Purely additive: anything that is not a satisfied `view` node (call/function/template/
* alias/firstchild, external, download, cross-origin, modified click) or any error falls through to
* a normal navigation, and deep links / F5 keep working because we pushState the real URL.
*
* Re-instantiation: L.require('view.x') returns a cached SINGLETON whose __init__ (the render)
* already ran, so calling it again repaints nothing. Take the class off the instance
* (prototype.constructor) and `new v.constructor()` for a fresh __init__ → load()+render(), which is
* what a full load does anyway. docs/spa-router.md.
*
* The path->node half lives in fs-menutree.js (the chrome needs it too); the "has a view poisoned
* this document with its CSS?" half in fs-sheets.js. */
/* --- stray-interval teardown for SPA nav ---
* A full load kills every window.setInterval the outgoing page set; SPA nav does not, so a view's
* poller keeps firing against a page that is gone (luci-app-podkop's log tailer runs
* `podkop check_logs` forever after you navigate away). Track view-set ids and clear them on nav,
* keeping L.Poll's own 1s tick (also a setInterval); L.Poll's queue is flushed in navigate().
* Hooked at module eval — before any view render can set a timer, and LuCI resolves a module's
* dependencies BEFORE running the dependent's factory, so this runs no later than it used to when
* it sat in menu-footstrap-common.js itself. */
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Set()));
(function hookIntervals() {
if (window.__fsIntervalsHooked) return;
window.__fsIntervalsHooked = true;
const _si = window.setInterval, _ci = window.clearInterval;
window.setInterval = function () {
const id = _si.apply(window, arguments);
_viewIntervals.add(id);
return id;
};
window.clearInterval = function (id) {
_viewIntervals.delete(id);
return _ci.apply(window, arguments);
};
})();
function clearViewIntervals() {
const keep = (L.Poll && L.Poll.timer) || null;
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
}
let _wired = false;
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
* navigation from a mere fragment change (see there). Seeded from the served page. */
let _curPath = window.location.pathname;
/* nav generation token: two quick clicks race their async require()s, and without it the FIRST
* view could render into #view after the second, leaving stale content under the newer
* URL/title/chrome. A resolved require whose generation is stale renders nothing. */
let _navGen = 0;
/* ---- Back must restore the scroll of WHICHEVER element is the scroller ----
* The two layouts scroll different elements: the sidebar layout pins .fs-shell to 100dvh and gives
* overflow-y to .fs-main (#maincontent), the top layout lets the document scroll. A browser restores
* an INNER scrollable region only across full loads, never on a same-document traversal — measured
* (docs/spa-router.md §2): Back opened the incoming page at 0, because the swap empties #view,
* scrollHeight collapses and the browser clamps scrollTop.
*
* The DOCUMENT scroller was left to the browser's own scrollRestoration ('auto') on the grounds that
* it is the case a UA does handle — and that is wrong for the same reason, measured on the stand: in
* the top layout, scroll Processes to 400, open System, press Back, and the page opens at 0 (the
* sidebar layout, restored here, comes back at 400). The UA restores at the traversal, i.e. BEFORE
* this handler swaps #view; the height then collapses under the restored offset and the clamp takes
* it back to 0, with nothing left to re-apply it. So record and replay BOTH offsets; the one that is
* not this layout's scroller is 0 and skipped.
*
* NOT by replaceState on scroll: Safari rate-limits history writes (100 per 30 s) and a scroll
* listener trips it. Each SPA entry instead carries a session-unique id (fsid) in history.state, and
* the offsets live in this in-memory Map — lost on a full load, which is exactly when the browser's
* own scrollable-region restoration takes over. The id is session-prefixed because a bare counter
* restarts with every document: an entry stamped by a PREVIOUS document of this tab would collide
* with a fresh one and restore another page's offset. */
const _scrollMem = new Map();
const _scrollSess = Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
let _histN = 0;
let _curId = null;
/* …and it is BOUNDED, because this document outlives every page in it. One entry per history entry
* per session, never evicted, is the same shape as the leaks this file already fixes elsewhere (the
* action-column resize listener, the view pollers) — small enough that nobody would see it and
* unbounded all the same. A browser keeps ~50 entries per tab and the ones past that cannot be
* traversed back to, so remembering more offsets than that can never be read. Least-recently-SAVED
* goes first: a Map iterates in insertion order and `set` on an existing key does not refresh it,
* so the re-save below is delete-then-set. */
const SCROLL_MEM_MAX = 50;
function newEntryId() { return _scrollSess + ':' + (++_histN); }
/* adopt the entry we are standing on: reuse its fsid if it has one, stamp one otherwise (entries
* created by a full load carry state === null). replaceState keeps the entry's own scroll record. */
function adoptEntry() {
const st = history.state;
if (st && st.fsid) { _curId = st.fsid; return; }
_curId = newEntryId();
try { history.replaceState(Object.assign({}, st, { fsid: _curId }), '', window.location.href); } catch (e) {}
}
/* the outgoing DOM is still on screen at both call sites (the click, the popstate), so this must
* run BEFORE _curId moves on to the incoming entry */
function saveScroll() {
if (!_curId) return;
const sc = document.getElementById('maincontent');
_scrollMem.delete(_curId); /* re-insert, so this entry counts as the most recently saved */
_scrollMem.set(_curId, { win: Math.round(window.scrollY) || 0, main: sc ? sc.scrollTop : 0 });
while (_scrollMem.size > SCROLL_MEM_MAX)
_scrollMem.delete(_scrollMem.keys().next().value);
}
/* Put the scrollers back where the entry left them — but only once the incoming view has grown that
* much height (docs/spa-router.md §5: restoring before the content exists is clamped to 0 and reads as
* "worked"). The view renders behind an RPC, so poll by frame; a newer navigation cancels via the
* generation, and a page that never reaches the old height again is simply left at the top. Each
* offset is waited for on its OWN scroller, so a layout switched between the two entries restores
* whichever half it can rather than blocking on the half that no longer scrolls. */
function restoreScroll(pos, gen) {
if (!pos || (!pos.win && !pos.main)) return;
let tries = 300; /* ~5 s at 60 fps — outlasts a slow RPC without polling forever */
(function tick() {
if (gen !== _navGen || --tries < 0) return;
const de = document.documentElement;
const sc = document.getElementById('maincontent');
let pending = false;
if (pos.main) {
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) sc.scrollTop = pos.main;
else pending = true;
}
if (pos.win) {
if (de.scrollHeight - de.clientHeight >= pos.win) window.scrollTo(0, pos.win);
else pending = true;
}
if (pending) requestAnimationFrame(tick);
})();
}
/* ---- the host half of "<host> | <page>" is read ONCE ----
* head.ut stamps it and it cannot change within a document, but navigate() used to re-derive it from
* the LIVE document.title on every hop — so any page that renames the tab became the host for every
* page after it, until a full load. Third-party views do rename it (log viewers, dashboards), and it
* takes only one: measured on the stand, a view setting `document.title = 'ACME Dashboard'` left the
* next hops reading "ACME Dashboard | Routing", "ACME Dashboard | System". Captured at seed(), i.e.
* at chrome init on the full load that started the session; the lazy branch is for a document whose
* chrome came up without seed() having run. */
let _titleHost = null;
function titleHost() {
if (_titleHost === null)
_titleHost = (document.title.split('|')[0] || '').trim();
return _titleHost;
}
/* The view class the page CURRENTLY on screen wants (what _curPath resolves to). Read by the
* stale-render repair below to tell "the superseded render happened to paint the right view
* anyway" from "it painted the wrong one". */
function currentViewClass() {
const segs = tree.segsFromPath(_curPath);
const res = segs && tree.resolveSegs(segs);
return tree.viewClassFor(res && res.node);
}
/* ---- a superseded FIRST render cannot be cancelled, so undo it ----
*
* _navGen stops a stale require() from calling `new view.constructor()` — but only on the CACHED
* path. On a FIRST visit the require() IS the render (see navigate()): it constructs the view, whose
* __init__ runs load() → render() → dom.content(#view) and registers its pollers, inside a promise
* we do not own. Nothing to cancel.
*
* So the fast double-click is a real bug: click Firewall (uncached), click Wireless 100 ms later.
* navigate(Wireless) flushes L.Poll's queue BEFORE Firewall's poller is added; Firewall then paints
* into the #view that now belongs to Wireless and registers a poller the flush can no longer catch —
* leaving Wireless's URL/title/menu/data-page, Firewall's content, and Firewall's poller running on
* every page afterwards.
*
* Repair by re-running the current navigation: navigate() is exactly the "put the document back the
* way a fresh load leaves it" routine. push=false — the URL never moved, only the DOM under it; if
* it declines (the superseded view injected CSS), the reload does it the hard way. The className
* check terminates this: if the superseded render painted the class the current path wants anyway
* (A → B → A while A was still loading), the DOM and its poller are correct — and with two uncached
* views racing it is also what stops a repair triggering a repair. */
function repairStaleRender(className) {
if (className === currentViewClass())
return;
console.warn('footstrap: a superseded view (' + className + ') rendered into the live page; re-rendering ' + _curPath);
if (!navigate(_curPath, false))
window.location.reload();
}
/* ---- the generation must be checked at the PAINT, not at the dispatch ----
*
* _navGen is checked once, in the require() callback, i.e. BEFORE `new view.constructor()`. But a
* view's __init__ is async — `ready.then(this.load).then(this.render).then(nodes =>
* dom.content(document.getElementById('view'), nodes))` — so the DOM write happens two awaits later,
* and every await is a point at which the whole event loop runs, another navigation included. The
* dispatch-time check has expired by the time it matters.
*
* Measured on the router: leave the (cached) package manager for System after 150 ms, and the paints
* into #view land 16010 System, 16490 package-manager — the view we walked away from paints LAST and
* wins, permanently. URL, <title>, data-page and the menu highlight all say System; #view shows
* Software. Only a reload clears it. The cached path is the COMMON one — after warm-up every
* navigation takes it — and it had no guard at all: repairStaleRender() is called only when !cached.
*
* The fix has to be a paint-time check, and there are two constraints on where it can live.
* ClassConstructor DISCARDS __init__'s return value (`this.__init__.apply(this, arguments)`, no
* return), so the construction promise is unreachable — nothing to await. But __init__ resolves
* `this.render` while it builds its chain, i.e. during `new`, so a wrapper installed on the
* prototype BEFORE the construct is the one that gets bound. `new` then returns synchronously (the
* first load() is already a microtask), which is what makes stamping the generation on the INSTANCE
* right after it safe — and it must be the instance, not a map keyed by class name, or A → B → A
* with everything cached would have the second construct's generation overwrite the first's.
*
* A superseded render resolves to a promise that never settles: the chain simply stops before
* dom.content(), so nothing paints and addFooter() never runs. Returning empty nodes instead would
* paint the emptiness over the live page, and throwing would hand LuCI's own .catch an error box to
* render into the page we just opened.
*
* A view instance we did not construct (the singleton require() builds on a FIRST visit — the render
* IS the require, so there is no window in which to arm anything) carries no stamp and is left alone:
* that path is repairStaleRender()'s, and stays its. */
const _guarded = new WeakSet();
function armRenderGuard(cls) {
if (_guarded.has(cls)) return;
_guarded.add(cls);
const orig = cls.prototype.render;
cls.prototype.render = function () {
/* unstamped: not ours to judge (see above) */
const stale = () => this.__fsGen !== undefined && this.__fsGen !== _navGen;
if (stale())
return new Promise(() => {});
return Promise.resolve(orig.apply(this, arguments)).then((nodes) => {
/* re-check: render() itself awaits (an RPC, usually), which re-opens the window */
return stale() ? new Promise(() => {}) : nodes;
});
};
}
/* The exact URL LuCI.require() will fetch for a class name, cache-bust and all. Matching it
* byte-for-byte is what makes a hover prefetch a warm cache hit for the later require(). */
function moduleUrl(className) {
const v = L.env.resource_version ? ('?v=' + L.env.resource_version) : '';
return (L.env.base_url || '') + '/' + className.replace(/\./g, '/') + '.js' + v;
}
/* ---- link prefetch: warm the module cache for a page the user is about to open ----
*
* A plain fetch(), NOT require(): require() instantiates, and a view's __init__ IS its render, so it
* would paint another page into #view. fetch() only fills the browser's HTTP cache, which the later
* require()'s XHR then hits. Deduped per class; failures are silent (a pure optimisation).
*
* TRANSITIVE, and that is where most of the win is: warming the view class alone leaves its own
* `require` pragmas one round-trip behind it. view/network/routes.js pulls tools/network.js (40.5 KB),
* and measured on the dev router at 120 ms RTT a first visit cost 418 ms with the view warmed against
* 296 ms with its deps warmed too — one RTT exactly. Over six pages, 1713 ms cold → 1184 warmed →
* 1052 warmed transitively. The bytes are in hand either way, so the scan is free.
*
* The scan MUST NOT be line-anchored. The shipped files are MINIFIED and every pragma sits on one
* line (`'use strict';'require view';'require fs';…`), so /^'require …'$/m matches nothing at all —
* silently, which is how the first attempt at this measured a win of zero. luci.js lexes the leading
* string literals; this reads the same head of the file with one regex. */
const PRAGMA_HEAD = 2000; /* bytes of leading literals to scan — luci.js stops at the first non-string token */
const PREFETCH_DEPTH = 3;
function pragmaDeps(src) {
const re = /(['"])require[ \t]+([^'"]+?)\1/g;
const head = src.slice(0, PRAGMA_HEAD);
const out = [];
let m;
while ((m = re.exec(head)))
out.push(m[2].split(/[ \t]+as[ \t]+/)[0]);
return out;
}
/* SIX CLASS NAMES HAVE NO FILE, and fetching one is a guaranteed 404 in the user's console. luci.js
* seeds its class registry with them at load — `const classes = { baseclass: Class, dom: DOM,
* poll: Poll, request: Request, session: Session, view: View }` — so require() answers them from
* memory and never asks the network, while `ls /www/luci-static/resources` has no baseclass.js,
* dom.js, poll.js, request.js or session.js at all. Every view file's pragmas name `view` and
* `baseclass`, so a dependency walk hits this on its very first step: measured, warming the recents
* at idle put 404s for view.js and poll.js in the console of every page load, and the first hover
* added baseclass.js. A require() that 404s is noise this theme refuses to make, and the same
* standard applies here.
*
* A future LuCI adding a seventh built-in would cost one 404 per session — `_prefetched` makes it
* at most one — and the list is a literal from luci.js, not a guess about it. */
const BUILTIN_CLASSES = new Set([ 'baseclass', 'dom', 'poll', 'request', 'session', 'view' ]);
/* Is this class already instantiated? require() attaches its singleton to the LuCI prototype
* (`ptr[parts[idx]] = instance`), so a SINGLE-segment name reads back as L[name] — which covers the
* libs a loaded page has already pulled in (ui, form, uci, rpc, fs, validation, and network/firewall
* once some network page has been open). Skipping them spends no request on a cache hit nobody needs.
* `instanceof L.Class` rather than a truthiness test: L.env, L.url and L.get are members too, and a
* dep whose name collided with one of those would otherwise be skipped without ever being loaded —
* which is why this cannot be the guard for the six above either: they are seeded as CONSTRUCTORS
* (and under other names — L.Poll, L.Request, L.Class), so no `instanceof` probe sees them. A DOTTED
* name (tools.network) is not attached unless its parent already exists, so it is simply fetched —
* and those are the ones the win comes from. */
function classLoaded(name) {
if (BUILTIN_CLASSES.has(name)) return true;
try { return name.indexOf('.') < 0 && window.L[name] instanceof window.L.Class; }
catch (e) { return false; }
}
/* view classes already required, i.e. the ones LuCI has an instance cached for. A class NOT in
* here is rendered by the require() itself (see navigate). */
const _seen = new Set();
const _prefetched = new Set();
/* className -> the promise of ITS OWN body being in the HTTP cache; navigate() waits on that.
* Deliberately the body and not the subtree, see _committed below. */
const _warming = new Map();
/* Roots a navigation has taken over. Speculation below them STOPS: require() is now fetching the same
* graph and pipelines its parse and eval against those fetches, so descending would only race it —
* measured at 120 ms RTT, waiting for the whole subtree instead cost 658 ms against 525 ms racing,
* for the sake of a duplicate that stopping avoids outright. Deps have not been asked for yet when a
* click arrives (they start only once the root body lands), so there is nothing in flight to collide
* with: this leaves zero duplicated bytes AND require()'s pipelining. */
const _committed = new Set();
function warmClass(name, depth, root) {
if (_prefetched.has(name)) return;
_prefetched.add(name);
if (classLoaded(name)) return;
let req;
try { req = fetch(moduleUrl(name), { credentials: 'same-origin' }); }
catch (e) { return; }
const body = req.then((res) => (res.ok ? res.text() : '')).catch(() => '');
_warming.set(name, body.then(() => {}, () => {}));
/* The visited set is global and the depth capped, so the walk terminates regardless of what the
* pragmas say — require() raises DependencyError on a cycle, but only for classes it actually
* loads, and this walks files it may never hand to require() at all. */
if (depth < PREFETCH_DEPTH)
body.then((src) => {
if (_committed.has(root)) return;
for (const d of pragmaDeps(src)) warmClass(d, depth + 1, root);
});
}
/* Warm the view a menu path resolves to, plus its dependency tree. `segs` is the menu path
* (`admin/network/routes`), the shape fs-search stores its recents in. */
function prefetchSegs(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const res = tree.resolveSegs(segs);
const className = tree.viewClassFor(res && res.node);
if (className) warmClass(className, 0, className);
}
function prefetchView(pathname) {
const segs = tree.segsFromPath(pathname);
if (segs) prefetchSegs(segs);
}
/* Wait for an in-flight prefetch of `className` instead of racing it — see the call site. Capped,
* because a wedged prefetch must never wedge a navigation: on a stalled connection require()'s own
* XHR and its error path are the better place to end up. */
const WARM_WAIT_MS = 5000;
function warmedThen(className) {
_committed.add(className);
const body = _warming.get(className);
if (!body) return Promise.resolve();
/* the loser of the race is cancelled: a prefetch that lands in 40 ms otherwise leaves a 5 s
* timer armed behind every single navigation, doing nothing but keeping its closure alive */
let t = 0;
return Promise.race([ body, new Promise((r) => { t = window.setTimeout(r, WARM_WAIT_MS); }) ])
.finally(() => window.clearTimeout(t));
}
/* The page we are standing on arrived as a full load, so LuCI has ALREADY required — hence
* instantiated and rendered — its view. Seed `_seen`, or the first SPA nav BACK to this page would
* take require()'s cached instance, skip the re-instantiation and render nothing at all. */
function seed() {
const here = tree.viewClassFor(tree.currentNode());
if (here)
_seen.add(here);
titleHost(); /* before any view can rename the tab — see there */
/* the served page's entry needs an id too, or the first Back TO it has nothing to look up */
adoptEntry();
}
/* Attempt an in-place navigation to `pathname`. Returns true if handled as a
* SPA nav (caller should preventDefault), false to let the browser do a normal
* full navigation. `push` adds a history entry (false when replaying popstate).
* `kbd` — the navigation was activated from the keyboard (see the focus block). */
function navigate(pathname, push, kbd) {
const segs = tree.segsFromPath(pathname);
if (!segs) return false;
/* The view on screen injected CSS that can repaint any page: this document is spent, and
* the only exit that leaves BOTH pages correct is a real navigation. See fs-sheets.js. */
if (sheets.documentPoisoned()) return false;
/* `segs` is what the user clicked, `rsegs` the leaf it resolves to; they differ for an
* alias/firstchild link, and a full load keeps BOTH — URL and pathinfo as requested,
* requestpath/dispatchpath/nodespec/title resolved. Mirror that split exactly, or an F5
* lands somewhere the click did not. */
const res = tree.resolveSegs(segs);
const node = res && res.node;
const className = tree.viewClassFor(node);
if (!className)
return false;
const rsegs = res.segs;
/* from here on the navigation is committed */
const gen = ++_navGen;
_curPath = pathname; /* what is on screen from now on — read by the popstate handler */
/* Ensure a #view, and clear what the OUTGOING page left as a SIBLING of #view inside .fs-content:
* dom.content() replaces only #view's OWN children, so anything a page emitted next to it rides
* along — the Status→Overview template emits <h2 name="content">Status</h2> there, hidden only
* by a body[data-page='admin-status-overview'] rule, so after an SPA nav the orphan showed on
* EVERY page until a full reload. Keep only the chrome that legitimately outlives a page (tabs,
* server notices, <noscript>); this also gives a template page that emits no #view a fresh one. */
const contentHost = document.querySelector('.fs-content');
if (!contentHost) return false;
Array.from(contentHost.children).forEach((c) => {
if (c.id !== 'view' && c.id !== 'tabmenu' &&
!c.classList.contains('alert-message') && c.nodeName !== 'NOSCRIPT')
c.remove();
});
/* …and the RUNTIME notifications, which live one level up and therefore survived every
* navigation. `ui.addNotification()` does `mc.insertBefore(msg, mc.firstElementChild)` on
* #maincontent — this theme's <main class="fs-main"> — while the sweep above only reaches
* children of .fs-content, a descendant of it. A full load clears them; SPA never did, so
* "Upload request failed" and every third-party banner stacked up over each following page for
* the rest of the session (reproduced: the banner outlives a nav, and only F5 removes it).
* The .fs-content banners kept above are the SERVER's notices (notices.ut) and legitimately
* outlive a page; these are not the same thing. */
const mainHost = document.getElementById('maincontent');
if (mainHost)
Array.from(mainHost.children).forEach((c) => {
if (c.classList.contains('alert-message')) c.remove();
});
if (!document.getElementById('view')) {
const v = document.createElement('div');
v.id = 'view';
contentHost.appendChild(v);
}
/* SAY THAT SOMETHING IS LOADING, on a route whose module has never been fetched.
*
* The chrome switches instantly — title, URL, body[data-page], the menu highlight — but the
* sweep above deliberately does not touch the children of #view, and LuCI's own spinner is
* written by `View.__init__`, i.e. only once the view module has ARRIVED. So between the click
* and the last round-trip of the module chain the user reads the PREVIOUS page's content under
* the new page's title, with nothing moving. Measured on the router at 600 ms latency: at 150 ms
* and 400 ms #view still held the System page while everything else said Processes; the
* "Loading view…" spinner appeared at 900 ms and the content at 1800 ms. A full load would have
* shown the browser's own progress for that whole window.
*
* Only for a COLD route: `_seen` means the class has already been through require(), where
* View.__init__ paints its spinner synchronously and there is no gap to fill.
*
* Deliberately the SAME markup and the SAME msgid luci-base uses, not a skeleton of our own:
* when View.__init__ replaces it there is nothing to see, and the string arrives already
* translated in the ~40 languages this theme ships no catalogue for (the chrome rule — a
* context-free msgid is how we inherit luci-base's translation). */
if (!_seen.has(tree.viewClassFor(node))) {
const vp = document.getElementById('view');
if (vp) {
const s = document.createElement('div');
s.className = 'spinning';
s.textContent = _('Loading view…');
vp.replaceChildren(s);
}
}
/* teardown: drop the outgoing view's pollers, then put the poll loop back into the state a FRESH
* LOAD leaves it in. The only non-view poller LuCI adds is the transient apply/reboot
* reachability check, so flushing the queue is safe.
*
* The re-arm matters: LuCI runs one 1 s tick and fires a queue entry only when
* `tick % interval == 0`, so leaving the OUTGOING page's tick running makes the incoming poller
* wait for the next multiple of its interval — up to `pollinterval`, 5 s. Wireless draws its
* station list from the first poll and sat spinning for 4950 ms against ~360 ms on a full load.
*
* stop() alone is NOT the fix: it deletes `tick`, and Poll.add() only auto-starts when
* `tick != null`, so the incoming pollers would never start at all. stop()+start() on an EMPTY
* queue leaves what a fresh document has (`tick = 0`, no timer armed); the view's first
* poll.add() then starts it and steps immediately — upstream's own sequence, since on a full
* load initDOM() runs Poll.start() on an empty queue before the view renders. */
if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0;
L.Poll.stop();
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) — a full load
* would have. L.Poll's own tick survives. */
clearViewIntervals();
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* run every registered navigation callback — today the search palette's recent-pages record and
* its close-on-navigate. The seam is deliberately inverted: a registrant calls onNavigate()
* (below) and the router names nobody, so an optional module that is not installed is not a
* DependencyError that takes out the whole chrome.
*
* The RESOLVED segments are passed in, and that is not convenience: this runs BEFORE L.env is
* re-pointed a few lines down, so a callback reading L.env.dispatchpath for "where are we going"
* would silently record the page being left. */
/* Logged, not swallowed. A registrant that throws is still isolated — the loop must finish, or a
* broken registrant would take the palette's recents and its close-on-navigate with it — but the
* empty catch made a registrant that throws on EVERY navigation indistinguishable from one that
* was never registered, which is the failure mode the .catch at the bottom of this file was
* given a console.error for. */
for (const fn of _navCbs) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
try { if (typeof ui.hideModal === 'function') ui.hideModal(); } catch (e) {}
/* point the runtime env at the new node so views, tabs and highlighting read the right
* path. For a fully-matched leaf, request == dispatch path. */
L.env.requestpath = rsegs.slice();
L.env.dispatchpath = rsegs.slice();
L.env.pathinfo = '/' + segs.join('/');
/* `readonly` is not decoration: luci.js implements hasViewPermission() as
* `!env.nodespec.readonly`, and views (network/interfaces, wireless, the package manager)
* plus luci.js's Save/Apply footer key their disabled state off it. Dropping it handed a
* read-only user LIVE Save/Apply buttons on an SPA nav, where a full load disabled them —
* and reading it off the LEAF was still dropping it for two thirds of the readonly pages,
* because the dispatcher folds the acls of the whole path into that one flag. See
* fs-menutree's readonlyForSegs(). */
L.env.nodespec = { satisfied: true, action: node.action, title: node.title,
depends: node.depends, readonly: tree.readonlyForSegs(rsegs) };
/* Keep <body data-page> in sync with the route: the server stamps the dispatch path
* (`ctx.path`) on every full load, and page-scoped CSS keys off it. `rsegs` is the RESOLVED
* leaf, so a firstchild URL like /admin/status yields the same "admin-status-overview" whether
* it arrives as a full load or a client nav. Without the re-stamp the incoming page keeps the
* previous page's data-page and its scoped styles silently do not apply. */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and immediately hand the new page to fs-sheets, which darkens every foreign sheet that
* belongs to a DIFFERENT page and re-lights the ones that belong to this one. This is what lets
* an invasive sheet stay in the document without spending it — the alternative was a full load
* on the way out of any page carrying one, which stock LuCI's five realtime views all do (see
* the page-ownership block in fs-sheets.js). It must run AFTER the stamp above and BEFORE the
* view renders, so nothing paints through a sheet that no longer owns the page. */
sheets.scopeToCurrentPage(rsegs);
/* Re-navigating to the page already on screen must REPLACE its history entry, not push a
* second one. Clicking the active menu item is ordinary, and a duplicate entry makes Back do
* nothing: popstate fires, `location.pathname === _curPath`, and the fragment guard below
* correctly returns — one dead Back press per stray click. A full load has no such trap. */
if (push) {
const same = pathname === window.location.pathname;
if (_curId == null) _curId = newEntryId();
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id */
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsnav: true, fsid: _curId }, '', pathname);
}
/* titles: <host> | <page> */
document.title = node.title ? (titleHost() + ' | ' + _(node.title)) : titleHost();
const tmain = document.querySelector('.fs-title-main');
if (tmain && node.title)
tmain.textContent = _(node.title);
chrome.renderChrome();
/* a full load starts at the top; the in-place swap must too, or navigating away from a long
* page opens the next one mid-scroll. In the desktop sidebar layout the window does NOT scroll —
* .fs-shell is exactly 100dvh with overflow:hidden and .fs-main owns overflow-y, so the sidebar
* can be static rather than a composited sticky layer (issue #7) — so reset it too; scrollTo on
* whichever of the two is not the scroller is a harmless no-op.
*
* A popstate replay resets nothing on purpose: BOTH scrollers are restored there from _scrollMem —
* see restoreScroll(). scrollRestoration is left at 'auto': the UA's own attempt lands before the
* swap and is undone by it (§2), so it neither helps nor hurts, and 'manual' would only take away
* the case that does work — a genuine full load. */
if (push) {
window.scrollTo(0, 0);
const sc = document.getElementById('maincontent');
if (sc) sc.scrollTo(0, 0);
}
/* ---- what a full load does for a keyboard/screen-reader user, and the SPA did not ----
* renderChrome() has just done `#topmenu.innerHTML = ''`, so the very <a> the user activated with
* Enter no longer exists: focus falls back to <body>, the next Tab restarts at the skip link, and
* nothing says the page changed — URL, title and #view all moved in silence. So do what a real
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md §3): a KEYBOARD
* activation (ev.detail === 0) moves focus to the skip link — a small target whose :focus overlay
* tells a sighted keyboard user where they are, with Enter jumping straight to the content; its
* text differs from the live region's announcement below, so the double announcement complements
* rather than repeats. A pointer activation (and a popstate replay, whose modality is unknowable)
* keeps the wrapper focus: focusing the skip link there would flash its overlay on every mouse
* click. <main> keeps tabindex="-1" and its outline-less :focus for exactly that path.
* preventScroll because the scroll position is decided just above — focus() would otherwise drag
* a popstate replay back to the top and undo the restoration. */
const skip = kbd ? document.querySelector('.fs-skip') : null;
const main = skip || document.getElementById('maincontent');
if (main) main.focus({ preventScroll: true });
const live = document.getElementById('fs-nav-status');
if (live) live.textContent = node.title ? _(node.title) : '';
/* Require through the runtime singleton `window.L`, NOT the bare `L` a module factory is handed:
* the dispatcher builds `window.L = new LuCI()` and `ui` augments THAT instance with
* itemlist/showModal/…, so a view required via the bare `L` throws "L.itemlist is not a
* function" mid-render (the two-L trap, docs/spa-router.md). require/instanceof errors fall back to a real
* navigation; render-time errors are handled inside LuCI.view, as on a full load.
*
* WHEN to re-instantiate is the subtle part. require() does not hand back a class — it caches an
* INSTANCE, so requiring a class not seen before CONSTRUCTS it, and a view's __init__ IS its
* render. On a first visit the require has therefore already painted the page, and a
* `new view.constructor()` after it painted a SECOND time — two renders, two pollers, double
* RPCs for as long as the user stayed. Only on a REVISIT does require() return the cached
* singleton whose __init__ already ran. `_seen` is that distinction, and it must be read BEFORE
* the require resolves, since the require is what fills LuCI's cache. */
/* Status→Overview needs the 3 template globals (progressbar/renderBox/renderBadge) an SPA
* arrival never defines. fs-overview.js defines them at its own module eval — which, as a
* chrome module, happens at chrome init, i.e. before any SPA navigation can occur. Not here:
* the router has no business owning luci-mod-status's globals. */
const RT = window.L;
const cached = _seen.has(className);
/* WAIT for an in-flight prefetch of this class rather than racing it. Two requests for the same
* URL do not coalesce, so a click landing before the prefetch does downloads the module TWICE,
* both at full latency, and gains nothing — measured at 120 ms RTT: the prefetch ran 2664→2788 ms
* and the require's XHR 2682→2788 ms for the same 8.6 KB. That is the NORMAL case on a touch
* device, where pointerover fires the same moment as the tap. Waiting costs nothing: the XHR
* would have waited for exactly those bytes.
*
* `_seen` is marked here and not before, because it means "this class has been through require()"
* and the wait introduces a window in which we may never get there. Marking it up front would
* make the NEXT navigation take the cached branch — `new view.constructor()` on a class whose
* require() is what renders it — i.e. two renders and two pollers for one page. */
warmedThen(className).then(() => {
/* superseded while waiting: never start the require. On a first visit the require IS the
* render, so starting it here would paint a page the user has already left and hand
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
if (gen !== _navGen) return null;
_seen.add(className);
return RT.require(className);
}).then((view) => {
if (view == null) return;
if (!(view instanceof RT.view))
throw new TypeError('Loaded class ' + className + ' is not a view');
if (gen !== _navGen) {
/* A newer navigation superseded this one. On the CACHED path nothing has happened
* yet — skipping the constructor below is the whole cancellation. On the FIRST-visit
* path the require() has ALREADY rendered into the live page and registered its
* pollers, with nothing to cancel: undo it. See repairStaleRender(). */
if (!cached)
repairStaleRender(className);
return;
}
if (cached) {
/* singleton: its __init__ already ran, re-run it. Arm the paint-time guard BEFORE the
* construct (__init__ binds this.render as it builds its chain) and stamp the
* generation right after it (the first load() is a microtask, so this lands first) —
* this navigation can still be superseded while that render awaits its RPC. */
const cls = view.constructor;
armRenderGuard(cls);
const inst = new cls();
inst.__fsGen = gen;
}
}).catch((e) => {
/* the full reload is a correct fallback, but swallowing the reason made every SPA-router
* regression look like "the page is just slow to load". Log, then fall back. */
console.error('footstrap: SPA nav to ' + className + ' failed, falling back to a full load', e);
if (gen === _navGen) window.location = pathname;
});
return true;
}
/* The same-origin nav URL an event's link points at, or null when the link is not ours to handle
* (new-tab target, download, bare #hash, cross-origin, unparsable). Shared by the click router and
* the hover prefetch, which used to carry drifting copies of this filter. */
function linkUrlFrom(ev) {
const a = ev.target.closest?.('a[href]');
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download'))
return null;
const raw = a.getAttribute('href');
if (!raw || raw.charAt(0) === '#') return null;
let url;
try { url = new URL(a.href, window.location.href); } catch (e) { return null; }
return url.origin === window.location.origin ? url : null;
}
/* Warm the view module behind an event's link — ONE filter for all three prefetch triggers, and the
* same one the click router applies two lines further down: navigate() pushes a bare path, so a link
* carrying ?query or #hash full-loads, and warming its module spends a request on a page the SPA
* path can never open. The click handler declined those from the start; the three triggers below had
* each grown a copy of the URL test without that half. */
function prefetchFrom(ev) {
const url = linkUrlFrom(ev);
if (url && !url.search && !url.hash)
prefetchView(url.pathname);
}
/* The last <a> a pointer crossed, kept only to stop `pointerover` re-firing per child span (see the
* listener). Cleared on every navigation: an element holds its parent, so retaining one anchor
* retains the whole detached tree the content swap has just thrown away — a small leak, but one that
* lasts until the pointer happens to cross some other link. */
let _lastHovered = null;
function wireRouter() {
if (_wired) return;
_wired = true;
document.addEventListener('click', (ev) => {
if (ev.defaultPrevented || ev.button !== 0 ||
ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)
return;
const url = linkUrlFrom(ev);
if (!url) return;
/* navigate() carries only the pathname: pushState-ing a bare path for a link that
* promised ?query= / #hash would strip both from the URL and from the view, which
* reads location.search. Let those links full-load. */
if (url.search || url.hash) return;
/* record the outgoing page's offset under the entry we are still ON; harmless when
* navigate() declines (a full load throws the whole Map away anyway) */
saveScroll();
if (navigate(url.pathname, true, ev.detail === 0))
ev.preventDefault();
}, false);
/* Warm the view module cache when the pointer enters a nav link. `pointerover` bubbles from
* EVERY element the pointer crosses — dragging across the process table fires it hundreds of
* times — so bail on the element first: the same <a> re-fires this for every child span it
* contains, and a non-link target is the overwhelmingly common case. */
document.addEventListener('pointerover', (ev) => {
const a = ev.target.closest?.('a[href]');
if (!a || a === _lastHovered) return;
_lastHovered = a;
prefetchFrom(ev);
}, { passive: true });
/* The pointer is not the only way a link gets chosen, and the other two ways got no prefetch at
* all. A KEYBOARD user Tabs to the link and presses Enter — no pointer event ever fires, so the
* whole optimisation was invisible to them; focusin is the keyboard's hover, and the Tab→Enter gap
* is human-scale, so the module is usually there by the time Enter lands. A TOUCH user gets a
* pointerover, but at the same moment as the tap, which is what the in-flight wait in navigate()
* is for rather than an earlier trigger. pointerdown adds the one pointer case pointerover cannot
* see: a link that scrolled UNDER a stationary pointer crosses no boundary and fires nothing.
* Neither needs the lastHovered guard above — they fire once per interaction, not per element
* crossed — and warmClass() dedupes per class anyway. */
document.addEventListener('focusin', prefetchFrom, { passive: true });
document.addEventListener('pointerdown', prefetchFrom, { passive: true });
window.addEventListener('popstate', () => {
/* an entry carrying a query belongs to a full load (we only ever push bare paths):
* replaying it as a bare-path SPA nav would drop the query the view expects */
if (window.location.search) {
window.location.reload();
return;
}
/* A FRAGMENT CHANGE IS NOT A NAVIGATION. Chrome fires `popstate` for a same-document
* fragment nav, so clicking an `<a href="#">` inside a view — a very common idiom for
* in-page controls — arrived here as if the user had pressed Back, and we re-ran navigate()
* for the path already on screen, RE-INSTANTIATING the view and wiping the state the click
* had just set (issue #3, "luci-app-filemanager does not work": its tab strip is four
* `<a href="#">` links whose handler does not preventDefault). The view changed only if the
* PATH changed; if just the fragment moved, the page owns it. */
if (window.location.pathname === _curPath)
return;
/* the outgoing DOM is still up: record its offset under the entry we are LEAVING, then
* adopt the entry we arrived on and look up what IT recorded when it was left */
saveScroll();
adoptEntry();
const target = _scrollMem.get(_curId);
if (!navigate(window.location.pathname, false))
window.location.reload();
else
restoreScroll(target, _navGen);
});
}
/* ---- the poll indicator must not outlive the poll ----
*
* LuCI shows the "Refreshing" pill on `poll-start`, flips it to "Paused" on `poll-stop`, and never
* hides it again (core calls ui.hideIndicator() only for `uci-changes`). Invisible on a full load,
* because Poll.start() dispatches `poll-start` only when the queue is non-empty — an unpolled page
* never grows a pill. But our router flushes the queue and calls stop() on every nav, and stop()
* DOES dispatch `poll-stop`, so walking from a polled page to an unpolled one left a "Paused" pill
* reporting on a poll that does not exist there. Rule: the pill exists iff there is something to
* poll. Registered at module eval, i.e. AFTER luci.js's own listener, so ours runs second and can
* take back what that one just painted. */
document.addEventListener('poll-stop', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
try { ui.hideIndicator('poll-status'); } catch (e) {}
}
});
/* Pause LuCI's 1s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
* overview left open in a background tab hammers ubus 24/7 (notably the pricey iwinfo getAssocList)
* on a low-power router. stop() only clearInterval()s (the queue survives); start() re-arms and runs
* one immediate step(), so data is fresh on refocus. A poller added while hidden will not auto-start
* (stop() deletes the tick) — start() picks it up on show: deferred, not lost. docs/spa-router.md. */
let _visWired = false;
function wireVisibility() {
if (_visWired) return;
_visWired = true;
/* respect a manual pause: the user can stop polling from the "Refreshing" indicator, and an
* unconditional start() on tab-show would silently undo it. Resume only what we paused. */
let wasActive = true;
document.addEventListener('visibilitychange', () => {
if (!L.Poll) return;
try {
if (document.hidden) {
wasActive = L.Poll.active();
if (wasActive) L.Poll.stop();
}
else if (wasActive) {
L.Poll.start();
}
} catch (e) {}
});
}
/* Callbacks to run on every SPA navigation, each handed the resolved segments of the INCOMING page
* (see navigate() — they run before L.env is re-pointed). The registry is INVERTED on purpose: a
* registrant calls in and the router names nobody, so it can never grow a static dependency on a
* module that may not be installed. Registrant today: fs-search's recent-pages record and its
* close-on-navigate. */
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
return baseclass.extend({
seed,
wire: wireRouter,
wireVisibility,
onNavigate,
/* fs-search warms the pages this admin actually uses (its recents) and the arrow-key-highlighted
* result, both of which the pointer/focus triggers above cannot see. The edge points that way
* round — search → router — because the router must keep no dependency on the palette. */
prefetchSegs
});
@@ -0,0 +1,406 @@
'use strict';
'require baseclass';
'require fs-menutree as tree';
'require fs-prefs as prefs';
'require fs-router as router';
'require fs-widgets as widgets';
/* Find a page by typing its name, instead of remembering which section owns it.
*
* A loaded router carries ~200 reachable menu nodes across 11 sections, and the only way to reach
* one was to know its parent — "Attended Sysupgrade" is under System, "Crontab" under System too,
* "Port Forwards" is a TAB of Network -> Firewall and appears in no menu list at all until you are
* already there. This indexes every node the dispatcher would render, tabs included.
*
* It costs no request: the tree is the SAME ACL-filtered /admin/menu blob the chrome already
* loaded (fs-menutree), so the palette knows exactly the pages this session is allowed to open —
* nothing to leak, nothing to 403 on. The index is built on the FIRST open, not at init: a user
* who never searches pays nothing, and a full load is the only thing that can change the tree.
*
* Navigation is deliberately NOT a call into the router: every result is a real <a href>, so a
* click bubbles to the router's own document-level handler and takes the SPA path (or falls back
* to a full load when the node is not SPA-able) with no second copy of that decision here. Enter
* synthesises the same click. */
/* ---- the index ---------------------------------------------------------- */
/* admin/<section>/<page>/<tab> — four levels is every path LuCI's dispatcher renders. */
const MAX_DEPTH = 4;
let _index = null;
/* The node's own children, ACL- and title-filtered exactly as ui.menu.getChildren() filters them
* — but NOT through getChildren() itself, and that is the whole reason this function exists.
*
* On an ALIAS node getChildren() returns a copy whose `children` are the alias TARGET's, which is
* right for drawing a menu and wrong for indexing: Network -> Firewall is an alias onto the
* `firewall/zones` VIEW, a leaf, so its five tabs (Port Forwards, Traffic Rules, NAT Rules, IP
* Sets, Custom Rules) came back as an empty list and "port" found nothing on a router that plainly
* has a Port Forwards page. Measured on the dev router: 78 indexed nodes through getChildren(),
* with every tab of every aliased page missing.
*
* Order does not matter here (search ranks by score, not by menu weight), so getChildren()'s sort
* is not reimplemented — only its two filters, which are what "this session may open it" means. */
function childrenOf(node) {
const kids = node.children || {};
const out = [];
for (const name in kids) {
const c = kids[name];
if (!c || !c.satisfied || !c.title) continue;
out.push({ name: name, node: c });
}
return out;
}
function walk(node, segs, trail, out, depth) {
childrenOf(node).forEach((entry) => {
const child = entry.node;
/* the chrome carries its own Logout (partials/logout.ut) — searching for it would open a
* confirmation the user did not ask for */
if (depth === 1 && entry.name === 'logout')
return;
const title = _(child.title);
const csegs = segs.concat([ entry.name ]);
out.push({
segs: csegs,
path: csegs.join('/'),
title: title,
trail: trail,
depth: depth,
/* Three haystacks, searched in this order of preference (see score()). `name` is the
* ENGLISH node name and is indexed on purpose: a Russian UI translates "Firewall" to
* "Межсетевой экран", and an admin who knows OpenWrt by its English docs types
* "firewall". Both find it. */
t: title.toLowerCase(),
p: trail.join(' ').toLowerCase(),
/* …minus the ROOT segment, which is `admin` on every single node. Indexed, it made
* every substring of "admin" — a, ad, adm, dmi, min, in — a hit on all ~200 pages, so
* `min` returned "Terminal", "Administration", "Attended Sysupgrade" and stopped at the
* 20-result cap sorted only by depth (measured). It carries no information precisely
* because it is shared by everything. */
n: csegs.slice(1).join(' ').toLowerCase()
});
if (depth < MAX_DEPTH)
walk(child, csegs, trail.concat([ title ]), out, depth + 1);
});
}
function buildIndex() {
const root = tree.tree();
const out = [];
if (!root) return out;
/* the mode (admin) is a container, not a destination: start one level in */
childrenOf(root).forEach((mode) => {
walk(mode.node, [ mode.name ], [], out, 1);
});
return out;
}
function index() {
if (!_index) _index = buildIndex();
return _index;
}
/* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit SOMETHING, so "fire port" finds Firewall -> Port
* Forwards while "fire xyz" finds nothing — an AND is what a user typing a second word means.
*
* Deliberately not a fuzzy subsequence match (the "fzf" kind): on a two-letter query it matches
* almost every entry and the ranking then decides everything, which reads as random. A substring
* ladder is predictable — what you typed is visibly in what you got. */
const HIT_NONE = 99;
function tokenScore(e, tok) {
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
if (e.t.includes(tok)) return 1; /* somewhere in the title */
if (e.n.includes(tok)) return 2; /* the English path segment */
if (e.p.includes(tok)) return 3; /* an ancestor's title */
return HIT_NONE;
}
function search(q, limit) {
const toks = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!toks.length) return [];
const hits = [];
for (const e of index()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
if (s === HIT_NONE) { sum = HIT_NONE; break; }
sum += s;
}
if (sum !== HIT_NONE)
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: the shallower page first */
}
hits.sort((a, b) => a.score - b.score);
return hits.slice(0, limit).map((h) => h.e);
}
/* ---- recently visited --------------------------------------------------- */
/* What the palette shows before a single character is typed. A router admin lives in three or four
* pages, so the empty state is the most-used view of this thing — an empty box would waste it.
*
* Only the PATH is stored, never the title: the title is resolved back through the index on every
* render, so it follows the UI language and a page that disappeared with its package simply drops
* out instead of lingering as a dead row. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the
* "these are paths" filter is this module's business. */
function loadRecent() {
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
}
let _recent = loadRecent();
function remember(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const path = segs.join('/');
_recent = [ path ].concat(_recent.filter((p) => p !== path)).slice(0, RECENT_MAX);
prefs.lsSet(RECENT_KEY, JSON.stringify(_recent));
}
function recentEntries() {
const byPath = new Map(index().map((e) => [ e.path, e ]));
return _recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, a tap or a focus first, so the FIRST visit of a
* session to a page still pays for its module chain. The recents list is the best predictor of that
* page available anywhere in the theme — an admin lives in three or four of them — and it is already
* on disk. Walking the whole menu instead would pull every view module on the box, which is the cost
* docs/spa-router.md warns about; five recents is a handful of files.
*
* The current page is skipped: wire() has just remembered it, so it heads the list, and it is loaded
* by definition. saveData is the user saying "not over this link", and speculation is exactly what
* has to go then — the per-link prefetch stays, because that one follows a deliberate hover or tap. */
const RECENT_WARM = 5;
function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
/* Nothing waits on this, so it belongs after the page has settled — at idle, with a timeout for a
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout, unlike
* fs-appearance's ~1 ms one: that wires a button the user may click immediately, this competes
* with the view's own module fetches and RPCs and must lose that race on purpose. */
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
else
window.setTimeout(go, 2000);
}
/* ---- the palette -------------------------------------------------------- */
const MAX_RESULTS = 20;
function wire() {
const btn = document.getElementById('fs-search-btn');
if (!btn) return;
/* Remember the page this full load landed on. The SPA path is covered by onNavigate below;
* this covers an F5, a non-SPA-able node and the very first page of a session. */
remember(L.env.dispatchpath || []);
/* The callback is handed the RESOLVED segments of the incoming page: reading L.env here would
* give the OUTGOING one, since the router fires its callbacks before it re-points L.env. */
router.onNavigate(remember);
/* after remember(), so the page we are standing on is the one head of the list that gets skipped */
warmRecent();
const input = E('input', {
'type': 'text',
'class': 'fs-search-input',
'role': 'combobox',
'aria-controls': 'fs-search-list',
'aria-expanded': 'true',
'aria-autocomplete': 'list',
'aria-label': _('Search pages', 'footstrap'),
'placeholder': _('Search pages…', 'footstrap'),
'autocomplete': 'off',
'autocapitalize': 'off',
'spellcheck': 'false'
});
const list = E('div', { 'id': 'fs-search-list', 'class': 'fs-search-list', 'role': 'listbox', 'aria-label': _('Pages', 'footstrap') });
const note = E('div', { 'class': 'fs-search-note' });
const ico = E('span', { 'class': 'fs-search-ico' });
ico.innerHTML = widgets.svgIcon('<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>');
const box = E('div', { 'class': 'fs-search-box' }, [
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
/* the note is a CAPTION for the rows below it ("Recently visited") and the box's EMPTY
* STATE when there are none, so it belongs above the list in both readings — it sat after
* the list at first, which put the caption under the rows it captions */
note,
list
]);
/* data-fs-chrome marks a Zone 1 ROOT (docs/third-party-apps.md): this is a `position: fixed` overlay parented
* to <body>, i.e. outside the <nav> that carries the mark in header.ut — exactly the shape that
* once left the Appearance popover unfenced while every test said the chrome was defended. */
const ov = E('div', {
'id': 'fs-search-ov',
'class': 'fs-search-ov',
'data-fs-chrome': '',
'role': 'dialog',
'aria-modal': 'true',
'aria-label': _('Search pages', 'footstrap'),
'hidden': ''
}, [ box ]);
document.body.appendChild(ov);
let opts = [], ents = [], at = -1;
/* Warm the highlighted page's module chain, DEBOUNCED. render() re-runs setActive(0) on every
* keystroke, so warming immediately would pull the top result of "w", "wi", "wir"… and only the
* last of those is a page anyone asked for. The delay also matches how the list is used: the
* highlight settles, then Enter. Only the ARROW KEYS and typing need this — the rows are real
* anchors in the document, so a mouse moving over one already reaches the router's own pointerover
* listener. warmClass() dedupes per class, so a row revisited costs nothing. */
let warmT = null;
function warmActive() {
if (warmT) window.clearTimeout(warmT);
warmT = window.setTimeout(() => {
warmT = null;
if (ents[at]) router.prefetchSegs(ents[at].segs);
}, 200);
}
function setActive(i) {
if (!opts.length) { at = -1; input.removeAttribute('aria-activedescendant'); return; }
at = (i + opts.length) % opts.length;
opts.forEach((o, n) => {
const on = (n === at);
o.classList.toggle('active', on);
o.setAttribute('aria-selected', on ? 'true' : 'false');
});
input.setAttribute('aria-activedescendant', opts[at].id);
opts[at].scrollIntoView({ block: 'nearest' });
warmActive();
}
function render(q) {
const entries = q ? search(q, MAX_RESULTS) : recentEntries();
ents = entries;
list.innerHTML = '';
opts = entries.map((e, i) => {
/* role="option" on the <a> rather than a <div> wrapping one: an option may not contain
* an interactive element, and the anchor has to stay a real link — it is what carries
* the click to the router, and what keeps middle-click and "copy link" working. */
const a = E('a', {
'class': 'fs-search-opt',
'role': 'option',
'id': 'fs-search-opt-' + i,
'aria-selected': 'false',
'href': L.url.apply(L, e.segs)
}, [
E('span', { 'class': 'fs-search-opt-title' }, [ e.title ]),
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
]);
/* close BEFORE the click reaches the router (which re-renders the chrome underneath),
* and without taking focus back — the user is going somewhere else */
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
});
note.textContent = opts.length
? (q ? '' : _('Recently visited', 'footstrap'))
: (q ? _('No pages found', 'footstrap') : _('Start typing to find a page', 'footstrap'));
note.hidden = !note.textContent;
setActive(0);
}
function open() {
if (!ov.hidden) return;
ov.hidden = false;
btn.setAttribute('aria-expanded', 'true');
input.value = '';
render('');
input.focus();
}
function close(returnFocus = true) {
if (ov.hidden) return;
ov.hidden = true;
btn.setAttribute('aria-expanded', 'false');
if (returnFocus) btn.focus();
}
input.addEventListener('input', () => render(input.value.trim()));
/* Keys are handled on the OVERLAY, not the input: the scrim is part of the dialog and a click
* on it moves focus to the overlay itself, where an Escape must still close. */
ov.addEventListener('keydown', (ev) => {
switch (ev.key) {
case 'Escape':
ev.preventDefault();
close();
return;
case 'ArrowDown':
ev.preventDefault(); setActive(at + 1); return;
case 'ArrowUp':
ev.preventDefault(); setActive(at - 1); return;
case 'Home':
if (!input.value) { ev.preventDefault(); setActive(0); }
return;
case 'End':
if (!input.value) { ev.preventDefault(); setActive(opts.length - 1); }
return;
case 'Enter':
if (at < 0 || !opts[at]) return;
ev.preventDefault();
/* a synthetic click carries detail 0, which is exactly what the router reads as a
* KEYBOARD activation — so the focus lands where a keyboard navigation puts it */
opts[at].click();
return;
case 'Tab':
/* aria-modal="true" is a promise that Tab cannot walk out into the page behind, and
* the input is the dialog's only tabbable element, so the trap is: stay. Escape (or a
* click outside) is the way out, and both hand focus back to the trigger. */
ev.preventDefault();
input.focus();
return;
}
});
/* a click on the scrim — anywhere outside the box — closes */
ov.addEventListener('click', (ev) => { if (ev.target === ov) close(); });
btn.addEventListener('click', () => { ov.hidden ? open() : close(); });
/* Back and Forward are navigations no listener here can see: every close above is a user act on
* the document (Escape, the scrim, the trigger, picking a result). An open palette therefore rode
* a popstate onto the next page, aria-modal and Tab-trapped, while the router moved focus behind
* it. Same reasoning as the Appearance popover's — see fs-appearance.js. returnFocus=false because
* the router places focus itself on a navigation. */
router.onNavigate(() => close(false));
/* Ctrl/Cmd+K is the shortcut every command palette has taught users, and `/` is the one every
* search field on the web has. `/` only when the user is not already typing somewhere: an
* <input>, a contenteditable, or a .cbi-dropdown (fs-select.js gives those their own typeahead,
* where a `/` is a search character, not a shortcut). */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
ev.preventDefault();
ov.hidden ? open() : close();
return;
}
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
ev.preventDefault();
open();
});
}
return baseclass.extend({
wire
});
@@ -0,0 +1,522 @@
'use strict';
'require baseclass';
'require ui';
'require dom';
'require fs-fit as fit';
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
*
* The native <select> stays the form field and MUST remain frameEl.firstChild:
* ui.Select.getValue() returns `this.node.firstChild.value`. Inserting our widget BEFORE it made
* getValue read a <div> and return `undefined`, which broke Save. So insert AFTER, and mirror the
* value both ways. Sharing the frameEl also ties our node to the widget's lifecycle, so a CBI
* re-render disposes of it — no orphans.
*
* Runs theme-wide (required from the footer); watches for selects added later by client CBI. */
function readChoices(sel) {
const choices = {};
Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; });
return choices;
}
/* cheap identity of the option list, to detect a script rebuilding it
* (select.replaceChildren, dependency-driven re-population, …) */
function choicesKey(sel) {
return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
}
/* undo enhance(): drop the widget, unhide the select, and — critically — cut every listener
* enhance() installed. The `change` listener used to survive teardown, and resync() calls
* teardown()+enhance() every time a script rebuilds the option list (CBI dependencies do this
* constantly on the firewall/network forms) — so the select accumulated one live listener per
* rebuild, each closing over a dead ui.Dropdown and its detached subtree: a leak that grew with
* every interaction. AbortController is the only way to drop an anonymous listener. */
function teardown(sel) {
if (sel._fsAbort) sel._fsAbort.abort();
if (sel._fsNode && sel._fsNode.parentNode)
sel._fsNode.parentNode.removeChild(sel._fsNode);
delete sel.dataset.fsSelect;
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
sel.removeAttribute('aria-hidden');
sel.style.display = '';
}
/* keep an enhanced select and its widget in step when a script drives the native element
* directly: ui.Select.setValue() rewrites value/options WITHOUT dispatching `change`, so
* enhance()'s mirror never fires and the widget went stale — showed the old value while Save
* read the new one. */
function resync(sel) {
const dd = sel._fsDd;
if (!dd || !sel._fsNode) return;
if (sel.disabled) { teardown(sel); return; } /* disabled later: back to native */
const key = choicesKey(sel);
if (key !== sel._fsKey) {
/* option list rebuilt — recreate the widget from the fresh options */
teardown(sel);
enhance(sel);
return;
}
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}
/* A VALUE written through the IDL is invisible to every observer there is.
*
* `sel.value = x` and `options[i].selected = true` — which is exactly what `ui.Select.setValue()`
* does, and `form.js`'s `updateDefaultValue()` calls it on every dependency pass — set no content
* attribute and add no node, so no MutationRecord is produced at all. relevant() therefore could
* never wake, resync() never ran, and the widget showed the old label while `getValue()` (and Save)
* read the new one. Reproduced on the router: `s.value = 'DROP'` left the widget unchanged.
*
* So this runs from the fitter, i.e. once per content mutation batch — the same cadence the tables
* already use — and it is deliberately the CHEAP half of resync(): a value compare per enhanced
* select, no choicesKey() over every option. Re-keying the widget stays behind relevant(), which now
* sees an option-list rebuild. */
function resyncValues() {
for (const sel of document.querySelectorAll('select[data-fs-select]')) {
const dd = sel._fsDd;
if (!dd || !sel._fsNode || sel.disabled) continue;
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}
}
function enhance(sel) {
if (sel.dataset.fsSelect || sel.disabled) return; /* disabled: NOT marked — it may be enabled later */
/* `multiple` and "not in a CBI field" are permanent, so mark it and stop re-testing on
* every scan */
if (sel.multiple || !sel.closest('.cbi-value-field, .td.cbi-value-field, .cbi-value')) {
sel.dataset.fsSelect = 'skip';
return;
}
const choices = readChoices(sel);
let dd;
try {
dd = new ui.Dropdown(sel.value, choices, {
sort: false,
optional: Object.prototype.hasOwnProperty.call(choices, '')
});
} catch (e) { return; }
const node = dd.render();
const ac = new AbortController();
sel.dataset.fsSelect = '1';
sel.style.display = 'none';
/* The hidden <select> leaves the CBI <label for=…> pointing at something no screen reader
* announces, and the visible widget nameless. Move the name over, drop the select from the
* a11y tree. */
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
/* In a TABLE section there is no .cbi-value and no .cbi-value-title at all — form.js builds
* `E('td', {class: 'td cbi-value-field'})` there — so on firewall zones, port forwards and
* static leases the widget was left with no accessible name while the native select it replaces
* is aria-hidden. The cell's `data-title` IS the column heading (LuCI fills it for the card
* stack), which is the same string the header cell shows. */
const name = (title && title.textContent.trim()) ||
(sel.closest('.td')?.getAttribute('data-title') || '').trim();
if (name)
node.setAttribute('aria-label', name);
/* Clicking the field's caption must reach the widget. form.js wires that label to
* `#widget.cbid…`.click()/focus() — which is the native <select> we just set `display: none` on,
* so on this theme the click did nothing at all (measured: focus stayed on <body>, no list
* opened), while stock bootstrap focuses the select. The <label for=…> is equally dead for the
* same reason. Re-point the gesture at the visible control — focus only, which is the parity
* stock gets: its `elem.click()` on a `<select>` opens no list either, so the gesture has always
* meant "put me on this control". */
if (title)
title.addEventListener('click', () => node.focus(), { signal: ac.signal });
sel.setAttribute('aria-hidden', 'true');
sel._fsDd = dd;
sel._fsNode = node;
sel._fsKey = choicesKey(sel);
sel._fsAbort = ac;
/* AFTER the select: it must stay frameEl.firstChild for ui.Select to read its value on save */
sel.parentNode.insertBefore(node, sel.nextSibling);
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
let syncing = false;
/* our widget -> native select (user picked an option) */
node.addEventListener('cbi-dropdown-change', () => {
const v = dd.getValue();
if (sel.value === v) return;
syncing = true;
sel.value = v;
sel.dispatchEvent(new Event('change', { bubbles: true }));
syncing = false;
}, { signal: ac.signal });
/* native select -> our widget (a script/CBI dependency changed and dispatched change on
* the select) — keeps the visible widget from going stale */
sel.addEventListener('change', () => {
if (syncing) return;
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}, { signal: ac.signal });
}
/* Tag standalone data tables so the stacking rules key off a static `.fs-dt` instead of a live
* `:has(.tr.table-titles)` the style engine re-evaluated on every mutation of these polled tables
* (Processes/routes/leases). Not a .cbi-section-table — config forms keep their own layout.
*
* `.table`, not `table.table` — the SAME selector relevant() and STACKABLE use. Stock LuCI
* happens to emit only real <table>s, but a third-party luci-app-* may emit a <div class="table">
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card. */
function tagDataTables() {
document.querySelectorAll('#view .table:not(.cbi-section-table):not(.fs-dt)').forEach((t) => {
/* THREE header markups, and each missing one cost a page. L.ui.Table emits
* `.tr.table-titles`; the apk Software page emits `.tr.cbi-section-table-titles` (missing
* it is why the package list once needed a stacking block of its own); and a third-party
* table may simply use a real `<thead>` — luci-mod-dashboard's device lists are
* `<thead class="thead dashboard-bg"><th class="th nowrap">`, matching neither name. They
* therefore never carded, and because those `th`s are `nowrap` they could not compress
* either: on a phone the right-hand columns were cut off by .fs-main's overflow clip.
* Reported from a router with wifi clients.
*
* `thead`, not `thead tr`: that markup is built by E(), which appends the `<th>`s straight
* to the `<thead>` — the parser's implied row never happens, so a `tr` in the selector finds
* nothing. Read as "the header ROW-ISH element", which is what its children are cells of.
*
* ANY of the three = a data table; NONE = a key/value include (System, Memory), which must
* never card. `thead` is the structural form of the same statement the two classes make, so
* it belongs in the same list rather than in a rule of its own. */
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead');
if (!head) return;
t.classList.add('fs-dt');
labelCells(t, head);
});
}
/* Give every cell the column heading it will show once the table cards.
*
* The card layout prints `attr(data-title)` above each value (theme/30-tables.css), and LuCI's own
* table builders fill that attribute in. A foreign table has no reason to: luci-mod-dashboard emits
* bare `<td class="td">`, so carding it would have produced a column of values with nothing saying
* which was the hostname and which the signal — worse than the clipped table it replaced.
*
* The heading is COPIED, not invented: it is the text of the header cell in the same position, so
* the card says exactly what the column header says. Never overwrites an existing data-title — if
* the app set one, that is the app's answer and it knows more than a positional guess. Cheap enough
* to re-run on every fit pass (it is skipped entirely once the cells carry the attribute), which
* matters because these tables are POLLED: the rows are replaced wholesale every few seconds, and
* the fresh ones arrive without it. */
function labelCells(t, head) {
const titles = [ ...head.children ].map((c) => (c.textContent || '').trim());
if (!titles.some(Boolean)) return;
for (const row of t.querySelectorAll('.tr, tbody tr')) {
if (row === head) continue;
const cells = row.children;
for (let i = 0; i < cells.length; i++) {
if (i >= titles.length || !titles[i]) continue;
if (cells[i].hasAttribute('data-title')) continue;
/* a cell that spans columns has no single heading to take */
if (cells[i].colSpan > 1) continue;
cells[i].setAttribute('data-title', titles[i]);
}
}
}
/* ---- CARD-STACK A DATA TABLE THAT NO LONGER FITS --------------------------------
*
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the DECISION.
* A data table used to card by @container at THREE thresholds (568 plain, 780 leases, 800 apk
* package list), the last two each carrying their own COPY of the card rules — CSS cannot share
* a block across two thresholds. All were really asking "does it OVERFLOW?", a fact the browser
* computes, so it is measured instead: the card rules live once in theme/30-tables.css on
* .fs-stacked, and a third-party table of unknowable width works too.
*
* A CONFIG table (.cbi-section-table) keeps its @container (960, theme/65-dropdown.css) and must
* NOT be measured: its rows hold widgets (enhance() above turns every <select> into a
* ui.Dropdown) and a widget bakes in the width of the layout it was laid out in, so
* un-collapsing it to read it CHANGES what is read. Measured on the router: the firewall zone
* table then reported needing 1747px where it really needs 1190px and overflowed its section by
* 557px — an overflow the CSS-only version never had. A data table has no widgets, which is why
* it is the one that gets measured. */
const STACKABLE = '#view .table.fs-dt';
/* "Too cramped to be a table any more" — a DESIGN judgement. It has to be one: these tables do
* NOT overflow when the room runs out (their cells break anywhere), they compress into an
* unreadable ribbon. Do NOT give the cells a min-width so that "cramped" MANUFACTURES an
* overflow: tried, and it carded the firewall's zone table at 1420px and still overflowed by
* 39px once carded — a floor big enough to force the overflow is big enough to break the card. */
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px
* tier .fs-content pads 16px a side, so 600 -> 568 of room */
/* The ribbon has one more shape, and CRAMPED cannot see it: the table has room by the number
* above and still shreds its FIRST column, because auto table layout hands width out by what
* each column DEMANDS. The leftmost column is the row's identity and usually the least greedy —
* a wide neighbour (a hostname plus an IPv6, a modulation string) simply takes the width, and
* `overflow-wrap: anywhere` (theme/30-tables.css) lets the identity be squeezed with no floor:
* it breaks mid-word rather than overflow, so there is no overflow for fit.overflows() to read.
*
* Measured on the router (Wireless, one station, `Access Point "vaka_devices" (phy6-ap0)`):
* viewport 900 -> the column is 101px and 5 lines, 850 -> 80px and 7, 800 -> 76px and 8, and at
* NO width did the table card — a nine-line tower of half-words next to columns with room to
* spare (issue #7). Below 767 the MAC column drops out (the stock phone contract) and the
* column springs back to 167px, which is why this only ever bit between roughly 780 and 900.
*
* So: past this many lines the identity has stopped being readable and the card view — which
* gives every field its own labelled row — is simply better. A DESIGN judgement like CRAMPED,
* and it has to be one: any number of lines is legible in isolation. 5 is what the reporter
* asked for and what the measurements above bracket. Only the first column, and deliberately:
* a value column wrapping to a few lines is a value being shown, not a table falling apart. */
const MAX_ID_LINES = 5;
/* Is any row's leftmost cell a tower? Text lines, not height — fs-fit.textLines() explains why.
* The height gate in front of it is not premature: this runs on every poll tick (once a second,
* every mutation), and Processes/Connections render hundreds of rows whose first cell is a PID
* that cannot be a tower — one cheap read each keeps the Range walk for the cells that could. */
function idTower(t) {
const cells = t.querySelectorAll('.tr > .td:first-child');
if (!cells.length) return false;
const cs = getComputedStyle(cells[0]);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2 || 16;
for (const cell of cells) {
if (cell.clientHeight < MAX_ID_LINES * lh) continue;
if (fit.textLines(cell) > MAX_ID_LINES) return true;
}
return false;
}
function fitTables() {
document.querySelectorAll(STACKABLE).forEach((t) => {
const was = t.classList.contains('fs-stacked');
/* fs-fit rule 1: a stacked table is a pile of flex rows and always "fits", so reading
* it as it stands un-stacks it and the next frame stacks it again — oscillation. */
t.classList.remove('fs-stacked');
const room = fit.roomFor(t);
if (!(room > 0)) { if (was) t.classList.add('fs-stacked'); return; }
/* idTower last: it is the only one that walks the rows */
const stack = room < CRAMPED || fit.overflows(t) || idTower(t);
/* write only on a real change: the poll re-renders these tables once a second, and
* toggling the class off and on each tick would invalidate style for every row of
* Processes/Leases for nothing */
if (stack) t.classList.add('fs-stacked');
else if (was) t.classList.remove('fs-stacked');
});
}
/* ---- A PINNED ACTIONS COLUMN IS ONLY VALID FOR THE LAYOUT MODE IT WAS MEASURED IN ----
*
* luci-base's `form.js` (stabilizeActionColumnWidth) measures the widest
* `td.cbi-section-actions > div` and writes that number as an INLINE `width` and `min-width` onto the
* header cell, the footer cell and every actions cell, caching it in `data-action-col-width`. It does
* re-run on window resize — but it only deletes the CACHE, never the inline widths, so the fresh
* measurement reads the width it pinned last time. The pin feeds itself and can only ever grow.
*
* On a stock theme that is invisible: a config table is a table at every width, so every measurement
* is taken in the same layout. This theme cards it under `@container fs-content (max-width: 960px)`
* (theme/65-dropdown.css), where the actions cell is `flex: 1 1 100%` and its buttons deliberately
* spread across the whole card — so a measurement taken there is the CARD's width, and carrying it
* into table mode makes the column absurd.
*
* Measured on the router, Network -> Firewall -> Zones: loaded at 1000px (carded) and grown to
* 1280px, the actions column pins 634px, the table renders 1267px inside a 1056px content column and
* the column scrolls sideways by 256px — permanently, because upstream's own re-measure reads the
* pin. A FRESH load at 1280px renders the same table at 966px with a 192px actions column. Shrinking,
* and growing within table mode, were always fine; it is the card -> table crossing that breaks.
*
* So drop the pin whenever the layout it was measured in stops being the layout on screen. Upstream
* re-measures from a clean DOM on its own resize listener and pins the right number; if it does not,
* the natural width is what we wanted anyway.
*
* THE KEY IS THE ROOM, NOT THE MODE, and starting from the mode alone missed half of it: the card ->
* table crossing is one way a pin goes stale, and the card simply getting NARROWER is the other. At
* 768px this theme has no sidebar (data-narrow) and the column is 712px; at 800px the sidebar returns
* and the column is 520px — the viewport grew, the room shrank, and the table was carded on both
* sides, so a mode test sees no change at all. Measured: firewall/zones and wireless both kept a
* `min-width: 670px` cell in a 520px column, 154px of scroll. Keying on the room catches both, since
* a mode change cannot happen without one.
*
* The room is the parent's content box (fit.roomFor), which the table's own width does not feed back
* into — so wiping the pin cannot change the key and set this oscillating. It fires once per CHANGE,
* never per tick, so it does not fight upstream for the pin on a polled page. */
function unpinActionColumn() {
for (const t of document.querySelectorAll('#view .table.cbi-section-table')) {
if (!t.querySelector('.cbi-section-actions')) continue;
/* ---- and CLAIM upstream's resize hook, because under SPA navigation it is a leak ----
*
* stabilizeActionColumnWidth ends by attaching `window.addEventListener('resize', …)` once per
* TABLE ELEMENT, guarded by this expando, and the callback closes over that element. Nothing
* ever removes it. On a stock theme the next page is a full load and the listener dies with the
* document; here the document lives for the whole session, so every visit to a config page
* leaves another listener holding another detached table.
*
* Measured on the router over 120 navigations: window went from 1 resize listener to 31, and a
* heap snapshot 280 navigations wide grew by 26 880 UniqueElementData, 23 600 Text nodes,
* 18 520 EventListener and 1 160 <form> — a straight 11.8 KB per navigation that never
* plateaus once the module cache is full.
*
* Setting the flag before upstream reaches it means the listener is never attached, and nothing
* is lost: what it existed to do — re-measure the column when the width changes — is what the
* wipe below now does, from the room rather than from a window event. The fitter runs
* SYNCHRONOUSLY on the mutation batch that inserts the table (fs-fit rule 2), which is what
* makes claiming it in time possible at all; a table we somehow reach late simply keeps
* upstream's listener, i.e. today's behaviour. */
t.__actionColResizeAttached = true;
const key = Math.round(fit.roomFor(t));
if (t._fsActRoom === key) continue;
const seen = (t._fsActRoom !== undefined);
t._fsActRoom = key;
/* the first sighting is not a CHANGE: nothing has been pinned in another layout yet */
if (!seen) continue;
delete t.dataset.actionColWidth;
t.querySelectorAll('.cbi-section-actions').forEach((el) => {
el.style.removeProperty('width');
el.style.removeProperty('min-width');
});
}
}
/* Does this batch contain anything we could care about? Without it EVERY mutation scheduled a
* full scan — and the poll rewrites content once a second, so on Overview/Processes/Leases we
* ran three document-wide querySelectorAll plus a choicesKey() over every option of every
* enhanced select (thousands of characters on the firewall page) every second, forever, to
* discover that nothing had changed. */
function relevant(mutations) {
/* attributeFilter narrows the ATTRIBUTE, not the element: `value`/`disabled` live on inputs
* and buttons too, and a poll rewriting an input's value would otherwise wake the whole
* scan. This half is ours alone; the added-node walk below is fs-fit's shared one. */
for (const m of mutations) {
if (m.type === 'attributes' && m.target.tagName === 'SELECT')
return true;
/* …and a REBUILT OPTION LIST. `sel.replaceChildren(new Option(…))` puts <option> elements in
* addedNodes, and the shared walk below asks whether an added node IS or CONTAINS a select —
* an <option> is neither, so the batch was dropped and resync() never re-keyed the widget.
* Measured on the firewall page: after replaceChildren the native select listed AAA/BBB
* while the widget still offered reject/drop/accept, and picking from the stale list wrote a
* value the new list does not contain, i.e. `''`. CBI dependency handling rebuilds option
* lists constantly, which is the case resync() was written for. */
if (m.type === 'childList' && m.target.tagName === 'SELECT')
return true;
}
/* `.table`, not `table.table` — the same selector tagDataTables() and STACKABLE use.
* Additions only: a select or a table going away costs us nothing to notice. */
return fit.touches(mutations, 'select.cbi-input-select, .table');
}
/* ---- TYPE-AHEAD: jump to an option by typing its first letters ---------------------
*
* A native <select> gives this for free, and it is the only way anyone picks a country out of
* 248 entries. enhance() hides the native select, and ui.Dropdown.handleKeydown (luci-base) does
* only Esc/Enter/Space/arrows — no letter search — so Wireless -> Country Code became 248 items
* you could only scroll. (Stock LuCI never had it either; bootstrap only appears to, because it
* leaves that field a real <select>.)
*
* One document-level listener (a dropdown's <ul> holds focus while open), for EVERY
* .cbi-dropdown — ours and LuCI's own. Native semantics: only while OPEN; printable keys, no
* modifiers; buffer resets after a pause; the SAME letter repeated cycles (how you reach the
* second "Germany"); matches the LABEL first, then the value, so "RU" and "Russia" both find it.
* SPACE is deliberately excluded: ui.Dropdown binds it to "toggle the focused item" and its
* handler fires first, so treating it as a character would select something.
*
* Only HIGHLIGHTS (setFocus, as the arrows do); Enter/Esc stay ui.Dropdown's. */
const TYPEAHEAD_RESET_MS = 1000;
let _taBuf = '', _taTimer = null, _taLast = null;
function typeaheadItems(sb) {
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
if (!ul) return [];
return [...ul.children].filter((li) =>
li.tagName === 'LI' &&
/* the "custom value" row (options.create) is an input, not a choice */
!li.querySelector('input:not([type="hidden"])') &&
li.getClientRects().length > 0);
}
function typeaheadLabel(li) {
return (li.textContent || '').trim().toLowerCase();
}
function wireTypeahead() {
document.addEventListener('keydown', (ev) => {
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
if (!ev.key || ev.key.length !== 1 || ev.key === ' ') return;
/* the create-item input is a text field — let the user type into it */
if (ev.target && ev.target.matches && ev.target.matches('input, textarea')) return;
const sb = ev.target.closest?.('.cbi-dropdown[open]');
if (!sb) return;
const items = typeaheadItems(sb);
if (!items.length) return;
/* a new dropdown starts a new search, however fast the user got here */
if (sb !== _taLast) { _taBuf = ''; _taLast = sb; }
const ch = ev.key.toLowerCase();
/* repeating one letter cycles; anything else extends the search */
const repeat = (_taBuf.length === 1 && _taBuf === ch);
const needle = repeat ? ch : (_taBuf + ch);
const start = items.findIndex((li) => li.classList.contains('focus'));
/* on a repeat, look AFTER the current item so the same letter walks forward; otherwise
* the search restarts from the top, as a native select does */
const from = repeat ? start + 1 : 0;
/* matches the LABEL first, then the value, so "RU" and "Russia" both find it */
const matches = (n) => (li) => typeaheadLabel(li).startsWith(n) ||
String(li.getAttribute('data-value') || '').toLowerCase().startsWith(n);
const match = matches(needle);
/* wrap around: the second pass covers what the first skipped */
let hit = items.slice(from).find(match) ?? items.find(match);
if (!hit && !repeat) {
/* the extended buffer matches nothing — treat this keystroke as a fresh search
* instead of swallowing it, so a mistyped letter is recoverable */
hit = items.find(matches(ch));
if (hit) _taBuf = '';
}
if (!hit) return;
_taBuf = repeat ? ch : (_taBuf + ch);
if (_taTimer) window.clearTimeout(_taTimer);
_taTimer = window.setTimeout(() => { _taBuf = ''; _taLast = null; }, TYPEAHEAD_RESET_MS);
/* the widget's own highlighter: adds .focus, scrolls the item into view and focuses it,
* so Enter (ui.Dropdown's handler) commits exactly what is highlighted */
const inst = dom.findClassInstance(sb);
if (inst && typeof inst.setFocus === 'function')
inst.setFocus(sb, hit, true);
else
hit.focus();
ev.preventDefault();
ev.stopPropagation();
});
}
return baseclass.extend({
__init__() {
wireTypeahead();
const scan = () => {
document.querySelectorAll('select.cbi-input-select:not([data-fs-select])').forEach(enhance);
document.querySelectorAll('select.cbi-input-select[data-fs-select="1"]').forEach(resync);
};
scan();
/* A table must be TAGGED .fs-dt before it can be fitted, and re-tagged whenever the poll
* brings a fresh one back — so the two travel as one fitter, which fs-fit runs now, on
* every content mutation (synchronously, pre-paint) and on every resize of #view. */
fit.add(() => { tagDataTables(); fitTables(); unpinActionColumn(); resyncValues(); });
/* one scan per frame, however many mutations arrive (fit.frame — the theme's shared
* coalescer) */
const scanSoon = fit.frame(scan);
new MutationObserver((mutations) => {
if (relevant(mutations)) scanSoon();
}).observe(document.body, {
childList: true, subtree: true,
/* `disabled` flips and attr-driven value writes never mutate childList;
* watch them so resync()/enhance() notice (filtered — cheap) */
attributes: true, attributeFilter: [ 'disabled', 'value', 'selected' ]
});
}
});
@@ -0,0 +1,833 @@
'use strict';
'require baseclass';
/* ---- a view's injected CSS: never DELETE it; leave a poisoned document by a real load ----
*
* A view's <style> dies with the document on a full load; SPA nav never reloads, so it restyles
* every page after. `luci-app-filemanager` injects `.cbi-button-apply, .cbi-button-reset,
* .cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered + important,
* outranking every cascade layer: one visit and Save/Reset are gone from every config page.
*
* But DELETING them on nav broke SSClash. A poller can be re-registered by re-rendering the view;
* a stylesheet only returns if its injector runs AGAIN, and a library importing CSS at MODULE EVAL
* never will (module cached for the life of the document). ACE's ace_editor.css (14 KB of
* absolutely-positioned layers, gutter, line boxes) is imported once — after the sweep, navigating
* back to its editor gave a black rectangle 2 007 346 px tall. Deletion was silently one-way.
*
* So: a sheet matching only its OWN app's widgets (`.ace_*`, `.cpu-status-view-mode-entry`) is
* inert elsewhere — LEAVE it. One reaching into the widget universe the THEME styles
* (`.cbi-button-save`, `pre`, `:root`) can repaint any page: that document is spent, so refuse to
* hand it to another view and fall back to a REAL page load — speed traded, never correctness, and
* the fresh document carries no view CSS, so SPA nav resumes right after. That refusal is the SPA
* router's (fs-router.js) — this module only answers the question.
*
* `invasiveSheet()` is that test; its universe is read back from cascade.css itself (same-origin,
* so `cssRules` is readable) rather than a hand-written list, so it tracks the theme. 0.3 ms per
* nav. Exempt: `[data-fs-shell]` (the one <style> the server emits — marked, not guessed at) and
* anything inside `#view` (dies with the content swap); LuCI core injects no <style> at runtime at
* all (checked: luci.js, ui.js, cbi.js). If cascade.css cannot be read, EVERY view sheet counts as
* invasive: fail to the slow path, never the broken one. */
let _themeNames = null;
/* What counts as a NAME — a class or an id — in a selector. This is the vocabulary the whole zone
* test is written in: themeNames() harvests the theme's names with it, pinnedToApp() looks for the
* app's own name with it, and judgeSheet() asks whether a part names anything of ours with it. Three
* copies of the pattern sat under a comment explaining that two copies of the JUDGEMENT would drift
* into disagreeing — and a vocabulary that disagrees with itself is the same bug one level down: widen
* it in the harvester alone and names enter `names` that the other two can never match, so a selector
* that does reach the chrome reads as pinned and is left unfenced.
*
* Shared safely BECAUSE every use is String.match(): a /g regex is stateful under .test(), but
* [Symbol.match] resets lastIndex first. Do not call .test() on this one. */
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;
/* ---- A QUOTED VALUE IS DATA, AND EVERY SCANNER BELOW USED TO READ IT AS SYNTAX ----
*
* `[title="a,b"]` is ONE selector part carrying a comma; `[href*="("]` is one attribute carrying an
* unbalanced paren; `[data-x=".foo"]` names no class at all. Read literally, each of the three
* scanners in this file gets a different wrong answer out of the same string:
* - selectorParts() split `.app-row[title="a,b"]` into `.app-row[title="a` and `b"]`. The second
* half carries no class or id, so pinnedToApp() called it UNPINNED and judgeSheet() called the
* sheet invasive — documentPoisoned() then reported the document spent and the SPA fell back to a
* full load on every navigation for the life of the page, which is the exact failure the <link>
* caching bug above was written to end.
* - fenceRules() rejoins the parts with ', ', so that same rule came back as `[title="a, b"]` —
* the app's own selector silently rewritten to match a value it never asked for, by the setter
* reporting success. Deleting a rule is what this file exists to prevent; changing one is worse,
* because nothing looks wrong afterwards.
* - stripPseudoArgs() counts parens, so `[href*="("]` drove `depth` to 1 and never back: the whole
* remainder of the selector was eaten and a part pinned by the app's own id read as unpinned.
*
* One masker answers it for all three, so the vocabulary cannot disagree with itself the way the
* comment above NAME_RE warns about. It replaces the CONTENT of every quoted string with spaces and
* is length-preserving 1:1, which is what lets selectorParts() scan the mask and still slice the
* ORIGINAL — the fence must write back the app's own bytes, not our reading of them. An escape and
* the character it escapes are both content, so `\"` cannot close the string. */
function maskStrings(text) {
let out = '', q = null;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q === null) {
out += ch;
if (ch === '"' || ch === '\'') q = ch;
continue;
}
if (ch === '\\') {
out += ' ';
if (i + 1 < text.length) { out += ' '; i++; }
continue;
}
out += (ch === q) ? ch : ' ';
if (ch === q) q = null;
}
return out;
}
/* a re-hosted <style>'s text is no longer what its app wrote — dedupeViewSheets keys on the
* original, or the app's next identical copy stops looking like a duplicate (see there) */
const origText = new WeakMap();
function themeNames() {
if (_themeNames) return _themeNames;
const names = new Set(); /* every class and id the theme styles */
const props = new Set(); /* every custom property it declares or reads */
const walk = (rules) => {
for (const r of rules) {
/* masked like every other read of a selector: a `.foo` inside one of OUR quoted values
* would enter `names` as a name we style, and pinnedToApp() — which masks — could then
* never match it. That is the harvester-only widening NAME_RE's comment describes, and
* it ends with a foreign selector that does reach the chrome reading as pinned. */
if (r.selectorText)
(maskStrings(r.selectorText).match(NAME_RE) || []).forEach((n) => names.add(n));
if (r.cssText)
(r.cssText.match(/--[A-Za-z_][\w-]*/g) || []).forEach((p) => props.add(p));
if (r.cssRules) walk(r.cssRules);
}
};
for (const ss of document.styleSheets) {
if (!ss.href || !(/\/cascade\.css/).test(ss.href)) continue;
try { walk(ss.cssRules); } catch (e) { return null; }
}
_themeNames = names.size ? { names, props } : null;
return _themeNames;
}
/* Is this selector part held inside the app's OWN markup by a name the theme does not know?
* `#cbi-podkop-section > .cbi-section-remove` is: podkop's section has to exist for it to match
* anything, so it can never reach another page — or our chrome. A part made ENTIRELY of names the
* theme knows (`*`, `.nav`, `#indicators`, `ul.nav > li > a`) has nothing pinning it anywhere.
*
* Functional pseudo-class arguments are stripped before looking for the pin, and that is the whole
* difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
* an app class too, but inside a NEGATION — it does not require the app's markup, it excludes it.
*
* Shared by invasiveSheet() (is this sheet dangerous?) and fenceRules() (which parts get fenced?) —
* they must agree by construction: a part judged able to reach another page is exactly a part able
* to reach the chrome. Two copies of this test would drift into disagreeing. */
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
* part carrying a comma, and splitting it there hands both halves to pinnedToApp() as garbage —
* `.cbi-button-save:not(.custom-save-button` keeps a visible app name (the argument regex needs a
* closing paren to fire), so the file manager's own motivating rule reads as pinned and is neither
* judged nor fenced. Measured on the router: `documentPoisoned()` said clean. `:not(a, b)` is
* ordinary modern CSS, not an exotic.
*
* Scans the MASK and slices the ORIGINAL (see maskStrings): a comma inside `[title="a,b"]` is a
* character in a value, not a separator — while the parts handed back must be the app's own bytes,
* because fenceRules() joins them straight back into selectorText. */
function selectorParts(text) {
const scan = maskStrings(text);
const out = [];
let depth = 0, start = 0;
for (let i = 0; i < scan.length; i++) {
const ch = scan[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
else if (ch === ',' && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
}
out.push(text.slice(start).trim());
return out.filter(Boolean);
}
/* Drop every functional pseudo-class ARGUMENT, nesting included. The old regex
* (`/:[a-z-]+\([^)]*\)/g`) stops at the first `)`, so `:not(:is(.app))` left a stray `)` and, worse,
* left `.app` looking like a pin.
*
* Works on the MASK, which does two things at once here: a paren inside `[href*="("]` no longer
* drives `depth` into a hole it never comes back from, and the `.foo` in `[data-x=".foo"]` stops
* looking like the app's own pin to pinnedToApp() — the only caller. Its output is read by NAME_RE
* and never written back to the CSSOM, so masking the content away costs nothing. */
function stripPseudoArgs(part) {
const scan = maskStrings(part);
let out = '', depth = 0;
for (let i = 0; i < scan.length; i++) {
const ch = scan[i];
if (ch === '(' ) { depth++; if (depth === 1) { out += ' '; continue; } }
if (ch === ')') { depth--; continue; }
if (!depth) out += ch;
}
return out;
}
function pinnedToApp(part, names) {
return (stripPseudoArgs(part).match(NAME_RE) || []).some((n) => !names.has(n));
}
/* A rule with a bare SELECTOR (`:root`, `pre`, `*`) still cannot touch us if none of its
* DECLARATIONS can: a custom property this theme never reads is inert. That is the difference
* between an app costing a full page load and not — `luci-app-temp-status` opens with
* `:root { --app-temp-status-temp: #147aff; … }`, and both it and the file manager's hex editor
* would otherwise read as "document spent" on the strength of the selector alone.
*
* Still invasive: any STANDARD property on a bare selector (the stock file manager writes
* `:root { color-scheme: light dark }`, re-pointing every UA widget at the OS preference), and any
* custom property the THEME reads — the point of the private `--fs-*` tier is that an app writing
* `--accent`/`--radius` on `:root` cannot repaint us, and this must keep it so for names we read. */
function inertDeclarations(rule, props) {
const st = rule.style;
if (!st || !st.length) return false; /* no declarations to judge -> judge by selector */
for (let i = 0; i < st.length; i++) {
const p = st.item(i);
if (p.slice(0, 2) !== '--') return false; /* a real property: it paints something */
if (props.has(p)) return false; /* a custom property the theme itself reads */
}
return true;
}
/* ---- the verdict is a property of the sheet, taken BEFORE we rewrite it -------------------
*
* An invasive verdict is STICKY, and it has to be: the moment rehostIntoThemeLayer() fences a sheet,
* what stands in the DOM is no longer the CSS its app wrote, and re-judging our own edit answers a
* different question than the one asked.
*
* It used to answer the right one BY ACCIDENT. The fence named a class (`.fs-sidebar`), so a fenced
* selector still carried a name the theme styles, still tripped the `themeHit` test, and
* documentPoisoned() went on reporting the document spent. Nothing said that was load-bearing —
* moving the fence onto an attribute leaves no class name in the text, every fenced document would
* have read CLEAN, and the SPA would have carried openclash's `*{padding:0!important}` into the next
* page with the chrome fenced and the content flattened. Take the verdict once, keep it.
*
* Only `true` is kept. A clean sheet can still GROW hostile rules — an app that builds its CSS with
* insertRule() has an EMPTY sheet the first time we look — so a clean verdict stays provisional and
* is re-taken on every ask. */
const _invasive = new WeakSet();
function invasiveSheet(el, universe) {
if (_invasive.has(el)) return true;
const v = judgeSheet(el, universe);
/* CACHE ONLY A VERDICT WE COULD ACTUALLY READ.
*
* A <link> has NO .sheet until its bytes land, and judgeSheet's "unreadable -> invasive" default
* therefore fires for EVERY linked app stylesheet at the instant <head>'s observer first sees
* it. Remembering that verdict is what turned a benign sheet into a permanently spent document:
* measured on the router, `luci-app-mwan3` ships ONE rule —
* `#mwan3-service-status > .alert-message { … }`, pinned to the app's own id, which this
* module's own judgement calls clean — and its <link> on the Overview left documentPoisoned()
* true for the life of the page, so every navigation FROM THE LANDING PAGE was a full load.
* Proven by serving those same bytes twice: as a <style> the document stayed clean, as a <link>
* it went poisoned. Nothing about the CSS decided it; only how it arrived.
*
* Safe to re-take, and the "never re-judge our own edit" rule above still holds, because the
* two cases do not overlap: rehostIntoThemeLayer() edits a <style>'s text and fences its rules
* — and a <style> in the document always HAS a sheet, so its verdict was cached when taken and
* is never re-taken. A <link> it never edits at all (it disables the element and re-imports the
* href into the theme layer), so a later read still sees the app's untouched CSS, which is the
* right question. A 404 or cross-origin sheet keeps answering `true` on every ask exactly as
* before — it simply is not remembered, which changes nothing.
*
* Conservative WHILE unreadable is preserved: `v` is still returned as taken, so a hostile
* sheet is fenced and re-hosted on sight and the document reads spent until the bytes prove
* otherwise. Only the memory is dropped. */
let readable;
try { readable = !!el.sheet; } catch (e) { readable = false; }
if (v && readable) _invasive.add(el);
return v;
}
/* true when this sheet can repaint a page that is not its own. A sheet that is not readable —
* still loading, 404, cross-origin — is invasive by default: unknown CSS takes the slow path,
* never the broken one. */
function judgeSheet(el, universe) {
let sheet;
try { sheet = el.sheet; } catch (e) { return true; }
if (!sheet) return true;
const { names, props } = universe;
let invasive = false;
const walk = (rules) => {
for (const r of rules) {
if (invasive) return;
if (r.selectorText) {
/* ONE question, the SAME one fenceRules() asks: is this part held inside the app's
* own markup by a name the theme does not know? `#cbi-podkop-section >
* .cbi-section-remove` is — podkop's section has to exist for it to match anything,
* so it can never reach another page or our chrome. A part with no such pin matches
* the same widgets everywhere, and that is what invasive MEANS.
*
* This used to ask a second question first — "does it name anything the theme
* styles?" — and skip the part when the answer was no. That is the hole: a pin is a
* name the theme does NOT know, so "names nothing of ours" was read as "pinned" when
* it often means the exact opposite. Measured on the router, verdict CLEAN and 95 of
* 338 chrome elements flattened, by two selectors an app could write by accident:
* *:not(#zzz) { padding: 0 !important } ← the `#` is inside a NEGATION
* [class] { padding: 0 !important } ← no class/id name at all
* Both are unpinned, both match the whole document. fenceRules() already keyed on
* pinnedToApp() alone and would have fenced them — it never got the chance, because
* the judge called the sheet clean and nothing was re-hosted. The comment above
* pinnedToApp() claims the two agree "by construction"; now they do. */
for (const p of selectorParts(r.selectorText)) {
if (pinnedToApp(p, names)) continue;
/* Unpinned, but it may still be unable to touch us: a rule whose every
* declaration is a custom property this theme never reads is inert wherever it
* lands (`:root { --app-temp-status-temp: … }`). */
if (inertDeclarations(r, props)) continue;
invasive = true;
return;
}
}
if (r.cssRules) walk(r.cssRules);
/* an @import's rules are not r.cssRules — follow it, or the verdict is blind to every
* rule behind it. Re-hosting a <link> produces exactly such a shim, and a sheet that
* judged its own shim inert would report a document clean while still carrying the
* poison into the next page. Unreadable (cross-origin) import: invasive, like any
* sheet we cannot read. */
if (r.styleSheet) {
let imported;
try { imported = r.styleSheet.cssRules; } catch (e) { invasive = true; return; }
if (imported) walk(imported);
}
}
};
try { walk(sheet.cssRules); } catch (e) { return true; }
return invasive;
}
/* Both element kinds count; the <link> half is not hypothetical: `luci-app-banip` and
* `luci-app-adblock` append `<link rel=stylesheet href=…/custom.css>` to <head> at MODULE EVAL,
* and it styles `.cbi-input-text`/`.cbi-input-select` — stock widgets, every page, unlayered. A
* <link> INSIDE the view tree (`luci-app-nlbwmon`) needs no handling: it dies with the swap. */
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
/* An invasive sheet we OWN is contained — scopeToCurrentPage() darkens it the moment the router
* stamps the new page, so it cannot reach the next page and the document is not spent. One we could
* not attribute (not re-hostable, so never owned: an @import at the top, a sheet built with
* insertRule(), anything unreadable) still spends it, which is the pre-existing behaviour and the
* conservative half.
*
* A SILENCED sheet is contained too, and missing that undid the whole of the above for the <link>
* half. Re-hosting a <link> owns the @import SHIM and silences the ORIGINAL for good (see
* rehostIntoThemeLayer) — but the original stays in the document and a disabled sheet still answers
* `cssRules`, so it re-judged as invasive on every ask, was owned by nobody, and this returned true
* for the life of the document. Which means the very apps this module was written for
* (`luci-app-banip`, `luci-app-adblock`, `luci-app-openclash` — all three inject a <link>) turned
* the SPA router off entirely: a full page load on every navigation, from the moment such a page was
* opened until the tab was closed. The <style> half never showed it, because a <style> is re-hosted
* IN PLACE and therefore owned.
*
* Sound for the same reason ownership is: `el.sheet.disabled = true` is what decides whether CSS
* paints (silence() explains why the element flag alone is not enough), and nothing re-enables it —
* scopeToCurrentPage() only ever touches sheets in `_owner`, and the original is deliberately not
* one. A sheet that paints nothing cannot poison the next page. */
function documentPoisoned() {
const names = themeNames();
return Array.prototype.some.call(
document.querySelectorAll(VIEW_SHEETS),
(el) => !el.closest('#view')
&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
}
/* ---- an invasive sheet still has to render ITS page: re-host it into the theme LAYER ----
*
* documentPoisoned() saves every page AFTER this one. It cannot save this one — the sheet is
* already applying. Every footstrap rule lives in a @layer, and an UNLAYERED normal declaration
* beats a layered one at any specificity, so a third-party reset owns the chrome outright:
* `luci-app-openclash` ships `* { margin: 0; padding: 0 }` (a log.htm reset, leaked document-wide
* by a <link> its Lua template prints into .fs-content). Measured on the router with those two
* rules and nothing else: menu text flush at x=0 with the icons clipped, submenu indent gone, tabs
* collapsed to a bare text row. On stock luci-theme-bootstrap — no layers — the same `*` (0,0,0)
* loses to any class selector on specificity and nobody ever noticed. That asymmetry is the whole
* bug, and it is ours: the layers are what handed a 0,0,0 selector the win (issue #8).
*
* So put the sheet back on specificity footing: re-host it into the EXISTING `theme` layer. Only
* same-layer arbitrates by specificity — which is exactly what makes bootstrap survive. Measured,
* all three placements, on the real cascade:
*
* app unlayered (today) chrome BROKEN app's own design OK
* app -> @layer theme chrome OK app's own design OK
* app -> @layer before theme chrome OK app's own design BROKEN
*
* The tempting shape is the third — give the app its own layer under the theme. Do NOT: the theme
* then beats the app at ANY specificity, including the rules it aims at its own page. OpenClash
* restyles its tabs with `#tab-header ul.cbi-tabmenu li` (1,1,2) and footstrap styles
* `ul.cbi-tabmenu li` (0,1,2) — demote the app and footstrap repaints the app author's own tabs.
* In `theme` the app keeps them, and `*` still loses to the chrome's 0,3,1. No new layer is
* declared; a re-opened `@layer theme` block appends to the one 00-header.css already names.
*
* What this deliberately does NOT fix: `base`. The app must outrank `theme` for its own page to
* work, so it sits above `base`, and `*` still wipes base's widget padding (cssdiff: `input`
* 4px->0, `.ifacebadge`, `strong` margins). That is not a regression — an unlayered `*` beats base
* today too — it is the price of the same trade, and the only way out would re-break the app.
*
* NEVER delete the sheet instead (see dedupeViewSheets below for what that cost). Re-hosting moves
* where a rule lands in the cascade; every rule still exists, so a library's "did I already import
* this?" check still finds its sheet. A <link> is DISABLED rather than removed, so an app that
* looks its own <link> back up by href still finds the element. */
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
*
* Re-hosting into the theme layer settles a fight on specificity. It cannot settle one against
* `!important`, because importance ranks ABOVE layers — measured: `* { padding: 0 !important }`
* still owns the chrome after re-hosting, and so does `#indicators { display: none !important }`.
* The only pure-CSS answer to a foreign flag is our own flag in an earlier layer (it wins — also
* measured), but that means ~550 of them, and `color`/`background` among them would beat this
* theme's OWN forced-colors block. Fixing the cascade by breaking high-contrast is not a fix.
*
* So do not out-rank the rule — put the chrome where it cannot be addressed. Appending
* `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's SUBJECT leaves it
* matching everything it used to except us. `!important` has nothing left to win.
*
* The chrome is NOT one element, and naming one is how this went wrong the first time: the fence
* said `.fs-sidebar`, which is the menu in both layouts (the bar is the same markup) — but the skip
* link is a sibling of .fs-shell and the Appearance popover hangs off <body>, so both stayed exposed
* while every test said the chrome was defended. `data-fs-chrome` is the fix: an element DECLARES
* that it is ours, where it is written (header.ut, fs-appearance.js), and the fence and the pin
* follow without being told. A future chrome root cannot forget to edit a constant in this file,
* because there is no constant naming it any more. `npm run chrome-fence` holds the three together.
*
* `:where()` is load-bearing and not cosmetic: it contributes ZERO specificity, so `*` stays 0,0,0
* and `#indicators` stays 1,0,0 and the app's rules keep their exact weight against each other and
* against the theme on its own page. A plain `:not(.fs-sidebar)` takes its argument's specificity
* and would silently re-order the app's stylesheet against itself.
*
* Only UNPINNED parts are fenced. A part pinned by the app's own name cannot reach the chrome
* anyway (proven by the same test invasiveSheet uses), so leaving it alone costs nothing and keeps
* the surgery as small as the danger.
*
* Two silent traps, both measured on the real CSSOM, both of which cost the app its rule:
* - A selector LIST must be fenced part by part. Appending to the whole `selectorText` fences only
* the last part: `*, ul` came back as `*, ul:where(…)` — `*` unfenced, chrome still exposed.
* - A pseudo-element must stay LAST. `a::after` + a tail append serialised to `a::after:where()`
* — the argument silently EATEN, leaving an empty `:where()` that matches NOTHING, and the
* setter reported success. The fence goes before the pseudo-element: `a:where(…)::after`. */
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
function fenceSelector(part) {
/* the getter always normalises a pseudo-element to `::`, incl. legacy `:before` */
const i = part.indexOf('::');
return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
}
function fenceRules(rules, names) {
for (const r of rules) {
if (r.selectorText) {
const parts = selectorParts(r.selectorText);
if (parts.length && parts.some((p) => !pinnedToApp(p, names))) {
/* The setter parses the whole selector and, on one it cannot parse, does NOTHING and
* does not throw — so it is atomic: never a half-written selector, and a failure just
* leaves the rule where it already was (unfenced, i.e. today's exposure). */
try {
r.selectorText = parts
.map((p) => (pinnedToApp(p, names) ? p : fenceSelector(p))).join(', ');
} catch (e) { /* left unfenced on purpose: the app keeps its rule */ }
}
}
if (r.cssRules) fenceRules(r.cssRules, names);
}
}
/* An @import's rules live in a sheet that is fetched separately, so they are not there the moment
* the shim is inserted — retry until they are, then fence. Bounded: a sheet that never becomes
* readable (404, cross-origin) simply stays unfenced, which is where we already were. */
function fenceImported(styleEl, names, tries) {
/* no initialiser: every path below assigns (the try, or the catch's null), so `= null`
* here was a dead store — eslint 10 puts no-useless-assignment in recommended and said so. */
let rules;
try {
const first = styleEl.sheet && styleEl.sheet.cssRules[0];
rules = first && first.styleSheet && first.styleSheet.cssRules;
} catch (e) { rules = null; }
if (rules) { fenceRules(rules, names); return; }
if (tries > 0) requestAnimationFrame(() => fenceImported(styleEl, names, tries - 1));
}
/* What a sheet IS, as text: the rules that are APPLYING, not the markup that may or may not have
* produced them. Serialised only ever to COMPARE — never re-parsed, so the serialiser cannot cost
* anyone a rule. */
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
/* ---- a <style>'s textContent is NOT its sheet, and both the wrap and the dedupe assumed it was ----
*
* Wrapping means re-setting textContent, which RE-PARSES: whatever the parse does not reproduce is
* deleted — silently, by the one fix in this file whose entire thesis is that deleting a view's CSS
* is one-way (see the head of the file). Two shapes where the text does not describe the sheet, and
* they are one question, not two:
* - an app that builds its CSS with insertRule() — an empty <style> appended first, rules pushed in
* after: the text is EMPTY while the rules apply, so the wrap writes `@layer theme {}` over a live
* sheet and every rule in it is gone.
* - a <style> carrying @import: it is invalid inside @layer and has to sit at the top of a sheet, so
* the wrapped copy comes back without it.
*
* So ask the exact question once — does re-parsing this text give back the sheet that is applying? —
* rather than enumerate the shapes that make it false; the enumeration is what missed insertRule().
* The probe is a CONSTRUCTIBLE sheet: never adopted, so nothing paints, no <head> mutation, and our
* own observer never sees it. It also drops @import (per spec), which is why that case needs no test
* of its own — the serialisations differ and the answer is already no.
*
* No probe means no answer, and the honest answer to "may I re-parse this?" when we cannot check is
* NO: the sheet keeps every rule and the fence still holds Zone 1 without it. */
let _probe = null;
function textIsSheet(el, live) {
try {
if (!_probe) _probe = new CSSStyleSheet();
_probe.replaceSync(el.textContent);
return serializeRules(_probe.cssRules) === serializeRules(live);
} catch (e) { return false; }
}
/* Sheets taken out of the cascade FOR GOOD — the re-hosted <link> originals. Kept because a
* silenced sheet is still an ELEMENT in the document that still answers `cssRules`, so every later
* ask re-judges it as invasive; documentPoisoned() explains what that cost. */
const _silenced = new WeakSet();
/* Take a re-hosted <link> out of the cascade — and MEAN IT.
*
* `el.disabled = true` alone does not do it, and the failure is silent and total. The IDL attribute
* forwards to the ELEMENT's own flag; the thing that decides whether the CSS paints is
* `el.sheet.disabled`, and a <link> that is still LOADING has no `.sheet` at all. Every runtime
* injection is in exactly that state when <head>'s observer hands it here — which is the case this
* module exists for (`luci-app-banip` and `luci-app-adblock` append their <link> at module eval,
* openclash prints one from its template). So the assignment landed on nothing, the sheet came up
* ENABLED when the bytes arrived, and the app's ORIGINAL, UNFENCED CSS went on painting beside the
* fenced @import shim.
*
* Measured on the router with `* { padding: 0 !important }` behind a runtime <link>: `el.disabled`
* read back `true`, `el.sheet.disabled` was `false`, and 95 of the 338 chrome elements were
* flattened — the sidebar's own padding went 0px 88px -> 0px — while the shim's fenced copy sat
* there matching nothing. Setting `el.sheet.disabled = true` by hand restored all of it. The
* documented "openclash: 47 damaged -> 0" holds only because THAT sheet is server-rendered and has
* therefore already loaded by the time the immediate pass sees it.
*/
function silence(el) {
_silenced.add(el);
el.disabled = true;
if (el.sheet) { el.sheet.disabled = true; return; }
/* no sheet yet: re-assert once there is one. `once` — the element is marked fsLayered, so this
* never re-arms, and a sheet that never loads has nothing to silence. */
el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
}
/* ---- PAGE OWNERSHIP: contain an invasive sheet instead of spending the document ----
*
* A foreign sheet is injected by ONE page and has no business painting any other. Before this, an
* invasive sheet made the whole document spent (documentPoisoned) and the SPA fell back to a full
* load on the way OUT — correct, but stock LuCI pays it: `view/status/cpu.js` and its four realtime
* siblings each `document.head.append(E('style', …))` at MODULE EVAL, and that style carries
* `svg text { fill: #eee; font-size: 9pt }` — real properties on a bare selector, so invasive by the
* only definition that catches `[class] { padding: 0 !important }`. Measured on the router: 37 of 38
* pages navigated in-place instead of 38, `status/realtime/load` 15 ms -> 157 ms, and the whole
* realtime family 2-10x slower.
*
* Removing the sheet on the way out is NOT the fix, and it is the obvious one: the append is at
* module top level, `L.require` caches the module, so a second visit re-runs nothing and the page
* would render unstyled. Disabling is reversible, which is the whole difference.
*
* OWNER = body[data-page] when the sheet was re-hosted. The order that makes this sound is in
* fs-router.js: it stamps data-page (line ~371) BEFORE require()ing the view class (~450), so at the
* moment a view module evaluates and appends its <style>, the attribute already names ITS page. On a
* full load the server stamped it. Either way "now" is the sheet's own page.
*
* Recorded on the element that PAINTS, never on the one that was permanently silenced: for a <link>
* that is the @import shim, and re-enabling the original instead would undo silence() and put the
* app's unfenced CSS back over the chrome — the measured 95-of-338 flattening.
*
* THE OWNER IS THE APP, NOT THE PAGE, and that is not a guess — per-page was written first and
* swept: `luci-app-zapret2` has three pages that share ONE injected <style>
* (`.label-status { … !important }`), so it was owned by whichever loaded first and arrived DARK on
* the other two. Per-page ownership silently un-styles any app whose pages share an injector, which
* is a whole class of app, not a corner. `admin/<group>/<app>` — the first three dispatch segments —
* is the smallest key that keeps an app's own pages together while still blocking the leak this
* exists to block: onto OTHER apps and onto stock pages. The sweep is
* `tools/...`-less on purpose (it needs a live router); re-run it against a router with third-party
* apps after touching this, and look for a sheet that a full load has and an SPA arrival does not.
*
* Segments, never the dash-joined `data-page`: a dispatch segment may itself contain a dash
* (`admin/system/package-manager`), so splitting the attribute on '-' would cut inside a name. */
const _owner = new WeakMap();
const APP_DEPTH = 3;
/* The router hands this over on every navigation (it holds the resolved segments). Until it does —
* the initial full load — ask the SERVER which page it dispatched to. */
let _curKey = null;
function appKey(segs) {
return (segs || []).slice(0, APP_DEPTH).join('/');
}
/* ---- THE URL IS NOT THE PAGE, and a sheet keyed on the URL is a sheet that dies ----
*
* `L.env.dispatchpath` is the leaf the SERVER resolved this request to; the address bar holds what
* was ASKED for, and LuCI's dispatcher walks a node down to its firstchild without rewriting it.
* Two shapes of that, both ordinary:
*
* /cgi-bin/luci/admin/status -> admin/status/overview the Status menu's OWN link
* /cgi-bin/luci/ -> admin/status/overview the landing page, on a router with no
* luci-mod-dashboard
*
* The URL says `admin/status` and `''`; the router, one navigation later, hands
* scopeToCurrentPage() the RESOLVED `admin/status/overview`. Those keys can never match, so the
* first SPA navigation away from such a page disables the sheets that page owns — and
* rehostIntoThemeLayer() has already silenced the app's original <link> for good, so nothing
* paints them again for the life of the document. A full-load key that is wrong is worse than no
* key at all: it is a sheet that works until you navigate.
*
* Measured on owrt2512 with luci-app-mwan3, whose status include injects
* `#mwan3-service-status > .alert-message { display:inline-block; width:15rem; … }`: full load on
* /admin/status renders the card as `inline-block 240px 96px`; System -> General and back leaves
* it `block 966px` — every interface card on the Overview stacked full-width. Keyed on the
* dispatch path: 240px before and after. */
function currentKey() {
if (_curKey !== null) return _curKey;
const dp = L.env && L.env.dispatchpath;
if (dp && dp.length) return appKey(dp);
/* no env to read (a document that never got the bootstrap): the URL is all there is */
const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
return appKey(p ? p.split('/') : []);
}
/* Both halves, for the reason silence() documents: el.disabled is the ELEMENT's flag and
* el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has no
* .sheet for the assignment to reach. */
function setEnabled(el, on) {
el.disabled = !on;
if (el.sheet) el.sheet.disabled = !on;
else if (!on) el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
}
/* Called by the router right after it stamps data-page, with the RESOLVED segments. Only sheets we
* OWN are touched: a clean sheet is harmless and an invasive one we could not attribute still
* poisons the document, so it keeps the full-load path rather than being silently disabled on its
* own page. */
function scopeToCurrentPage(segs) {
if (segs) _curKey = appKey(segs);
const key = currentKey();
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.closest('#view') || !_owner.has(el)) return;
setEnabled(el, _owner.get(el) === key);
});
}
function rehostIntoThemeLayer(el, universe) {
if (el.dataset.fsLayered) return;
if (el.tagName === 'LINK') {
/* A <link>'s rules cannot be moved into a layer in place — but an @import CAN name one.
* The href is already absolute and same-origin, so the re-fetch is a cache hit. */
const s = document.createElement('style');
s.dataset.fsLayered = '1';
s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme);';
el.dataset.fsLayered = '1';
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
silence(el);
_owner.set(s, currentKey()); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, 60); /* ~1s of frames; a cache hit lands on the first */
return;
}
let rules;
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
/* No rules yet: nothing to re-host, nothing to fence — and, crucially, nothing to MARK. An app
* that appends an empty <style> and fills it with insertRule() arrives here first; marking it
* handled now would leave the sheet it is about to build unfenced for the life of the document. */
if (!rules || !rules.length) return;
/* Handled — and never twice. fenceRules() is NOT idempotent: pinnedToApp() strips a functional
* pseudo-class before looking for the app's own name, so an already-fenced selector reads as
* unpinned all over again and a second pass appends a second fence. The mark is the only thing
* that says the work is done, so it has to be set for every path below, wrapped or not. */
el.dataset.fsLayered = '1';
_owner.set(el, currentKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
/* Wrap only if the text still IS the sheet (see textIsSheet). When it is not, the sheet stays
* unlayered — Zone 2 exactly where it already was, which is a trade — rather than lose rules,
* and it is still FENCED below: the fence is pure CSSOM, needs no re-parse, and is the half that
* answers `!important` anyway.
*
* Layer by TEXT, fence by CSSOM, in that order: re-setting textContent re-parses the sheet and
* would throw away any selector we had already rewritten. A <style>'s url()s resolve against the
* document either way, so re-parsing costs nothing here — which is exactly NOT true of a <link>
* (measured: cssText serialises `url("img.png")` still relative, so inlining a linked sheet would
* silently re-base every image and font in it. That is why a <link> keeps its @import). */
if (textIsSheet(el, rules)) {
origText.set(el, el.textContent); /* dedupeViewSheets keys on this — see there */
el.textContent = '@layer theme {\n' + el.textContent + '\n}';
}
try { if (el.sheet) fenceRules(el.sheet.cssRules, universe.names); } catch (e) { /* unfenced, not broken */ }
}
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one. If
* cascade.css cannot be read we cannot classify, so re-host NOTHING and leave the cascade exactly
* as it is: documentPoisoned() already fails every sheet to the slow path in that case, and
* silently demoting an app we could not judge is the one move with no way back for its author. */
function rehostInvasiveSheets() {
const universe = themeNames(); /* {names, props} — NOT a bare Set; the fence wants .names */
if (!universe) return;
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.dataset.fsLayered) return;
if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
});
}
/* ---- the one thing that IS safe to remove: a byte-identical second copy ----
*
* Not deleting view CSS costs where an app injects on EVERY render: `luci-app-podkop` calls
* injectGlobalStyles() from render() (4 KB, no guard) and `luci-app-mosdns` re-appends three
* CodeMirror <link>s, so every SPA re-visit adds a copy that never stops being parsed. Dropping an
* EXACT duplicate cannot break anyone, for the reason the sweep failed: the rules do not go away —
* the surviving copy is byte-identical, and a library's "have I already imported this?" check (what
* ACE died on) still finds its sheet. Keep the FIRST copy: it is what any handle the app kept
* points at.
*
* Key a <style> on what its APP wrote, not on what stands in the DOM: re-hosting rewrites the text
* (@layer wrapper), so a wrapped first copy and podkop's next byte-identical injection would no
* longer match — the duplicate detector would go quiet exactly where it earns its keep, and the
* copies would pile up again. A <link> keys on href, which re-hosting leaves alone.
*
* Re-hosting must therefore run BEFORE this, never after: a fresh copy of a <link> is appended to
* <head>, which is EARLIER in document order than a template's <link> down in .fs-content, so the
* "keep the first" rule would keep the raw copy, drop the re-hosted one, and strand its @import
* shim — the chrome breaking again while shims pile up once per render (measured; that is the leak
* this function exists to prevent). Re-host first and both copies are equivalent by the time they
* are compared, so whichever survives is already layered and the loser's shim is a byte-identical
* duplicate this same pass collapses. */
function sheetKey(el) {
if (el.tagName === 'LINK') return 'LINK|' + el.href;
const t = origText.get(el);
if (t !== undefined) return 'STYLE|' + t;
/* Not wrapped, so no original was kept — and this sheet's textContent may not BE its sheet (see
* textIsSheet): every insertRule-built <style> has an empty one, so keying on the text gave them
* all the same key and the second one was REMOVED as a "duplicate" of a sheet it shares nothing
* with. That is the deletion this file exists to prevent, dressed as a dedupe. Key on what is
* applying instead. */
let rules;
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return null; }
/* A sheet with no rules is a duplicate of nothing — and it is very likely a <style> an app has
* appended but not yet filled: removing it strands the handle it is about to insertRule through. */
if (!rules || !rules.length) return null;
return 'STYLE|' + serializeRules(rules);
}
function dedupeViewSheets() {
const seen = new Set();
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.closest('#view')) return;
const key = sheetKey(el);
if (key === null) return;
if (seen.has(key)) el.remove();
else seen.add(key);
});
}
/* ---- THE LAYER ORDER IS A DOCUMENT-WIDE FACT, AND A SHEET INSERTED FIRST CAN REWRITE IT ----
*
* `@layer tokens, base, theme, page;` in 00-header.css is what makes theme beat base. That
* statement only holds while cascade.css is the FIRST sheet in the document to name a layer — the
* order is fixed by first appearance, and an earlier sheet naming `theme` makes theme the FIRST
* layer, i.e. the WEAKEST. Every later name is appended after it, so the whole cascade inverts:
* `tokens, base, page` end up above `theme` and base's `* { padding: 0 }` wins over the chrome's
* own rules. Measured on the router: `.fs-content` padding 24px/28px -> 0, and the top bar, the
* tabs and every button flattened with it.
*
* Which is exactly what re-hosting an app's sheet into `@layer theme` can cause, because WHERE the
* app put its <style> is the app's choice. Ace (shipped by `luci-app-ssclash`, and by any package
* that embeds an editor) calls `dom.importCssString`, which inserts its <style> as the FIRST CHILD
* of <head> — ahead of cascade.css. Wrapping that sheet, as the fence must, moved the first mention
* of `theme` to the top of the document and took the theme layer down with it. It is lazy, too:
* Ace adds more of those sheets on first hover, which is the "reloading fixes it until I touch
* anything" in the report.
*
* The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
* (moving an existing one does NOT — measured, both ways): re-declare the canonical order from a
* fresh <style> placed first in <head>. Cheap, idempotent, and it states the same order 00-header.css
* does — one more copy of it, which is why the text is derived from nothing and simply repeated
* here, in the one other place that can see the whole document. */
const LAYER_ORDER = '@layer tokens, base, theme, page;';
let _layerStmt = null;
function reassertLayerOrder() {
const head = document.head;
if (!head) return;
/* The anchor is whichever of ours comes first: cascade.css, or the statement a previous pass
* already put in front of it. Only a sheet ahead of THAT can have named a layer before we did —
* on a page with no foreign sheet this is one querySelectorAll and out. */
const own = [...document.querySelectorAll('link[rel~="stylesheet"]')]
.find((l) => (/\/cascade\.css/).test(l.href || ''));
if (!own) return;
const anchor = _layerStmt && _layerStmt.isConnected ? _layerStmt : own;
const ahead = [...document.querySelectorAll('style, link[rel~="stylesheet"]')]
.some((el) => el !== anchor && el !== own &&
(anchor.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING));
if (!ahead) return;
/* A FRESH element every time: re-inserting the same node is a move, and a move does not re-run
* the ordering (measured — the inverted document stayed inverted). Dropping the previous one
* keeps this at one spare <style> per document however many sheets an app injects. */
if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER;
head.insertBefore(_layerStmt, head.firstChild);
}
/* Watch <head> rather than deduping on navigation: the copy arrives too late otherwise — podkop
* injects from its render(), which resolves AFTER the router's require() callback, so a nav-time
* sweep left the document permanently carrying one stale duplicate (bounded, never zero). The
* observer collapses the copy in the microtask it appears in. It cannot loop: a removal produces a
* mutation with no ADDED nodes, and the handler bails unless a stylesheet was added.
*
* The immediate pass is not the observer's job and cannot be: a legacy Lua page's <link> is in the
* SERVER's HTML (openclash prints it into .fs-content), so it is parsed and applying long before
* this module is even fetched — there is no mutation to observe. It is re-hosted on the first pass
* instead, which costs a brief flash of unstyled chrome before the modules land; the page then
* settles correct, where today it stays broken. Runtime injections land in <head> (podkop, banip,
* adblock, the file manager), which is what the observer watches — deliberately not the whole
* document, since LuCI's poll rewrites content every second and this would fire on every tick. */
function watchViewSheets() {
/* Dedupe the immediate pass too, in the observer's order (re-host strictly first — see there).
* It used to re-host only, which left the server-rendered duplicate — the one case this pass
* exists for — uncollapsed for the life of the document. Measured with the real
* luci-app-openclash: it prints the same <link href=oc.css> from three templates, so its
* Overwrite Settings page carried two identical links and the two @import shims we make for
* them, parsing 117 KB of CSS twice. The observer never fires for either: both are in the
* SERVER's HTML, so there is no mutation to see. */
rehostInvasiveSheets();
dedupeViewSheets();
reassertLayerOrder(); /* strictly AFTER the re-host: it is the wrap that can invert the order */
const mo = new MutationObserver((muts) => {
for (const m of muts)
for (const n of m.addedNodes)
if (n.nodeName === 'STYLE' || n.nodeName === 'LINK') {
/* `continue`, not `return`: our own statement can share a batch with the very
* sheet that made it necessary, and bailing on the batch would skip that one. */
if (n === _layerStmt) continue;
rehostInvasiveSheets(); /* strictly before the dedupe — see there */
dedupeViewSheets();
reassertLayerOrder();
return;
}
});
mo.observe(document.head, { childList: true });
/* …and <body>, because `document.head.appendChild` is a CONVENTION, not a rule. An app that
* appends its <style> to <body> (or to documentElement) after chrome init was seen by nothing:
* the immediate pass had already run and the mutation was not under observation. Measured with
* `* { padding: 0 !important }` in a body-appended <style>: 95 of 338 chrome elements flattened
* and the sheet never marked. documentPoisoned() still saw it, so the SPA fell back to full
* loads — the page you are ON stayed broken, which is the half that matters.
*
* childList WITHOUT subtree, exactly as for <head>: this fires only for DIRECT children of
* <body>, and LuCI's poll rewrites content inside #view — a descendant — so the per-tick cost
* the head-only choice was protecting stays zero. */
mo.observe(document.body, { childList: true });
}
return baseclass.extend({
documentPoisoned,
scopeToCurrentPage,
watchViewSheets
});
@@ -0,0 +1,39 @@
'use strict';
'require baseclass';
/* ---- theme identity: the version footstrap SHIPS, and the repo it came from ----
*
* Shown at the foot of the Appearance tab with NO network call: it is what is installed, not what
* is available. Which version is available is the package manager's question — the installer adds
* the feed, and `apk upgrade` answers it.
*
* FS_VERSION is stamped at build/deploy: the theme Makefile (Build/Prepare) and dev-sync.sh rewrite
* the '0.0.0-dev' literal below — BY FILE NAME, so this constant cannot move to another file without
* changing both seds. An unstamped source checkout stays 'dev'. */
const FS_VERSION = '0.0.0-dev';
/* The parentheses around the regex are load-bearing — do not "tidy" them away. luci.mk minifies
* this file with jsmin, whose regex-vs-division test is a ONE-character lookback against a fixed
* allow-list. `n` (the last letter of `return`) is not on it, so `return /re/` is read as a
* division and the regex's `//` swallows the rest of the file — exiting 0 (openwrt/luci#8299).
*
* The shape of the version is the WHOLE test — do NOT re-add a `FS_VERSION !== '0.0.0-dev'`
* comparison. CI runs terser over this file BEFORE the Makefile stamps the version, so at that
* moment both sides of that comparison are the same literal: terser folded it to `&& !1` and
* every released build reported itself as '(dev)' forever after. An SDK/buildbot build has no
* terser step, so it worked locally and only ever broke in a release. A regex test is not constant-folded —
* proven by the same minified output, which kept this very `.test()` call — so the sentinel is
* excluded by its SHAPE instead. `-dev$` and not `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps
* `git describe` ('0.9.4-12-gabc1234') and that must keep counting as a real version. */
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
return baseclass.extend({
VERSION: FS_VERSION,
/* the project's page, for the link at the foot of the Appearance tab. One string, used once —
* it was assembled from a separate FS_REPO constant that the retired updater needed to build
* API paths from, and nothing has needed the bare `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
isReal,
/* what the popover's version row prints */
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -0,0 +1,301 @@
'use strict';
'require baseclass';
'require fs-prefs as prefs';
/* The theme's own UI primitives: the disclosure pair the menu is built on, the two controls the
* Appearance popover is built on, and the popup placement both hand-placed popups share. Nothing
* here knows what it is being used FOR — that is the point, and it is why the menu and the popover
* can each take what they need without either requiring the other. */
/* The chrome's ONE inline-SVG wrapper. Every icon this theme draws is the same 24x24 stroked
* outline and differs only in its path data, but the wrapper was written out per call site — in the
* menu, in the search box, and again in four .ut partials — so `stroke-width` and the two linecap
* attributes were free to drift between icons that are meant to look like one set. Body in, markup
* out; the caller supplies only the shape.
*
* aria-hidden: every icon here sits beside its own label (or inside a control that has one), and an
* unlabelled <svg> is otherwise announced as a graphic in its own right. */
function svgIcon(body, cls) {
return '<svg class="' + (cls || 'fs-ico') + '" aria-hidden="true" viewBox="0 0 24 24" fill="none" '
+ 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">'
+ body + '</svg>';
}
/* ---- disclosure primitives, shared by the menu ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows and
* hides. These lived once per menu file back when there were two, and the copies had already drifted
* (only one Escape handler learnt to check flyout mode). The trigger SELECTOR stays a parameter. */
/* `.open` and aria-expanded must never disagree — `.open` alone told a sighted user everything and
* a screen-reader user nothing — so every open and close goes through this one function.
* `linkSel` is the layout's trigger (the menu's `:scope > a`). */
function setOpen(li, on, linkSel) {
li.classList.toggle('open', on);
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
}
/* An <a role="button"> is given Enter by the browser but NOT Space, and a
* disclosure control has to answer both. */
function wireSpaceKey(link) {
link.addEventListener('keydown', (ev) => {
if (ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
link.click();
});
}
/* Dismissal both ways: a click outside closes; and WCAG 2.2 SC 1.4.13 (Content on Hover or Focus)
* requires a hover/focus panel to be dismissible from the KEYBOARD, with focus handed back to the
* trigger. `when` restricts both to flyout mode, where `.open` means "popup panel" — closing an
* unfolded ACCORDION because the user clicked elsewhere on the page would be wrong. */
function wireDismiss(opts) {
const active = () => (opts.when ? opts.when() : true);
document.addEventListener('click', (ev) => {
/* `closest?.` — a document-level listener sees whatever anyone dispatches, and a click
* whose target is not an Element (document itself, a text node from a synthetic dispatch)
* has no closest(). The throw would come out of THIS listener, i.e. the menu would stop
* closing its flyouts for the rest of the session. Every other document-level handler in
* the theme already guards it (fs-router's link router, fs-search's shortcuts,
* fs-select's typeahead); this one was the odd one out. */
if (active() && !ev.target.closest?.(opts.inside))
opts.close();
});
document.addEventListener('keydown', (ev) => {
if (ev.key !== 'Escape' || !active()) return;
const open = document.querySelector(opts.open);
if (!open) return;
const trigger = open.querySelector(opts.trigger);
opts.close();
trigger?.focus();
});
}
/* THE SEGMENTED CONTROL AND THE RANGE WRAPPER WERE HERE, and they are not coming back: the
* Appearance page draws its enums with `ui.Select` and its numbers with `ui.RangeSlider` — LuCI's
* own widgets, present on every release this theme supports and already dressed by this theme's
* stylesheet (`select` in base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css).
*
* They were written for the floating popover this page used to be, where a native <select> read as
* a hole in the card. On a page there is no such argument, and a control LuCI maintains is one this
* theme cannot get wrong on its own — the roving tabindex here was itself a bug fix for a
* radiogroup that announced one control and offered N tab stops. */
/* ---- colour: reading what the page is ACTUALLY painted, and what that costs in contrast --------
*
* The Appearance page has to answer two questions no stored value can: what colour is a role right
* now (the palette's own, when the axis is off — and there is no copy of the palette in JS, on
* purpose), and what contrast does the user's colour land at. Both are questions about the computed
* cascade, so both are asked of the browser.
*
* `getComputedStyle(root).getPropertyValue('--fs-accent')` cannot answer either: a custom property's
* computed value is the token stream after var() substitution, so a palette hex comes back as a hex
* but `oklch(from … l c H)` comes back as that function, unevaluated. Setting the expression as a
* real `color` on a real element and reading it back gets the browser to resolve it — which is the
* whole point, since relative colour, color-mix() and the tint's calc() are exactly the values the
* theme is made of. One hidden probe, reused: an element per query would be a layout thrash on
* every drag of the hue slider. */
let _probe = null;
function probeColor(expr) {
if (!_probe) {
/* off-screen rather than display:none — a display:none element still computes `color`, but
* keeping it laid out avoids depending on that being true of every engine. It carries no
* text and no size, so it paints nothing. */
_probe = E('span', { 'style': 'position:fixed;left:-9999px;top:0;width:0;height:0;overflow:hidden;pointer-events:none', 'aria-hidden': 'true' });
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the PREVIOUS colour standing, which
* would report a stale answer as a fresh one. */
_probe.style.color = '';
_probe.style.color = expr;
return getComputedStyle(_probe).color;
}
/* A computed colour string -> [r,g,b] 0..255, or null. Engines do not agree on the notation: both
* `rgb(9, 105, 218)` and the space-separated `rgb(9 105 218 / .5)` are current, and a colour outside
* sRGB (which oklch() readily produces) comes back from some engines as `color(srgb .03 .41 .85)`,
* i.e. 0..1 floats. Take the first three numbers and scale by whether the form is the 0..1 one —
* the alpha after the slash is deliberately ignored, since every token this reads is opaque. */
function parseColor(s) {
const nums = String(s || '').match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(String(s)) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to REPORT: the theme never
* corrects a colour behind the user's back, it says what the colour costs and leaves the choice
* with them (03-palettes.css derives the ink over a fill, which is a different question — that one
* has a right answer). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
});
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
}
function contrastRatio(fgExpr, bgExpr) {
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
if (!fg || !bg) return null;
const a = luminance(fg), b = luminance(bg);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
/* #rrggbb from whatever probeColor returned, because <input type="color"> accepts nothing else —
* not a name, not an rgb() string, not a short hex. An unparseable colour becomes black rather than
* throwing: the swatch is a convenience beside the text field, and the text field is authoritative. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One COLOUR axis: a native swatch, the hex field beside it, and a button back to the palette's own
* colour. It reports through onPick as a hex STRING, or 0 for "back to the palette" — the caller
* hands either straight to fs-prefs.js's colorAxis.
*
* THERE IS NO HUE SLIDER, and it was here. The axis still stores a hue (1–360) and the stylesheet
* still rotates the palette by one — a value saved before this, or a router default written then,
* goes on working — but nothing in the UI produces one any more. Two controls for one value read as
* two settings however carefully they mirrored each other, and the slider was the half that could
* not do the job the axes exist for: rotating a hue keeps the palette's chroma, so no angle of it
* reaches a grey. A field that takes the colour you want is the whole control.
*
* `opts.probe` is the live token to read the effective colour back from (so the field shows the
* PALETTE's colour while the axis is off, with no copy of the palette in JS), and `opts.contrast`
* is the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color is the one control here that is NOT ours: the browser draws the picker, which is
* the right call — it is the picker the user already knows, it is keyboard- and
* screen-reader-accessible without us reimplementing a colour wheel, and on a phone it is the
* native one. The text field beside it is what makes a value shareable (an admin pastes a hex
* from a brand guide), and what a browser without a real picker falls back to. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
});
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds RIGHT NOW — the control keeps no copy of its own, because the page it
* lives on can change the axis behind it (a preset, Reset to default) and a private copy would
* then be the stale one. `current` is only the value at build time. */
const currentOf = o.read || (() => current);
/* Repaint everything that MIRRORS the axis rather than sets it. Called after every edit — and
* after a preset, a palette switch or a dark-mode flip, through the returned refresh() — because
* all three change what "the palette's own colour" is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* Do not fight the user mid-edit: `#0` is a legal thing to have typed so far, and
* overwriting the field on every keystroke made the input impossible to type into. */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette is also the axis's STATE readout: enabled means this axis
* is holding a colour of its own, disabled means what the field shows is the palette's. */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* SAY WHAT IT MEANS, NOT WHAT IT MEASURES. This used to read "On a card 5.4:1 · AA", which is
* three pieces of jargon for one plain fact: whether the colour you just picked can be read.
* The ratio and the WCAG level are what a designer checks, and an admin recolouring a router
* is not one — the number stays, in the title, where it costs nothing and is there for
* anyone who does want it.
*
* The thresholds are still WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI
* SHAPE, which is why a hairline is graded on the second one (`kind: 'shape'`). A border
* below 3:1 is not a failure the way unreadable text is — a faint hairline is a legitimate
* thing to want — so it says "faint" and warns rather than "not enough" and alarms.
*
* The class names are written out WHOLE rather than built from a suffix: the theme's own
* namespace is swept for dead CSS by matching fs-* tokens in the source (tools/
* fs-orphans.mjs), and a name assembled by concatenation is invisible to that sweep — the
* three rules would read as dead CSS and the fragment as an unstyled class. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
: (r >= 4.5)
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
: (r >= 3)
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
/* the measurement itself, for whoever wants it: the ratio and the threshold it was judged by */
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and on Enter, NOT on every keystroke: a half-typed `#0096` is a valid prefix of
* two different colours and applying it would repaint the page under the cursor. An unparseable
* value on blur snaps back to what the axis actually holds, so the field can never claim a
* colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
clear.addEventListener('click', () => pick(0));
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller owns when this runs again; it cannot be done here, because probeColor() needs the
* document and this control is not in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
/* How close a popup may come to the viewport edge before it is nudged back in. Read by BOTH popups
* the theme places by hand — the Appearance popover (fs-appearance.js) and the menu's dropdown
* edge-clamp (menu-footstrap.js) — which had each written their own `8`. */
const EDGE_GAP = 8;
/* Place the popover next to its trigger and keep it inside the viewport. It is position:fixed on
* <body> because the sidebar is `overflow-y: auto` (which computes overflow-x to `auto` too), so
* an absolutely-positioned popover parented to the Appearance row was clipped off the sidebar
* edge. The top bar opens downward from the button's right edge, the sidebar sideways out of the
* rail; both are then clamped. */
function placePopover(btn, pop) {
const gap = EDGE_GAP, r = btn.getBoundingClientRect();
const w = pop.offsetWidth, h = pop.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const top_layout = prefs.isTopLayout();
let left = top_layout ? (r.right - w) : (r.right + gap);
let top = top_layout ? (r.bottom + gap) : (r.bottom - h);
/* sidebar: if there is no room to the right, fall back above the trigger */
if (!top_layout && left + w > vw - gap) {
left = r.left;
top = r.top - h - gap;
}
pop.style.left = Math.max(gap, Math.min(left, vw - w - gap)) + 'px';
pop.style.top = Math.max(gap, Math.min(top, vh - h - gap)) + 'px';
}
return baseclass.extend({
svgIcon,
setOpen,
wireSpaceKey,
wireDismiss,
colorControl,
probeColor,
toHex,
EDGE_GAP,
placePopover
});
@@ -0,0 +1,83 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-router as router';
'require fs-appearance as appearance';
'require fs-overview as overview';
'require fs-prefs as prefs';
'require fs-sheets as sheets';
'require fs-search as search';
/* The chrome BOOTSTRAP: load the menu tree once, hand it to the parts that need it, and wire them
* in the right order. It renders nothing itself — every piece lives in its own module:
*
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
* fs-prefs the Appearance axes and their localStorage
* fs-widgets disclosure primitives, the seg/slider controls, popup placement
* fs-chrome mode menu, section tabs, the rail toggle, the "does it still fit" measurements
* fs-router the SPA client router (docs/spa-router.md)
* fs-sheets the guard against a view's injected CSS repainting every later page
* fs-search the page-search palette (indexes the same tree, on first open)
* fs-appearance the Appearance controls, appended to the stock System page
* fs-overview the overview grid — a THEME module, not a luci-mod-status include
* fs-version the shipped version string (shown in the Appearance section, no network)
*
*
* They compose by CALLING each other, never by inheriting: LuCI instantiates every required module
* into a singleton, so `base.extend` across modules throws and a module cannot subclass another
* (docs/conventions.md — proven, not assumed). The same constraint is why the MAIN menu arrives as a callback:
* menu-footstrap.js is the one renderer, and it injects renderMainMenu here rather than overriding
* a method. LuCI raises DependencyError on a require() cycle, so the graph above is a DAG by
* construction — the shared halves (fs-menutree, fs-prefs) were pulled out precisely so that no two
* modules have to reach across into each other. */
return baseclass.extend({
/* entry point: load the menu tree, render the mode menu (which drives the injected
* renderMainMenu) and the section tabs, and wire the chrome. */
init(renderMainMenu) {
/* FIRST, and outside the promise: a third-party sheet that outranks the chrome is already
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Nothing below depends on
* it, and hanging it off ui.menu.load() only made the broken frame last a round-trip
* longer — or forever, since the .catch() below swallows a menu failure into console. */
sheets.watchViewSheets();
prefs.guardDarkStamp(); /* a third party stamping :root — same shape, different vector */
ui.menu.load().then((menu) => {
tree.setTree(menu);
chrome.setRenderMain(renderMainMenu);
/* the view this full load already rendered — see fs-router's seed() */
router.seed();
/* the bar's "does the menu fit beside the brand" measurement joins the engine the
* tables use: it re-runs on every #view resize (a rail collapse and a layout toggle
* produce one) and on content mutations */
fit.add(chrome.fitChrome);
chrome.renderChrome();
/* Both of these ADD to a stock page rather than owning a route: each watches
* body[data-page] and does nothing anywhere else. A theme may not register a
* dispatcher node — it outlives the theme that registered it. */
appearance.wire();
/* after setTree(): the palette indexes that tree — lazily, on its first open, but its
* recent-pages list is recorded from the first navigation onwards */
search.wire();
chrome.wireRail();
chrome.wireIndicatorCounts();
/* BEFORE router.wire(): the router restamps body[data-page] on every SPA navigation,
* and that attribute is what fs-overview keys off. Wiring its observer after the
* router's would still work today (the first stamp is the server's, already in the
* DOM), but it would make a route change racy against a listener that is not attached
* yet — cheap to order correctly, expensive to debug. */
overview.wire();
router.wire();
router.wireVisibility();
/* fs-chrome's renderTabMenu warns about exactly this, and the root chain was left bare: a
* throw anywhere in the calls above took out the menu, the router and the Appearance popover
* together, silently. It still fails — there is no sane partial recovery — but loudly. */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
});
@@ -0,0 +1,335 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-widgets as widgets';
'require menu-footstrap-common as common';
/* The theme's ONE menu renderer: a vertical #topmenu the CSS also turns into the top bar and the
* rail flyouts — same markup, no second renderer (docs/chrome.md). The disclosure primitives it builds
* sections on come from fs-widgets and the auto-collapse preference from fs-prefs; the rest of the
* chrome (mode menu, tabs, rail, router, popover) is bootstrapped by menu-footstrap-common, which
* this file composes with by injecting renderMainMenu into common.init — a callback, not an
* override, because a required LuCI module is a singleton and cannot be subclassed (docs/conventions.md).
* Spec: docs/chrome.md */
/* A NULL PROTOTYPE, because `ICONS[key]` is a lookup by a MENU NODE NAME — a string a third-party
* package chooses in its menu.d, not one this theme knows. A plain object literal answers
* `constructor` and `__proto__` out of Object.prototype, so a node with either name resolved to a
* truthy non-string, skipped the `|| ICONS._default` fallback, and iconSvg() concatenated it into
* link.innerHTML below — `function Object() { [native code] }` and `[object Object]` respectively
* (measured). The dotted reads (ICONS.vpn, ICONS._default) are unaffected. */
const ICONS = Object.assign(Object.create(null), {
status: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
system: '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
services: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2.6"/>',
network: '<circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="18" cy="18" r="2.4"/><path d="M8 16 16 8M8 18h7.5M18 8.5V16"/>',
vpn: '<rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
docker: '<rect x="3" y="11" width="4" height="4" rx=".7"/><rect x="8" y="11" width="4" height="4" rx=".7"/><rect x="13" y="11" width="4" height="4" rx=".7"/><rect x="8" y="6" width="4" height="4" rx=".7"/><path d="M18 13c0 4-3 6-8 6-4 0-7-2-7-4"/>',
_default: '<circle cx="12" cy="12" r="8.5"/>'
});
function iconSvg(name) {
const key = String(name || '').toLowerCase();
const body = ICONS[key]
|| ((/vpn|wireguard|openvpn/).test(key) ? ICONS.vpn : null)
|| ((/dock|container|lxc/).test(key) ? ICONS.docker : null)
|| ((/net|wifi|wireless|firewall|dhcp/).test(key) ? ICONS.network : null)
|| ((/serv|dnsmasq|cron/).test(key) ? ICONS.services : null)
|| ((/stat|overview|dash/).test(key) ? ICONS.status : null)
|| ICONS._default;
return widgets.svgIcon(body);
}
/* `.open` means two things: in the expanded sidebar, an unfolded accordion section (several may
* be open, the active one starts open); in the rail or the bar, a popup panel (exactly one open,
* hover drives it, a tap toggles it, cleared on outside click and once a real mouse enters). */
/* Is a section's panel a POPUP (flyout / bar dropdown) rather than an unfolded accordion? Must
* read the same input the STYLESHEET does: `data-narrow` (fitShell() in fs-chrome.js
* stamps it before the menu renders). NEVER a viewport breakpoint: it used to be
* matchMedia('(max-width: 767px)') while the CSS turns the sidebar into a bar as soon as the
* content column drops below --fs-content-min — 780px with the sidebar expanded. At 770-775px on
* the router the chrome was a bar while the menu still believed it was an accordion: Escape and
* click-outside did not close a panel (widgets.wireDismiss gates on this) and clampDropdown
* refused to place it. */
function flyoutMode() {
return prefs.currentRail() || prefs.isTopLayout() ||
document.documentElement.hasAttribute('data-narrow');
}
/* The trigger — a bare <a>. widgets.setOpen keeps `.open` and aria-expanded in step. */
const TRIGGER = ':scope > a';
const OPEN_LI = '#topmenu > li.open';
/* ---- dropdown edge-clamp (the BAR, every width and every bar state) ------
* A bar panel hangs off its OWN item (li position:relative, ul left:0 — theme/20-shell.css), so an
* item near the right edge would push its panel past the viewport. Nudge it back inside. Runs at
* ANY width now that the top bar is measured (no 768 floor); the rail flies panels out sideways
* and needs a different placement, so it is excluded below. */
/* the viewport edge gap, defined once in fs-widgets.js — the Appearance popover keeps a popup off
* the edge by the same amount, and the two used to state it separately */
const EDGE_GAP = widgets.EDGE_GAP;
/* Is this panel a BAR dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Ask what the STYLESHEET asks, exactly as flyoutMode() does: `data-narrow` is what
* turns the sidebar layout into a bar, and it also disables the rail (the rail's rules are all
* scoped `:not([data-narrow])`), so a narrow window is a bar even with the rail on. Gating this on
* isTopLayout() alone was issue #19's second half: the panel was anchored per item by the CSS but
* the clamp declined to place it, so an item near the right edge overflowed the viewport. */
function barDropdown() {
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
}
function clampDropdown(li) {
if (!barDropdown()) return;
const menu = li.querySelector(':scope > ul');
if (!menu) return;
/* One pending measure per item: sweeping the pointer across the bar otherwise queues a
* frame per item crossed, each a write-then-read of layout, none cancelled once the
* pointer has moved on. */
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
li._fsClampRaf = window.requestAnimationFrame(() => {
li._fsClampRaf = 0;
menu.style.left = ''; /* back to the CSS anchor before measuring */
const r = menu.getBoundingClientRect();
if (!r.width) return; /* still hidden — nothing to place */
/* measured after a frame: on pointerenter the :hover rule that reveals the panel
* has not applied yet, so it would still measure 0x0 */
const overflowRight = r.right - (window.innerWidth - EDGE_GAP);
if (overflowRight > 0)
menu.style.left = -Math.min(overflowRight, r.left - EDGE_GAP) + 'px';
});
}
/* a nudge computed for the old width, or for a bar we have since left, is wrong — drop them
* and let the next hover/tap recompute. */
function clearClamps() {
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
}
function setOpen(li, on) {
widgets.setOpen(li, on, TRIGGER);
}
function closeFlyouts(except) {
document.querySelectorAll(OPEN_LI).forEach((o) => {
if (o !== except) setOpen(o, false);
});
}
/* Restore the accordion after LEAVING flyout mode (rail expanded, window grew). closeFlyouts()
* alone used to run on both transitions — right going IN (a stuck popup is worse than a folded
* section), wrong coming OUT: it stripped `.open` from everything, and since the markup is not
* rebuilt on a rail toggle the remembered set was never re-applied, so expanding the rail folded
* every section and "Keep open" quietly meant nothing. */
function restoreAccordion() {
const auto = prefs.currentAutoCollapse();
document.querySelectorAll('#topmenu > li.has-sub').forEach((li) => {
const name = li.dataset.name || '';
setOpen(li, li.classList.contains('active') || (!auto && _openSections.has(name)));
});
}
/* Which top-level sections are unfolded, by node name. renderChrome() rebuilds #topmenu on every
* SPA nav, so without this a section the user opened (Keep open) would refold on every tab
* switch. Only consulted in the expanded sidebar with auto-collapse off; flyouts and
* auto-collapse only ever re-open the active one. Persisted in localStorage because a
* module-level Set does not survive a full page load, and plenty of LuCI pages are not SPA-able
* (non-`view` nodes, any F5). */
const OPEN_KEY = 'fs-menu-open';
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; this list is a SET
* (membership is the only question asked of it), which is the one part that differs. */
function loadOpenSections() {
return new Set(prefs.lsGetArr(OPEN_KEY));
}
function saveOpenSections() {
prefs.lsSet(OPEN_KEY, JSON.stringify(Array.from(_openSections)));
}
const _openSections = loadOpenSections();
/* main sections -> vertical sidebar list (#topmenu), collapsible */
function renderMainMenu(tree, url, level) {
const ul = level ? E('ul', {}) : document.querySelector('#topmenu');
const children = ui.menu.getChildren(tree);
if (!ul || children.length === 0 || level > 1)
return E([]);
/* dispatchpath = [mode, section, subsection, …]; sections sit at
* index (level+1) because the first call gets the mode. */
const idx = (level || 0) + 1;
children.forEach((child) => {
/* the chrome carries its own Logout entry (partials/logout.ut, in every layout), so
* drop the tree's top-level admin/logout node or it shows up twice */
if (!level && child.name === 'logout')
return;
const submenu = renderMainMenu(child, url + '/' + child.name, (level || 0) + 1);
const hasSub = !!submenu.firstElementChild;
const isActive = (L.env.dispatchpath[idx] === child.name);
/* expanded sidebar + Keep open: a section starts open if it is the active one OR was
* left open before this re-render. Auto-collapse and flyout mode ignore the set. */
const keepOpen = hasSub && !level && !flyoutMode() && !prefs.currentAutoCollapse();
const startOpen = hasSub && !flyoutMode() &&
(isActive || (keepOpen && _openSections.has(child.name)));
if (keepOpen && startOpen && !_openSections.has(child.name)) {
_openSections.add(child.name);
saveOpenSections();
}
const chevron = hasSub
? '<svg class="fs-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'
: '';
/* `active` is a CLASS: it paints the item and says nothing to a screen reader, so the
* menu had no "you are here" at all. aria-current="page" belongs on the LEAF only — a
* section header is a disclosure button, not a link to the current page. */
const link = E('a', {
'href': hasSub ? '#' : L.url(url, child.name),
'class': (isActive && !hasSub) ? 'active' : '',
'aria-current': (isActive && !hasSub) ? 'page' : null
});
link.innerHTML = (level ? '' : iconSvg(child.name)) + '<span class="fs-label"></span>' + chevron;
link.querySelector('.fs-label').textContent = _(child.title);
/* collapsed rail: the label is hidden, so carry it as an attribute — CSS renders it as
* the flyout's heading (sections) or as a tooltip (leaves) */
if (!level) {
link.setAttribute('data-label', _(child.title));
if (hasSub)
submenu.setAttribute('data-title', _(child.title));
}
const li = E('li', {
'class': [
isActive ? 'active' : '',
hasSub ? 'has-sub' : '',
/* pre-opening the active section is an accordion affordance; in flyout
* mode it would pop a panel open on page load */
startOpen ? 'open' : ''
].join(' ').trim()
}, [ link, submenu ]);
/* for restoreAccordion(): the markup is not rebuilt on a rail toggle, so the
* remembered set has to be matched back to live <li>s by name */
if (!level) li.dataset.name = child.name;
if (hasSub) {
/* W3C APG disclosure-navigation: a section header is a BUTTON owning a panel, not
* a link to "#". Not role="menu" — APG is explicit that site navigation must not
* take on the menubar pattern's arrow-key semantics. */
/* THE ID MUST BE INJECTIVE, because a menu node name is a string a third-party
* package picks in its own menu.d and two of them may differ only in punctuation.
* `[^a-z0-9]+` -> '-' folded `foo.bar` and `foo-bar` onto ONE id, so two sections at
* the same level emitted the same `id` and both triggers' aria-controls resolved to
* whichever panel stands first in the document — a screen reader is then told the
* closed section owns the open one's contents. Escaping to the code point keeps the
* id readable for the ordinary all-alphanumeric case and cannot collide. */
const subId = 'fs-sub-' +
String(child.name).replace(/[^a-z0-9]/gi, (c) => '_' + c.charCodeAt(0).toString(16)) +
'-' + idx;
submenu.id = subId;
link.setAttribute('role', 'button');
link.setAttribute('aria-controls', subId);
link.setAttribute('aria-expanded', startOpen ? 'true' : 'false');
link.addEventListener('click', (ev) => {
ev.preventDefault();
const open = li.classList.contains('open');
/* flyout panels are exclusive, but must NOT touch the remembered set: it
* mirrors the desktop "Keep open" state, and wiping it here meant one tap
* in the phone flyout lost the user's open sections back on desktop */
if (flyoutMode()) {
closeFlyouts();
setOpen(li, !open);
if (!open) clampDropdown(li); /* tap-opened panel must fit too */
return;
}
/* the expanded-sidebar accordion folds the others back only when asked
* (Appearance -> Submenus) */
if (prefs.currentAutoCollapse()) { closeFlyouts(); _openSections.clear(); }
setOpen(li, !open);
/* remember the accordion state so any navigation restores it (Keep open) */
if (!open) _openSections.add(child.name);
else _openSections.delete(child.name);
saveOpenSections();
});
widgets.wireSpaceKey(link);
/* hybrid devices: once a real MOUSE enters, drop the tap-opened panel so hover is
* authoritative and two panels never stack. Guarded on pointerType — a touch tap
* fires pointerenter ('touch') BEFORE the click, and clearing there would break
* tap-to-close. */
li.addEventListener('pointerenter', (ev) => {
if (ev.pointerType === 'mouse' && flyoutMode())
closeFlyouts();
/* the bar opens this panel on hover (pure CSS), so it must be placed on
* hover too, not only when a tap sets .open */
clampDropdown(li);
});
}
ul.appendChild(li);
});
return ul;
}
return baseclass.extend({
__init__() {
common.init(renderMainMenu);
/* Click-outside and Escape close an open flyout. Gated on flyoutMode(): outside it
* `.open` means "unfolded accordion", which must not fold on a click elsewhere. */
widgets.wireDismiss({
when: flyoutMode,
inside: '#topmenu > li.has-sub',
open: OPEN_LI,
trigger: TRIGGER,
close: () => closeFlyouts()
});
/* Entering flyout mode: fold everything, or a section left open as an accordion
* reappears as a popup panel stuck on screen. Leaving it: restoreAccordion().
*
* Watch the ATTRIBUTE, not the rail button: fs-chrome's wireRail() registers its click
* handler from inside the ui.menu.load() promise, i.e. AFTER this runs, so a listener
* added here would fire first and read the old data-rail. data-layout rides along —
* toggling the layout live is the same transition, so it needs no menu re-render. */
const modeChanged = () => {
clearClamps();
flyoutMode() ? closeFlyouts() : restoreAccordion();
};
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it). It was
* missing here, so dragging a window 800 -> 770px turned the sidebar into a bar with
* the accordion still unfolded inside it, and no transition handler ran. */
new MutationObserver(modeChanged).observe(document.documentElement, {
attributes: true, attributeFilter: [ 'data-rail', 'data-layout', 'data-narrow' ]
});
/* No 768 media-query listener any more: the top bar is measured at every width
* (fitChrome, no floor), so crossing 768 no longer flips the chrome — nothing to
* transition that the attribute observer and the resize clamp-clear below don't cover. */
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame
* (the shared coalescer) because resize fires dozens of times a second while a window
* is dragged */
window.addEventListener('resize', fit.frame(clearClamps));
/* Appearance -> Submenus -> auto-collapse. fs-prefs.js owns the stored value and says only
* that it changed; applying it is entirely ours, because every piece of the state is —
* the remembered set, the `.open` class and the aria-expanded that setOpen keeps in step.
*
* restoreAccordion() already IS "apply the current auto-collapse setting": with the set
* cleared and auto ON it computes open = the active section alone, and with auto OFF it
* restores what "Keep open" remembered. Skipped in flyout mode — a section there is a
* popup, and force-opening the active one would leave it stuck on screen. */
document.addEventListener('fs-autocollapse', (ev) => {
if (ev.detail && ev.detail.on) {
_openSections.clear();
saveOpenSections();
}
if (!flyoutMode()) restoreAccordion();
});
}
});
+81
View File
@@ -0,0 +1,81 @@
# THE SINGLE SOURCE of the openwrt/luci commit this project borrows two BUILD TOOLS from, and
# of their checksums. Sourced as shell (`. luci-upstream.pin`) by update-po.sh and by CI.
#
# Both are downloaded over the network and then EXECUTED as gates:
# - jsmin.c compiled; decides whether our shipped JS is safe (its regex-vs-division
# lookback can silently eat a file — see docs/conventions.md)
# - i18n-scan.pl run by perl; decides whether the translation catalogue is complete
# Off a moving `master`, the gate would be whatever upstream pushed last.
#
# The commit used to be written out twice — in .github/workflows/build.yml and in update-po.sh
# — each with a comment saying "bump them together" and nothing enforcing it, so bumping one
# left half the toolchain on an older tree, silently.
#
# To bump: change LUCI_PIN, re-fetch both files at the new commit, update their sha256s.
LUCI_PIN=5cf20c5741dc34eea0faaf7324ef3751b05fa1f3
JSMIN_SHA256=0568b14873f27f339cf66a9c43603d0bc8ca501ff62522ea924c6e8daf1be215
I18N_SCAN_SHA256=a1823d1fdd942f31a105e1e53b401e146bc10527f231e016930b1a5123ef21d3
# THERE IS NO UCODE PIN, AND THAT IS DELIBERATE — do not add one back.
#
# The theme's .ut templates still have to be compile-checked: they are copied to the router
# verbatim and never parsed at build time, so a stray brace in header.ut builds green, releases,
# and then every user's LuCI silently falls back to another theme (docs/architecture.md).
#
# That check used to live here — clone ucode at a pinned commit, build it with cmake, run
# `ucode -T -c` with the router runtime stubbed out through `-L`. It is now the fifth assertion
# of the `verify` job (docs/ci.md), which runs the router's OWN interpreter against the INSTALLED
# templates, on both release lines. Nothing to pin, nothing to build, nothing stubbed — and it
# checks the interpreter each branch actually ships rather than one commit for both.
# usign — OpenWrt's ed25519 signer. CI builds it to SIGN each release package; the router
# verifies with the /usr/bin/usign it already has (base-files depends on usign, so it is on
# every OpenWrt image — the signature costs the theme no new runtime dependency).
#
# This is the commit the router's own binary is built from (`usign-2025.10.03~c4c72b1b`), so the
# signer in CI and the verifier in the field are the same code. It needs neither cmake nor
# libubox: `cc *.c` over the six sources plus the bundled base64.c is the whole build.
#
# CI builds it TWICE for two different jobs (verify the SDK, sign the release), through
# tools/build-usign.sh so the two cannot drift onto different commits.
USIGN_PIN=c4c72b1b07945ee192361dc751291a7c98d6adcd
# OpenWrt's own usign RELEASE key, and the keyring commit it comes from — what CI checks the SDK
# with. The SDK is the least verified input in this repo and the only one that ends up inside the
# package users install: jsmin.c and i18n-scan.pl are LINTERS pinned by commit and sha256, while the
# toolchain that compiles the released artifact arrived on nothing but TLS.
#
# The sha256 next to the tarball cannot fix that, and the reason is this project's own argument
# against GitHub's asset digest (docs/conventions.md): `sha256sums` is served by the SAME host, from the SAME
# directory, unsigned — whoever can replace openwrt-sdk-*.tar.zst can replace the checksum beside it,
# and the check then verifies the attacker's SDK. It is worth keeping for a truncated download; it is
# not a verification, and the comment in build.yml used to say it was.
#
# What makes it one is `sha256sums.sig`, an ed25519 signature over that file — verified with a key
# this repo pins from a DIFFERENT host (github.com/openwrt/keyring, the mirror of the keyring
# openwrt/openwrt itself ships), so downloads.openwrt.org cannot vouch for itself. Proven end to end
# with the router's own usign: signature OK, one byte flipped in sha256sums -> "verification failed".
#
# Note the mirror does not carry the git.openwrt.org commit openwrt/openwrt pins, so this is the
# mirror's own. To bump: change the commit and re-fetch every key it serves, then update their
# sha256s — the sha256 is what actually holds the content, exactly as for jsmin.c.
#
# The two branches do NOT share a key: 24.10 has a release key of its own while 25.12 is signed by
# the unattended-build key. Pinning one for both was wrong in the only way it could be — the 24.10
# leg refused to build, which is the gate doing its job on its author.
#
# Both live here now. They used to sit in .github/workflows/build.yml's matrix, beside the channel
# they are a property of; that matrix went away with the SDK build (the release is built by owfeed,
# which fetches and verifies its own host apk). build-apk.sh — the local SDK build, the one path
# that still needs them — read them out of that file, so leaving them there would have left a
# convenience script parsing a matrix that no longer exists.
#
# The key id IS the file name in the keyring, and it is the keynum usign embeds in the signature. If
# a build ever comes back "verification failed", read that keynum first (the script prints it):
# OpenWrt rotating a branch's key looks exactly like an attack. The sha256 is what actually holds
# the content — the id only says which file to fetch.
OPENWRT_KEYRING_PIN=fbae29d730f81c892f52e0ff00fe867444aeeae6
OPENWRT_SDK_KEY_2512=b5043e70f9a75cde
OPENWRT_SDK_KEY_2512_SHA256=d7ac10f9ed1b38033855f3d27c9327d558444fca804c685b17d9dcfb0648228f
OPENWRT_SDK_KEY_2410=d310c6f2833e97f7
OPENWRT_SDK_KEY_2410_SHA256=e3624aa9be785362a172595b4919b233268871c4365a9b8da2b42ac41745ad95
+129
View File
@@ -0,0 +1,129 @@
#!/bin/sh
# Rename the PRIVATE `--fs-*` custom properties in a built cascade.css to short names.
#
# ./mangle-tokens.sh <cascade.css> <reserved-source-dir>...
#
# Run from the package Makefile over $(PKG_BUILD_DIR) only — NEVER over a dev build. The names are
# 16.1% of the sheet (21640 B over 1770 occurrences, 123 distinct) and mean nothing to a browser;
# this is the same trade terser already makes for the JS, where top-level identifiers are mangled
# because a LuCI resource file is function-scoped. Measured: 134240 -> ~119600, i.e. -14.6 KB, and
# uhttpd serves /www with no compression, so those are wire bytes on every cold load as well as
# flash bytes.
#
# WHY IT IS SAFE, and each clause is a thing that was checked rather than assumed:
# * `--fs-*` is the PRIVATE tier. The outbound contract with third-party apps is the `--*-color-*`
# export tier, a different prefix, and it is not touched. Verified on the router: no installed
# luci-app reads a `--fs-` name.
# * The RESERVED set is DERIVED, not listed: every `--fs-` name that appears in the theme's JS or
# in a .ut template crosses a seam (fs-prefs.js writes `--fs-tint`, head.ut's pre-paint writes
# `--fs-radius-base`, fs-chrome.js reads `--fs-sidebar-w` …) and keeps its name. 15 of 123 today.
# A new one cannot be forgotten, because nothing here names them.
# * Point the dirs at the SOURCE tree, not at $(PKG_BUILD_DIR). In CI the build tree's JS has
# already been through terser and its comments are gone, so five names that only appear in a
# comment stopped being reserved — the same source produced a different sheet depending on who
# built it (10 reserved via CI, 15 via a plain SDK build). Reading the source over-reserves by
# about a kilobyte, and over-reserving is the direction that cannot break anything.
# * The scan is STRING-AWARE and reads the WHOLE identifier before deciding. A prefix match would
# be a silent corruption: `--fs-space-2` is a prefix of `--fs-space-2-5`.
# * The short names are `--a`…`--z`, `--A`…`--Z`, then `--aa`… . The shortest custom property
# otherwise present in this sheet is 5 characters (`--box`), so a collision is impossible;
# the script re-checks that instead of trusting it.
#
# What it costs: the SHIPPED sheet is unreadable when debugging on a router. Everything that reads
# the theme's own names — dev-sync.sh, galdiff.py, the a11y gate, docs/gallery.html (4 references)
# and docs/devkit (29) — runs against an unmangled build, which is why this is a package-build step
# and not part of build-css.sh. Verify a change here with cssdiff.py, mangled against plain.
set -e
CSS="${1:-}"
[ -n "$CSS" ] && [ -f "$CSS" ] || { echo "usage: mangle-tokens.sh <cascade.css> <dir>..." >&2; exit 1; }
shift
[ $# -gt 0 ] || { echo "mangle-tokens: no reserved-source dir given" >&2; exit 1; }
RES="$CSS.reserved.$$"
MAP="$CSS.map.$$"
trap 'rm -f "$RES" "$MAP" "$CSS.tmp.$$"' EXIT
# every --fs- name mentioned anywhere in the JS or the templates keeps its name
for d in "$@"; do
[ -d "$d" ] || { echo "mangle-tokens: $d is not a directory" >&2; exit 1; }
find "$d" -type f \( -name '*.js' -o -name '*.ut' \) -exec cat {} +
done | grep -oE -- '--fs-[a-z0-9-]+' | sort -u > "$RES"
[ -s "$RES" ] || { echo "mangle-tokens: reserved set came out EMPTY — refusing (a seam name would be renamed and the theme would break silently)" >&2; exit 1; }
awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
# read the identifier starting at i (which points at the first "-" of "--")
function ident(s, i, j, n) {
n = length(s); j = i
while (j <= n && isname(substr(s, j, 1))) j++
return substr(s, i, j - i)
}
BEGIN {
while ((getline line < RESFILE) > 0) reserved[line] = 1
q = ""
}
{ css = css $0 "\n" }
END {
n = length(css)
# ---- pass 1: count every manglable name, string-aware ----
i = 1
while (i <= n) {
c = substr(css, i, 1)
if (q != "") { if (c == "\\") { i += 2; continue } ; if (c == q) q = ""; i++; continue }
if (c == "\"" || c == "'"'"'") { q = c; i++; continue }
if (c == "-" && substr(css, i, 5) == "--fs-") {
id = ident(css, i)
if (!(id in reserved)) { if (!(id in cnt)) order[++ncnt] = id; cnt[id]++ }
i += length(id); continue
}
i++
}
# ---- short-name alphabet, hottest name gets the shortest ----
A = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
nsh = 0
for (a = 1; a <= 52; a++) short[++nsh] = "--" substr(A, a, 1)
for (a = 1; a <= 52; a++) for (b = 1; b <= 52; b++) short[++nsh] = "--" substr(A, a, 1) substr(A, b, 1)
# selection sort by count (asort() is not portable to busybox/mawk)
for (x = 1; x <= ncnt; x++) {
best = x
for (y = x + 1; y <= ncnt; y++) if (cnt[order[y]] > cnt[order[best]]) best = y
t = order[x]; order[x] = order[best]; order[best] = t
}
if (ncnt > nsh) { print "mangle-tokens: more names than short forms" > "/dev/stderr"; exit 1 }
for (x = 1; x <= ncnt; x++) {
map[order[x]] = short[x]
# a mangled name must not already exist in the sheet
if (index(css, short[x] ":") || index(css, "var(" short[x] ")")) {
print "mangle-tokens: " short[x] " already used in the sheet" > "/dev/stderr"; exit 1
}
print order[x] " -> " short[x] " x" cnt[order[x]] > MAPFILE
}
# ---- pass 2: rewrite ----
q = ""; i = 1; out = ""
while (i <= n) {
c = substr(css, i, 1)
if (q != "") { if (c == "\\") { out = out substr(css, i, 2); i += 2; continue }
if (c == q) q = ""; out = out c; i++; continue }
if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue }
if (c == "-" && substr(css, i, 5) == "--fs-") {
id = ident(css, i)
out = out ((id in map) ? map[id] : id)
i += length(id); continue
}
out = out c; i++
}
printf "%s", out > OUTFILE
print ncnt " names mangled, " (length(css) - length(out)) " bytes saved" > "/dev/stderr"
}
' OUTFILE="$CSS.tmp.$$" "$CSS"
before=$(wc -c < "$CSS")
mv "$CSS.tmp.$$" "$CSS"
after=$(wc -c < "$CSS")
echo "mangle-tokens: $before -> $after bytes (-$((before - after))), $(wc -l < "$RES") name(s) reserved"
+662
View File
@@ -0,0 +1,662 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.9\n"
#: htdocs/luci-static/resources/fs-appearance.js:232
msgctxt "footstrap"
msgid "Accent"
msgstr "Acento"
#: htdocs/luci-static/resources/fs-appearance.js:357
msgctxt "footstrap"
msgid "As in file"
msgstr "Como en el archivo"
#: ucode/template/themes/footstrap/sysauth.ut:37
msgid "Authorization Required"
msgstr "Se requiere autorización"
#: htdocs/luci-static/resources/fs-appearance.js:169
msgctxt "footstrap"
msgid "Auto"
msgstr "Auto"
#: htdocs/luci-static/resources/fs-appearance.js:199
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr "Colapso automático"
#: htdocs/luci-static/resources/fs-appearance.js:615
msgctxt "footstrap"
msgid "Background"
msgstr "Fondo"
#: htdocs/luci-static/resources/fs-widgets.js:223
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr "Apenas visible %s"
#: htdocs/luci-static/resources/fs-appearance.js:288
msgctxt "footstrap"
msgid "Borders"
msgstr "Bordes"
#: htdocs/luci-static/resources/fs-appearance.js:269
msgctxt "footstrap"
msgid "Cards"
msgstr "Tarjetas"
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Choose SVG"
msgstr "Elegir SVG"
#: htdocs/luci-static/resources/fs-appearance.js:309
#: htdocs/luci-static/resources/fs-appearance.js:311
msgctxt "footstrap"
msgid "Choose image"
msgstr "Elegir imagen"
#: htdocs/luci-static/resources/fs-widgets.js:222
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Bien visible %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
#: ucode/template/themes/footstrap/header.ut:133
msgid "Collapse menu"
msgstr "Contraer menú"
#: htdocs/luci-static/resources/fs-appearance.js:347
#: htdocs/luci-static/resources/fs-appearance.js:612
msgctxt "footstrap"
msgid "Colours"
msgstr "Colores"
#: htdocs/luci-static/resources/fs-appearance.js:182
msgctxt "footstrap"
msgid "Compact"
msgstr "Compacto"
#: htdocs/luci-static/resources/fs-appearance.js:508
msgctxt "footstrap"
msgid "Confirm reset"
msgstr "Confirmar restablecimiento"
#: htdocs/luci-static/resources/fs-widgets.js:232
msgctxt "footstrap"
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
msgstr "Contraste %s:1 (WCAG AA exige aquí %s:1)"
#: htdocs/luci-static/resources/fs-appearance.js:275
msgctxt "footstrap"
msgid "Controls"
msgstr "Controles"
#: htdocs/luci-static/resources/fs-prefs.js:1014
#: htdocs/luci-static/resources/fs-prefs.js:1016
msgctxt "footstrap"
msgid "Could not process the image."
msgstr "No se pudo procesar la imagen."
#: htdocs/luci-static/resources/fs-appearance.js:484
msgctxt "footstrap"
msgid "Could not save the default. Reload the page and try again."
msgstr ""
"No se pudo guardar la configuración predeterminada. Recargue la página e "
"inténtelo de nuevo."
#: htdocs/luci-static/resources/fs-appearance.js:250
msgctxt "footstrap"
msgid "Danger"
msgstr "Peligro"
#: htdocs/luci-static/resources/fs-appearance.js:171
msgctxt "footstrap"
msgid "Dark"
msgstr "Oscuro"
#: htdocs/luci-static/resources/fs-appearance.js:616
msgctxt "footstrap"
msgid "Defaults"
msgstr "Predeterminados"
#: htdocs/luci-static/resources/fs-appearance.js:181
msgctxt "footstrap"
msgid "Density"
msgstr "Densidad"
#: htdocs/luci-static/resources/fs-appearance.js:318
msgctxt "footstrap"
msgid "Dim"
msgstr "Oscurecimiento"
#: htdocs/luci-static/resources/fs-widgets.js:225
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Se lee bien %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
msgid "Expand menu"
msgstr "Expandir menú"
#: htdocs/luci-static/resources/fs-appearance.js:413
msgctxt "footstrap"
msgid "File"
msgstr "Archivo"
#: root/usr/share/rpcd/acl.d/luci-theme-footstrap.json:3
msgid ""
"Footstrap Appearance router-wide defaults (Save as default) and the login "
"background and pattern uploads"
msgstr ""
"Valores predeterminados de Apariencia de Footstrap para el router (Guardar "
"como predeterminado) y subida del fondo de inicio de sesión y del patrón"
#: ucode/template/themes/footstrap/partials/notices.ut:25
msgid "Go to firmware upgrade..."
msgstr "Ir a la actualización del firmware..."
#: ucode/template/themes/footstrap/partials/notices.ut:16
msgid "Go to password configuration..."
msgstr "Ir a la configuración de contraseñas..."
#: htdocs/luci-static/resources/fs-appearance.js:238
msgctxt "footstrap"
msgid "Good"
msgstr "Correcto"
#: htdocs/luci-static/resources/fs-widgets.js:227
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr "Se lee mal %s: solo texto grande"
#: htdocs/luci-static/resources/fs-appearance.js:611
msgctxt "footstrap"
msgid "Interface"
msgstr "Interfaz"
#: ucode/template/themes/footstrap/sysauth.ut:32
msgid "Invalid username and/or password! Please try again."
msgstr "¡Nombre de usuario y/o contraseña incorrectos! Inténtelo de nuevo."
#: ucode/template/themes/footstrap/partials/notices.ut:31
msgid "JavaScript required!"
msgstr "¡Se requiere JavaScript!"
#: htdocs/luci-static/resources/fs-appearance.js:198
msgctxt "footstrap"
msgid "Keep open"
msgstr "Mantener abierto"
#: htdocs/luci-static/resources/fs-appearance.js:184
msgctxt "footstrap"
msgid "Large"
msgstr "Grande"
#: htdocs/luci-static/resources/fs-appearance.js:163
msgctxt "footstrap"
msgid "Layout"
msgstr "Diseño"
#: htdocs/luci-static/resources/fs-appearance.js:170
msgctxt "footstrap"
msgid "Light"
msgstr "Claro"
#: htdocs/luci-static/resources/fs-router.js:466
msgid "Loading view…"
msgstr "Cargando vista…"
#: ucode/template/themes/footstrap/sysauth.ut:58
msgid "Log in"
msgstr "Iniciar sesión"
#: ucode/template/themes/footstrap/partials/logout.ut:11
#: ucode/template/themes/footstrap/partials/logout.ut:13
msgid "Logout"
msgstr "Cerrar sesión"
#: ucode/template/themes/footstrap/partials/footer.ut:27
msgid "Lua compatibility mode active"
msgstr "Modo de compatibilidad Lua activo"
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "Memory"
msgstr "Memoria"
#: ucode/template/themes/footstrap/header.ut:130
#: ucode/template/themes/footstrap/header.ut:141
msgid "Menu"
msgstr "Menú"
#: ucode/template/themes/footstrap/partials/notices.ut:23
msgid ""
"No changes to settings will be stored and are lost after rebooting. This "
"mode should only be used to install a firmware upgrade"
msgstr ""
"No se guardarán los cambios en la configuración y se perderán después de "
"reiniciar el sistema. Este modo solo debe usarse para instalar una "
"actualización de firmware"
#: htdocs/luci-static/resources/fs-search.js:316
msgctxt "footstrap"
msgid "No pages found"
msgstr "No se encontraron páginas"
#: ucode/template/themes/footstrap/partials/notices.ut:13
msgid "No password set!"
msgstr "¡No se ha establecido ninguna contraseña!"
#: htdocs/luci-static/resources/fs-appearance.js:183
msgctxt "footstrap"
msgid "Normal"
msgstr "Normal"
#: htdocs/luci-static/resources/fs-appearance.js:411
msgctxt "footstrap"
msgid "Off"
msgstr "Apagado"
#: htdocs/luci-static/resources/fs-search.js:233
msgctxt "footstrap"
msgid "Pages"
msgstr "Páginas"
#: htdocs/luci-static/resources/fs-appearance.js:174
#: htdocs/luci-static/resources/fs-widgets.js:180
msgctxt "footstrap"
msgid "Palette"
msgstr "Paleta"
#: ucode/template/themes/footstrap/sysauth.ut:49
msgid "Password"
msgstr "Contraseña"
#: htdocs/luci-static/resources/fs-appearance.js:412
msgctxt "footstrap"
msgid "Pattern"
msgstr "Patrón"
#: htdocs/luci-static/resources/fs-prefs.js:882
#: htdocs/luci-static/resources/fs-prefs.js:884
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr "Elija un archivo SVG."
#: htdocs/luci-static/resources/fs-prefs.js:1029
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr "Por favor, seleccione un archivo de imagen."
#: ucode/template/themes/footstrap/sysauth.ut:39
msgid "Please enter your username and password."
msgstr "Por favor, ingrese su nombre de usuario y contraseña."
#: htdocs/luci-static/resources/fs-search.js:315
msgctxt "footstrap"
msgid "Recently visited"
msgstr "Visitado recientemente"
#: htdocs/luci-static/resources/fs-appearance.js:310
#: htdocs/luci-static/resources/fs-appearance.js:344
msgctxt "footstrap"
msgid "Remove"
msgstr "Eliminar"
#: htdocs/luci-static/resources/fs-appearance.js:459
#: htdocs/luci-static/resources/fs-appearance.js:519
msgctxt "footstrap"
msgid "Reset to default"
msgstr "Restablecer a valores predeterminados"
#: htdocs/luci-static/resources/fs-appearance.js:458
#: htdocs/luci-static/resources/fs-appearance.js:518
msgctxt "footstrap"
msgid "Reset to saved"
msgstr "Restablecer a lo guardado"
#: htdocs/luci-static/resources/fs-appearance.js:187
msgctxt "footstrap"
msgid "Rounding"
msgstr "Redondeo"
#: htdocs/luci-static/resources/fs-appearance.js:532
msgctxt "footstrap"
msgid "Router default"
msgstr "Configuración predeterminada del enrutador"
#: htdocs/luci-static/resources/fs-appearance.js:452
#: htdocs/luci-static/resources/fs-appearance.js:473
msgctxt "footstrap"
msgid "Save as default"
msgstr "Guardar como predeterminado"
#: htdocs/luci-static/resources/fs-appearance.js:473
msgctxt "footstrap"
msgid "Saved as default"
msgstr "Guardado como predeterminado"
#: htdocs/luci-static/resources/fs-appearance.js:345
msgctxt "footstrap"
msgid "Scale"
msgstr "Escala"
#: ucode/template/themes/footstrap/partials/search.ut:14
#: ucode/template/themes/footstrap/partials/search.ut:16
msgid "Search"
msgstr "Buscar"
#: htdocs/luci-static/resources/fs-search.js:227
#: htdocs/luci-static/resources/fs-search.js:254
msgctxt "footstrap"
msgid "Search pages"
msgstr "Buscar páginas"
#: htdocs/luci-static/resources/fs-search.js:228
msgctxt "footstrap"
msgid "Search pages…"
msgstr "Buscar páginas…"
#: htdocs/luci-static/resources/fs-appearance.js:164
msgctxt "footstrap"
msgid "Sidebar"
msgstr "Barra lateral"
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr "Barra lateral y superior"
#: ucode/template/themes/footstrap/header.ut:109
msgid "Skip to content"
msgstr "Saltar al contenido"
#: htdocs/luci-static/resources/fs-search.js:316
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr "Empiece a escribir para encontrar una página"
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "Storage"
msgstr "Almacenamiento"
#: htdocs/luci-static/resources/fs-appearance.js:346
msgctxt "footstrap"
msgid "Strength"
msgstr "Intensidad"
#: htdocs/luci-static/resources/fs-appearance.js:196
msgctxt "footstrap"
msgid "Submenus"
msgstr "Submenús"
#: htdocs/luci-static/resources/fs-appearance.js:613
msgctxt "footstrap"
msgid "Surfaces"
msgstr "Superficies"
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "System"
msgstr "Sistema"
#: ucode/template/themes/footstrap/partials/notices.ut:22
msgid "System running in recovery (initramfs) mode."
msgstr "El sistema se está ejecutando en modo de recuperación (initramfs)."
#: htdocs/luci-static/resources/fs-prefs.js:830
msgctxt "footstrap"
msgid ""
"That SVG contains script or external references, which this theme will not "
"install."
msgstr ""
"Ese SVG contiene scripts o referencias externas, y este tema no lo instalará."
#: htdocs/luci-static/resources/fs-prefs.js:854
msgctxt "footstrap"
msgid "That file could not be read."
msgstr "No se ha podido leer el archivo."
#: htdocs/luci-static/resources/fs-prefs.js:1018
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr "Ese archivo no es una imagen legible."
#: htdocs/luci-static/resources/fs-prefs.js:826
#: htdocs/luci-static/resources/fs-prefs.js:829
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr "Ese archivo no es una imagen SVG."
#: htdocs/luci-static/resources/fs-prefs.js:885
msgctxt "footstrap"
msgid "That file is too large."
msgstr "Ese archivo es demasiado grande."
#: htdocs/luci-static/resources/fs-prefs.js:1031
msgctxt "footstrap"
msgid "That image is too large."
msgstr "Esa imagen es demasiado grande."
#: htdocs/luci-static/resources/fs-appearance.js:168
#: htdocs/luci-static/resources/fs-appearance.js:356
msgctxt "footstrap"
msgid "Theme"
msgstr "Tema"
#: ucode/template/themes/footstrap/partials/notices.ut:14
msgid ""
"There is no password set on this router. Please configure a root password to "
"protect the web interface."
msgstr ""
"Este enrutador no tiene contraseña. Configure una contraseña de "
"administrador para proteger la interfaz web."
#: htdocs/luci-static/resources/fs-appearance.js:208
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr "Tinte (identificación del enrutador)"
#: htdocs/luci-static/resources/fs-appearance.js:223
msgctxt "footstrap"
msgid "Tint strength"
msgstr "Intensidad del tinte"
#: htdocs/luci-static/resources/fs-widgets.js:228
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr "Demasiado tenue para leerse %s"
#: htdocs/luci-static/resources/fs-appearance.js:165
msgctxt "footstrap"
msgid "Top"
msgstr "Arriba"
#: htdocs/luci-static/resources/fs-prefs.js:897
#: htdocs/luci-static/resources/fs-prefs.js:900
#: htdocs/luci-static/resources/fs-prefs.js:958
#: htdocs/luci-static/resources/fs-prefs.js:1042
#: htdocs/luci-static/resources/fs-prefs.js:1045
msgctxt "footstrap"
msgid "Upload failed."
msgstr "Error al cargar el archivo."
#: htdocs/luci-static/resources/fs-appearance.js:383
#: htdocs/luci-static/resources/fs-appearance.js:422
msgctxt "footstrap"
msgid "Uploading…"
msgstr "Cargando…"
#: ucode/template/themes/footstrap/sysauth.ut:43
msgid "Username"
msgstr "Nombre de usuario"
#: htdocs/luci-static/resources/fs-appearance.js:303
msgctxt "footstrap"
msgid "Wallpaper"
msgstr "Fondo de pantalla"
#: htdocs/luci-static/resources/fs-appearance.js:244
msgctxt "footstrap"
msgid "Warning"
msgstr "Advertencia"
#: ucode/template/themes/footstrap/partials/notices.ut:32
msgid ""
"You must enable JavaScript in your browser or LuCI will not work properly."
msgstr ""
"Debe activar JavaScript en su navegador o LuCI no funcionará correctamente."
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "in the sidebar"
msgstr "en la barra lateral"
#: htdocs/luci-static/resources/fs-appearance.js:235
#: htdocs/luci-static/resources/fs-appearance.js:241
#: htdocs/luci-static/resources/fs-appearance.js:247
#: htdocs/luci-static/resources/fs-appearance.js:253
#: htdocs/luci-static/resources/fs-appearance.js:272
#: htdocs/luci-static/resources/fs-appearance.js:291
msgctxt "footstrap"
msgid "on a card"
msgstr "en una tarjeta"
#: htdocs/luci-static/resources/fs-appearance.js:278
msgctxt "footstrap"
msgid "on a control"
msgstr "en un control"
#: htdocs/luci-static/resources/fs-appearance.js:213
msgctxt "footstrap"
msgid "on the canvas"
msgstr "en el lienzo"
#~ msgctxt "footstrap"
#~ msgid "Amber"
#~ msgstr "Ámbar"
#~ msgctxt "footstrap"
#~ msgid "Crimson"
#~ msgstr "Carmesí"
#~ msgctxt "footstrap"
#~ msgid "Emerald"
#~ msgstr "Esmeralda"
#~ msgctxt "footstrap"
#~ msgid "Graphite"
#~ msgstr "Grafito"
#~ msgctxt "footstrap"
#~ msgid "Ocean"
#~ msgstr "Océano"
#~ msgctxt "footstrap"
#~ msgid "Palette default"
#~ msgstr "Colores de la paleta"
#~ msgctxt "footstrap"
#~ msgid "Slate"
#~ msgstr "Pizarra"
#~ msgctxt "footstrap"
#~ msgid "Violet"
#~ msgstr "Violeta"
#~ msgctxt "footstrap"
#~ msgid "Cancel"
#~ msgstr "Cancelar"
#~ msgctxt "footstrap"
#~ msgid "Cats"
#~ msgstr "Gatos"
#~ msgctxt "footstrap"
#~ msgid "Dinosaurs"
#~ msgstr "Dinosaurios"
#~ msgctxt "footstrap"
#~ msgid "Download"
#~ msgstr "Descargar"
#~ msgctxt "footstrap"
#~ msgid "Download this wallpaper?"
#~ msgstr "¿Descargar este fondo?"
#~ msgctxt "footstrap"
#~ msgid "Downloading…"
#~ msgstr "Descargando…"
#~ msgctxt "footstrap"
#~ msgid "The download did not match what this theme expects."
#~ msgstr "La descarga no coincide con lo que espera el tema."
#~ msgctxt "footstrap"
#~ msgid ""
#~ "This pattern is not part of the theme package. It is about %d kB and is "
#~ "downloaded once, from the project on GitHub, and stored on the router."
#~ msgstr ""
#~ "Este patrón no forma parte del paquete del tema. Ocupa unos %d kB y se "
#~ "descarga una sola vez desde el proyecto en GitHub y se guarda en el "
#~ "router."
#~ msgid "Appearance"
#~ msgstr "Apariencia"
#~ msgctxt "footstrap"
#~ msgid "Check"
#~ msgstr "Comprobar"
#~ msgctxt "footstrap"
#~ msgid "Edit"
#~ msgstr "Edición"
#~ msgctxt "footstrap"
#~ msgid "New version available"
#~ msgstr "Nueva versión disponible"
#~ msgctxt "footstrap"
#~ msgid "On"
#~ msgstr "Activado"
#~ msgctxt "footstrap"
#~ msgid "Update now"
#~ msgstr "Actualizar ahora"
#~ msgctxt "footstrap"
#~ msgid "Updates"
#~ msgstr "Actualizaciones"
#~ msgctxt "footstrap"
#~ msgid "Transparency"
#~ msgstr "Transparencia"
#~ msgctxt "footstrap"
#~ msgid "AA"
#~ msgstr "AA"
#~ msgctxt "footstrap"
#~ msgid "Hairline on a card"
#~ msgstr "Línea en una tarjeta"
#~ msgctxt "footstrap"
#~ msgid "Text in the chrome"
#~ msgstr "Texto en la interfaz"
#~ msgctxt "footstrap"
#~ msgid "Text on a card"
#~ msgstr "Texto en una tarjeta"
#~ msgctxt "footstrap"
#~ msgid "below AA"
#~ msgstr "por debajo de AA"
+654
View File
@@ -0,0 +1,654 @@
msgid ""
msgstr ""
"Project-Id-Version: luci-theme-footstrap\n"
"Language: ru\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: htdocs/luci-static/resources/fs-appearance.js:232
msgctxt "footstrap"
msgid "Accent"
msgstr "Акцент"
#: htdocs/luci-static/resources/fs-appearance.js:357
msgctxt "footstrap"
msgid "As in file"
msgstr "Как в файле"
#: ucode/template/themes/footstrap/sysauth.ut:37
msgid "Authorization Required"
msgstr "Требуется авторизация"
#: htdocs/luci-static/resources/fs-appearance.js:169
msgctxt "footstrap"
msgid "Auto"
msgstr "Авто"
#: htdocs/luci-static/resources/fs-appearance.js:199
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr "Сворачивать"
#: htdocs/luci-static/resources/fs-appearance.js:615
msgctxt "footstrap"
msgid "Background"
msgstr "Фон"
#: htdocs/luci-static/resources/fs-widgets.js:223
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr "Едва заметна %s"
#: htdocs/luci-static/resources/fs-appearance.js:288
msgctxt "footstrap"
msgid "Borders"
msgstr "Границы"
#: htdocs/luci-static/resources/fs-appearance.js:269
msgctxt "footstrap"
msgid "Cards"
msgstr "Карточки"
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Choose SVG"
msgstr "Выбрать SVG"
#: htdocs/luci-static/resources/fs-appearance.js:309
#: htdocs/luci-static/resources/fs-appearance.js:311
msgctxt "footstrap"
msgid "Choose image"
msgstr "Выбрать изображение"
#: htdocs/luci-static/resources/fs-widgets.js:222
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Хорошо видна %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
#: ucode/template/themes/footstrap/header.ut:133
msgid "Collapse menu"
msgstr "Свернуть меню"
#: htdocs/luci-static/resources/fs-appearance.js:347
#: htdocs/luci-static/resources/fs-appearance.js:612
msgctxt "footstrap"
msgid "Colours"
msgstr "Цвета"
#: htdocs/luci-static/resources/fs-appearance.js:182
msgctxt "footstrap"
msgid "Compact"
msgstr "Компактно"
#: htdocs/luci-static/resources/fs-appearance.js:508
msgctxt "footstrap"
msgid "Confirm reset"
msgstr "Подтвердить сброс"
#: htdocs/luci-static/resources/fs-widgets.js:232
msgctxt "footstrap"
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
msgstr "Контраст %s:1 (WCAG AA требует здесь %s:1)"
#: htdocs/luci-static/resources/fs-appearance.js:275
msgctxt "footstrap"
msgid "Controls"
msgstr "Элементы форм"
#: htdocs/luci-static/resources/fs-prefs.js:1014
#: htdocs/luci-static/resources/fs-prefs.js:1016
msgctxt "footstrap"
msgid "Could not process the image."
msgstr "Не удалось обработать изображение."
#: htdocs/luci-static/resources/fs-appearance.js:484
msgctxt "footstrap"
msgid "Could not save the default. Reload the page and try again."
msgstr "Не удалось сохранить умолчание. Обновите страницу и повторите."
#: htdocs/luci-static/resources/fs-appearance.js:250
msgctxt "footstrap"
msgid "Danger"
msgstr "Опасность"
#: htdocs/luci-static/resources/fs-appearance.js:171
msgctxt "footstrap"
msgid "Dark"
msgstr "Тёмная"
#: htdocs/luci-static/resources/fs-appearance.js:616
msgctxt "footstrap"
msgid "Defaults"
msgstr "Умолчания"
#: htdocs/luci-static/resources/fs-appearance.js:181
msgctxt "footstrap"
msgid "Density"
msgstr "Плотность"
#: htdocs/luci-static/resources/fs-appearance.js:318
msgctxt "footstrap"
msgid "Dim"
msgstr "Затемнение"
#: htdocs/luci-static/resources/fs-widgets.js:225
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Хорошо читается %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
msgid "Expand menu"
msgstr "Развернуть меню"
#: htdocs/luci-static/resources/fs-appearance.js:413
msgctxt "footstrap"
msgid "File"
msgstr "Файл"
#: root/usr/share/rpcd/acl.d/luci-theme-footstrap.json:3
msgid ""
"Footstrap Appearance router-wide defaults (Save as default) and the login "
"background and pattern uploads"
msgstr ""
"Общие настройки внешнего вида Footstrap для роутера (Сохранить как "
"умолчание), загрузка фонового изображения и узора"
#: ucode/template/themes/footstrap/partials/notices.ut:25
msgid "Go to firmware upgrade..."
msgstr "Перейти к обновлению прошивки..."
#: ucode/template/themes/footstrap/partials/notices.ut:16
msgid "Go to password configuration..."
msgstr "Перейти к настройке пароля..."
#: htdocs/luci-static/resources/fs-appearance.js:238
msgctxt "footstrap"
msgid "Good"
msgstr "Норма"
#: htdocs/luci-static/resources/fs-widgets.js:227
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr "Плохо читается %s — только крупный текст"
#: htdocs/luci-static/resources/fs-appearance.js:611
msgctxt "footstrap"
msgid "Interface"
msgstr "Интерфейс"
#: ucode/template/themes/footstrap/sysauth.ut:32
msgid "Invalid username and/or password! Please try again."
msgstr "Неверное имя пользователя и/или пароль! Попробуйте ещё раз."
#: ucode/template/themes/footstrap/partials/notices.ut:31
msgid "JavaScript required!"
msgstr "Требуется JavaScript!"
#: htdocs/luci-static/resources/fs-appearance.js:198
msgctxt "footstrap"
msgid "Keep open"
msgstr "Не сворачивать"
#: htdocs/luci-static/resources/fs-appearance.js:184
msgctxt "footstrap"
msgid "Large"
msgstr "Крупно"
#: htdocs/luci-static/resources/fs-appearance.js:163
msgctxt "footstrap"
msgid "Layout"
msgstr "Раскладка"
#: htdocs/luci-static/resources/fs-appearance.js:170
msgctxt "footstrap"
msgid "Light"
msgstr "Светлая"
#: htdocs/luci-static/resources/fs-router.js:466
msgid "Loading view…"
msgstr "Загрузка представления…"
#: ucode/template/themes/footstrap/sysauth.ut:58
msgid "Log in"
msgstr "Войти"
#: ucode/template/themes/footstrap/partials/logout.ut:11
#: ucode/template/themes/footstrap/partials/logout.ut:13
msgid "Logout"
msgstr "Выход"
#: ucode/template/themes/footstrap/partials/footer.ut:27
msgid "Lua compatibility mode active"
msgstr "Включён режим совместимости с Lua"
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "Memory"
msgstr "Память"
#: ucode/template/themes/footstrap/header.ut:130
#: ucode/template/themes/footstrap/header.ut:141
msgid "Menu"
msgstr "Меню"
#: ucode/template/themes/footstrap/partials/notices.ut:23
msgid ""
"No changes to settings will be stored and are lost after rebooting. This "
"mode should only be used to install a firmware upgrade"
msgstr ""
"Изменения настроек не сохраняются и будут потеряны после перезагрузки. Этот "
"режим следует использовать только для установки обновления прошивки"
#: htdocs/luci-static/resources/fs-search.js:316
msgctxt "footstrap"
msgid "No pages found"
msgstr "Страницы не найдены"
#: ucode/template/themes/footstrap/partials/notices.ut:13
msgid "No password set!"
msgstr "Пароль не установлен!"
#: htdocs/luci-static/resources/fs-appearance.js:183
msgctxt "footstrap"
msgid "Normal"
msgstr "Обычно"
#: htdocs/luci-static/resources/fs-appearance.js:411
msgctxt "footstrap"
msgid "Off"
msgstr "Выкл."
#: htdocs/luci-static/resources/fs-search.js:233
msgctxt "footstrap"
msgid "Pages"
msgstr "Страницы"
#: htdocs/luci-static/resources/fs-appearance.js:174
#: htdocs/luci-static/resources/fs-widgets.js:180
msgctxt "footstrap"
msgid "Palette"
msgstr "Палитра"
#: ucode/template/themes/footstrap/sysauth.ut:49
msgid "Password"
msgstr "Пароль"
#: htdocs/luci-static/resources/fs-appearance.js:412
msgctxt "footstrap"
msgid "Pattern"
msgstr "Узор"
#: htdocs/luci-static/resources/fs-prefs.js:882
#: htdocs/luci-static/resources/fs-prefs.js:884
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr "Выберите файл SVG."
#: htdocs/luci-static/resources/fs-prefs.js:1029
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr "Выберите файл изображения."
#: ucode/template/themes/footstrap/sysauth.ut:39
msgid "Please enter your username and password."
msgstr "Введите имя пользователя и пароль."
#: htdocs/luci-static/resources/fs-search.js:315
msgctxt "footstrap"
msgid "Recently visited"
msgstr "Недавние"
#: htdocs/luci-static/resources/fs-appearance.js:310
#: htdocs/luci-static/resources/fs-appearance.js:344
msgctxt "footstrap"
msgid "Remove"
msgstr "Убрать"
#: htdocs/luci-static/resources/fs-appearance.js:459
#: htdocs/luci-static/resources/fs-appearance.js:519
msgctxt "footstrap"
msgid "Reset to default"
msgstr "Сбросить к умолчанию"
#: htdocs/luci-static/resources/fs-appearance.js:458
#: htdocs/luci-static/resources/fs-appearance.js:518
msgctxt "footstrap"
msgid "Reset to saved"
msgstr "Сбросить к сохранённым"
#: htdocs/luci-static/resources/fs-appearance.js:187
msgctxt "footstrap"
msgid "Rounding"
msgstr "Скругление"
#: htdocs/luci-static/resources/fs-appearance.js:532
msgctxt "footstrap"
msgid "Router default"
msgstr "По умолчанию для роутера"
#: htdocs/luci-static/resources/fs-appearance.js:452
#: htdocs/luci-static/resources/fs-appearance.js:473
msgctxt "footstrap"
msgid "Save as default"
msgstr "Сохранить по умолчанию"
#: htdocs/luci-static/resources/fs-appearance.js:473
msgctxt "footstrap"
msgid "Saved as default"
msgstr "Сохранено по умолчанию"
#: htdocs/luci-static/resources/fs-appearance.js:345
msgctxt "footstrap"
msgid "Scale"
msgstr "Масштаб"
#: ucode/template/themes/footstrap/partials/search.ut:14
#: ucode/template/themes/footstrap/partials/search.ut:16
msgid "Search"
msgstr "Поиск"
#: htdocs/luci-static/resources/fs-search.js:227
#: htdocs/luci-static/resources/fs-search.js:254
msgctxt "footstrap"
msgid "Search pages"
msgstr "Поиск по страницам"
#: htdocs/luci-static/resources/fs-search.js:228
msgctxt "footstrap"
msgid "Search pages…"
msgstr "Поиск по страницам…"
#: htdocs/luci-static/resources/fs-appearance.js:164
msgctxt "footstrap"
msgid "Sidebar"
msgstr "Сбоку"
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr "Боковая панель и шапка"
#: ucode/template/themes/footstrap/header.ut:109
msgid "Skip to content"
msgstr "Перейти к содержимому"
#: htdocs/luci-static/resources/fs-search.js:316
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr "Начните вводить название страницы"
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "Storage"
msgstr "Накопитель"
#: htdocs/luci-static/resources/fs-appearance.js:346
msgctxt "footstrap"
msgid "Strength"
msgstr "Насыщенность"
#: htdocs/luci-static/resources/fs-appearance.js:196
msgctxt "footstrap"
msgid "Submenus"
msgstr "Подменю"
#: htdocs/luci-static/resources/fs-appearance.js:613
msgctxt "footstrap"
msgid "Surfaces"
msgstr "Поверхности"
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "System"
msgstr "Система"
#: ucode/template/themes/footstrap/partials/notices.ut:22
msgid "System running in recovery (initramfs) mode."
msgstr "Система работает в режиме восстановления (initramfs)."
#: htdocs/luci-static/resources/fs-prefs.js:830
msgctxt "footstrap"
msgid ""
"That SVG contains script or external references, which this theme will not "
"install."
msgstr ""
"В этом SVG есть скрипт или внешние ссылки — тема не станет его устанавливать."
#: htdocs/luci-static/resources/fs-prefs.js:854
msgctxt "footstrap"
msgid "That file could not be read."
msgstr "Не удалось прочитать файл."
#: htdocs/luci-static/resources/fs-prefs.js:1018
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr "Не удалось прочитать файл как изображение."
#: htdocs/luci-static/resources/fs-prefs.js:826
#: htdocs/luci-static/resources/fs-prefs.js:829
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr "Этот файл — не изображение SVG."
#: htdocs/luci-static/resources/fs-prefs.js:885
msgctxt "footstrap"
msgid "That file is too large."
msgstr "Файл слишком большой."
#: htdocs/luci-static/resources/fs-prefs.js:1031
msgctxt "footstrap"
msgid "That image is too large."
msgstr "Изображение слишком большое."
#: htdocs/luci-static/resources/fs-appearance.js:168
#: htdocs/luci-static/resources/fs-appearance.js:356
msgctxt "footstrap"
msgid "Theme"
msgstr "Тема"
#: ucode/template/themes/footstrap/partials/notices.ut:14
msgid ""
"There is no password set on this router. Please configure a root password to "
"protect the web interface."
msgstr ""
"На этом роутере не задан пароль. Установите пароль root, чтобы защитить веб-"
"интерфейс."
#: htdocs/luci-static/resources/fs-appearance.js:208
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr "Оттенок (опознание роутера)"
#: htdocs/luci-static/resources/fs-appearance.js:223
msgctxt "footstrap"
msgid "Tint strength"
msgstr "Насыщенность оттенка"
#: htdocs/luci-static/resources/fs-widgets.js:228
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr "Слишком блёкло, не читается %s"
#: htdocs/luci-static/resources/fs-appearance.js:165
msgctxt "footstrap"
msgid "Top"
msgstr "Сверху"
#: htdocs/luci-static/resources/fs-prefs.js:897
#: htdocs/luci-static/resources/fs-prefs.js:900
#: htdocs/luci-static/resources/fs-prefs.js:958
#: htdocs/luci-static/resources/fs-prefs.js:1042
#: htdocs/luci-static/resources/fs-prefs.js:1045
msgctxt "footstrap"
msgid "Upload failed."
msgstr "Не удалось загрузить."
#: htdocs/luci-static/resources/fs-appearance.js:383
#: htdocs/luci-static/resources/fs-appearance.js:422
msgctxt "footstrap"
msgid "Uploading…"
msgstr "Загрузка…"
#: ucode/template/themes/footstrap/sysauth.ut:43
msgid "Username"
msgstr "Имя пользователя"
#: htdocs/luci-static/resources/fs-appearance.js:303
msgctxt "footstrap"
msgid "Wallpaper"
msgstr "Обои"
#: htdocs/luci-static/resources/fs-appearance.js:244
msgctxt "footstrap"
msgid "Warning"
msgstr "Предупреждение"
#: ucode/template/themes/footstrap/partials/notices.ut:32
msgid ""
"You must enable JavaScript in your browser or LuCI will not work properly."
msgstr ""
"Включите JavaScript в браузере, иначе LuCI не будет работать корректно."
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "in the sidebar"
msgstr "в боковой панели"
#: htdocs/luci-static/resources/fs-appearance.js:235
#: htdocs/luci-static/resources/fs-appearance.js:241
#: htdocs/luci-static/resources/fs-appearance.js:247
#: htdocs/luci-static/resources/fs-appearance.js:253
#: htdocs/luci-static/resources/fs-appearance.js:272
#: htdocs/luci-static/resources/fs-appearance.js:291
msgctxt "footstrap"
msgid "on a card"
msgstr "на карточке"
#: htdocs/luci-static/resources/fs-appearance.js:278
msgctxt "footstrap"
msgid "on a control"
msgstr "на элементе формы"
#: htdocs/luci-static/resources/fs-appearance.js:213
msgctxt "footstrap"
msgid "on the canvas"
msgstr "на холсте"
#~ msgctxt "footstrap"
#~ msgid "Amber"
#~ msgstr "Янтарь"
#~ msgctxt "footstrap"
#~ msgid "Crimson"
#~ msgstr "Багрянец"
#~ msgctxt "footstrap"
#~ msgid "Emerald"
#~ msgstr "Изумруд"
#~ msgctxt "footstrap"
#~ msgid "Graphite"
#~ msgstr "Графит"
#~ msgctxt "footstrap"
#~ msgid "Ocean"
#~ msgstr "Океан"
#~ msgctxt "footstrap"
#~ msgid "Palette default"
#~ msgstr "Цвета палитры"
#~ msgctxt "footstrap"
#~ msgid "Slate"
#~ msgstr "Сланец"
#~ msgctxt "footstrap"
#~ msgid "Violet"
#~ msgstr "Фиалка"
#~ msgctxt "footstrap"
#~ msgid "Cancel"
#~ msgstr "Отмена"
#~ msgctxt "footstrap"
#~ msgid "Cats"
#~ msgstr "Коты"
#~ msgctxt "footstrap"
#~ msgid "Dinosaurs"
#~ msgstr "Динозавры"
#~ msgctxt "footstrap"
#~ msgid "Download"
#~ msgstr "Скачать"
#~ msgctxt "footstrap"
#~ msgid "Download this wallpaper?"
#~ msgstr "Скачать эти обои?"
#~ msgctxt "footstrap"
#~ msgid "Downloading…"
#~ msgstr "Скачивание…"
#~ msgctxt "footstrap"
#~ msgid "The download did not match what this theme expects."
#~ msgstr "Скачанный файл не совпал с тем, что ожидает тема."
#~ msgctxt "footstrap"
#~ msgid ""
#~ "This pattern is not part of the theme package. It is about %d kB and is "
#~ "downloaded once, from the project on GitHub, and stored on the router."
#~ msgstr ""
#~ "Этот узор не входит в пакет темы. Около %d кБ, скачивается один раз из "
#~ "проекта на GitHub и сохраняется на роутере."
#~ msgid "Appearance"
#~ msgstr "Оформление"
#~ msgctxt "footstrap"
#~ msgid "Check"
#~ msgstr "Проверить"
#~ msgctxt "footstrap"
#~ msgid "Edit"
#~ msgstr "Редактирование"
#~ msgctxt "footstrap"
#~ msgid "New version available"
#~ msgstr "Доступна новая версия"
#~ msgctxt "footstrap"
#~ msgid "On"
#~ msgstr "Вкл"
#~ msgctxt "footstrap"
#~ msgid "Update now"
#~ msgstr "Обновить сейчас"
#~ msgctxt "footstrap"
#~ msgid "Updates"
#~ msgstr "Обновления"
#~ msgctxt "footstrap"
#~ msgid "Transparency"
#~ msgstr "Прозрачность"
#~ msgctxt "footstrap"
#~ msgid "AA"
#~ msgstr "AA"
#~ msgctxt "footstrap"
#~ msgid "Hairline on a card"
#~ msgstr "Линия на карточке"
#~ msgctxt "footstrap"
#~ msgid "Text in the chrome"
#~ msgstr "Текст в хроме"
#~ msgctxt "footstrap"
#~ msgid "Text on a card"
#~ msgstr "Текст на карточке"
#~ msgctxt "footstrap"
#~ msgid "below AA"
#~ msgstr "ниже AA"
@@ -0,0 +1,521 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: htdocs/luci-static/resources/fs-appearance.js:232
msgctxt "footstrap"
msgid "Accent"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:357
msgctxt "footstrap"
msgid "As in file"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:37
msgid "Authorization Required"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:169
msgctxt "footstrap"
msgid "Auto"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:199
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:615
msgctxt "footstrap"
msgid "Background"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:223
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:288
msgctxt "footstrap"
msgid "Borders"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:269
msgctxt "footstrap"
msgid "Cards"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Choose SVG"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:309
#: htdocs/luci-static/resources/fs-appearance.js:311
msgctxt "footstrap"
msgid "Choose image"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:222
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:369
#: ucode/template/themes/footstrap/header.ut:133
msgid "Collapse menu"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:347
#: htdocs/luci-static/resources/fs-appearance.js:612
msgctxt "footstrap"
msgid "Colours"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:182
msgctxt "footstrap"
msgid "Compact"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:508
msgctxt "footstrap"
msgid "Confirm reset"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:232
msgctxt "footstrap"
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:275
msgctxt "footstrap"
msgid "Controls"
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1014
#: htdocs/luci-static/resources/fs-prefs.js:1016
msgctxt "footstrap"
msgid "Could not process the image."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:484
msgctxt "footstrap"
msgid "Could not save the default. Reload the page and try again."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:250
msgctxt "footstrap"
msgid "Danger"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:171
msgctxt "footstrap"
msgid "Dark"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:616
msgctxt "footstrap"
msgid "Defaults"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:181
msgctxt "footstrap"
msgid "Density"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:318
msgctxt "footstrap"
msgid "Dim"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:225
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:369
msgid "Expand menu"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:413
msgctxt "footstrap"
msgid "File"
msgstr ""
#: root/usr/share/rpcd/acl.d/luci-theme-footstrap.json:3
msgid ""
"Footstrap Appearance router-wide defaults (Save as default) and the login "
"background and pattern uploads"
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:25
msgid "Go to firmware upgrade..."
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:16
msgid "Go to password configuration..."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:238
msgctxt "footstrap"
msgid "Good"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:227
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:611
msgctxt "footstrap"
msgid "Interface"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:32
msgid "Invalid username and/or password! Please try again."
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:31
msgid "JavaScript required!"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:198
msgctxt "footstrap"
msgid "Keep open"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:184
msgctxt "footstrap"
msgid "Large"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:163
msgctxt "footstrap"
msgid "Layout"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:170
msgctxt "footstrap"
msgid "Light"
msgstr ""
#: htdocs/luci-static/resources/fs-router.js:466
msgid "Loading view…"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:58
msgid "Log in"
msgstr ""
#: ucode/template/themes/footstrap/partials/logout.ut:11
#: ucode/template/themes/footstrap/partials/logout.ut:13
msgid "Logout"
msgstr ""
#: ucode/template/themes/footstrap/partials/footer.ut:27
msgid "Lua compatibility mode active"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "Memory"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:130
#: ucode/template/themes/footstrap/header.ut:141
msgid "Menu"
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:23
msgid ""
"No changes to settings will be stored and are lost after rebooting. This "
"mode should only be used to install a firmware upgrade"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:316
msgctxt "footstrap"
msgid "No pages found"
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:13
msgid "No password set!"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:183
msgctxt "footstrap"
msgid "Normal"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:411
msgctxt "footstrap"
msgid "Off"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:233
msgctxt "footstrap"
msgid "Pages"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:174
#: htdocs/luci-static/resources/fs-widgets.js:180
msgctxt "footstrap"
msgid "Palette"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:49
msgid "Password"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:412
msgctxt "footstrap"
msgid "Pattern"
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:882
#: htdocs/luci-static/resources/fs-prefs.js:884
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1029
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:39
msgid "Please enter your username and password."
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:315
msgctxt "footstrap"
msgid "Recently visited"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:310
#: htdocs/luci-static/resources/fs-appearance.js:344
msgctxt "footstrap"
msgid "Remove"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:459
#: htdocs/luci-static/resources/fs-appearance.js:519
msgctxt "footstrap"
msgid "Reset to default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:458
#: htdocs/luci-static/resources/fs-appearance.js:518
msgctxt "footstrap"
msgid "Reset to saved"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:187
msgctxt "footstrap"
msgid "Rounding"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:532
msgctxt "footstrap"
msgid "Router default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:452
#: htdocs/luci-static/resources/fs-appearance.js:473
msgctxt "footstrap"
msgid "Save as default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:473
msgctxt "footstrap"
msgid "Saved as default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:345
msgctxt "footstrap"
msgid "Scale"
msgstr ""
#: ucode/template/themes/footstrap/partials/search.ut:14
#: ucode/template/themes/footstrap/partials/search.ut:16
msgid "Search"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:227
#: htdocs/luci-static/resources/fs-search.js:254
msgctxt "footstrap"
msgid "Search pages"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:228
msgctxt "footstrap"
msgid "Search pages…"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:164
msgctxt "footstrap"
msgid "Sidebar"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:109
msgid "Skip to content"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:316
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "Storage"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:346
msgctxt "footstrap"
msgid "Strength"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:196
msgctxt "footstrap"
msgid "Submenus"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:613
msgctxt "footstrap"
msgid "Surfaces"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:34
msgid "System"
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:22
msgid "System running in recovery (initramfs) mode."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:830
msgctxt "footstrap"
msgid ""
"That SVG contains script or external references, which this theme will not "
"install."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:854
msgctxt "footstrap"
msgid "That file could not be read."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1018
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:826
#: htdocs/luci-static/resources/fs-prefs.js:829
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:885
msgctxt "footstrap"
msgid "That file is too large."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1031
msgctxt "footstrap"
msgid "That image is too large."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:168
#: htdocs/luci-static/resources/fs-appearance.js:356
msgctxt "footstrap"
msgid "Theme"
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:14
msgid ""
"There is no password set on this router. Please configure a root password to "
"protect the web interface."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:208
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:223
msgctxt "footstrap"
msgid "Tint strength"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:228
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:165
msgctxt "footstrap"
msgid "Top"
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:897
#: htdocs/luci-static/resources/fs-prefs.js:900
#: htdocs/luci-static/resources/fs-prefs.js:958
#: htdocs/luci-static/resources/fs-prefs.js:1042
#: htdocs/luci-static/resources/fs-prefs.js:1045
msgctxt "footstrap"
msgid "Upload failed."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:383
#: htdocs/luci-static/resources/fs-appearance.js:422
msgctxt "footstrap"
msgid "Uploading…"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:43
msgid "Username"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:303
msgctxt "footstrap"
msgid "Wallpaper"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:244
msgctxt "footstrap"
msgid "Warning"
msgstr ""
#: ucode/template/themes/footstrap/partials/notices.ut:32
msgid ""
"You must enable JavaScript in your browser or LuCI will not work properly."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "in the sidebar"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:235
#: htdocs/luci-static/resources/fs-appearance.js:241
#: htdocs/luci-static/resources/fs-appearance.js:247
#: htdocs/luci-static/resources/fs-appearance.js:253
#: htdocs/luci-static/resources/fs-appearance.js:272
#: htdocs/luci-static/resources/fs-appearance.js:291
msgctxt "footstrap"
msgid "on a card"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:278
msgctxt "footstrap"
msgid "on a control"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:213
msgctxt "footstrap"
msgid "on the canvas"
msgstr ""
@@ -0,0 +1,2 @@
config footstrap 'settings'
@@ -0,0 +1,134 @@
#!/bin/sh
# FRESH INSTALL OR UPGRADE? A fresh install may activate this theme; an upgrade must NEVER change
# the theme the user is on. The guard used to be `[ "$PKG_UPGRADE" = 1 ]` alone — apk never sets
# that and neither does postinst, so only dev-sync.sh (which exports it by hand) ever took the
# upgrade branch: on a real router the guard was DEAD, and an upgrade that found an empty
# mediaurlbase would activate this theme behind the user's back. The marker file is the state that
# really tells the two apart — it exists iff this script has completed at least once.
FS_MARKER=/usr/share/luci-theme-footstrap/.installed
if [ "$PKG_UPGRADE" = 1 ] || [ -f "$FS_MARKER" ]; then
PKG_UPGRADE=1
else
PKG_UPGRADE=0
fi
# Every theme name this package has EVER registered — delete them all; the ONE surviving entry
# is re-added below (layout, mode and palette are client-side toggles in the Appearance popover,
# so they are not theme entries). The Makefile's postrm must delete the same set (it runs when
# this file is already gone, so it cannot source it) and the two lists had already drifted into a
# different ORDER — which is how one of them would quietly lose a name. Only the assignment is
# pinned: the Makefile writes `$$t` where this writes `$t`.
# @mirror theme/legacy-names
LEGACY_THEMES="Footstrap FootstrapDark FootstrapLight FootstrapTop FootstrapTopDark FootstrapTopLight FootstrapSidebar FootstrapOnTop"
# @endmirror
for t in $LEGACY_THEMES; do
uci -q delete "luci.themes.$t"
done
# The ONE theme entry: sidebar and top bar are one template morphed by :root[data-layout].
uci set luci.themes.Footstrap=/luci-static/footstrap
# Normalise the active theme. Every legacy media path below is now neither registered in
# luci.themes nor on disk, so an install left pointing at one is stranded: the Design page does
# not list it (it cannot be switched away from) and its media dir 404s. They all map onto the one
# surviving path. Non-footstrap themes are left alone; a fresh install defaults to footstrap.
#
# MIGRATION — a router that was on the top-nav theme must STAY on the top bar. The layout lives
# in the browser (localStorage 'fs-layout') and a shell script cannot write that, so carry it
# server-side: footstrap_layout is the router's DEFAULT layout, which head.ut stamps onto
# <html data-layout> and the client's own choice then overrides. Set only when the old path says
# top-nav, and only if unset, so a re-run never clobbers a deliberate change.
cur=$(uci -q get luci.main.mediaurlbase)
new=$cur
case "$cur" in
# was on the top-nav theme (incl. its legacy dark/light variants) -> keep the bar
/luci-static/footstrap-top*)
[ -n "$(uci -q get luci.main.footstrap_layout)" ] || \
uci set luci.main.footstrap_layout=top
new=/luci-static/footstrap ;;
# any other footstrap path (incl. the legacy -dark/-light) -> the built-in default (the bar):
# this branch deliberately sets no footstrap_layout, and absent means top (head.ut)
/luci-static/footstrap*) new=/luci-static/footstrap ;;
"") [ "$PKG_UPGRADE" = 1 ] || new=/luci-static/footstrap ;;
esac
# Last-resort guard against a blank UI: whatever stays active must be on disk — media dir AND
# ucode template. If a partial upgrade or a hand-edited mediaurlbase points at neither, fall back
# to stock bootstrap (a plain UI beats no UI); skipped if bootstrap is not installed either.
#
# SCOPED TO OUR OWN THEME, and the scoping is the point: `new` defaults to whatever the router
# already had, so without the case this guard judged OTHER PEOPLE'S themes — installing footstrap
# would inspect an active luci-theme-argon and, if its template dir was not named after its media
# basename (or it was mid-upgrade), quietly rewrite mediaurlbase to bootstrap.
case "$new" in
/luci-static/footstrap*)
theme=${new##*/}
if [ ! -d "/www/luci-static/$theme" ] ||
[ ! -f "/usr/share/ucode/luci/template/themes/$theme/header.ut" ]; then
[ -d /www/luci-static/bootstrap ] &&
[ -f /usr/share/ucode/luci/template/themes/bootstrap/header.ut ] &&
new=/luci-static/bootstrap
fi
;;
esac
[ "$new" = "$cur" ] || uci set luci.main.mediaurlbase="$new"
uci commit luci
# Appearance "Save as default" writes the router-wide axis defaults into /etc/config/footstrap
# through the scoped uci ACL (config 'footstrap' only). The rpcd uci `set` backend the browser
# calls needs the section to ALREADY EXIST, so guarantee it here — the shipped conffile carries it,
# but a router that installed a footstrap predating this feature has no such file. `uci set` refuses
# to CREATE the config file (only the section within one), so touch it first. Idempotent.
[ -f /etc/config/footstrap ] || touch /etc/config/footstrap
if ! uci -q get footstrap.settings >/dev/null 2>&1; then
uci set footstrap.settings=footstrap
uci commit footstrap
fi
# Login/page background upload (Appearance -> Background image). cgi-upload writes the image to
# /etc/footstrap/login-bg (a fixed, ACL-scoped path — kept in /etc so it survives a package upgrade
# and, via lib/upgrade/keep.d, a sysupgrade). uhttpd serves only /www, so a symlink there exposes it
# at /luci-static/footstrap/bg (uhttpd follows symlinks). Both are recreated on every install/upgrade
# (postinst re-runs this script) and are idempotent; /www is repopulated from firmware on sysupgrade,
# so recreating the symlink here rather than shipping it is what makes it reliable on both filesystems.
mkdir -p /etc/footstrap 2>/dev/null
[ -d /www/luci-static/footstrap ] && ln -sf /etc/footstrap/login-bg /www/luci-static/footstrap/bg 2>/dev/null
# The uploaded PATTERN (Appearance -> Wallpaper -> Pattern) is the same arrangement, with ONE
# difference that is not cosmetic: the exposed name KEEPS THE .svg EXTENSION. uhttpd types a
# response by extension, and an SVG served as application/octet-stream is one no browser will
# paint — as a CSS mask, as a background image or at all.
[ -d /www/luci-static/footstrap ] && ln -sf /etc/footstrap/pattern.svg /www/luci-static/footstrap/pattern.svg 2>/dev/null
# The admin-installed webfonts (fonts/set-font.sh) are a whole DIRECTORY rather than one file: the
# faces plus the @font-face sheet generated next to them. Same arrangement as the two images above,
# and linking a directory has TWO traps, both of which end with `ln` exiting 0 and the link sitting
# one level too deep, at .../fonts/fonts — where no browser looks, so the fonts silently do not
# serve and nothing reports a thing:
# - the link already exists and points at a directory: `ln -sf` follows it. `-n` is the fix.
# - the path is a REAL directory, which every footstrap before 0.12.1 shipped here full of
# woff2 files. `-n` does not help — measured on a 25.12 router: exit 0, link created inside.
# So clear the path first unless it is already the symlink we want. What is being deleted can only
# be ours, and its contents are package files of a version that no longer exists.
if [ -d /www/luci-static/footstrap ]; then
[ ! -e /www/luci-static/footstrap/fonts ] || [ -L /www/luci-static/footstrap/fonts ] ||
rm -rf /www/luci-static/footstrap/fonts
ln -sfn /etc/footstrap/fonts /www/luci-static/footstrap/fonts 2>/dev/null
fi
# The two doodle wallpapers earlier versions downloaded into /www on demand. They were never
# package files, so no package manager will ever remove them, and nothing serves them any more —
# the axis they belonged to now paints an admin-uploaded SVG. Left behind they are 200 kB of a
# router's flash held by a feature that no longer exists.
rm -f /www/luci-static/footstrap/cats.svg /www/luci-static/footstrap/dinos.svg 2>/dev/null
# these caches embed theme paths — drop them so the switch applies on the next request
rm -f /tmp/luci-indexcache* 2>/dev/null
rm -rf /tmp/luci-modulecache 2>/dev/null
# Write the marker LAST, so a run that dies half-way is still a fresh install next
# time rather than an upgrade.
mkdir -p "$(dirname "$FS_MARKER")" 2>/dev/null
: > "$FS_MARKER" 2>/dev/null
exit 0
@@ -0,0 +1,13 @@
# Preserve what the ADMIN put on this router across sysupgrade: the login/page background
# (Appearance -> Wallpaper -> File), the wallpaper pattern (Appearance -> Wallpaper -> Pattern) and
# the webfonts installed by fonts/set-font.sh. All three are written at runtime — an upload or a
# script, never the package — so all three survive a package upgrade on their own; a firmware
# sysupgrade, however, only keeps paths listed here (or in a conffile / /etc/sysupgrade.conf).
# NOT conffiles: they live outside root/etc/config, and tools/conffiles.mjs would reject a
# conffiles-define entry the package does not ship.
/etc/footstrap/login-bg
/etc/footstrap/pattern.svg
# A directory, and the whole of it: the faces plus the fonts.css generated beside them. Losing the
# sheet while keeping the faces would leave footstrap.settings.fonts pointing at nothing, which is
# the one state that costs a 404 on every page.
/etc/footstrap/fonts
@@ -0,0 +1,44 @@
{
"luci-theme-footstrap": {
"description": "Footstrap Appearance router-wide defaults (Save as default) and the login background and pattern uploads",
"read": {
"uci": [
"footstrap"
]
},
"write": {
"ubus": {
"uci": [
"set",
"commit"
],
"file": [
"remove",
"exec"
]
},
"uci": [
"footstrap"
],
"cgi-io": [
"upload"
],
"file": {
"/etc/footstrap/login-bg": [
"write",
"remove"
],
"/bin/chmod 644 /etc/footstrap/login-bg": [
"exec"
],
"/etc/footstrap/pattern.svg": [
"write",
"remove"
],
"/bin/chmod 644 /etc/footstrap/pattern.svg": [
"exec"
]
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Drop whole-line `#` comments from the shell files under root/ IN A BUILD TREE.
#
# ./strip-shell.sh <dir>
#
# Same trade as strip-templates.sh and build-css.sh: the reader gets the comments from git, the
# router gets the bytes. `root/etc/uci-defaults/30_luci-theme-footstrap` is 71% comment lines and
# `root/lib/upgrade/keep.d/luci-theme-footstrap` is 95% (six lines of reasoning above one path).
#
# ONLY a line whose first non-blank character is `#`, and never the shebang. That rule is safe here
# and the reason is checked, not assumed: sh has no block-comment syntax, so the only way a `#` can
# be something other than a comment is inside a string or a heredoc — a trailing `# …` after code is
# left alone because the line does not START with it, and there is no heredoc anywhere under root/
# (grepped). If one is ever added, this script has to learn about it: a `# …` line inside a heredoc
# is DATA.
#
# Files are matched by their shebang or by being under a directory whose contents are shell
# (uci-defaults, keep.d), never by extension — none of them have one.
set -e
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-shell.sh <dir>" >&2; exit 1; }
if find "$DIR" -type f -exec grep -l '<<' {} + 2>/dev/null | grep -q .; then
echo "strip-shell: a heredoc appeared under $DIR — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1
fi
before=0
after=0
found=0
for f in $(find "$DIR" -type f | sort); do
# shell only: a shebang, or the two comment-only manifests this package ships
case "$(head -c 2 "$f")" in
'#!') ;;
*) case "$f" in */keep.d/*) ;; *) continue ;; esac ;;
esac
found=$((found + 1))
b=$(wc -c < "$f")
awk 'NR == 1 && /^#!/ { print; next }
/^[ \t]*#/ { next }
{ print }' "$f" > "$f.tmp$$"
# never leave a file that lost its shebang or came out empty
if [ ! -s "$f.tmp$$" ]; then
echo "strip-shell: $f came out empty — refusing" >&2
rm -f "$f.tmp$$"
exit 1
fi
cat "$f.tmp$$" > "$f" # cat, not mv: keep the original mode (uci-defaults must stay +x)
rm -f "$f.tmp$$"
a=$(wc -c < "$f")
before=$((before + b))
after=$((after + a))
done
[ "$found" -gt 0 ] || { echo "strip-shell: no shell file under $DIR" >&2; exit 1; }
echo "strip-shell: $found file(s), $before -> $after bytes (-$((before - after)))"
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# Strip `{# … #}` template comments from the .ut files IN A BUILD TREE.
#
# ./strip-templates.sh <dir>
#
# Run from the package Makefile (Build/Prepare) over $(PKG_BUILD_DIR), never over the source tree:
# git keeps every word, the router does not need any of them. Same trade this project already makes
# for JS (jsmin/terser) and CSS (build-css.sh) — templates were simply never included in it, and
# they are 58% comments. `{# … #}` alone is 16112 of 39426 bytes (41%).
#
# sh + awk only, like build-css.sh: an OpenWrt buildbot has no node and this must not become the
# reason a build needs one.
#
# WHAT IS AND IS NOT TOUCHED, and the distinction is the whole safety argument:
# * `{# … #}` — a TEMPLATE comment. ucode treats `{#` as a comment opener everywhere outside a
# `{% … %}` code block, so a `{#` that survives in this tree is a comment BY DEFINITION; if one
# ever sat inside a <script> string the template would already be broken. Verified before
# writing this: 0 of them appear inside a code block, and every opener has exactly one closer.
# * `/* … */` inside `{% … %}` — a ucode CODE comment, 6844 bytes more. NOT touched. Stripping it
# needs a lexer that knows ucode strings, and 17 of the `/*` in this tree sit OUTSIDE any code
# block (they are CSS/JS comments in inline <style>/<script>). That is exactly the shape that
# made the old `sed 's/;}/}/g'` in build-css.sh eat a data-URI: a scanner that cannot see
# strings. Not worth 6.8 KB.
#
# Whitespace control is EMULATED, not ignored: `{#- …` also eats the whitespace before the comment
# and `… -#}` the whitespace after it, which is how ucode itself renders them. Every .ut here opens
# with a licence block closing `-#}` to swallow the newline before <!DOCTYPE html>; dropping the
# comment without the trim would put that newline back.
set -e
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-templates.sh <dir>" >&2; exit 1; }
before=0
after=0
found=0
for f in $(find "$DIR" -name '*.ut' -type f | sort); do
found=$((found + 1))
b=$(wc -c < "$f")
awk '
BEGIN { RS = "^$" } # slurp the whole file
{
s = $0; n = length(s); i = 1; out = ""
while (i <= n) {
if (substr(s, i, 2) == "{#") {
j = i + 2
trimleft = (substr(s, j, 1) == "-")
# find the closer
k = index(substr(s, j), "#}")
if (k == 0) { out = out substr(s, i); break } # unterminated: leave as is
end = j + k - 1 # index of "#" in "#}"
trimright = (substr(s, end - 1, 1) == "-")
if (trimleft) sub(/[ \t\r\n]+$/, "", out)
i = end + 2
if (trimright) while (i <= n && match(substr(s, i, 1), /[ \t\r\n]/)) i++
continue
}
out = out substr(s, i, 1); i++
}
printf "%s", out
}
' "$f" > "$f.tmp$$"
mv "$f.tmp$$" "$f"
a=$(wc -c < "$f")
before=$((before + b))
after=$((after + a))
done
[ "$found" -gt 0 ] || { echo "strip-templates: no .ut under $DIR" >&2; exit 1; }
echo "strip-templates: $found file(s), $before -> $after bytes (-$((before - after)))"
+26
View File
@@ -0,0 +1,26 @@
/*!
* luci-theme-footstrap
* Copyright 2026 footstrap contributors
* Licensed under the Apache License v2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Portions of styles/base are derived from luci-theme-bootstrap, whose notices
* the Apache License requires this file to carry for as long as that code ships:
* Copyright 2012 Nut & Bolt <david@nut-bolt.nl>
* Copyright 2011 Twitter, Inc
*/
/* Layer order — declared here and nowhere else. A later layer beats an earlier one
* regardless of specificity, so a theme rule never needs !important to outrank base;
* one that needs it to beat another footstrap rule belongs in a later layer instead.
* Unlayered rules beat every layer — that slot is deliberately empty, it is the
* escape hatch.
*
* tokens design tokens + the outbound bridge to LuCI's --*-color-* names styles/0*
* base widget defaults every LuCI view assumes exist styles/base/*
* theme footstrap components and layouts styles/theme/*
* page per-page corrections styles/pages/*
*
* build-css.sh concatenates those four dirs into cascade.css — generated and
* gitignored, never edit it. */
@layer tokens, base, theme, page;
+575
View File
@@ -0,0 +1,575 @@
@layer tokens {
/* ============================================================
* Design tokens. TWO TIERS, and the split is load-bearing:
*
* PRIVATE --fs-* every rule in this theme reads ONLY these.
* EXPORT --*-color-* LuCI's conventional names, defined FROM the private tier and
* read by NOBODY here — they exist so a third-party luci-app-*
* keeps working and keeps following the palette and dark mode.
*
* :root is a SHARED scope and every luci-app-* drops CSS into the same document,
* UNLAYERED — which outranks every cascade layer. An app declaring --accent, --radius,
* --text, --border — or, likelier, the CONVENTIONAL --text-color-high, which base itself
* once read 158 times — silently repainted this theme: 312 of 336 elements on
* docs/gallery.html with a hostile :root, 0 after the split. The bridge is OUTWARD-only;
* audit.py fails on any read of an export name inside styles/.
*
* docs/design-system.md. Light = :root; dark = :root[data-darkmode="true"].
* ============================================================ */
:root {
/* Palette COLOURS are in 03-palettes.css (one swappable block per colourway × mode);
* everything here is palette-INDEPENDENT or var()-derived, so it follows the active
* palette. The INKS (--fs-on-accent &c.) are in the palette blocks too, beside the
* fills they must be legible against: a dark palette has LIGHT fills and needs a DARK
* ink, and the single global `--fs-on-accent: #fff` that used to sit here failed WCAG
* AA on seven of the eight dark-palette fills (down to 1.69:1). */
--fs-shadow: 0 1px 3px rgba(15,23,37,.08);
/* pop surfaces (menus, dropdown lists, Appearance popover, select picker): one value
* for the .28/.30/.32 literals that had drifted apart across them. */
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30);
/* the top bar's edge: a drop below + a 1px inset seam, both a quiet 25% of the border
* colour. The one bar/card shadow that was written inline (50-toplayout.css) — an
* unnamed derived level, the drift shape the ladder exists to prevent. */
--fs-shadow-bar: 0 1px 3px color-mix(in srgb, var(--fs-border) 25%, transparent),
inset 0 -1px 0 color-mix(in srgb, var(--fs-border) 25%, transparent);
/* ---- TWO FOCUS RINGS, and WHICH ONE A CONTROL TAKES IS THE CONTRACT ------------
* -soft is a TINT (10-15% of the accent), so composited on the surface it lands on it
* measures 1.15 / 1.27 / 1.17 / 1.29 : 1 across palette x mode. That is a GLOW, not an
* indicator: WCAG 1.4.11 wants 3:1 for the thing that tells you where focus is. It is
* legitimate only BESIDE a second channel — every field here also flips border-color to
* --fs-accent, and that change measures 4.88-6.52:1, so the border is the indicator and
* the tint is the halo around it.
*
* -solo is for a control where the ring is the WHOLE indicator: nothing else about it
* changes on focus. Three groups were relying on the tint alone and therefore showed
* effectively nothing under the keyboard — every sidebar menu link and the chrome's icon
* buttons (10-chrome.css), the section tabs (40-tabs.css) and both range sliders. The
* 2px of surface is what lets it read against a FILLED control too (an accent-filled
* button or the active tab pill would otherwise take an accent ring on an accent box);
* solid accent then measures 5.19 / 5.39 / 6.52 / 8.52 on panel, 4.88-9.14 on the page.
* It had been written out as a literal twice (the filled buttons, the active pill) before
* it had a name — the drift shape the ladder exists to prevent.
*
* The invalid twin takes -fill (18%), not -soft: a red ring must read as an ALARM, not
* as a hover tint. It is paired with a red BORDER, so the tint rule above applies. */
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* the theme's ONE colour literal, deliberately: black at .7 is the absence of light
* behind a dialog, not a tint of any token — a palette-tinted scrim over a dark page
* dims nothing. A token so the RULES stay literal-free. */
--fs-scrim: rgba(0, 0, 0, .7);
/* ---- ROLE TINT LADDER (derived) ---------------------------------------
* A tint OF a role, mixed FROM it, so it follows the palette with nothing
* hand-maintained (the trap the *-hsl/*-rgb bridges fell into — foot of this file).
* What these mixes lacked was NAMES, and an unnamed level drifts in silence: the same
* outline border was 40% in a table and 45% in the action bar, the same diff block
* 30% in base and 18% in theme, the same hover fill 12% here and 18% there — four
* strengths where the design has two.
*
* -soft 12% a quiet FILL — outline-button :hover
* -fill 18% a stronger FILL — callout/diff surface, the invalid ring
* -line 40% a HAIRLINE — outline-button border, diff border
* -line-hi 55% that hairline on :hover
*
* The role × step matrix is COMPLETE on purpose, read or not: a gap is how the drift
* started (--fs-accent-soft existed, good/warn/danger had no sibling, so every rule
* wanting one invented a percentage). --fs-accent-soft is the one member NOT here —
* its strength is the only one that differs per MODE (10% light / 15% dark;
* hicontrast 10/14), so it sits in the palette blocks.
* ----------------------------------------------------------------------- */
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
--fs-warn-soft: color-mix(in srgb, var(--fs-warn) 12%, transparent);
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
--fs-accent-fill: color-mix(in srgb, var(--fs-accent) 18%, transparent);
--fs-good-fill: color-mix(in srgb, var(--fs-good) 18%, transparent);
--fs-warn-fill: color-mix(in srgb, var(--fs-warn) 18%, transparent);
--fs-danger-fill: color-mix(in srgb, var(--fs-danger) 18%, transparent);
--fs-accent-line: color-mix(in srgb, var(--fs-accent) 40%, transparent);
--fs-good-line: color-mix(in srgb, var(--fs-good) 40%, transparent);
--fs-warn-line: color-mix(in srgb, var(--fs-warn) 40%, transparent);
--fs-danger-line: color-mix(in srgb, var(--fs-danger) 40%, transparent);
--fs-accent-line-hi: color-mix(in srgb, var(--fs-accent) 55%, transparent);
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
--fs-warn-line-hi: color-mix(in srgb, var(--fs-warn) 55%, transparent);
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
/* THE WARN ROW HAS NO READER TODAY, and it is kept on purpose — do not "clean it up".
* `good` and `danger` are consumed by the button variants (.cbi-button-positive /
* -negative / -reset, theme/55-buttons.css) and `warn` only by -fill, because LuCI emits
* no warning BUTTON. The row is what makes the next component that needs one reach for a
* token instead of mixing its own percentage, which is the whole reason these four
* strengths are stated once here rather than at each call site. A grep that finds no
* `var(--fs-warn-line)` is therefore expected, not a finding. */
/* frosted pop surface (top-nav dropdown, rail flyout). NOT merged into --fs-bar-bg
* (88%): a bar is a thin strip the page scrolls under and seeing content ghost
* through it is the point; a dropdown carries a whole menu and must stay readable. */
--fs-glass: color-mix(in srgb, var(--fs-panel) 96%, transparent);
/* one blur radius for every frosted surface — the bar said 10px and the pops 12px,
* a difference nobody chose. */
--fs-blur: blur(12px);
/* the circular-arrows refresh glyph, as a mask so its colour follows the palette
* (--fs-good active / --fs-dim paused). ONE copy: the poll-status pill collapses to it
* both in the rail (20-shell.css) and in the top bar before it stacks (50-toplayout.css),
* and inlining the SVG twice is exactly the drift css-dup exists to catch. STATIC glyph
* on purpose — see the rail note; a spinner makes an idle poll look busy and moves the
* click target that pauses it. */
/* OURS. It was derived from Lucide's `refresh-cw` (ISC) — `M21 3v5h-5` was byte-identical,
* and the r=9 arc and `L21 8` terminus came with it — which is a second outside obligation
* this theme never declared, found while auditing the first. Redrawn as two OPEN
* arcs with solid triangular heads: a different construction, not a nudge of the same one
* (Lucide caps a continuous stroke with an L-shaped hook). Solid heads and not chevrons
* because it renders at 18px — measured, chevrons dissolve at that size. It is a MASK, so
* fill and stroke both give alpha. */
--fs-icon-refresh: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");
/* ...and the size it is drawn at. De-duplicating the SVG left its GEOMETRY to drift anyway:
* the rail said 19px and the top bar 18px for the same glyph, which css-dup cannot see —
* the declaration bodies differ, so it goes quiet. The two sites legitimately differ in
* TECHNIQUE (the rail masks the element and drops the border; the bar keeps the bordered
* square to match Appearance/Log out); the size was nobody's decision. */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* the 1px raised edge on a plate carrying text, plus its text shadow. .ifacebox
* mixed --fs-dim and .ifacebadge --fs-panel for the SAME edge at the same 5%. */
--fs-emboss: inset 0 1px 0 color-mix(in srgb, var(--fs-panel) 5%, transparent);
--fs-text-emboss: 0 1px 1px color-mix(in srgb, var(--fs-panel) 75%, transparent);
/* Hover cue for a SOLID control — the one cue that cannot be a colour, since the fill
* it shifts is whatever the role token says (the three sites had drifted to
* 1.06/1.08/1.12). PER MODE, AND THE DIRECTION FLIPS, measured from rendered pixels:
* `filter` recolours the TEXT too, and a light-mode solid button is a saturated fill
* with WHITE ink, which cannot get brighter — on --fs-accent (#0969da) white is
* 5.19:1 at rest, brightness(1.08) drops it to 4.59:1 and a visible 1.15 to 4.08:1,
* an AA failure caused by hovering. So light DARKENS (0.90 -> 6.16:1: bigger cue AND
* better contrast); dark, whose fill is light and ink dark, brightens (1.15 -> ~8:1).
* Both move the button away from the page it sits on. */
--fs-hover-lift: brightness(.90);
/* ---- MOTION — four durations, because the UI does four things. There used to be
* seven (.12/.125/.15/.2/.22/.25/.4) and four curves (ease, ease-in, linear, one
* cubic-bezier), none of it chosen — two rules even declared the SAME three
* properties at the same duration in a different ORDER, so a grep could not see they
* were the same rule. There is deliberately NO easing token: every transition and
* animation omits the timing function and takes the CSS default (ease) — one curve,
* nothing to keep in sync, fewer bytes than naming it. `ease` IS the initial value of
* animation-timing-function, so writing it in the shorthand changes nothing; four
* animations said it and one did not, which is the evidence nobody chose it. A rule
* needing another curve is making a design decision and must justify it in a comment.
*
* The scale governs `animation` too — that half was missed the first time. Only the
* transitions were converted, so `fs-fade` (ONE keyframe, one gesture) was ridden at
* .14s, .16s and .3s: the same drift as the refresh glyph's 19px/18px, and invisible to
* css-dup for the same reason — the declaration bodies differ, so it goes quiet.
* 95-a11y-media.css kills all of this under prefers-reduced-motion.
* ----------------------------------------------------------------------- */
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
--fs-dur-fill: .4s; /* a progress bar growing to its value */
--fs-content-max: 1280px; /* content column cap */
/* content column side padding — read by .fs-content, the top bar's centering calc
* and the notification width calc, so they cannot drift apart. */
--fs-content-pad: calc(28px * var(--fs-density-space));
/* ------------------------------------------------------------------------
* THE SHELL'S GEOMETRY — tokens, not literals, because the JS reads them back with
* getPropertyValue(): the CSS lays the sidebar out, and fs-chrome.js
* (fitShell()) subtracts that cut from the viewport to decide whether what is LEFT
* is still readable. The JS used to keep its own copies (SIDEBAR_W = 224, RAIL_W =
* 68, CONTENT_MIN = 500) against bare literals in the CSS: narrow the rail here and
* the measurement silently went on subtracting the old width.
* ---------------------------------------------------------------------- */
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
--fs-content-min: 500px; /* below this a content column stops being readable */
/* translucent blurred bar surface — the top-nav bar and the sidebar's phone bar are
* one component in two markups; one token keeps their glass tint identical. */
--fs-bar-bg: color-mix(in srgb, var(--fs-panel) 88%, transparent);
/* height of that bar, and the scroll-margin every anchor target needs to clear it
* (base/10-reset.css) — if the two drift, an in-page jump lands under the bar. */
--fs-bar-h: calc(46px * var(--fs-density-box));
/* the square icon-button in the bar/rail: the logo box, Appearance (.fs-themerow),
* Log out (.fs-logout) and the collapsed poll pill are all one size so the right
* cluster reads as a row of equal squares. Was a bare 34px in three files with the
* logo left at 30 — the odd one out. */
--fs-btn-size: calc(34px * var(--fs-density-box));
/* Corner-radius scale. One user-tunable base (Appearance → Rounding, 0–20px,
* localStorage `fs-radius`, pre-painted inline on :root by partials/head.ut before
* first paint); the three semantic radii scale from it proportionally so every
* surface rounds in step, and pills stay fully round. At base 12: card 12, control
* 10, chip 8. Every border-radius in theme/pages reads one of these — the only bare
* px left are 0 / 50% / hairline insets and two that are not a card's corner
* (theme/60-inputs.css 8px = half the 16px scrollbar; theme/70-modal.css 4px). */
--fs-radius-base: 12px;
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
--fs-radius-sm: calc(var(--fs-radius-base) * 2 / 3); /* chips, code, small insets, rail toggle (8 @12) */
--fs-radius-pill: 999px; /* pills, toggles, badges — always round */
/* A card's inner gutter (.cbi-section). A token because it is read back to UNDO it:
* theme/45-misc.css bleeds the realtime graphs out to the card's border edge, and the
* offset it needs is this padding plus the card's 1px border. Written as a literal in
* both places, narrowing the gutter here would silently re-clip every graph — the same
* trap --fs-sidebar-w exists to close for fitShell(). */
--fs-card-pad: calc(16px * var(--fs-density-space));
/* The vertical rhythm BETWEEN cards (.cbi-section, tab groups, alerts, action bars).
* It was the same 16px hand-written in seven files — 45-misc.css even carried a comment
* apologising for it ("Same 16px every .cbi-section already uses") — which is an
* unnamed level, and an unnamed level drifts (see the role-tint ladder above). */
--fs-card-gap: calc(16px * var(--fs-density-space));
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* ---- TYPE SCALE — footstrap's own, and the reason it exists is not tidiness ----
*
* styles/base carried a 13/18 rhythm with 9px and 8.5px halves of it. That is
* luci-theme-bootstrap's scale, i.e. Twitter's 2011 choice, and it arrived here with the
* fork rather than by anyone deciding it fits Manrope. Naming those same numbers as tokens
* would have changed nothing that matters — the numbers ARE the borrowed expression — so
* the scale is re-derived here and base reads it instead of restating literals.
*
* 13px stays the base DELIBERATELY, and it is not the same 13 as upstream's: a router UI is
* dense tables of addresses and counters, and the size was re-chosen for that, not
* inherited. What changes is the RHYTHM around it — a unitless 1.5 instead of a hard 18px.
* Unitless matters: `line-height: 18px` is inherited as 18px into a 20px heading and clips
* it, while 1.5 is re-resolved against each element's own size.
*
* Every STEP is a whole px; the line box 1.5 computes is not (13 -> 19.5), and that is fine
* — a fractional line box is what unitless leading gives on any odd size, and the baseline
* still snaps. What must stay whole is a BOX: never size an element `--fs-type * --fs-leading`
* (a half-pixel border is upstream's 8.5px bug wearing a calc) — take the nearer --fs-space
* step, which is what a line of text at this scale rounds to anyway. */
/* FIVE steps, and the count is the point. The scale had grown to eight — 10, 11, 12, 13, 14,
* 16, 20, 26 — where four of the gaps were a single pixel, which is below the threshold at
* which anyone reads a size as *different*: it does not build hierarchy, it just makes two
* rules that look unrelated in a grep. The 1px neighbours were folded together (10 into 11,
* 12 into 13) and the 14 was split by what it was actually doing — chrome and menu items are
* body text (13), a section title is a title (16) — which is what finally puts real distance
* between a heading and the text under it, where before there was one pixel.
* Ratio is roughly 1.25 per step. Anything that needs a size not on this list is making a
* design decision and should say so in a comment. */
/* ---- DENSITY: one axis, two multipliers, and it only works because of the scales ----
*
* Compact / Normal / Large is a CLIENT preference (Appearance), stamped as
* :root[data-density] — absent means Normal, so the default costs no attribute and no
* override, exactly like every other axis here.
*
* THREE multipliers, because three different kinds of size want different treatment and one
* knob gets all three wrong:
*
* --fs-density-type TEXT. Compact does not touch it at all: "compact" is a request for
* less air, not for smaller writing, and shrinking the text is how a
* density control turns into an accessibility problem.
* --fs-density-space pure AIR — padding, margin, gap. This is what actually buys the room,
* so it moves the most (.65 at Compact).
* --fs-density-box boxes that must HOLD text or an icon: the sidebar column, the rail,
* the bar height, the square icon buttons, the control-height floors.
* These cannot follow the air: with type unchanged, a sidebar cut by
* 35% would no longer fit the menu labels it exists to show. They move
* gently (.85), which is roughly what their own padding gave up.
*
* This is only expressible because every size in the theme now reads one of these tokens:
* before that, a density axis would have meant hunting ~240 literals and would have gone
* stale the first time someone wrote a number. Add a size OUTSIDE these ladders and it
* silently stops responding to the axis — that is the cost, and the reason the ladders are
* the contract.
*
* Type is rounded to whole pixels (a fractional font size renders soft); space is not, since
* a sub-pixel padding is invisible and rounding it would collide steps together. */
--fs-density-type: 1;
--fs-density-space: 1;
--fs-density-box: 1;
/* ---- CONTROL HEIGHTS: three steps, and they follow the density too -----------
* A minimum height is what stops a button collapsing onto its text, so it is part of how
* dense the UI feels — leave it literal and Compact shrinks the writing while the buttons
* stay put, which is the opposite of the setting. These had drifted to EIGHT values
* (28/30/32/36/38/40/42/44) for what the design has three of; each was moved by at most 2px
* onto the nearest step.
* Not to be confused with --fs-control-h below: that is a FIELD's height, derived from the
* text it must hold, and it already follows the type ladder. These are the floors a chrome
* control gets when its content is not text. */
--fs-ctl-h-sm: calc(30px * var(--fs-density-box)); /* dense/small: .btn.small, table-row buttons */
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px)); /* eyebrow, badges, tooltips, dense metadata */
--fs-type: calc(round(13px * var(--fs-density-type), 1px)); /* body, tables, fields, menu items — the base */
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px)); /* section titles (h3/legend), alert headings */
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px)); /* page titles */
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px)); /* h1 — only a third-party app emits a bare one */
/* ---- WEIGHT: there are only ever three, and they had six spellings ----------
* The theme ships no font at all — the stacks name Manrope and JetBrains Mono first and
* fall through to the system faces — so these are not a ramp to pick from: they are the
* two weights a reader is likely to actually have, plus the "un-bold this"
* value. `700` and `bold` were both in use and are the same weight; so are `400` and
* `normal`, and 400 has no face at all (it resolves onto the 600 one — that is why body
* text is semibold by design, and why adding a 400 face would silently restyle every page).
* Naming them leaves one spelling each. */
--fs-weight-normal: 400; /* "not bold" — no face of its own; resolves onto the 600 */
--fs-weight: 600; /* the UI default: body, labels, controls */
--fs-weight-bold: 700; /* titles, table headers, the eyebrow */
--fs-leading: 1.5; /* unitless ON PURPOSE — see above (13 -> 19.5px) */
--fs-leading-tight: 1.2; /* headings and single-line plates, where 1.5 looks loose */
/* ---- THE EYEBROW — one microlabel idiom, named once -------------------------
*
* Uppercase + tracked + bold + muted is ONE thing this theme draws in a dozen places: table
* column headers, the stacked-card `data-title` labels, the Appearance group labels, the rail
* flyout titles, the login field titles, the search-result section note. It had drifted into
* four spellings of that one idiom — letter-spacing .04/.05/.06em, weight 600 vs 700, colour
* --fs-dim vs --fs-faint — the same unnamed-level drift the role x step matrix above exists to
* stop, only in typography. An eyebrow is a LABEL, so it takes the quietest ink and the widest
* tracking of the set; the alert table's 600 was the odd one out and read lighter than every
* other header on the page.
*
* SIZE is deliberately NOT here: 10px (a card/chrome label) and 11px (a table header, i.e.
* --fs-type-xs) are two real tiers, and collapsing them would resize half the tables.
*
* Two things that look like eyebrows are NOT and must stay out: `h6` is a HEADING (it takes the
* type scale and the heading ink), and `.fs-navlabel` is the menu's section separator, whose
* .1em is deliberately wider than any label — it separates, it does not caption. */
--fs-eyebrow-tracking: .06em;
--fs-eyebrow-weight: 700;
--fs-eyebrow-color: var(--fs-faint);
/* ---- SPACE SCALE — a 4px grid ----------------------------------------------
*
* Same story: the 18px/9px pair base used was upstream's line-height and half of it, so
* every gutter in the theme was a function of Twitter's leading. A 4px grid is independent
* of the type scale, which is the point — changing the font size must not silently
* re-space every widget. `--fs-card-pad` (16px) and `--fs-content-pad` (28px) are already
* on this grid and stay named for their role; these are the generic steps.
*
* Do NOT add a step because one rule wants 10px. An unnamed level is how the radius tints
* drifted into four strengths for two decisions (see the ladder above): pick the nearer
* step, or argue in a comment for why this rule is genuinely different. */
/* The grid is 4px, but the theme genuinely works at 2px between the low steps: a chip's
* inset, a nav item's row padding and a card's inner gutter all landed on 6/10/14 and no
* amount of rounding makes them 4/8/12 without visibly changing the control. So the half
* steps are NAMED rather than pretended away — they were the single largest group of
* magic numbers left in the tree (6px x26, 10px x32, 14px x13, 2px x13). Above 16px the
* grid stays 4px: nothing needs finer, and every value that had drifted between the steps
* up there (22, 26, 42) was snapped onto one. */
--fs-space-0-5: calc(2px * var(--fs-density-space));
--fs-space-1: calc(4px * var(--fs-density-space));
--fs-space-1-5: calc(6px * var(--fs-density-space));
--fs-space-2: calc(8px * var(--fs-density-space));
--fs-space-2-5: calc(10px * var(--fs-density-space));
--fs-space-3: calc(12px * var(--fs-density-space));
--fs-space-3-5: calc(14px * var(--fs-density-space));
--fs-space-4: calc(16px * var(--fs-density-space));
--fs-space-5: calc(20px * var(--fs-density-space));
--fs-space-6: calc(24px * var(--fs-density-space));
--fs-space-7: calc(28px * var(--fs-density-space));
/* The top of the ladder is sparse on purpose — these three are gutters and insets, not
* rhythm, and each has exactly one or two users (the login card's bottom margin, the gauge's
* right gutter, the room a field leaves for its own chevron). They are named so that no
* padding in this theme is an unexplained number, not because a fourth caller is expected. */
--fs-space-8: calc(32px * var(--fs-density-space));
--fs-space-10: calc(40px * var(--fs-density-space));
/* The spinner glyph's box (base/95-luci.css `.spinning::before`, its width AND height — one
* number, one name). It became a token because a SECOND file used to encode the same size:
* the glyph was absolutely positioned, so theme/55-buttons.css had to clear it with a
* `padding-left` derived from `left` + `width` + a gap, and the two files drifted with
* nothing to notice. That padding is gone — the glyph sits in the flow now (issues #15 and
* #22) — so this is once again read in one place, which is where a resize belongs. */
--fs-spin-size: var(--fs-space-5);
/* One control height for every field that is a single line — input, select, .cbi-select,
* .cbi-dropdown, and the checkbox/radio row that must line up beside them. DERIVED, not
* measured by hand: the text box (--fs-text x --fs-leading = 19.5px) plus the vertical
* inset those fields carry (2 x --fs-space-1) plus both 1px borders = 29.5 -> ceil 32.
* It was a bare 30px in four rules, which was upstream's same arithmetic over upstream's
* 18px leading — so re-scaling the type left every control the wrong height for its own
* text, silently. Stated once here so the two cannot drift again. */
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
/* HOW WIDE AN ELASTIC CONTROL GETS IN A FORM ROW. A `.cbi-value-field` is `flex: 1`, so
* anything asking for 100% takes the whole card — and a form whose every control ends at a
* different x reads as broken even when each width is defensible on its own. Fixed-width
* controls (a select, a number box) stay at their own 210px; this is the ceiling for the
* ones that stretch: the text/url/search inputs, the dynlist and the range slider.
*
* 440px is not on the 4px step scale and does not want to be — it is a COLUMN WIDTH, the
* same kind of number as the 180px label column beside it, and it was already the figure
* three rules had reached independently. Named here so a fourth one cannot pick 460. */
--fs-field-max: 440px;
/* ---- Stacking scale ----------------------------------------------------
* Every z-index in the theme comes from here. There used to be nine bare numbers
* (2, 40, 50, 60, 70, 800, 900, 1000, 1100) across seven files, with nothing
* recording what should sit above what — so the Appearance popover (1000) painted
* OVER an open modal (#modal_overlay, 900). As an ordered list, that bug is obvious.
*
* Bottom to top:
* raise in-flow child that must clear its siblings (zonebadge tooltip)
* sticky the sticky bars: top-nav, sidebar phone bar
* flyout nav panels: rail flyouts, top-nav dropdowns, rail tooltips
* header base's sticky <header> (above content, below any popup)
* popover the Appearance popover — ABOVE the chrome, BELOW a modal
* overlay #modal_overlay: a modal is modal, it covers the chrome
* tooltip .cbi-tooltip — must read over modal content
* dropdown an OPEN .cbi-dropdown list — over everything, including inside a modal
* ------------------------------------------------------------------------ */
--fs-z-raise: 2;
--fs-z-sticky: 50;
--fs-z-flyout: 70;
--fs-z-header: 800;
--fs-z-popover: 850;
--fs-z-overlay: 900;
--fs-z-tooltip: 1000;
--fs-z-dropdown: 1100;
/* ---- EXPORT TIER — OUTBOUND ONLY ---------------------------------------
* Nothing in styles/ may read these (audit.py fails the build if it starts to). They
* exist so a third-party luci-app-* addressing them still gets our palette and dark
* mode, and so an app overwriting one restyles only its own widgets.
*
* high/medium/low is a PROMINENCE scale, not decoration. It was once three aliases of
* ONE token — a flat colour where the consumer asked for a gradation — and apps DO
* ask: luci-app-podkop paints its "no data" latency with --primary-color-low and got
* the same vivid accent as a live value; luci-app-justclash reads eleven of these
* names; stock firewall.js and status/cpu.js read them too.
*
* Direction is bootstrap's, which every app was calibrated against: `high` = the most
* pronounced value of the role (--text-color-high the readable text, -low the faint
* one; --border-color-high the visible rule, -low the hairline) — except
* --background-color-*, which is an ELEVATION axis (high = raised, low = recessed).
* The MECHANISM differs per family, by measurement — see each block.
*
* Binding constraint: apps read a level as `color:` about as often as they read it as
* `background:` (counted across bootstrap's cascade, firewall.js, cpu.js), so EVERY
* level must clear WCAG AA as text on --fs-bg/--fs-panel/--fs-panel2 AND carry a
* legible --on-*-color as a fill.
* ---------------------------------------------------------------------- */
--background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
--background-color-low: var(--fs-panel2);
/* --text-color-low cannot move: --fs-dim IS the muted text colour and already
* sits ON the AA floor (4.56:1 on --fs-panel2 in dark), so a fainter step would
* be an illegible grey. It stays equal to -medium — the ramp is still wide, -high
* being --fs-text — and a duplicate beats a value nobody can read. */
--text-color-highest: var(--fs-text);
--text-color-high: var(--fs-text);
--text-color-medium: var(--fs-dim);
--text-color-low: var(--fs-dim);
/* borders carry no text, so here -low MAY fade toward the surface: a hairline
* is supposed to disappear into it. */
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
--border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
/* ACCENT RAMPS: the axis is CHROMA at constant lightness — `high` is the pure palette
* colour and each step mixes it toward --fs-dim in oklch, which interpolates lightness
* linearly and so drains saturation while leaving luminance put. Contrast barely moves
* across the ramp, so every level stays printable as TEXT. Both alternatives were
* measured and rejected:
*
* fade `low` toward the surface (the intuitive "muted") spends contrast we do not
* have: on --fs-panel2 in dark every accent already sits at 4.56–4.59:1, +0.06 over
* AA, and an 8% fade took --warn-color-low to 4.18:1 and its ink ON it to 4.45:1.
*
* push `high` toward --fs-text (free in contrast terms) collapses the ramp in DARK
* mode, where --fs-text is near-white: the strong end comes back washed out, 0.055
* from the weak end, i.e. flat. Lightness is not an axis in both modes at once.
*
* Bootstrap's own ramp walks lightness (its --primary-color-low is its DARKEST blue),
* which is why it reads inverted in dark mode and lands at 3.6:1 — below AA. Keep its
* direction, drop its mechanism. Every step is mixed FROM the token it grades: no
* second, hand-kept copy of a colour (foot of this file). tools/export-tier.mjs proves
* all of it across {footstrap,hicontrast} × {light,dark} — AA as text on all three
* surfaces, a legible --on-*-color as a fill, and that the ramp is not secretly flat. */
--primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
--on-primary-color: var(--fs-on-accent);
--error-color-high: var(--fs-danger);
--error-color-medium: color-mix(in oklch, var(--fs-danger) 78%, var(--fs-dim));
--error-color-low: color-mix(in oklch, var(--fs-danger) 45%, var(--fs-dim));
--on-error-color: var(--fs-on-danger);
--success-color-high: var(--fs-good);
--success-color-medium: color-mix(in oklch, var(--fs-good) 78%, var(--fs-dim));
--success-color-low: color-mix(in oklch, var(--fs-good) 45%, var(--fs-dim));
--on-success-color: var(--fs-on-good);
--warn-color-high: var(--fs-warn);
--warn-color-medium: color-mix(in oklch, var(--fs-warn) 78%, var(--fs-dim));
--warn-color-low: color-mix(in oklch, var(--fs-warn) 45%, var(--fs-dim));
--on-warn-color: var(--fs-on-warn);
--fs-disabled-opacity: .6;
color-scheme: light;
}
/* ---- the DENSITY axis, and it is exactly two declarations per step ----------------
*
* Normal is a bare :root — no attribute, no override — so the default path costs nothing and a
* browser that has never touched the control renders identically to before this existed.
*
* Space moves further than type in both directions (0.8/1.25 against 0.92/1.15), because that is
* what the two settings are actually for: Compact is a request for less AIR, not for smaller
* writing — matching them would put body text at 10.4px, under the floor the rest of this file
* defends. At Compact the base is 12px and at Large 15px; the eyebrow never drops below 10px.
*
* These multiply the LADDERS, not the rules, so a size that is on a ladder follows the axis for
* free and one that is not silently ignores it. That is the whole reason the ladders came first. */
:root[data-density="compact"] {
/* Type moves LEAST (.9 against .65 for the air), and that ordering is the point: padding is
* where the room actually is, so leaning on the text instead would cost legibility to save
* very little. At .9 the base is 12px and the eyebrow 10px — the floor this file is willing
* to go to, and only because the user asked for this mode by name. */
--fs-density-type: .9;
--fs-density-box: .85;
--fs-density-space: .65;
}
:root[data-density="large"] {
/* Large is about the TEXT, so the air does not grow with it: padding stays on the Normal
* rhythm and only the type — and the boxes that have to hold it — get bigger. Inflating the
* spacing as well just pushed content off the screen and made the setting read as "zoom",
* which is the browser's job, not the theme's. */
--fs-density-type: 1.15;
--fs-density-box: 1.15;
--fs-density-space: 1;
}
:root[data-darkmode="true"] {
/* palette colours are in 03-palettes.css; only mode-level, palette-independent
* tokens live here */
--fs-shadow: 0 1px 2px rgba(0,0,0,.4);
--fs-disabled-opacity: .45;
/* the hover cue flips direction here — see --fs-hover-lift above. A dark-mode solid
* button is a LIGHT fill with DARK ink, so brightening moves it away from the page
* and away from its own ink; in light mode that same move erases its white ink's
* contrast. */
--fs-hover-lift: brightness(1.15);
color-scheme: dark;
}
/* BOTH component bridges are GONE: the HSL one (--*-hsl triples, --*-h/s/l parts) and the
* RGB one that outlived it (--accent-rgb, --error-color-high-rgb, --success-color-high-rgb,
* read as `rgba(var(--x), .3)`). Same mistake in two notations — a hand-kept second copy of
* a colour that already exists as a token: it goes stale in silence when a palette is
* recoloured, and if the triple goes missing the declaration is invalid at computed-value
* time, so the tint vanishes with NO error (audit.py cannot catch that — the var IS defined,
* only elsewhere). Every rule that read them takes color-mix() over the palette token now,
* for identical output:
* rgba(var(--error-color-high-rgb), .3) -> color-mix(in srgb, var(--fs-danger) 30%, transparent)
* A tint of X is mixed FROM X. Do not reintroduce a component copy of a colour in any
* notation. The one surviving --*-rgb is --zone-color-rgb, and it is not ours:
* luci-mod-network writes it inline on a zone badge (base/95-luci.css). */
}
+436
View File
@@ -0,0 +1,436 @@
@layer tokens {
/* ============================================================
* COLOURWAYS (palettes)
*
* Every colour is a variable; every palette is one self-contained block per mode. Three ship
* — footstrap (GitHub Primer, the default), hicontrast and bootstrap (the stock LuCI theme's
* colours) — but the structure is the point: a new colourway is copy-one-pair-and-recolour.
*
* TO ADD A COLOURWAY "sunset":
* 1. Copy the two hicontrast blocks below, rename data-palette to "sunset".
* 2. Set every colour. FIVE are declared as -base with the live token derived from them,
* so the colour axes can recolour them with no second copy to keep in sync: the canvas
* --fs-bg-base → --fs-bg (TINT block), the accent --fs-accent-base → --fs-accent and
* the three status colours --fs-good-base / --fs-warn-base / --fs-danger-base (COLOUR
* AXES block). A palette never declares --fs-good itself.
* 3. Set the four INKS (--fs-on-accent/-good/-warn/-danger) — see below.
* 4. Register the name in all four places, because each one fails DIFFERENTLY and quietly:
* the PALETTE axis in fs-prefs.js (live switching), the `_sd_pal` whitelist AND the
* pre-paint switch in partials/head.ut (a router default outside the whitelist becomes
* '' and the page paints in the default palette), the label map in fs-appearance.js (an
* unnamed palette is missing from the picker, not broken), and matrix() in
* tools/lib/gallery.mjs — a palette absent there is one export-tier.mjs and
* a11y-gallery.mjs never measure, so it ships ungated.
* Nothing else — layout, components and the --*-color-* export bridge (02-tokens) key off
* these tokens, and every tint/shadow/hairline is a color-mix() OVER them.
*
* THE INKS ARE PER PALETTE AND PER MODE, and that is not a style choice. A single global
* `--fs-on-accent: #fff` used to sit on every accent/good/warn/danger fill (solid buttons,
* the active tab pill, the toggle knob, .fs-seg, the login submit). White holds up on the
* DARK fills of a LIGHT palette and nowhere else: on footstrap dark it measured 2.83–3.70:1
* and on hicontrast dark --fs-good 1.69:1, where WCAG AA wants 4.5:1 for text and 3:1 for a
* UI shape like the toggle knob. A dark palette has LIGHT fills, so its ink must be DARK.
* Check a new colourway's inks against all four of its own fills: every pair below clears
* 4.5:1.
*
* A tint of X is mixed FROM X — do not reintroduce a component copy of a colour in any
* notation (both bridges, --*-hsl then --*-rgb, are gone; foot of 02-tokens.css).
*
* Default: footstrap fills bare :root (no data-palette), so a no-JS load still has
* colours; every other colourway is opt-in via its own data-palette value. Specificity:
* :root[data-palette=…][data-darkmode=…] (0,3,0) beats the bare/default blocks, and each
* block sets a COMPLETE token set so the winner fully applies.
* ============================================================ */
/* Native <select> closed-control chevron (theme/60-inputs.css). A data-URI cannot
* reference var() or currentColor, so each palette block bakes its own --fs-dim into
* the stroke — the only way the glyph can follow palette and mode. */
/* ============================================================
* BACKGROUND TINT — "which router am I on?"
*
* One hue (Appearance → Tint, 0–360°, localStorage `fs-tint`, pre-painted by
* partials/head.ut) washed into the CANVAS — --fs-bg, the surface the cards float on — so
* a whole LuCI install reads as green / violet / amber at a glance. localStorage is keyed
* by ORIGIN, so the cue is per router with nothing server-side: the same browser shows the
* main router green and the AP violet, and a screenshot pasted into a ticket says which
* box it came from. Off (no data-tint) = the palette exactly as it was.
*
* NOTHING ELSE MOVES: the cards, the chrome and the semantic colours keep the palette's
* own values. An identity cue has no business restating itself on every table row, and
* re-hueing the status colours would make it lie about status.
*
* WHY --fs-bg-base EXISTS: a custom property cannot be defined from itself — `--fs-bg:
* color-mix(…, var(--fs-bg), …)` is a cycle, invalid at computed-value time, and would
* drop the colour silently. So a palette names the RAW canvas, this block owns the one
* derivation, and every rule goes on reading --fs-bg.
*
* WHY IT SETS CHROMA + HUE INSTEAD OF MIXING A COLOUR IN. The obvious `color-mix(in oklch,
* var(--fs-bg-base) 96%, <tint>)` is a trap: in a POLAR space the result's HUE lands on
* the tint's almost immediately, so the percentage never controlled the strength. Measured
* on the dark canvas (28,33,40), a "2%" mix at hue 165 gave 25,36,31 — fully green — and
* "6%" gave 23,36,31, the same colour: turning the knob down changed nothing, exactly as
* users reported. Light mode failed the other way — the mix left sRGB and clipped, so the
* tint was invisible on blue hues and obvious on green ones, i.e. the cue's strength
* depended on WHICH colour you picked. So instead: give the canvas the user's hue at a
* chroma we choose, leave its LIGHTNESS alone (one relative-colour declaration).
* ONE HONEST KNOB --fs-tint-c is the strength, in chroma, and behaves the same on
* every hue — no gamut clipping at these values, no hue surprise.
* CONTRAST IS FREE `l` is copied through, so AA margins barely move. The mix dragged
* lightness toward the anchor: an early attempt cost
* --primary-color-high on --fs-bg its whole margin in hicontrast
* light, 4.61:1 down to 4.40:1 — under AA.
*
* Proven, not assumed: tools/export-tier.mjs runs the AA matrix over six hues × both modes
* × every palette (it caught that 4.40:1) and tools/a11y-gallery.mjs runs axe over the
* tinted gallery at two extreme hues. Change --fs-tint-c and re-run both.
*
* The shipped chroma is deliberately tiny — the dark floor (.011) is below the chroma the
* dark canvas already carries (.0153). This is an IDENTITY cue, not a colourway: a large
* flat field is where the eye is most sensitive to a cast, so a tint this faint still
* tells two routers apart while the page still looks like the theme you chose. Loud is a
* different feature and belongs in a palette, where the cards and chrome can move too.
* ============================================================ */
:root {
--fs-bg: var(--fs-bg-base);
/* The UI accent, derived from the palette's own -base hex — so the accent-hue axis
* (below) can rotate it without a second copy to keep in sync. Absent an accent hue,
* --fs-accent IS the palette's -base value.
*
* There used to be a second pair here, --fs-accent-lt / --fs-accent-lt-base, described
* as "the logo gradient's light end" and rotated beside the accent. The brand mark has
* no gradient: partials/brand.ut draws the OpenWrt arcs with a baked #00b5e2 and one
* mode-flipped ring, so nothing read either token — the pair was defined in both
* palettes, rotated through an oklch(from …) on every accent hue, and listed as a step
* in "TO ADD A COLOURWAY" above, all to paint nothing. Removed rather than left as a
* reserved name, because a comment promising that the accent hue moves the logo is
* worse than no comment: it describes behaviour a reader can verify is absent. */
--fs-accent: var(--fs-accent-base);
/* The three STATUS colours, derived from the palette's -base hex for exactly the reason
* the accent is: Appearance → Colours gives each one its own axis, and an axis that
* rotated a token the palettes declared directly would need a second copy of every value
* to rotate FROM. Absent an axis, each --fs-good IS the palette's -base value, so a
* palette that sets only the -base half paints identically to before the axes existed. */
/* The SURFACES, split the same way and for the same reason as the accent: Appearance →
* Surfaces rewrites the -base half, and a custom property defined through itself is a cycle
* that drops the colour silently. A palette declares the raw value; every rule reads the
* derived one.
*
* A Transparency slider lived here briefly, mixing each of these with `transparent` so the
* wallpaper showed through the cards. It is gone: thinning the surface thins the TEXT's
* background out from under it, so every card became a legibility problem at exactly the
* setting that looked interesting, and the contrast readouts on the page could not even
* report it — a ratio against a semi-transparent backdrop is a ratio against whatever
* happens to be behind that pixel. The wallpaper axis is the honest way to have a picture. */
--fs-panel: var(--fs-panel-base);
--fs-panel2: var(--fs-panel2-base);
--fs-border: var(--fs-border-base);
--fs-good: var(--fs-good-base);
--fs-warn: var(--fs-warn-base);
--fs-danger: var(--fs-danger-base);
/* The hues the user picked, in degrees (Appearance → Colours). Declared here, not only
* inline by head.ut/fs-prefs.js, so the stylesheet stands on its own: a var() with no
* definition anywhere is invalid at computed-value time and takes the whole declaration
* down SILENTLY. Their values are irrelevant while the matching data-* is absent. */
--fs-accent-h: 0;
--fs-tint-h: 0;
--fs-good-h: 0;
--fs-warn-h: 0;
--fs-danger-h: 0;
/* Tint DENSITY (Appearance → Density), a multiplier on the tint chroma below: 1 = the designed
* strength, 0 = none, up to 2. fs-prefs.js/head.ut set it inline (value/100) when the user
* drags the slider; paired with the hue, it is the second half of the router-identity Tint. */
--fs-tint-strength: 1;
/* The uploaded login/page background (Appearance → Background image). --fs-login-bg-url is
* the url() head.ut/fs-prefs.js stamp inline; `none` here is the same stand-on-its-own
* default the hues carry above (a var() with no definition is invalid at computed-value
* time). --fs-photo-scrim is the wash laid OVER the photo so on-canvas text stays legible on
* an arbitrary image: derived from --fs-bg, so it follows the palette, the mode AND the tint,
* and its opacity bounds the effective canvas contrast regardless of what the photo is. Both
* are inert until data-login-bg is present (16-login-bg.css). */
--fs-login-bg-url: none;
/* --fs-photo-dim is the scrim OPACITY over a File photo (Wallpaper → File → Dim), a
* per-browser axis like the rest; head.ut/fs-prefs.js override it inline. The scrim is mixed from --fs-bg-BASE,
* NOT --fs-bg, so it is always NEUTRAL — the router-identity Tint colours --fs-bg and must
* never leak onto a photo; the Tint's Density is a separate control. */
--fs-photo-dim: 74%;
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
/* The uploaded PATTERN (Appearance → Wallpaper → Pattern), tiled behind the content by
* 15-wallpaper.css. --fs-pattern-url is the url() head.ut/fs-prefs.js stamp inline and is
* `none` here for the same reason --fs-login-bg-url is: nothing is uploaded yet, and a var()
* with no definition at all is invalid at computed-value time. The other two are ordinary
* per-browser axes the same two places override inline — the tile's edge length and the
* layer's opacity. .2 is where a neutral line reads as a drawing rather than as texture:
* measured on a tinted canvas, .15 was swallowed by the hue. */
--fs-pattern-url: none;
--fs-pattern-size: 440px;
--fs-pattern-strength: .2;
/* HOW STRONG THE CUE IS — a chroma, and a function of the HUE, because the canvas is
* not neutral: every palette's canvas is a blue-grey (measured: oklch hue 248–264)
* and the dark one carries chroma .0153 of it. A flat chroma made the blue/violet end
* of the wheel INVISIBLE — asking for blue at .011 painted the canvas *less* blue
* than the palette already was. The cue is read against the untinted page, not against
* grey, so it must out-chroma the canvas's own cast where the two agree. Hence a floor
* plus two cos() terms, each peaking on one hue and gone 90° away:
*
* +BOOST at 258° the canvas's own hue; without it blue and violet did nothing.
* -DAMP at 55° red-through-yellow, which the eye picks up first: at a shared
* floor the warm half shouted while the cold half whispered, and a
* cue is only usable if every hue lands with about the same weight.
* (Dark only — on a near-white canvas warm is the quietest hue there
* is.)
*
* One expression per mode, no per-hue table to keep in sync with the palettes. LIGHT
* runs the HIGHER floor, which sounds backwards until you look at the surface:
* near-white has almost no chroma of its own (.0034), so the tint is the only colour
* there and 8 thousandths of it simply did not read. */
--fs-tint-c: calc((.016 + .012 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))) * var(--fs-tint-strength));
}
:root[data-darkmode="true"] {
--fs-tint-c: calc((.011 + .016 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))
- .004 * max(0, cos((var(--fs-tint-h) - 55) * 1deg))) * var(--fs-tint-strength));
}
:root[data-tint="hue"] { --fs-bg: oklch(from var(--fs-bg-base) l var(--fs-tint-c) var(--fs-tint-h)); }
/* ============================================================
* THE COLOUR AXES — five colours, two ways to set each
*
* Accent, Tint, Good, Warn and Danger are one axis pointed at five tokens (fs-prefs.js's
* colorAxis). Each is OFF (no attribute = the palette exactly as it shipped) or set one of
* two ways, which is what the attribute's VALUE says:
*
* data-x="hue" the slider — oklch(from <the palette's own> l c H). Lightness and chroma
* are the palette's, so every contrast margin the palette was measured at
* survives the rotation and the ink does NOT need recomputing.
* data-x="hex" the colour field — the user's colour EXACTLY, stamped inline on :root by
* head.ut/fs-prefs.js. An inline custom property outranks every layer, so
* the declaration is not repeated here; what IS here is the consequence.
*
* WHY THE INK IS DERIVED, AND ONLY IN HEX MODE. --fs-on-accent is the ink ON the accent
* fill — a solid button's label, the toggle knob, the active tab pill. The palettes pick it
* per mode against fills they were measured against (white over footstrap light's #0969da is
* 5.19:1; over the dark palette's light fills white collapses to 2.85:1, hence #10151c
* there). A user-entered colour is measured against nothing, so keeping the palette's ink
* would put white on #ffd400 — 1.2:1, an unreadable button, on the one control whose whole
* job is to be pressed.
*
* So in hex mode the ink follows the LIGHTNESS of whatever was entered:
* `oklch(from <fill> clamp(0, (l - .62) * -100, 1) 0 0)` is black above L .62 and white
* below it, chroma zeroed so it is a neutral either way. The multiplier only has to be big
* enough to saturate the clamp — there is deliberately no ramp between the two, because a
* mid-grey ink is the one answer that fails against both.
*
* L .62 is where black and white cross over in sRGB (a mid grey is ~4.6:1 against each), so
* the worse of the two is never much under AA and the better is far over it. It is derived
* from the fill rather than picked per palette on purpose: an axis is set per BROWSER and
* composes with either palette and either mode, so there is no block a per-palette answer
* could live in.
*
* Tint has no ink row: it colours the CANVAS, whose text is --fs-text, a palette token this
* axis must not move. That is also why the Appearance page reports the contrast a custom
* canvas lands at instead of quietly correcting it.
* ============================================================ */
:root[data-accent="hue"] { --fs-accent: oklch(from var(--fs-accent-base) l c var(--fs-accent-h)); }
:root[data-good="hue"] { --fs-good: oklch(from var(--fs-good-base) l c var(--fs-good-h)); }
:root[data-warn="hue"] { --fs-warn: oklch(from var(--fs-warn-base) l c var(--fs-warn-h)); }
:root[data-danger="hue"] { --fs-danger: oklch(from var(--fs-danger-base) l c var(--fs-danger-h)); }
/* The attribute is REPEATED to win on specificity, and that is load-bearing rather than a tic.
* A palette's dark block is `:root[data-darkmode="true"]` — also (0,2,0) — and it declares the
* ink too; at equal specificity the later rule wins, and the palettes are later in this file. So
* the single-attribute form silently lost in DARK MODE ONLY: measured on the router, a grey
* #4b5563 accent kept the dark palette's near-black ink (rgb(16,21,28) on rgb(75,85,99), 1.9:1)
* while light mode correctly derived white. Repeating the attribute makes it (0,3,0) and the
* rule wins on specificity in both modes — never on source order, which is the house rule
* precisely because moving a block down the file is a fix that the next edit undoes. */
:root[data-accent="hex"][data-accent] { --fs-on-accent: oklch(from var(--fs-accent) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-good="hex"][data-good] { --fs-on-good: oklch(from var(--fs-good) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-warn="hex"][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-danger="hex"][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); }
/* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ---- */
:root,
:root[data-palette="footstrap"] {
/* LIGHT */
--fs-bg-base: #f6f8fa;
--fs-panel-base: #ffffff;
--fs-panel2-base: #f6f8fa;
--fs-border-base: #d0d7de;
--fs-text: #1f2328;
--fs-dim: #656d76;
--fs-faint: #68727e;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%23656d76" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #0969da;
/* a tint OF --fs-accent, so mixed FROM it. It used to restate the accent's RGB as a
* literal, which goes stale silently the moment the accent is recoloured (or the
* accent-hue axis rotates it) — the failure that killed the *-rgb bridge. */
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
--fs-track: #eaeef2;
--fs-good-base: #197c36;
--fs-warn-base: #946300;
--fs-danger-base: #cf222e;
/* ink ON the fills above — white clears AA on all four: 5.19/5.08/4.87/5.36 */
--fs-on-accent: #fff;
--fs-on-good: #fff;
--fs-on-warn: #fff;
--fs-on-danger: #fff;
}
:root[data-darkmode="true"],
:root[data-palette="footstrap"][data-darkmode="true"] {
/* DARK (dimmed) */
--fs-bg-base: #1c2128;
--fs-panel-base: #22272e;
--fs-panel2-base: #2d333b;
--fs-border-base: #444c56;
--fs-text: #adbac7;
--fs-dim: #8f9cab;
--fs-faint: #959ca5;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%238c99a8" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #569df5;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #373e47;
--fs-good-base: #5aad5d;
--fs-warn-base: #c79128;
--fs-danger-base: #ea7a74;
/* dark ink: these fills are LIGHT. White measured 2.85/2.85/2.83/3.70 — a fail on
* all four. #10151c (a shade of --fs-bg) gives 6.43/6.44/6.47/4.95. */
--fs-on-accent: #10151c;
--fs-on-good: #10151c;
--fs-on-warn: #10151c;
--fs-on-danger: #10151c;
}
/* ---- hicontrast — deeper, more saturated (opt-in) ---- */
:root[data-palette="hicontrast"] {
/* LIGHT
* The accent/good/warn/danger below used to be BRIGHTER than the default palette's,
* which made the palette named "hicontrast" the lower-contrast one: --fs-good
* #17b978 as a label on --fs-panel measured 2.55:1 against the default's 5.08:1.
* These four serve both as text on --fs-panel (status labels, outline buttons, port
* speeds) and as fills under white ink, and both uses want the same thing — ≥4.5:1
* against white — so darkening fixes both at once. Now 5.22/5.35/5.93/5.80, i.e.
* genuinely above the default palette, which is what the name promises. */
--fs-bg-base: #eef1f6;
--fs-panel-base: #ffffff;
--fs-panel2-base: #f6f8fb;
--fs-border-base: #e4e9f1;
--fs-text: #101725;
--fs-dim: #5b6675;
--fs-faint: #69727f;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%235b6675" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #0b6fbd;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
--fs-track: #eaeef4;
--fs-good-base: #0f7a52;
--fs-warn-base: #8a5a00;
--fs-danger-base: #c02b2b;
/* white on the fills above: 5.22/5.35/5.93/5.80 */
--fs-on-accent: #fff;
--fs-on-good: #fff;
--fs-on-warn: #fff;
--fs-on-danger: #fff;
}
:root[data-palette="hicontrast"][data-darkmode="true"] {
/* DARK */
--fs-bg-base: #0a0d13;
--fs-panel-base: #111620;
--fs-panel2-base: #161c28;
--fs-border-base: #232b3a;
--fs-text: #e8edf5;
--fs-dim: #9ba6b8;
--fs-faint: #8f9aad;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%239ba6b8" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #3ba7ff;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 14%, transparent);
--fs-track: #1b2230;
--fs-good-base: #43e0a0;
--fs-warn-base: #ffb454;
--fs-danger-base: #ff6b6b;
/* dark ink: white on these neons was 2.57/1.69/1.76/2.78 — a white label on --fs-good
* was unreadable. #0a0d13 (= --fs-bg-base) gives 7.55/11.49/11.03/7.01. */
--fs-on-accent: #0a0d13;
--fs-on-good: #0a0d13;
--fs-on-warn: #0a0d13;
--fs-on-danger: #0a0d13;
}
/* ---- classic — the colours of luci-theme-bootstrap, the LuCI default (opt-in) ----
*
* The one palette named after another PACKAGE, because that is what it is: the surfaces,
* greys and semantic colours of the stock theme, so an admin who wants the look they already
* know keeps it and still gets the chrome, the client navigation and the Appearance axes.
*
* Bootstrap computes everything from HSL axes; these are those axes evaluated:
* --background-color high/medium/low #ffffff / #f9f9f9 / #f5f5f5 (dark #222 / #282828 / #2c2c2c)
* --text-color high/medium #404040 / #808080 (dark #bfbfbf / #7f7f7f)
* --border-color high/medium #cccccc / #dddddd (dark #555555 / #444444)
* The canvas and the card are BOTH --background-color-high: bootstrap is flat and lets the
* border carry the structure, so copying its surfaces means copying that too. panel2 is its
* --background-color-low, the surface it stripes tables and hovers rows with.
*
* WHERE THIS DEVIATES, AND WHY IT HAS TO. Bootstrap's semantic colours do not clear AA on
* bootstrap's own surfaces, and tools/export-tier.mjs holds every palette to 4.5:1 on all
* three — the export tier is what other people's apps print text in. Measured on --fs-panel2:
* success #00ac59 2.73:1, warn #efbd0b 1.61:1, error #f62b12 3.65:1, primary #1976d2 4.22:1;
* dark error #d15653 3.44:1, success #00a66c 4.44:1. So each one is taken from the STEP OF
* BOOTSTRAP'S OWN RAMP that clears it where such a step exists — primary/success/error light
* are its -medium/-low values verbatim — and where none does, the hue and saturation are kept
* and only the value is moved until the colour clears 4.95:1. Warn is the only large move:
* no yellow readable as text on white is still that yellow, which is the same trade the
* default palette made (#946300).
*
* Bootstrap's dark HEADER GRADIENT (#333 → #222, in both modes) is not here: the chrome reads
* --fs-panel, so a palette cannot give the bar its own colour. That would need a chrome token
* pair, which is a change to the chrome, not a colourway. */
:root[data-palette="bootstrap"] {
/* LIGHT */
--fs-bg-base: #ffffff;
--fs-panel-base: #ffffff;
--fs-panel2-base: #f5f5f5;
--fs-border-base: #cccccc;
--fs-text: #404040;
--fs-dim: #6a6a6a;
--fs-faint: #6e6e6e;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%236a6a6a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
/* --primary-color-medium; -high (#1976d2) measured 4.22:1 on --fs-panel2 */
--fs-accent-base: #1564c0;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
--fs-track: #eeeeee;
--fs-good-base: #007936; /* --success-color-low */
--fs-warn-base: #826706; /* #efbd0b darkened; 1.61:1 as text is not a rounding error */
--fs-danger-base: #d00000; /* --error-color-low */
/* ink ON the fills above — white clears AA on all four: 5.81/5.54/5.40/5.70 */
--fs-on-accent: #fff;
--fs-on-good: #fff;
--fs-on-warn: #fff;
--fs-on-danger: #fff;
}
:root[data-palette="bootstrap"][data-darkmode="true"] {
/* DARK */
--fs-bg-base: #222222;
--fs-panel-base: #282828;
/* bootstrap's --background-color-low in dark is #2c2c2c, and that is the one surface value
* copied rather than reproduced: 0.016 from --fs-panel, under the 0.02 export-tier.mjs
* wants, so an app asking --background-color-high/-low for a gradation got one colour
* twice. Bootstrap's LIGHT pair is 0.039 apart and its dark pair is not — the same ramp
* check catches it in the theme this palette is named after. #303030 is 0.031. */
--fs-panel2-base: #303030;
--fs-border-base: #444444;
--fs-text: #bfbfbf;
--fs-dim: #9a9a9a;
--fs-faint: #969696;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%239a9a9a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #53a4c2;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #333333;
--fs-good-base: #1aaf7b;
--fs-warn-base: #aa9968;
--fs-danger-base: #dd807e;
/* dark ink: these fills are LIGHT, and bootstrap's own --on-*-color in dark is likewise
* its --background-color-high. #222222 gives 5.65/5.65/5.66/5.65. */
--fs-on-accent: #222222;
--fs-on-good: #222222;
--fs-on-warn: #222222;
--fs-on-danger: #222222;
}
}
@@ -0,0 +1,112 @@
@layer base {
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
/* scroll-margin-top: an anchor jump must clear the sticky bar or it lands underneath
* it (WCAG 2.2 SC 2.4.11). Token, never a literal — the bar is 46px, not the 40 this
* used to guess. */
*, *::before, *::after {
scroll-margin-top: var(--fs-bar-h);
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
}
abbr[title], acronym[title] {
border-bottom: 1px dotted;
font-weight: inherit;
cursor: help;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
ol, ul {
list-style: none;
}
html {
font-size: 100%;
height: 100%;
}
/* :focus-visible, not :focus — the ring is for the keyboard; a mouse click focuses too and
* the UA heuristic keeps the ring off it. Replaces the 2001-era `thin dotted` (plus its
* `a:hover, a:active { outline: 0 }` companion, needless once the ring is keyboard-only):
* dotted 1px was the only focus indicator generic links had, and it is near-invisible on
* a dark panel. */
a:focus-visible {
outline: 2px solid var(--fs-accent);
outline-offset: 2px;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -.5em;
}
sub {
bottom: -.25em;
}
/* font-size: theme/60-inputs.css (the control-typography rule + `option`) — absorbed */
button,
input,
select,
option,
textarea {
margin: 0;
box-sizing: border-box;
vertical-align: baseline;
}
button,
input[type="button"],
input[type="reset"],
input[type="submit"] {
cursor: pointer;
appearance: button;
word-break: break-all;
}
/* disabled-opacity fade: 30-forms.css, same elements + pointer-events/cursor */
input[type="search"] {
appearance: textfield;
/* NO box-sizing: content-box. It contradicts the universal border-box above;
* the theme sizes fields by padding, so a search box came out ~22px too wide. */
}
input[type="search"]::-webkit-search-decoration {
appearance: none;
}
textarea {
overflow: auto;
vertical-align: top;
font-family: var(--fs-font-mono);
}
/* LuCI's .control-group is bootstrap's JOINED input-group: the trailing button was pulled back
* over the input so their 1px borders coincided, and its left corners were squared to match.
* This theme does not join controls — theme/55-buttons.css gives every button the full token
* radius, and a LAYER beats base unconditionally, so the squared corners never applied while
* the pull-back still did: the button's ROUNDED left corner sat on top of the input's rounded
* right corner (issue #7 — package-manager's Filter/Clear and "Download and install"/OK).
* Separate controls with a real gap instead.
*
* The password reveal is the one group that IS joined, and theme/60-inputs.css builds that seam
* itself (one border around the group, `gap: 0`) — it does not need the pull-back either. */
.control-group {
display: inline-flex;
gap: var(--fs-space-2);
}
}
@@ -0,0 +1,163 @@
@layer base {
/* The body box, links, and the type scale a view inherits from plain markup.
* body background/padding/font-family: theme/20-shell.css — absorbed.
*
* Every size and gutter here reads the scale in 02-tokens.css. It used to state literals —
* 13/18 with 9px and 8.5px halves, a 30/24/18/16/14/13 heading ramp, a 25px list indent — which
* was luci-theme-bootstrap's scale rather than a decision anyone made about Manrope. */
body {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
/* unitless: it inherits as a FACTOR, so a 20px heading gets 30px of leading instead of the
* flat 18px a length would push down onto it and clip. */
line-height: var(--fs-leading);
color: var(--fs-text);
min-height: 100%;
display: flex;
flex-direction: column;
/* gutter, not `overflow-y: scroll`: same no-reflow guarantee, without a dead
* scrollbar down every short page. */
scrollbar-gutter: stable;
}
a {
color: var(--fs-accent);
text-decoration: none;
line-height: inherit;
font-weight: inherit;
}
a:hover {
color: var(--fs-accent);
text-decoration: underline;
}
.nowrap:not(span) {
/* :not(span) — a badge must be free to wrap on a narrow screen, and nowrap
defeats every other width constraint on it */
white-space: nowrap;
}
p,
.cbi-map-descr,
.cbi-section-descr,
.table .tr.cbi-section-table-descr .th {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
margin-bottom: var(--fs-space-2);
/* prose, so no orphans: LuCI's descriptions are one or two sentences of help text and a
* single trailing word on its own line reads as a rendering fault. `pretty`, not
* `balance` — balance is capped at a few lines and re-flows the WHOLE block, which is for
* headings; pretty only reworks the tail. Unsupported browsers ignore it and wrap as
* before, so there is nothing to guard. */
text-wrap: pretty;
}
p small {
font-size: var(--fs-type-xs);
color: var(--fs-dim);
}
/* The heading ramp lives in theme/45-misc.css — absorbed whole (weights, sizes, ink);
* theme/30-tables.css owns `legend`. Only the `<h_ small>` treatment remains here. */
/* One rule for every `<h_ small>`: relative, so it tracks whatever step its heading sits on
* instead of the four hand-set sizes (18/14/14/12) this used to carry. */
h1 small,
h2 small,
h3 small,
h4 small,
h5 small,
h6 small {
font-size: .7em;
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
ul, ol {
margin: 0 0 var(--fs-space-4) var(--fs-space-6);
}
ul ul,
ul ol,
ol ol,
ol ul {
margin-bottom: 0;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
li {
color: var(--fs-dim);
}
dl {
margin-bottom: var(--fs-space-4);
}
dl dt {
font-weight: var(--fs-weight-bold);
}
dl dd {
margin-inline-start: var(--fs-space-2);
}
hr {
margin: var(--fs-space-5) 0;
border: 0;
border-bottom: 1px solid var(--fs-border);
}
strong {
font-style: inherit;
font-weight: var(--fs-weight-bold);
}
em {
font-style: italic;
font-weight: inherit;
line-height: inherit;
}
small { font-size: var(--fs-type) }
address {
display: block;
margin-bottom: var(--fs-space-4);
}
/* no shared padding: both members set their own below (code a chip's inset, pre a block's) */
code, pre {
font-family: var(--fs-font-mono);
font-size: var(--fs-type);
border-radius: var(--fs-radius-sm);
}
code {
/* panel2 (6.45:1), not a border-level surface: --fs-text on the latter is 4.40:1
* in dark — under AA. Still reads as a chip. */
background-color: var(--fs-panel2);
color: var(--fs-text);
padding: 1px var(--fs-space-1);
}
pre {
--border-color: color-mix(in srgb, var(--fs-border) 15%, transparent);
background-color: var(--fs-panel2);
display: block;
padding: var(--fs-space-2);
margin: 0 0 var(--fs-space-4);
border: 1px solid var(--border-color);
border-radius: var(--fs-radius-sm);
white-space: pre-wrap;
word-wrap: break-word;
}
}
@@ -0,0 +1,266 @@
@layer base {
/* Forms: input types, the .cbi-value row layout, and the focus/disabled/
* readonly/invalid states shared by every CBI widget. */
fieldset {
margin-bottom: var(--fs-space-2);
padding-top: var(--fs-space-2);
}
/* size: theme/30-tables.css (`legend`) — absorbed */
fieldset legend {
display: block;
line-height: 1;
color: var(--fs-text);
padding-top: var(--fs-space-5);
}
/* Control typography STAYS IN BASE, deliberately — the one absorption that was tried and
* reverted (measured): as a bare-element rule in the theme layer it out-ranked every CLASSED
* base refinement regardless of specificity — `.zonebadge-empty { font-size: smaller }` lost
* to it and the firewall's zone badges grew from 10.8 to 13px. In base, classed rules beat it
* on specificity, which is the contract they were written against. `select` is not a member:
* its face/size/leading live in its own theme rule (60-inputs.css). */
label,
input,
button {
font-family: var(--fs-font-sans);
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
line-height: normal;
}
.cbi-value {
display: flex;
flex-wrap: wrap;
margin-bottom: var(--fs-space-4);
align-items: baseline;
}
.cbi-value-field {
/* logical, not margin-left: this is the gap AFTER the label column, and LuCI ships four
* RTL languages (ar/fa/he/ur). Identical to margin-left while :root carries no dir=rtl —
* which nothing in openwrt/luci sets today — so this buys nothing yet and costs nothing;
* it is here so the geometry is already correct if dir is ever stamped. */
margin-inline-start: var(--fs-space-5);
flex: 1;
/* a flex item won't shrink below min-content by default, so a nested table or one
* long unbreakable value pushed the whole row out of the card */
min-width: 0;
}
/* size + ink: theme/60-inputs.css (`.cbi-value-title`) — absorbed. Left: the label
* column's geometry, which theme does not touch. */
.cbi-value label.cbi-value-title {
/* the nudge that lands the label's first baseline on the field's. Retuned for the 1.5
* leading in 20-typography.css — it was 6px against a hard 18px line box.
* line-height: theme/60-inputs.css (.cbi-value-title) — absorbed with the control rule
* that made it necessary. */
padding-top: var(--fs-space-1);
/* 180px is a column WIDTH, not a rhythm: it is what fits LuCI's longest option labels
* beside a field, so it is not on the 4px step scale and does not want to be. */
flex: 0 0 180px;
/* `end`, not `right`: the label is aligned TOWARDS its field, which is a logical intent.
* NOT the same as base/95-luci.css's `.left/.right/.center` — those are LuCI's forcing
* utilities, emitted 59 times by form.js, and a class named `right` means right in any
* direction. Never sweep those onto start/end. */
text-align: end;
}
.cbi-value > .cbi-section,
.cbi-value > .cbi-tblsection {
width: 100%;
}
label > input[type="checkbox"],
label > input[type="radio"] {
vertical-align: text-top;
margin: 0;
}
label[for] {
cursor: pointer;
}
/* The shared field box (the 210px x control-height default, #localtime's widening, and the
* per-widget escapes: file, button types, checkbox/radio, .cbi-select, readonly, the
* cell-filling widths): theme/60-inputs.css — absorbed WHOLE. The escapes had to move with
* the box: each beat it here in-layer, and left behind they would lose to a theme rule by
* layer no matter their specificity. */
.cbi-dynlist {
height: auto;
/* min-height: theme/60-inputs.css — absorbed */
display: inline-flex;
flex-direction: column;
}
/* item paint: theme/60-inputs.css — absorbed. Left: the drag-and-drop machinery
* UIDynamicList needs. */
.cbi-dynlist > .item {
margin-bottom: var(--fs-space-1);
padding: var(--fs-space-1) 2em var(--fs-space-1) var(--fs-space-2);
position: relative;
pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
overflow: hidden;
word-break: break-all;
cursor: move; /* drag-and-drop */
user-select: text; /* text selection in drag-and-drop */
}
/* display/padding/border + radius + × ink: theme/60-inputs.css (the bare `.item::after`
* plate + its dynlist refinement) — absorbed. Left: what pins the plate to the chip. */
.cbi-dynlist > .item::after {
content: "×";
position: absolute;
align-items: center;
top: -1px;
inset-inline-end: -1px;
bottom: -1px;
font-weight: var(--fs-weight-bold);
pointer-events: auto;
}
/* indication line for drag-and-drop in UIDynamicList*/
.cbi-dynlist > .item.drag-over {
border-top: 1px solid var(--fs-text);
}
/* Make item being dragged in UIDynamicList partially transparent*/
.cbi-dynlist > .item.dragging {
opacity: .5;
}
/* prevent pointer changing when over the span element in UIDynamicList */
.cbi-dynlist > .item > span {
pointer-events: none;
}
.cbi-dynlist > .add-item {
display: flex;
}
/* indication line for drag-and-drop in UIDynamicList*/
.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
border-top: 1px solid var(--fs-text);
}
/* indication line for drag-and-drop in UIDynamicList*/
.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
border-top: 1px solid var(--fs-text);
}
.cbi-dynlist > .add-item > input,
.cbi-dynlist > .add-item > button {
/* flex: theme/60-inputs.css — absorbed */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* the same one-line control height, so a toggle lines up with the field above it */
.cbi-value-field > .cbi-checkbox,
.cbi-value-field > div > .cbi-radio {
height: var(--fs-control-h);
display: inline-flex;
align-items: center;
}
.cbi-radio {
cursor: pointer;
gap: var(--fs-space-1);
}
/* .cbi-select (the shell, its inner <select>, the ▾ plate): theme/60-inputs.css — absorbed
* whole. Do not re-add an option rule here either: layer beats specificity, so theme's bare
* `option` outranks even `.cbi-select select option:checked`. */
/* Checkbox/radio drawing: theme/60-inputs.css — absorbed whole (it undoes the generic input
* box, which lives there now). */
::placeholder {
color: var(--fs-dim);
}
/* Transitions and the focus/hover ring: absorbed onto the theme's one ring token
* (--fs-focus-ring / --fs-focus-ring-invalid) in theme/55-buttons.css, 60-inputs.css and
* 65-dropdown.css. The old generic glow here rang every clicked button and every hovered
* widget in a second, unnamed focus style — the absorption was a redesign, not a move. */
input[disabled],
button[disabled],
select[disabled],
textarea[disabled],
.cbi-select[disabled]::before,
.cbi-dropdown[disabled]:not(.btn):not(.cbi-button),
input[type="checkbox"][disabled]::before,
input[type="checkbox"][disabled]::after,
input[type="radio"][disabled]::before,
input[type="radio"][disabled]::after {
opacity: var(--fs-disabled-opacity);
pointer-events: none;
cursor: default;
}
/* readonly border fade: theme/60-inputs.css — absorbed with the generic box */
.cbi-optionals,
.cbi-section-create {
padding: 0 0 var(--fs-space-3) var(--fs-space-3);
}
/* `gap`, not the -3px/+3px negative-margin pair this carried: that trick predates flex gap and
* it leaks — the outer -3px pulls the row 3px into whatever sits beside it, which is why the
* children had to pay it back. One gap says the same thing and stays inside the box. */
.cbi-section-create {
display: inline-flex;
align-items: center;
gap: var(--fs-space-2);
}
.cbi-section-create > * {
flex: 1 1 auto;
}
/* the input's cell-filling width: theme/60-inputs.css — absorbed with the generic box */
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. The legacy `.actions`
* plate has no theme override, so it stays (coverage: some luci-app-* still emits it). */
.actions {
background: var(--fs-panel2);
margin-bottom: var(--fs-space-4);
/* was 17px 20px 18px 17px — an asymmetric inset nobody chose; it is a plate, so it takes
* the card's own gutter on every side. */
padding: var(--fs-card-pad);
border-top: 1px solid var(--fs-border);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
text-align: end;
}
/* PHYSICAL, mirrored by hand — see the note on `.close` in base/90-widgets.css for why `float`
* does not take a logical keyword here. This one contradicted its OWN container: `.actions`
* above sets `text-align: end`, which follows the writing direction, while the float did not,
* so in RTL the plate aligned its content to the left while the secondary action stayed on the
* right — the two halves of one row pulling opposite ways. Measured in a live document at
* 1400px: 582px from the reading edge in LTR, 10px in RTL. */
.actions .secondary-action,
.cbi-page-actions .secondary-action{
float: right;
}
:root[dir="rtl"] .actions .secondary-action,
:root[dir="rtl"] .cbi-page-actions .secondary-action { float: left; }
.actions .secondary-action a,
.cbi-page-actions .secondary-action a {
line-height: var(--fs-control-h);
}
.actions .secondary-action a:hover,
.cbi-page-actions .secondary-action a:hover {
text-decoration: underline;
}
.cbi-page-actions > form {
display: inline;
margin: 0;
}
}
@@ -0,0 +1,94 @@
@layer base {
/* Tables. LuCI builds them out of divs carrying .table/.tr/.th/.td rather than
* real table markup, so the display roles have to be assigned by hand. */
.tr { display: table-row; }
.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; }
.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; }
.table {
display: table;
width: 100%;
margin-bottom: var(--fs-space-4);
padding: 0;
font-size: var(--fs-type);
border-collapse: collapse;
position: relative;
}
/* NO per-cell border-top, and that is a fix, not an omission. The theme draws a table's
* separators on the ROW (theme/30-tables.css: header `border-bottom`, then every body row but
* the last), and it reset the cell border only inside `.cbi-section`. A `.table` that LuCI
* renders straight into `#view` — the apk package list, Status → Firewall's nftables — is
* outside that reset, so it kept base's cell border on top of the theme's row border: every
* separator drawn twice, and, because that cell line is straight and runs to the table's very
* edge while the theme's frame is rounded, a square corner cutting across the radius (issue #7,
* "лишняя граница, не имеющая скругления"). */
.table .th, .table .td {
display: table-cell;
/* middle is deliberate: a cell's content is a mix of text, badges and 36px buttons, and
* top-aligning mixed-height rows reads as a layout fault. (The `Fixme` that sat here was
* upstream bootstrap's own stale marker, imported with the rule.) */
vertical-align: middle;
/* was `10px 10px 9px` — a bottom 1px shy of the top for no reason anyone recorded, which
* is what an inherited number looks like. Symmetric, on the grid. */
padding: var(--fs-space-2) var(--fs-space-3);
text-align: start;
/* AN UNBREAKABLE VALUE MUST NOT LEAVE THE CARD, in ANY .table. Stock has always had this
* (`overflow-wrap: break-word` on every cell); the theme replaced it with a rule that first
* asks what KIND of table it is — theme/30-tables.css breaks long values only for
* `#view .table[id] .td, table.fs-dt .td`, i.e. a table carrying an id or a
* `.tr.table-titles` header row. A third-party status page rendering a key/value
* `<div class="table">` with neither matched nothing, so its cells kept
* `overflow-wrap: normal` and one unbroken token — an ICCID, a firmware string, an APN, a
* base64 key, a DUID — held the column open and pushed the table outside its card.
* Measured against luci-app-3ginfo-lite's own markup (`admin/modem`, three such tables).
*
* NOT scoped to the phone tier, and that was measured rather than assumed. Scoping it to
* `max-width: 767px` looked like the cautious choice and left a hole exactly where the
* column is narrow but the WINDOW is not: with a 130-character token the table escaped its
* card by 147px at 900px, 247px at 800px and 279px at 768px, and was contained at every
* width with this rule unscoped. That is the same argument the fit engine is built on —
* whether something fits is a property of the CONTENT and its column, not of the viewport.
*
* The cost is nil, which is the other half of the decision: across 8 router pages × 4
* widths × 849 text cells, turning this on changes NOTHING — same cell overflows (0), same
* tables past their column (0), same multi-line cells, same mid-word breaks, same total
* line count. It bites only on the unbreakable token it exists for.
*
* `anywhere`, not `break-word`: `break-word` permits the break without reducing the cell's
* min-content contribution, so a `display: table` box still cannot shrink below its widest
* token and the column stays open — tried, and the table stayed 355px wide in a 346px
* column. The narrower `anywhere` rules in theme/ keep winning where they apply. */
overflow-wrap: anywhere;
}
.table .tr:first-child .th {
padding-top: var(--fs-space-2);
font-weight: var(--fs-weight-bold);
vertical-align: top;
}
/* the empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps
* the table's own height when there is nothing to show. */
.tr.placeholder {
height: calc(3em + var(--fs-space-4));
}
.tr.placeholder > .td {
position: absolute;
inset-inline: 0;
bottom: 0;
text-align: center;
line-height: 3em;
}
.tr.drag-over-above,
.tr.drag-over-below {
border: 2px solid var(--fs-accent);
border-width: 2px 0 0 0;
}
.tr.drag-over-below {
border-width: 0 0 2px 0;
}
}
@@ -0,0 +1,128 @@
@layer base {
/* Page chrome: the sticky <header> band, dropdown anchoring, the tab strip,
* breadcrumbs, footer. styles/theme/ repaints these; what stays here is the
* geometry a view can rely on whatever the skin. */
header {
position: sticky;
top: 0;
z-index: var(--fs-z-header);
overflow: visible;
color: var(--fs-dim);
display: flex;
}
li.menu, .dropdown {
position: relative;
}
.dropdown:hover ul.dropdown-menu {
inset-inline-start: 0;
}
/* Tab-strip visuals (margins, gradients, per-state colours): theme/40-tabs.css —
* absorbed. Left: strip geometry. */
.tabs > li, .cbi-tabmenu > li {
flex: 0 1 auto;
display: flex;
align-items: center;
}
.tabs > li > a, .cbi-tabmenu > li > a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
outline: none;
}
/* The tab focus ring lives in theme/40-tabs.css now, and it had to move: an accent outline is
* invisible on the ACTIVE pill, whose fill is that same accent. The rule that stood here was
* repainted by the theme in full (same selectors, `outline: none` + a box-shadow ring), so
* leaving it would only be a dead declaration audit.py --strict reports. */
.cbi-tab-disabled[data-errors]::after {
content: attr(data-errors);
/* badge fill: theme/40-tabs.css — absorbed */
color: var(--fs-on-danger);
height: var(--fs-space-4);
min-width: var(--fs-space-4);
/* a pill, so it says so — `8px` was half the height above, restated by hand */
border-radius: var(--fs-radius-pill);
text-align: center;
margin-inline-end: var(--fs-space-1);
padding: 0 var(--fs-space-1);
display: inline-flex;
flex-direction: column;
justify-content: center;
font-size: var(--fs-type);
}
.cbi-tab-descr {
/* the negative top pulls it under the tab strip it describes */
margin: calc(var(--fs-space-2) * -1) 0 var(--fs-space-4) 0;
}
/* hangs directly under the tab strip. `top: 35px` was that strip's height written out again —
* a hidden pair with whatever sizes the tab. 100% IS the strip's height, by definition. */
.tabs .menu-dropdown, .tabs .dropdown-menu {
top: 100%;
border-width: 1px;
/* square top-LEFT only: the panel hangs off the tab above it and the two share that
* corner. Was a bare `0 6px 6px 6px`. */
border-radius: 0 var(--fs-radius) var(--fs-radius) var(--fs-radius);
}
/* the caret beside a tab's label: centred on the text box rather than nudged 15px down it. */
.tabs a.menu:after, .tabs .dropdown-toggle:after {
border-top-color: var(--fs-dim);
align-self: center;
margin-inline-start: var(--fs-space-1);
}
.tabs li.open.menu .menu, .tabs .open.dropdown .dropdown-toggle {
border-color: var(--fs-border);
}
.tabs li.open a.menu:after, .tabs .dropdown.open .dropdown-toggle:after {
border-top-color: var(--fs-text);
}
.breadcrumb {
padding: var(--fs-space-2) var(--fs-space-3);
margin: 0 0 var(--fs-space-4);
background: linear-gradient(to bottom, var(--fs-panel), var(--fs-panel2));
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
display: flex;
flex: 0;
}
.breadcrumb li {
list-style: none;
}
.breadcrumb li:not(:last-child)::after {
content: "|";
padding: 0 var(--fs-space-1);
}
.breadcrumb .active a {
color: var(--fs-dim);
}
/* Footer paint: theme/20-shell.css — absorbed. Left: the flex skeleton the layout
* needs — margin-top:auto pins the footer to the bottom of the flex-column body.
* Do not re-add `justify-content: space-between` / `footer span { margin-bottom }`:
* they came from a two-column footer this theme does not have and only pushed the
* single credit <span> off centre; theme centres it, so they are dead too. */
footer {
margin-top: auto;
display: flex;
flex-wrap: wrap;
align-items: baseline;
}
footer ul.breadcrumb {
margin-inline-start: auto;
}
}
@@ -0,0 +1,81 @@
@layer base {
/* The modal overlay ui.js shows for config diffs, uploads and login, plus the
* colour variants shared by .btn / .alert-message / .cbi-tooltip. */
#modal_overlay {
position: fixed;
top: 0;
bottom: 0;
background: var(--fs-scrim);
z-index: var(--fs-z-overlay);
overflow: auto;
transition: opacity var(--fs-dur);
opacity: 0;
visibility: hidden;
}
.modal {
width: 90%;
/* clear of the sticky bar, and centred. Was `5em auto` — a rhythm in the dialog's own font. */
margin: var(--fs-space-6) auto;
min-height: var(--fs-control-h);
max-width: 600px;
/* a modal is a CARD — the large radius, like every other panel */
border-radius: var(--fs-radius-lg);
background: var(--fs-panel);
border: 1px solid var(--fs-border);
/* box-shadow: theme/70-modal.css — absorbed. It was `0 0 3px var(--fs-panel2)`, a shadow in
* the colour of the page (in footstrap light panel2 and bg are the same hex), so the theme
* gives every .modal --fs-shadow-pop instead and this one was dead. */
/* was `1em 1em .5em 1em` — a bottom half the top because the button row below carries its
* own margin. The card's own gutter on every side; the button row's spacing is its business. */
padding: var(--fs-card-pad);
min-width: 270px;
}
/* .modal > * (normal leading, the .5em rhythm, max-width): theme/70-modal.css — absorbed
* with the heading ramp, which would out-layer the leading here */
/* .modal > pre/textarea (pre-wrap, fill the dialog): theme/70-modal.css — absorbed with
* the generic field box (its 210px would out-layer the width here) */
.modal > .button-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.modal > .button-row > button:not(:last-of-type) {
margin-inline-end: var(--fs-space-2);
}
.modal > .button-row > button:first-of-type {
margin-inline-end: auto;
}
body.modal-overlay-active {
overflow: hidden;
height: 100dvh; /* dvh: 100vh counts the mobile URL bar and cropped the modal */
}
body.modal-overlay-active #modal_overlay {
inset-inline: 0;
opacity: 1;
visibility: visible;
}
/* the cross inside a button/alert: `line-height: 1` centres the glyph in its own box, where
* this said 18px — upstream's leading, which stopped matching the moment the scale moved. */
.btn .close, .alert-message .close {
font-family: var(--fs-font-sans);
line-height: 1;
}
/* THE WHOLE COLOUR-WORD BLOCK IS ABSORBED — do not re-add any of it.
*
* `.btn.<word>` -> theme/55-buttons.css. `.alert-message.<word>` -> theme/35-alerts.css, which
* paints plain `.alert-message`: a later layer beats this one whatever the specificity, so
* every variant here was already dead for the alert. `.cbi-tooltip.<word>` -> theme/35-alerts.css
* as well; those were genuinely alive and genuinely un-themed, and the note that used to sit
* here said so ("theme does not claim those") for as long as nothing rendered them. The gallery
* renders all four now, which is what turned the claim into a measurement. */
}
@@ -0,0 +1,107 @@
@layer base {
/* `.btn` and `.cbi-button` are interchangeable in LuCI markup; a row action carries
* both plus a `-<variant>` class (edit, remove, apply, save). The paint (box, fill,
* radius) is theme/55-buttons.css's — absorbed, along with the `.item::after` member,
* whose plate now lives with the dynlist in theme/60-inputs.css. Left here: --on-color
* (NOT paint — base/95-luci.css masks the spinner glyph with it, so it must track the
* variant), and what theme does not restate. */
.btn,
.cbi-button {
--on-color: var(--fs-text);
cursor: pointer;
font-size: var(--fs-type);
white-space: pre;
}
/* border-color: theme/60-inputs.css — absorbed. The ink stays: theme paints no colour here. */
.cbi-input-invalid,
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li,
.cbi-value-error input {
color: var(--fs-danger);
}
/* `--on-color` ONLY. The `border-color: var(--on-color)` that used to sit in each of these was
* dead on arrival and had been for as long as the theme has painted buttons: theme/55-buttons.css
* sets `border` on `.cbi-button, .btn`, and a later layer beats this one whatever the
* specificity — so a `.cbi-button-edit` never wore the accent border declared here, it wore
* `--fs-border` from the theme. (Proven, not reasoned: stripping them changes no computed style
* over docs/gallery.html, which renders every one of these variants.)
*
* `--on-color` stays because it is NOT paint: base/95-luci.css masks the spinner glyph
* (`.spinning::before`) with it, so it has to track the button's variant. The colour a variant
* WEARS is theme/55-buttons.css's business, in one place. */
.cbi-button-positive,
.cbi-button-fieldadd,
.cbi-button-add,
.cbi-button-save {
--on-color: var(--fs-good);
}
.btn.primary,
.cbi-button-action,
.cbi-button-apply,
.cbi-button-reload,
.cbi-button-edit {
--on-color: var(--fs-accent);
}
.cbi-button-negative,
.cbi-section-remove .cbi-button,
.cbi-button-reset,
.cbi-button-remove {
--on-color: var(--fs-danger);
}
.cbi-page-actions::after {
display: table;
content: "";
clear: both;
}
.cbi-page-actions > * {
vertical-align: middle;
}
/* No `float` here, and its absence is measured rather than assumed: theme/55-buttons.css makes
* `.cbi-page-actions` a flex container, and float does not apply to a flex item — so the
* `float: left` that used to sit beside this margin computed to `none` and moved nothing. It
* was not merely inert, it was misleading: it read as the one declaration in this file that had
* been missed by the move to logical properties, so the next person to sweep for physical sides
* would have "fixed" a rule that does nothing. There is no context that revives it — print
* hides the whole container (theme/97-print.css). Verified in a live document in both writing
* directions: the button sits 569px from the reading edge with the float and 569px without it,
* and the same before and after removal. */
.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) {
margin-inline-end: var(--fs-space-1);
}
/* Fills and borders: theme/55-buttons.css — absorbed. --on-color stays (the spinner mask, see
* above). The `border-color` that used to be here carried a stale note claiming theme did not
* set it on `.cbi-page-actions .cbi-button-apply` — theme/55-buttons.css sets `border: 1px solid
* transparent` on exactly that selector, and has for a while. */
.btn.primary,
.cbi-button-action.important,
.cbi-page-actions .cbi-button-apply,
.cbi-section-actions .cbi-button-edit {
--on-color: var(--fs-on-accent);
}
.cbi-button-positive.important,
.cbi-page-actions .cbi-button-save {
--on-color: var(--fs-on-good);
}
.cbi-button-negative.important {
--on-color: var(--fs-on-danger);
/* fill + border: theme/55-buttons.css — absorbed */
}
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
--on-color: var(--fs-good);
border-color: var(--on-color);
/* neutral fill: theme/55-buttons.css — absorbed */
}
}
@@ -0,0 +1,144 @@
@layer base {
/* Small widgets a view or a third-party luci-app-* can drop anywhere: tooltip,
* progress bar, zone badge, button states, close cross, alert box, .label pill. */
/* .cbi-title-ref colour: theme/45-misc.css — absorbed. The arrow stays: content,
* not paint. */
.cbi-title-ref::after {
content: "➙";
}
.cbi-tooltip-container {
cursor: help;
}
.cbi-tooltip {
position: absolute;
z-index: var(--fs-z-tooltip);
/* logical: `left: -10000px` in an RTL document parks the tooltip 10 000px past the
* INLINE-END edge, which is scrollable overflow — every page grew a 10 000px horizontal
* scrollbar in ar/fa/he (measured). Past the inline-START edge is unreachable in both
* directions, which is what parking means. */
inset-inline-start: -10000px;
/* box-shadow: theme/35-alerts.css — absorbed (was `0 0 2px var(--fs-border)`, a hairline
* halo on a surface that floats over arbitrary content) */
border-radius: var(--fs-radius-sm);
background: var(--fs-panel);
white-space: pre;
padding: var(--fs-space-0-5) var(--fs-space-1);
opacity: 0;
transition: opacity var(--fs-dur-fade);
}
.cbi-tooltip-container:hover .cbi-tooltip:not(:empty) {
inset-inline-start: auto;
opacity: 1;
transition: opacity var(--fs-dur-fade);
}
/* .cbi-progressbar paint: theme/25-progressbar.css — absorbed. The fill's starting
* width stays: the poll JS animates the inline width up from it. */
.cbi-progressbar > div {
width: 0%;
}
.zonebadge .cbi-tooltip {
padding: 1px;
background: inherit;
margin: -1.6em 0 0 -5px;
border-radius: var(--fs-radius-sm);
pointer-events: none;
/* box-shadow: theme/35-alerts.css now states ONE elevation for every .cbi-tooltip, so this
* specialisation no longer needs one of its own */
}
.zonebadge .cbi-tooltip > * {
margin: 1px;
z-index: var(--fs-z-raise);
position: relative;
}
.zone-forwards {
display: flex;
flex-wrap: nowrap;
}
.zone-forwards > * {
flex: 1 1 40%;
padding: 1px;
text-wrap: auto;
}
.zone-forwards > span {
flex-basis: 10%;
text-align: center;
}
.zone-forwards .zone-src,
.zone-forwards .zone-dest {
display: flex;
flex-direction: column;
}
/* pressed-state shadow (.btn.active/:active): theme/55-buttons.css — absorbed */
.btn.disabled,
.btn[disabled] {
cursor: default;
opacity: var(--fs-disabled-opacity);
}
/* `.btn.large` / `.btn.small`: theme/55-buttons.css, in full — absorbed. */
/* close cross — colour, shadow, opacities: theme/70-modal.css — absorbed. Left:
* geometry. */
/* `line-height: 1` centres the glyph in its own box; it was 13.5px under a 20px face — a
* hand-nudge that only lands for one font size, and a half-pixel baseline besides. */
/* PHYSICAL, and mirrored by hand below — the same shape as `::-webkit-resizer`
* (theme/60-inputs.css) and the two chevrons (theme/20-shell.css). `float` takes no logical
* keyword this theme can rely on: `inline-end` is newer than the `oklch(from …)` and `:has()`
* this stylesheet already requires only in Chromium, and it is the one property here where a
* browser that does not know the keyword drops the declaration and leaves the cross floating
* nowhere. Measured in a live document at 1400px: the cross sat 549px from the reading edge in
* LTR and 41px in RTL, i.e. it jumped from the end of the heading to its start — in front of
* the title, where a dismissal control is not. It appears in every modal and in every
* `.alert-message`, so this is the RTL bug with the widest reach. */
.close {
float: right;
font-size: var(--fs-type-xl);
font-weight: var(--fs-weight-bold);
line-height: 1;
}
:root[dir="rtl"] .close { float: left; }
.close:hover {
text-decoration: none;
}
/* alert paint (surface, ink, border, radius, shadow, padding): theme/35-alerts.css —
* absorbed. Left: geometry + the white-space reset, which theme does not set. */
.alert-message {
position: relative;
margin-bottom: var(--fs-space-2);
white-space: unset;
}
.alert-message .close {
margin-top: 1px;
}
/* color + margin + the content reset (border/leading/background/padding):
* theme/35-alerts.css — absorbed; the reset had to move with the heading ramp. */
.alert-message button {
margin: var(--fs-space-1) 0;
}
/* `.label` pill + variants: theme/35-alerts.css — absorbed. So is the indicator pill:
* ui.js's showIndicator() only ever emits into #indicators, which theme/20-shell.css
* paints, so every `header [data-indicator]` is one of those. Left: the three
* properties theme does not touch. */
header [data-indicator] {
white-space: nowrap;
text-shadow: none;
margin: var(--fs-space-1) 0 var(--fs-space-1) var(--fs-space-1);
}
}
@@ -0,0 +1,699 @@
@layer base {
/* Widgets with no generic equivalent: CBI section tables, interface/zone badges,
* the uci change dialog, spinners, the file browser, and a few rules luci-mod-*
* depends on. */
/* .hidden: theme/60-inputs.css (with the .cbi-* variants LuCI emits) */
form.inline { display: inline; margin-bottom: 0; }
#modemenu li:last-child span.divider { display: none }
/* #syslog: absorbed into pages/70-syslog.css — in base it LOST to the theme layer's generic
* form-field box (a layer beats specificity), so the log rendered as a 210px column. */
/* `:not(.placeholder)` — a hover cue promises a row you can act on, and the empty-table row
* ("There are no active port maps.") is not one. The theme's own row hover already spares it
* (theme/30-tables.css, issue #5.2), but this rule paints the CELL, which nothing in a later
* layer was undoing: measured on Services → UPnP, whose table is `.table.cbi-section-table` with
* a `tr placeholder` that carries no `.cbi-section-table-row` class, so the theme's guarded rule
* never matched it — the cell still lit to --fs-panel2 (issue #15). Fixing it here rather than
* repainting it later keeps one hover contract instead of two. */
.cbi-section-table .tr:not(.placeholder):hover .td,
.cbi-section-table .tr:not(.placeholder):hover .th,
.cbi-section-table .tr:not(.placeholder):hover::before {
background-color: var(--fs-panel2);
}
.cbi-section-table .tr.cbi-section-table-descr .th {
font-weight: var(--fs-weight-normal);
}
.cbi-section-table-titles {
font-weight: var(--fs-weight-bold);
}
/* The GridSection name column, and on 24.10 the THEME draws the whole of it — this is the one
* place the two releases' markup genuinely differs, and one stylesheet serves both.
* 25.12's form.js renders the name as a real `th` + `td.cbi-section-table-titles` and keeps
* `data-title` on the row only for the touch-sort drag handle; 24.10 renders no cell at all and
* expects `::before` to generate the column from `data-title`. Upstream ships a different
* cascade.css per branch (25.12's says `content: none`, 24.10's `content: attr(data-title) " "`),
* so taking 25.12's copy verbatim deleted the Name column on every 24.10 router — the header and
* every rule's name, on Firewall -> Port forwards / NAT rules (issue #9, reproduced on the 24.10
* container, unchanged on 25.12).
*
* Each row type therefore asks the MARKUP, never the release:
* header — carries `data-title` on 24.10 only; 25.12 appends a real `th` instead.
* row — BOTH set `data-title`, so the discriminator is the name cell 25.12 adds.
* descr — has neither, so it follows the header that already proved the branch. It needs the
* empty spacer (`data-title` is absent, so the content is just " ") or its column
* labels sit one cell left of the values they describe. A descr row with no header
* row above it (every column carrying a description but no title) therefore keeps no
* spacer — no shipped page renders that, so it is untested rather than handled.
* Never key on the VALUE: the header's data-title is _('Name'), i.e. translated.
*
* This rule states only that the column EXISTS — its padding, type and separator are the cell
* rules' job in theme/30-tables.css, next to the real cells it has to line up with. (Upstream
* declares the border-top here; ours would draw the stray line above the header that issue #7
* removed from every other cell.) */
.cbi-section-table-titles.named[data-title]::before,
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before,
.cbi-section-table-row[data-title]:not(:has(> .cbi-section-table-titles))::before {
content: attr(data-title) " ";
display: table-cell;
vertical-align: middle;
}
.left { text-align: left !important; }
.right { text-align: right !important; }
.center { text-align: center !important; }
.top { vertical-align: top !important; }
.middle { vertical-align: middle !important; }
.bottom { vertical-align: bottom !important; }
table table td,
.cbi-value-field table td {
border: none;
}
/* cell-filling field widths (+ the numeric-cell refinement): theme/30-tables.css —
* absorbed with the generic field box, which would out-layer them here */
.table.cbi-section-table .td.cbi-section-table-cell {
white-space: nowrap;
text-align: end;
}
.td.cbi-section-actions {
text-align: end;
vertical-align: middle;
width: 15%;
}
.td.cbi-section-actions > * {
display: flex;
}
.td.cbi-section-actions > :not(.cbi-dropdown) > *,
.td.cbi-section-actions > * > form > * {
flex: 1 1 4em;
/* at 1px apart, adjacent row actions sat inside each other's 24px target circle
* (WCAG 2.2 SC 2.5.8) — see the min-height in theme/55-buttons.css */
margin: 0 var(--fs-space-1);
}
.td.cbi-section-actions > * > form {
display: inline-flex;
margin: 0;
}
.table.valign-middle .td {
vertical-align: middle;
}
.cbi-rowstyle-2,
.tr.table-titles,
.tr.cbi-section-table-titles {
background: var(--fs-panel2);
}
th[data-sortable-row] { cursor: pointer; }
th[data-sort-direction="asc"]::after { content: "\a0\25b2"; }
th[data-sort-direction="desc"]::after { content: "\a0\25bc"; }
/* The help sentence, indented to clear the ? glyph its ::before hangs in the margin. The
* indent and the glyph's own `left` are ONE relationship — both said 1.25em, stated twice — so
* they now derive from a single local. */
.cbi-value-description {
--help-indent: 1.25em;
margin: var(--fs-space-1) 0 0 var(--help-indent);
position: relative;
}
.cbi-value-description:not(:empty)::before {
/* OURS, and drawn as a STROKE like every other glyph this theme owns (--spinner-icon below,
* --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid). It replaces a solid
* filled path copied verbatim from luci-theme-bootstrap: a drawn path is authored expression
* in a way `padding: 8px` is not, which made it the sharpest item in that inheritance. */
--help-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='9.4'/><path d='M8.9 9.4a3.2 3.2 0 1 1 3.9 3.6v1.4'/><path d='M12.8 17.6h-.02'/></svg>");
position: absolute;
inset-inline-start: calc(var(--help-indent) * -1);
content: "\a0";
display: inline-block;
width: 1em;
height: 1em;
margin-inline-end: var(--fs-space-1);
background: var(--fs-accent);
mask-image: var(--help-icon);
mask-size: cover;
-webkit-mask-image: var(--help-icon);
-webkit-mask-size: cover;
}
/* tint (and the 6%-not-12% reasoning): theme/30-tables.css — absorbed */
.cbi-section-error {
border: 1px solid var(--fs-danger);
border-radius: var(--fs-radius);
padding: var(--fs-space-2);
margin-bottom: var(--fs-space-4);
}
.cbi-section-error ul { margin: 0 0 0 var(--fs-space-5); }
.cbi-section-error ul li {
/* colour: theme/35-alerts.css — absorbed */
font-weight: var(--fs-weight-bold);
}
.ifacebox {
border: 1px solid var(--fs-border);
margin: 0 var(--fs-space-3);
text-align: center;
white-space: nowrap;
/* surface: theme/45-misc.css (flat) — absorbed */
border-radius: var(--fs-radius-sm);
box-shadow: var(--fs-emboss);
display: inline-flex;
flex-direction: column;
/* a plate of single-line labels, not prose */
line-height: var(--fs-leading-tight);
min-width: 100px;
}
.ifacebox .ifacebox-head {
border-bottom: 1px solid var(--fs-border);
/* the head sits INSIDE the box's 1px border, so its own radius is the box's less that
* border — restating a smaller literal is how the two drifted apart before. */
border-radius: calc(var(--fs-radius-sm) - 1px) calc(var(--fs-radius-sm) - 1px) 0 0;
padding: var(--fs-space-0-5);
/* surface + ink: theme/45-misc.css — absorbed */
}
.ifacebox .ifacebox-head[style] {
text-shadow: var(--fs-text-emboss);
}
/* The !important fights luci-mod-network's inline zone-colour `style=` — a raw zone
* colour is too bright on a dark panel, so re-mix it at .4/.3.
*
* NO FALLBACK on --zone-color-rgb: load-bearing, do not "fix" it. LuCI sets that var
* inline only where there IS a zone colour; elsewhere (the overview's .ifacebox-head,
* carrying a plain inline background-color) it is missing, so the declaration is
* invalid at computed-value time and — being !important — resolves to `unset`, wiping
* that inline background. That is the DESIRED outcome. A fallback grey makes the rule
* apply there instead, tinting five interface boxes that today correctly show the panel
* behind them (measured, cssdiff.py). */
[data-darkmode="true"] .zonebadge[style],
[data-darkmode="true"] .ifacebox-head[style] {
background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important;
}
.ifacebox .ifacebox-body {
padding: var(--fs-space-1);
}
#cbi-network-interface .ifacebox-body {
white-space: normal;
}
#cbi-network-interface .ifacebox img {
width: 24px;
height: 24px;
}
.ifacebadge {
display: inline-block;
flex-direction: row;
border: 1px solid var(--fs-border);
padding: var(--fs-space-0-5);
/* surface: theme/45-misc.css (flat) — absorbed */
color: var(--fs-text);
text-shadow: var(--fs-text-emboss);
border-radius: var(--fs-radius-sm);
box-shadow: var(--fs-emboss);
cursor: default;
/* a badge is one line of label — the tight step, like .ifacebox above */
line-height: var(--fs-leading-tight);
}
.ifacebadge img {
width: 32px;
height: 32px;
vertical-align: middle;
}
.ifacebadge-active {
/* border-color: theme/45-misc.css (accent) — absorbed */
font-weight: var(--fs-weight-bold);
}
.network-status-table {
display: flex;
flex-wrap: wrap;
}
.network-status-table .ifacebox {
margin: var(--fs-space-2);
flex-grow: 1;
}
.network-status-table .ifacebox-body {
display: flex;
flex-direction: column;
height: 100%;
text-align: start;
/* `.ifacebox` above is `white-space: nowrap` — right for a plate of one-word labels, wrong
* for THIS plate, whose lines are addresses: an uncompressed IPv6 (`Address:
* 2001:db8:1a2b:3c4d:5e6f:7a8b:9c0d:1e2f/64`) is ~380px of unbreakable text, and the
* Overview's upstream boxes are flex items that shrink. Measured on the fork container at
* a 390px viewport: the box ended at x=348 and the address ran to x=393 — 45px of text
* printed outside its own border, over the card behind it.
*
* `normal` alone does not fix it and is the obvious half-fix: an address has no spaces, so
* there is nothing to wrap AT. `overflow-wrap: anywhere` is what allows a break inside the
* token — and `anywhere` rather than `break-word` on purpose, because only `anywhere`
* lowers the box's min-content size, so flexbox can shrink the item to the line instead of
* handing it a width its content will overflow. The Interfaces page had this fixed for its
* own copy (`#cbi-network-interface .ifacebox-body`, below); the status plate never did. */
white-space: normal;
overflow-wrap: anywhere;
}
.network-status-table .ifacebox-body > * {
margin: var(--fs-space-1);
}
.network-status-table .ifacebox-body > span {
flex: 10 10 auto;
height: 100%;
}
.network-status-table .ifacebox-body > div {
margin: -.125em;
display: flex;
flex-wrap: wrap;
}
#dsl_status_table .ifacebox-body span > strong {
display: inline-block;
min-width: 35%;
}
.ifacebadge.large,
.network-status-table .ifacebox-body .ifacebadge {
display: flex;
flex: 1;
padding: var(--fs-space-1);
min-width: 220px;
margin: var(--fs-space-1);
}
.ifacebadge.large {
display: inline-flex;
}
.network-status-table .ifacebox-body .ifacebadge > span {
overflow: hidden;
text-overflow: ellipsis;
}
.ifacebadge > *,
.ifacebadge.large > * {
margin: 0 var(--fs-space-1);
}
.zonebadge {
padding: var(--fs-space-0-5);
border-radius: var(--fs-radius-sm);
display: inline-block;
white-space: nowrap;
color: var(--fs-text);
text-shadow: var(--fs-text-emboss);
}
.zonebadge > em,
.zonebadge > strong {
margin: 0 var(--fs-space-1);
display: inline-block;
}
/* .zonebadge input width: theme/30-tables.css — absorbed with the generic box */
.zonebadge > .ifacebadge {
margin-inline-start: var(--fs-space-0-5);
}
.zonebadge-empty {
/* width/style only — colour + ink: theme/45-misc.css */
border: 1px dashed var(--fs-border);
font-style: italic;
font-size: smaller;
}
/* color: theme/60-inputs.css — absorbed. */
div.cbi-value var,
.td.cbi-value-field var {
font-style: italic;
}
div.cbi-value var[data-tooltip],
.td.cbi-value-field var[data-tooltip],
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container {
cursor: help;
border-bottom: 1px dotted var(--fs-accent);
}
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container .cbi-tooltip {
font-style: normal;
white-space: normal;
color: var(--fs-text);
}
#modal_overlay > .modal.uci-dialog,
#modal_overlay > .modal.cbi-modal {
max-width: 900px;
}
#modal_overlay > .modal.login > button.important {
/* no !important needed: the ID makes this (1,3,1), which no button rule can reach */
/* the login dialog's one action, a step up the scale — it was a bare 120% of whatever the
* button happened to be. */
font-size: var(--fs-type-lg);
margin-top: var(--fs-space-2);
width: 100%;
}
/* a list of changed settings, one per line: prose leading, not a hand 170%. */
.uci-change-list {
line-height: var(--fs-leading);
white-space: pre;
}
/* one changed-setting row. It carried `line-height: 15px` — a hand number under a 13px face;
* a row of single-line code takes the tight step like every other plate here. */
.uci-dialog div > del,
.uci-dialog div > ins,
.uci-dialog div > var {
margin-bottom: var(--fs-space-0-5);
border: 1px solid var(--fs-border);
line-height: var(--fs-leading-tight);
overflow: hidden;
text-overflow: ellipsis;
padding: var(--fs-space-0-5);
position: relative;
background-color: var(--fs-panel2);
}
.uci-dialog div > ins {
background-color: var(--fs-good-fill);
border-color: var(--fs-good-line);
}
.uci-dialog div > del {
background-color: var(--fs-danger-fill);
border-color: var(--fs-danger-line);
}
.uci-dialog var > ins {
background-color: var(--fs-good-fill);
}
.uci-dialog var > del {
background-color: var(--fs-danger-fill);
}
.uci-change-list > var > * {
overflow: hidden;
text-overflow: ellipsis;
}
.uci-dialog del,
.uci-dialog ins,
.uci-dialog var {
text-decoration: none;
font-family: var(--fs-font-mono);
font-style: normal;
color: var(--fs-text);
display: block;
}
.uci-change-legend {
display: flex;
flex-wrap: wrap;
}
.uci-change-legend-label {
flex-basis: 150px;
margin: var(--fs-space-0-5);
display: flex;
align-items: center;
}
.uci-change-legend-label > ins,
.uci-change-legend-label > del,
.uci-change-legend-label > var {
margin-inline-end: var(--fs-space-1);
width: 16px;
height: 16px;
display: flex;
}
.uci-change-legend-label > * > * {
flex-basis: 100%;
padding: 1px;
}
#applyreboot-section {
line-height: 300%;
}
@keyframes flash {
0% { opacity: 1; }
50% { opacity: .5; }
100% { opacity: 1; }
}
.flash {
animation: flash var(--fs-dur-fade);
}
#view > div.spinning:first-child {
display: table;
margin: 15vh auto;
}
/* Rotate in CSS, NEVER with a SMIL <animateTransform> inside the data-URI: that runs in
* the SVG's own document, which the page's `prefers-reduced-motion` cannot reach — the
* one spinner a motion-sensitive user could not turn off. theme/95-a11y-media.css now
* stops it like everything else. (The circle under the arc is rotationally symmetric,
* so spinning the whole box looks identical.) */
/* ---- THE GLYPH SITS IN THE FLOW, AND THAT IS THE FIX FOR A WHOLE BUG CLASS ----
*
* It used to be `position: absolute; left: var(--fs-space-2)` over a `position: relative`
* host, which means the host had to be told to leave room for it — a `padding-left` in
* theme/55-buttons.css that had to OUT-RANK whatever else was padding that element. That is a
* race nobody can win: every padding rule aimed at a button is a candidate, and the loser is
* silent — the glyph simply draws on top of the label. Twice reported from the field, each
* time by a more specific rule than the last: issue #15 (`.td .cbi-button`, 0,2,0 — Network →
* Interfaces, "Restart") and issue #22 (`.cbi-section .table[id] .td .cbi-button`, 0,5,0 —
* the overview's DHCP leases, "Reserve IP"), where the ladder had padding-left at 10px against
* the 32px the glyph needs. A third rule would have done it again.
*
* In the flow the glyph reserves its OWN space, so no host needs a padding at all and there is
* nothing left to out-rank. `flex: 0 0 auto` because the buttons are `inline-flex` and a flex
* item with a mask is squeezable; `vertical-align: middle` for the non-flex hosts (`em.spinning`
* = "Loading…", `#view > div.spinning`). The centring that `top: 50%` + a derived margin used
* to do is now the host's own `align-items: center` / baseline alignment. */
.spinning::before {
--spinner-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");
display: inline-block;
flex: 0 0 auto;
vertical-align: middle;
margin-inline-end: var(--fs-space-1);
width: var(--fs-spin-size);
height: var(--fs-spin-size);
content: " ";
background: var(--on-color, #000);
mask: var(--spinner-icon) center/cover no-repeat;
-webkit-mask: var(--spinner-icon) center/cover no-repeat;
/* The two deliberate exceptions to the motion scale (02-tokens.css), both because this
* is a LOOP, not a state change: 1s is a spinner period, not one of the four durations;
* and `linear` is the one justified curve — `ease` would slow at each turn's seam, so an
* infinite rotation visibly stutters once a second instead of spinning. */
animation: fs-spin 1s linear infinite;
}
/* One user in this theme (`.spinning::before` above) — the rail's poll glyph is
* deliberately STATIC (theme/20-shell.css; the spin was what broke that click).
* Unscoped anyway: keyframe names resolve document-wide, so a luci-app-* may ride it. */
@keyframes fs-spin { to { transform: rotate(360deg); } }
[data-darkmode="true"] .spinning::before {
background: var(--on-color, #fff);
}
[data-tab-title] {
height: 0;
opacity: 0;
overflow: hidden;
}
/* the revealed state of both collapsibles — tab pane and file browser — in one rule */
[data-tab-active="true"],
.cbi-filebrowser.open {
opacity: 1;
height: auto;
overflow: visible;
transition: opacity var(--fs-dur-fade);
}
.cbi-filebrowser {
min-width: 210px;
max-width: 100%;
/* width/style only; radius: theme/60-inputs.css — absorbed */
border: 1px solid var(--fs-border);
display: flex;
flex-direction: column;
opacity: 0;
height: 0;
overflow: hidden;
}
/* padding: theme/60-inputs.css — absorbed. The zero side padding here was what let a 36px
* themed button touch this element's own clip box and lose its corners to it. */
.cbi-filebrowser > * {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
margin: .25em .25em 0 .25em;
white-space: nowrap;
border-bottom: 1px solid var(--fs-border);
}
.cbi-filebrowser .cbi-button-positive {
margin-inline-end: .25em;
}
.cbi-filebrowser > div {
border-bottom: none;
}
.cbi-filebrowser > ul > li {
display: flex;
flex-direction: row;
}
/* row hover surface, and the row's whole flex sizing: theme/60-inputs.css — absorbed. The
* proportional split that lived here (name `flex: 10` / actions `flex: 3`) fitted base's ~20px
* buttons and not the theme's 36px ones, which is how a file row came to clip its own Delete. */
.cbi-filebrowser > ul > li > div:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.cbi-filebrowser > ul > li > div:last-child {
text-align: end;
}
.cbi-filebrowser > ul > li > div:last-child > button {
padding: .125em .25em;
}
.cbi-filebrowser .upload {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -.125em .25em -.125em;
padding: 0 0 .125em 0;
border-bottom: 1px solid var(--fs-border);
}
.cbi-filebrowser .upload > * {
margin: .125em;
flex: 1;
}
.cbi-filebrowser .upload > .btn {
flex-basis: 60px;
}
.cbi-filebrowser .upload > div {
flex: 10;
min-width: 150px;
}
/* the upload input's width: theme/60-inputs.css — absorbed with the generic box */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
/* .fade-in's animation: theme/45-misc.css (fs-fade) — absorbed. The @keyframes above
* STAY though nothing here rides them: `fade-in` is an upstream animation NAME a
* luci-app-* may ride (coverage contract, docs/conventions.md). */
.fade-out {
animation: fade-out var(--fs-dur-fade);
}
.assoclist .ifacebadge {
display: flex;
flex-direction: column;
align-items: center;
white-space: normal;
text-align: center;
}
.assoclist .ifacebadge > img {
margin: var(--fs-space-1);
}
.assoclist .td:nth-of-type(3),
.assoclist .td:nth-of-type(5) {
width: 25%;
}
.assoclist .td:nth-of-type(6) button {
word-break: normal;
}
/* Statistics renders its graphs as PNGs on a white ground, so dark mode inverts them. 180deg is
* not a nudge of upstream's 150 — it is the arithmetic: invert() maps every hue to h+180, so
* rotating 180 back restores the ORIGINAL hue while keeping the inverted lightness.
*
* Measured on collectd's own cpu-plugin series, reading the rendered pixel back: at 150deg
* System (red, h=0) came back at h=301 and User (blue, h=240) at h=180 — 59 and 60 degrees out,
* i.e. the blue plot was drawn CYAN. At 180deg all three primaries are exact.
*
* It is NOT exact for every colour and cannot be: CSS hue-rotate is a linear matrix
* approximation, not a true HSL rotation, so a non-primary survives only approximately —
* Wait I/O (amber, h=41) still lands 13deg out at 180. Do not "fix" that by tuning the angle:
* moving off 180 trades an exact red/green/blue for a marginally better amber. */
[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
filter: invert(100%) hue-rotate(180deg);
}
/* Realtime graphs (Status → Load/Traffic) inject resources/svg/*.svg into a div with
* an inline style. The <svg> root carries none itself, so the background needs no flag.
* Its grid <line>s do — `style="stroke:black"` in the .svg — and a black hairline is
* invisible on a dark panel; only an author !important beats an inline declaration. */
#view div[style] > svg {
background-color: var(--fs-panel);
}
#view div[style] > svg line[style] {
stroke: var(--fs-text) !important;
}
/* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label) +
* pages/20-overview.css (chevron variant) — absorbed. */
}
@@ -0,0 +1,74 @@
@layer page {
/* LOGIN — the SERVER-rendered form of themes/footstrap/sysauth.ut, as a centred card.
*
* Scoped by `.fs-login`, a class that template puts on the form. It used to be found by
* `form:has(> .cbi-map input[name="luci_username"])`, on the assumption that the markup was
* stock LuCI's and unnameable — 49 characters × 17 rules, ~0.8 KB of the stylesheet. The
* form is ours; naming it is free. (ui.js builds no login form of its own — it has no
* `luci_username` anywhere — so nothing else ever matched those selectors.)
*
* The theme ships its own sysauth.ut so the page carries NO chrome: without one, LuCI
* falls back to the generic template, which includes the header WITHOUT blank_page and
* draws the whole sidebar around a page whose only control is a password field.
*
* Do NOT turn this into a modal (form in a <section hidden>, revealed by a view module).
* Tried: BLANK PAGE, NO WAY TO LOG IN — the view runs before a session exists, its RPCs
* answer "Access denied", the promise rejects, render() never runs. Server-rendered, the
* form works with JS off. */
.fs-login {
/* A DEFINITE width, never `max-width`: the card is a direct flex item of <body>
* (base/20-typography.css makes body a flex column so the footer can margin-top:auto),
* and on the cross axis an `auto` side margin overrides the stretch and sizes the item
* to FIT-CONTENT. `max-width: 400px` capped nothing — the card collapsed to its
* min-content, ~131px, and the description wrapped one word per line. */
width: min(400px, calc(100% - 32px));
margin: 7vh auto var(--fs-space-10);
background: var(--fs-panel); border: 1px solid var(--fs-border); border-radius: var(--fs-radius-lg);
/* the token, not a literal 45%-black 60px halo: that ignored the palette and drew
* a heavy grey cloud under a white card in light mode. */
padding: var(--fs-space-6) var(--fs-space-6) var(--fs-space-6); box-shadow: var(--fs-shadow-pop);
}
.fs-login .cbi-map,
.fs-login .cbi-section,
.fs-login .cbi-section-node {
background: none; border: 0; padding: 0; margin: 0; box-shadow: none;
}
/* the login card IS the panel — the generic "heading gets its own card" rule would
* draw a second frame inside it */
.fs-login .cbi-map > h2,
.fs-login .cbi-map-descr {
background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0;
}
.fs-login .cbi-map > h2 {
font-size: var(--fs-type-xl); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; margin: 0 0 var(--fs-space-1-5);
}
.fs-login .cbi-map-descr { color: var(--fs-dim); font-size: var(--fs-type); margin: 0 0 var(--fs-space-5); }
.fs-login .cbi-value {
display: block; border: 0; padding: 0 0 var(--fs-space-4); margin: 0;
}
.fs-login .cbi-value-title {
display: block; text-align: start; width: auto; max-width: none; padding: 0 0 var(--fs-space-1-5);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase; color: var(--fs-eyebrow-color);
}
.fs-login .cbi-value-field {
width: 100%; margin: 0; padding: 0;
}
.fs-login .cbi-value-field input {
width: 100%; margin: 0; box-sizing: border-box;
}
.fs-login .alert-message { margin: 0 0 var(--fs-space-3-5); border-radius: var(--fs-radius); }
/* a column, not a block: some auth plugins add a second action (Reset), and the
* full-width buttons below carry margin:0 — they ended up glued together */
.fs-login .cbi-page-actions {
display: flex; flex-direction: column; gap: var(--fs-space-2-5);
background: none; border: 0;
padding: 0; margin: var(--fs-space-1-5) 0 0; text-align: start;
}
.fs-login .cbi-page-actions .btn,
.fs-login .cbi-page-actions input[type="submit"] {
width: 100%; box-sizing: border-box; min-height: var(--fs-ctl-h-lg); margin: 0; border-radius: var(--fs-radius-lg);
background: var(--fs-accent); color: var(--fs-on-accent);
border: 0; font-weight: var(--fs-weight-bold); font-size: var(--fs-type-lg);
}
.fs-login .cbi-page-actions .btn:hover { filter: var(--fs-hover-lift); }
}
@@ -0,0 +1,301 @@
@layer page {
/* EVERY !important HERE FIGHTS AN INLINE `style=` — nothing else can, an inline
* declaration outranks every author rule in every layer. luci-mod-status's 29_ports.js
* writes the ports grid, its tiles and their inner rows as:
* E('div', {style: 'display:grid;grid-template-columns:repeat(auto-fit, minmax(70px, 1fr));margin-bottom:1em'})
* E('div', {class: 'ifacebox', style: 'margin:.25em;min-width:70px;max-width:100px'})
* E('div', {class: 'ifacebox-head cbi-tooltip-container', style: 'display:flex'}) <- the zone bar we hide
* E('div', {class: 'cbi-tooltip-container', style: 'text-align:left;font-size:80%'}) <- the traffic figures
* Each flag is matched against the property that inline style really sets; one fighting
* only another footstrap rule would be redundant (`page` is the last layer). No cascade
* layer can replace those that remain — keep them. */
/* The `.cbi-title .label` pill: theme/35-alerts.css (one owner — a copy here had
* drifted). Only the overview-specific chevron toggle stays.
* index.js emits a span with a raw "Hide"/"Show" text node + data-style (expanded =
* inactive, collapsed = active); data-indicator='poll-status' is unique to these
* toggles. font-size:0 hides the word, ::after borders draw the chevron. */
.cbi-title .label[data-indicator="poll-status"] {
font-size: 0;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--fs-radius-sm);
}
.cbi-title .label[data-indicator="poll-status"]::after {
content: "";
width: 7px;
height: 7px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 1px;
transform: rotate(45deg); /* default: chevron down (collapsed / Show) */
margin-top: -2px; /* optical centring of the down chevron */
transition: transform var(--fs-dur-move);
}
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after {
transform: rotate(-135deg); /* expanded / Hide -> chevron up */
margin-top: var(--fs-space-0-5);
}
/* Stock overview layout: fs-overview.js wraps the stock System / Memory / Storage
* sections in .fs-ovl; this is the grid only — System left across both rows, Memory +
* Storage right. Stock styling and data untouched. */
/* the chrome already shows the page title, so the stock <h2> is a duplicate */
body[data-page="admin-status-overview"] #view > h2[name="content"],
body[data-page="admin-status-overview"] #maincontent h2[name="content"] { display: none; }
.fs-ovl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--fs-space-4);
/* no align-items: the default stretch is what levels the two columns' bottom edges */
margin-bottom: var(--fs-card-gap); /* match stock .cbi-section spacing below the grid */
}
/* min-width:0 so a grid item shrinks to its TRACK, not to its content's min-width
* (auto): a long System value (model/firmware) blew the single-column track past a
* ~344px phone and clipped. */
.fs-ovl > .fs-ovl-sys,
.fs-ovl > .fs-ovl-mem,
.fs-ovl > .fs-ovl-sto { margin: 0; min-width: 0; }
.fs-ovl > .fs-ovl-sys { grid-column: 1; grid-row: 1 / 3; }
.fs-ovl > .fs-ovl-mem { grid-column: 2; grid-row: 1; }
.fs-ovl > .fs-ovl-sto { grid-column: 2; grid-row: 2; }
@container fs-view (max-width: 800px) {
.fs-ovl { grid-template-columns: 1fr; }
.fs-ovl > .fs-ovl-sys,
.fs-ovl > .fs-ovl-mem,
.fs-ovl > .fs-ovl-sto { grid-column: 1; grid-row: auto; }
}
/* Stock Port status (29_ports.js) reskin — pure CSS over the stock .ifacebox markup:
* icon-in-a-box tiles become left-aligned cards (name + link dot, speed, TX/RX).
*
* EVERY rule is scoped to `.ifacebox:has(img[src*="/port_"])` because Network
* (30_network.js) and Wireless (60_wifi.js) reuse the SAME .ifacebox/-head/-body
* classes — only Port tiles carry an icons/port_*.svg icon. Do not unscope.
* Link state = the icon src (*_up = link, *_down = none). Child order: 1 name-head,
* 2 body(icon+speed), 3 zone-bar head, 4 body(traffic). */
/* grid wrapper. auto-fit + minmax(126, 200) is count-agnostic: many ports shrink
* toward 126 and wrap; few ports cap at 200 instead of one stretching the full width.
* 2-port device or 24-port switch alike. */
body[data-page="admin-status-overview"] div:has(> .ifacebox img[src*="/port_"]) {
gap: var(--fs-space-2-5); /* no inline gap — no flag needed */
grid-template-columns: repeat(auto-fit, minmax(126px, 200px)) !important;
margin-bottom: 0 !important;
}
/* FLEX-WRAP, NOT A GRID WITH A THRESHOLD, and that is the fix for issue #7's "съезжает вёрстка".
*
* The card was `display: grid; grid-template-columns: 1fr auto` — speed left, traffic right —
* and "do those two still fit side by side?" was answered by a @container threshold (158px).
* That threshold is a PROXY for a question about the CONTENT, and a proxy calibrated on English:
* `no link` is ~45px, `нет соединения` is ~100px, and the traffic figures are `nowrap` by design
* (`▲ 151.2 MiB` ~85px, against ~35px when the counters are zero). On a Russian router a DOWN
* port that had carried traffic needed ~193px inside a card whose content box is 178 — and a
* grid does not wrap: the figures overflowed the card and ran UNDER THE NEXT ONE. With the
* counters at zero the same card fitted, which is exactly what the reporter described.
*
* Flexbox asks the real question for free: the two cells sit on one row while they fit and the
* traffic drops to its own row when they do not. No threshold to calibrate, no locale to get
* wrong, and no JS — an observer would have to re-measure on every poll (29_ports.js rebuilds
* these tiles every 5s) to compute what the layout algorithm already knows.
*
* `white-space: nowrap` on the speed cell (below) is what makes the TRAFFIC give way rather than
* the speed: a flex item shrinks to its min-content, and for wrapping text that is its longest
* word — the speed would silently break onto two lines instead. */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) {
margin: 0 !important;
min-width: 0 !important;
max-width: none !important;
/* …and `width` too, which stock never writes but a FORK does: on ImmortalWrt 25.12 the tile
* carries `style="margin:.25em;width:100px"` instead of stock's `min-width:70px;max-width:100px`.
* Releasing only the two stock properties left the fork's fixed width in charge, so the grid
* handed each tile a 200px track and the tile drew itself 100px wide inside it — reported
* against 0.10.1 with a console dump showing `maxW:"none"` (ours won) next to `width:"100px"`
* (theirs did), on a Redmi AX6000. Naming all three costs nothing on stock, where `width` is
* `auto` already. */
width: auto !important;
/* …and THAT is only half of it, which 0.10.2 got wrong and made worse. The same fork writes
* `align-items:center;justify-items:center` on the grid CONTAINER, so a tile's `justify-self`
* resolves to `center` — and a centred item with `width: auto` is sized `fit-content`, not
* stretched to its track. This card is `container-type: inline-size`, i.e. size-CONTAINED, so
* its max-content contribution is ZERO: releasing the fork's `width:100px` handed the tile a
* fit-content width of nothing but padding, and every row (name, speed, traffic) spilled out
* to the right of a ~17px pill (reported against 0.10.2, ImmortalWrt 25.12 r37854).
* Stating the self-alignment here needs NO flag — the fork's declarations are `*-items` on the
* PARENT, which only supplies the value when the item says `auto`; a direct `*-self` wins on
* the cascade proper. On stock, where both are already `stretch`, this is a no-op. */
justify-self: stretch;
align-self: stretch;
text-align: start;
background: var(--fs-panel2);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
padding: var(--fs-space-2) var(--fs-space-3);
container-type: inline-size; container-name: fs-port;
display: flex;
/* `flex-direction: row` is NOT redundant: base/95-luci.css paints every .ifacebox
* `display: inline-flex; flex-direction: column`, and the grid this rule used to declare
* made that direction moot. Switching to flex woke it up — the name, the speed and the
* figures stacked into a column, `flex-basis: 100%` became a HEIGHT, and flex-grow grew the
* wrong axis. Cost an hour; state the axis. */
flex-direction: row;
flex-wrap: wrap;
column-gap: var(--fs-space-2); row-gap: var(--fs-space-1);
align-items: baseline;
}
/* drop the stock icon */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) img { display: none; }
/* base/95-luci.css draws a border-bottom on EVERY .ifacebox-head; the port card carries
* two heads (the name, child 1, and the zone-colour line, child 3), so that is two grey
* dividers stacked around the colour line. Drop them — the colour line is the only
* divider this card wants. Page layer beats base, no !important needed. */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) .ifacebox-head { border-bottom: 0; border-radius: 0; }
/* zone-colour bar (child 3): WAN red, LAN green — the stock Port-status indicator
* that distinguishes the Internet port from the LAN ports (issue #13). Its inner
* .zonebadge segments already carry firewall.getZoneColorStyle() inline (a 3px-tall
* strip, opacity 0.25 when the port has no carrier) and a `.cbi-tooltip` naming the
* zone/networks on hover, so we do NOT restyle it — we only PLACE it: a full-width
* colour line on its own row BETWEEN the name (child 1) and the speed/traffic bodies,
* where it reads as the card's divider. `order` puts it there — name is order 0, this
* order 1, the two bodies order 2 (below); without the order the bodies fall in source
* order (name, speed, bar, traffic) and the bar splits speed from traffic. The inline
* `display:flex` already shows it — this used to be hidden with `display:none
* !important`, now removed. No `overflow`/`border-radius`: both clip the
* absolutely-positioned tooltip. */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:nth-child(3) {
order: 1;
flex: 0 0 100%;
width: 100%;
margin: var(--fs-space-1) 0 var(--fs-space-1-5);
}
/* name row + its ::after status dot */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
/* Its OWN row — and `width`, NOT `flex-basis: 100%`, which is the obvious way to write this
* and does not work here: the card carries `container-type: inline-size`, so its main size is
* not a definite length to resolve a percentage flex-basis against, and the browser falls
* back to content sizing. Measured on the router: `flex: 0 0 100%` left the name 46px wide,
* sitting beside the speed; `width: 100%` gives it the full 176. */
flex: 0 0 auto;
width: 100%;
display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-1-5);
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-text);
}
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child::after {
content: "";
width: 8px; height: 8px;
border-radius: 50%;
background: var(--fs-good); /* linked (default) */
flex: 0 0 auto;
}
/* no link (*_down.svg): hollow ring, dim name */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-head:first-child { color: var(--fs-dim); }
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-head:first-child::after {
background: transparent;
border: 1.5px solid var(--fs-dim);
}
/* speed: green when linked, dim when not */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) {
order: 2; /* below the zone-colour line (child 3, order 1) */
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-good); text-align: start;
/* Two halves of one mechanism.
* `nowrap` (and NO `min-width: 0`) = "I do not give way": a flex item's automatic minimum
* size is its min-content, which for nowrap text is the whole string — so the speed cannot be
* squeezed and the TRAFFIC is what wraps. With min-width: 0 it would shrink instead and the
* figures would overflow exactly as before.
* `flex-grow: 1` = "I take the slack", which is what pushes the traffic to the right edge
* while the two share a row. `margin-left: auto` on the traffic was the first attempt and is
* wrong: Chrome counts it when breaking lines, so the figures took a row of their own even on
* a card they fitted (measured). */
flex: 1 1 auto; white-space: nowrap;
}
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) br { display: none; }
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-body:nth-child(2) { color: var(--fs-dim); }
/* traffic: tx over rx, as stock's <br> leaves them — CSS cannot reorder br-separated
* text runs. The 11px flag fights the inline `font-size:80%`. */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) {
order: 2; /* shares the speed's row, below the zone line */
text-align: end;
/* Grows but never shrinks. Growing is what anchors the figures to the card's right edge on
* EITHER row — sharing the speed's, or one of its own — since `text-align: right` then has a
* box that reaches that edge. `margin-left: auto` would have done the same job on a shared
* row and was the first attempt: wrong, because Chrome counts an auto margin when it breaks
* lines, so the figures took a row of their own even on a card they fitted (measured).
* `flex-shrink: 0` keeps the nowrap figures from being squeezed into an overflow. */
flex: 1 0 auto;
}
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container {
font-family: var(--fs-font-mono);
font-size: var(--fs-type-xs) !important;
color: var(--fs-dim);
line-height: var(--fs-leading);
white-space: nowrap;
/* Fights the element's own inline `text-align:left` (29_ports.js) so the ▲/▼ lines align on
* their right edge and the units form a column instead of a ragged edge.
*
* It used to be scoped to `@container fs-port not (max-width: 158px)`, with a SECOND
* @container below it stacking the two cells on a narrow card. Both were the pixel threshold
* the flex wrap above replaces: the cell now reaches the card's right edge on whichever row
* it lands, so the alignment is one answer and there is no width at which to switch.
* Deleting that threshold IS the fix — it is what had been calibrated on an English string. */
text-align: end !important;
}
/* Meter rows — any stock .table row carrying a .cbi-progressbar (Memory, Storage, CPU
* load, Network connections, …): label + value on one line, bar below. Keyed off the
* progressbar (:has), not per-section, so it covers every such row — including a
* third-party one. The bar itself is theme/25-progressbar.css. */
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)):has(.cbi-progressbar) { display: block; }
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--fs-space-1) var(--fs-space-2-5);
/* Halved from 8/10. A progressbar row is a LABEL over a 10px bar — the row is already
* two lines tall, so the outer padding was doing the work of a gap twice over and Memory
* and Storage read as mostly empty space. The bottom stays the larger of the two: the bar
* sits on that edge, and equal padding reads as the bar hanging low in its row. */
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
border: 0;
position: relative;
}
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td {
display: block;
/* beats base's `.td[width="33%"]` with no flag: the includes set a width
* ATTRIBUTE, not an inline style, so a later layer wins. */
width: auto;
padding: 0;
border: 0;
}
/* label. Undoes the key/value `white-space: nowrap` on a first column: a mount point
* ("192.168.3.26:/volume1/… (/mnt/hdd_isync)") is one unbreakable line, so the row's
* min-content width blew past the card and Memory/Storage stopped shrinking on a phone.
* padding-inline-end = the gutter for the value, which is pinned over the bar's far edge
* (::after, out of flow — nothing else reserves space for it). */
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
grid-column: 1; grid-row: 1;
align-self: center;
min-width: 0;
white-space: normal;
/* overflow-wrap dropped — base/40-tables.css breaks every cell; the copy that stood here
* moved nothing against 267 matching cells. */
padding-inline-end: min(200px, 50%);
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-text);
}
/* bar cell — spans full width on row 2 */
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:nth-child(2) {
grid-column: 1 / -1; grid-row: 2;
}
/* Drop the per-row dividers theme/30-tables.css adds (.cbi-section .table .td) —
* cleaner card. No !important: that rule is `theme`, this file is `page`, so the later
* layer already wins. */
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .table .td,
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .table .th,
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .cbi-value { border-bottom: 0; }
/* System key/value table draws its dividers on .tr (not .td) — remove those too */
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .table .tr { border-bottom: 0; }
}
@@ -0,0 +1,100 @@
@layer page {
/* Software disk-space bar -> the overview Memory/Storage meter: label + value on one
* line, bar below (the value is the .cbi-progressbar title, lifted out by ::after).
* Scoped by body[data-page], NOT by :has(#disk-space-label) — 24.10 ships no such id. */
body[data-page="admin-system-package-manager"] #view .controls > div:first-child { position: relative; display: block; flex: 1 1 100%; width: 100%; margin: 0 0 var(--fs-space-1-5); }
/* The package list's row SHAPE. #packages is a DATA table (`<table class="table"
* id="packages">`, no .cbi-section-table class; fs-select.js tags it .fs-dt off its
* .tr.cbi-section-table-titles header), so it takes the shared card from
* theme/30-tables.css when measured too narrow. This only changes how a row READS: a
* package is a name plus a few short facts, so label-left / value-right on one line
* beats the half-width label-above-value pairs a config row wants.
*
* No @container guard, and do not re-add one — the old 800px was a guess at "the row no
* longer fits". Overflow is the trigger now, and fs-select.js measures it (.fs-stacked). */
/* `:not(...)` on the header rows, and it is load-bearing: this selector carries an ID, so it
* is (1,1,0) in the PAGE layer — it beat theme/30-tables.css's
* `.table.fs-stacked .tr.cbi-section-table-titles { display: none }` and put the column
* header back on screen in card mode, where every cell already prints its own label. */
/* ---- the package NAME is an identifier: do not let the column collapse under it ----
*
* theme/30-tables.css gives every data cell `overflow-wrap: anywhere`, which — unlike
* `break-word` — also lowers the column's MIN-CONTENT width to a single character. Version and
* Size are `white-space: nowrap` and cannot shrink, so auto table layout took the whole
* shortfall out of column 1: measured with the filter on "app", the name column came out
* 81-101px — NARROWER than Size (88px) — and 6 of the first 8 names rendered broken mid-word
* (`apparmor-` / `profiles`), while a hyphen made it worse by offering a legal break point.
* It shows up when a filter is applied because that is when long names reach the top of the
* list; the squeeze itself is always there.
*
* nowrap here does NOT mean "never wrap": it makes the overflow HONEST, which is what
* fs-select.js's measurement needs — where the row genuinely does not fit, the table cards
* (.fs-stacked) and this rule stops applying, so the name wraps freely in card mode. That is
* the same trap idTower() works around in fs-select.js: `anywhere` hides the overflow by
* shredding a cell, so nothing is left for fit.overflows() to read. Verified 560-1440px: 0
* broken names, no table overflow, and the card threshold unchanged. */
#packages:not(.fs-stacked) .tr > .td:first-child { white-space: nowrap; }
#packages.fs-stacked .tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr) {
display: block; padding: var(--fs-space-3) var(--fs-space-4);
}
/* ---- the value is not ONE flex item, and space-between tore it apart ----
*
* A card row is label-left / value-right, which was `justify-content: space-between` over the
* ::before and the cell's content. But flex makes every ELEMENT its own item, and the filter
* on this page wraps each match in `<ins>`: `<ins>luc</ins>i-app-acl` is TWO items, so
* space-between pushed them to opposite edges and the package name read as `luc` … 85px …
* `i-app-acl` (measured at 390px, filter "luc": gaps of 110/85/77/69px mid-word). The `gap`
* added 14px between them on top.
*
* So do not distribute the free space BETWEEN the items — hand it all to the label: everything
* else then packs flush right in DOM order, however many elements the value happens to be. */
#packages.fs-stacked .td {
display: flex; align-items: baseline; justify-content: flex-end;
gap: 0; padding: var(--fs-space-1) 0; border: 0; text-align: end;
/* `min-width: 0` and `width: auto` are not restated: theme/30-tables.css sets both on
* `.table.fs-stacked .td`, which this table is one of, and switching them off here moved
* nothing across 500 matching cells. `text-align: end` above IS this file's own — it
* overrides that rule's `start`. */
flex: none;
}
/* An auto inline-end margin is what replaces space-between: it eats the whole free line, so the
* value packs flush against the far edge as one unbroken run. The inline-end padding keeps a gutter when the row is
* full and the auto margin collapses to nothing (the `gap` used to do that, but a gap applies
* between EVERY pair of items — including the two halves of a highlighted name). */
#packages.fs-stacked .td[data-title]::before {
content: attr(data-title); flex: 0 0 auto; text-align: start; margin-bottom: 0;
margin-inline-end: auto; padding-inline-end: var(--fs-space-3-5);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
text-transform: uppercase; color: var(--fs-eyebrow-color); white-space: nowrap;
}
/* Column 4 = Description. NOT `[data-title="Description"]`: LuCI builds this table's cells from
* the heading's innerText, and theme/30-tables.css uppercases `.th` — so the attribute really
* says "DESCRIPTION" and the old literal matched ZERO elements on every router, in every
* language (measured). The theme's own CSS was rewriting the string the theme's own CSS keyed
* off. Columns: 1 Package name | 2 Version | 3 Size | 4 Description | 5 actions. */
/* `overflow-wrap` dropped: base/40-tables.css breaks every cell now and this copy moved nothing
* against 100 matching cells. `word-break` stays — switching IT off moved all 100. */
#packages.fs-stacked .td:nth-child(4) { display: block; text-align: start; word-break: break-word; }
#packages.fs-stacked .td:nth-child(4)::before { display: block; margin-bottom: var(--fs-space-1); }
#packages.fs-stacked .td.cbi-section-actions { justify-content: flex-end; margin-top: var(--fs-space-1-5); }
#packages.fs-stacked .td.cbi-section-actions::before,
#packages.fs-stacked .td:not([data-title])::before { content: none; }
/* The "Disk space:" caption. Keyed on WHAT IT SITS BESIDE, not on `#disk-space-label` — that id
* is 25.12+ only (`E('label',{'id':'disk-space-label'}, …)`; 24.10's package-manager.js emits a
* bare `E('label',{}, …)`), so the id rule was dead on 24.10 and the caption kept base's
* 250px-wide label instead of this line. Exactly the trap the note at the top of this file and
* theme/90-responsive.css both warn about — and it was still here, in the rule the note is
* about. The disk section is the only `label` immediately followed by a `.cbi-progressbar`,
* which is the discriminator 90-responsive.css already uses for the same element.
*
* NOT `:has(:where(+ .cbi-progressbar))`: a leading combinator makes it a RELATIVE selector, and
* only `:has()` itself accepts one — `:where()` takes a plain complex-selector list, so wrapping
* it there makes the whole rule invalid and the browser drops it. stylelint parsed that happily
* and `css-metrics` measured it happily; what caught it was reading the computed `display` back
* off the router (still `inline`, on both releases). */
body[data-page="admin-system-package-manager"] #view label:has(+ .cbi-progressbar) {
display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim);
}
}
@@ -0,0 +1,24 @@
@layer page {
/* SSH-Keys renders its whole view as a single bare <div> in #view with no .cbi-section
* wrapper, so its text sat frameless over the wallpaper while every other page gets an
* opaque --fs-panel card. No section markup to hook, so give that lone wrapper the same
* card (mirrors theme/30-tables.css `.cbi-section`).
*
* Scoped to this page on purpose: a general "card any bare #view > div" was tried and
* reverted — it also wrapped whole tabbed views (Startup). Other bare-view pages get
* their own entry here. */
body[data-page="admin-system-admin-sshkeys"] #view > div {
background: var(--fs-panel);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow);
padding: var(--fs-space-4);
}
/* An authorized_keys entry is a ~400-char base64 blob: the one dynlist that must not
* take theme/60-inputs.css's 440px cap. Must live in `page` — in `base` it was dead,
* since a layer beats specificity and theme's plain `.cbi-dynlist` capped it anyway. */
body[data-page="admin-system-admin-sshkeys"] .cbi-dynlist {
max-width: none;
}
}
@@ -0,0 +1,79 @@
@layer page {
/* DHCP leases. The .leases/.leases6 tables (JS-tagged .fs-dt) carry 7–8 mono columns,
* and the theme's general rule lets any data cell break to fit — which splits an IPv4 or
* a MAC over two lines. So: every cell on ONE line, and only the long opaque columns
* (DUID, the IPv6 list, the hostname) may wrap. That is enough to reflow the table while
* IPv4/MAC/IAID stay intact.
*
* Do NOT re-add a card-stack copy here (this file held one, at its own 780px container
* threshold, because the nowrap gives this table a ~736px floor no other data table
* has). The nowrap IS that floor, and a floor is what makes a table OVERFLOW when the
* room runs out — fs-select.js measures the overflow and sets .fs-stacked, so the ~780
* is DISCOVERED and the card lives once, in theme/30-tables.css. `:not(.fs-stacked)` is
* the whole guard: once carded, the cells must wrap again.
*
* `.lases` IS NOT A TYPO HERE — it is upstream's, and dropping it is what made this whole file
* dead on 24.10. `40_dhcp.js` tags the IPv4 table `class="table lases"` there and
* `class="table leases"` on 25.12+ (grepped on both containers). Measured on 24.10 with the
* theme active: every one of the five cells computed `white-space: normal`, so the IPv4 and the
* MAC wrapped across two lines — issue #7's exact symptom, on the release nobody re-checked. */
#view table.fs-dt:is(.leases, .lases, .leases6):not(.fs-stacked) .td { white-space: nowrap; }
/* ---- NEVER MATCH A `data-title` VALUE: it is a TRANSLATED, RENDER-DEPENDENT string ----
*
* LuCI fills `data-title` from the column HEADING, so on a localised router the value is the
* translation ("MAC-адрес"), and an English literal in a selector simply never matches — the rule
* is dead in ~40 languages while looking perfectly alive here. That is exactly how the reported
* bug survived (#7): the MAC nowrap below worked on an English router and the reporter, on a
* Russian one, still had his MAC split across two lines.
*
* Worse, for tables whose rows LuCI builds from the heading's `innerText`, the value is what the
* heading RENDERS — and `theme/30-tables.css` uppercases `.th`. So the theme's own CSS rewrites
* the string the theme's own CSS matches on: `#packages` really carries `data-title="DESCRIPTION"`,
* and the `[data-title="Description"]` rule that used to be here matched NOTHING, on every router,
* in every language (measured: 0 elements).
*
* Anchor on the COLUMN instead. A translation cannot reorder columns, and `tools/css-i18n.mjs`
* fails the build on any `[data-title="…"]` value match so this cannot come back. */
/* The long opaque columns, which may wrap. overflow-wrap:anywhere (not word-break) takes the
* natural breaks first — the hyphens in "Yandex-Station-Midi-61QK" — and only splits mid-token
* when nothing else fits; short names stay on one line.
*
* ---- COUNT FROM THE END, NEVER FROM THE START ----
*
* The LEADING column is optional on 25.12+: `40_dhcp.js` opens both tables with
* `L.hasSystemFeature('odhcpd','dhcpv4') ? E('th', …, _('Interface')) : E([])`, so a router that
* serves DHCP through odhcpd has one column more than one that does not, and every
* `:nth-child()` here shifts by one. Reproduced on the router by inserting that column into the
* live table: the MAC cell went from `nowrap` to `normal` — issue #7 straight back, on exactly
* the routers that run odhcpd, and invisible on the ones that do not. The TRAILING columns are
* identical either way, so `:nth-last-child()` cannot shift.
*
* 25.12+ .leases [Interface] Hostname IPv4 MAC DUID IAID Remaining Static
* -7 -6 -5 -4 -3 -2 -1
* .leases6 [Interface] Hostname IPv6 DUID IAID Remaining Static
* -6 -5 -4 -3 -2 -1
* 24.10 .lases Hostname IPv4 MAC Remaining Static (no DUID/IAID column at all)
* -5 -4 -3 -2 -1
* .leases6 Host IPv6 DUID Remaining Static
* -5 -4 -3 -2 -1
*
* so: the hostname, the IPv6 list and DUID may wrap; IPv4/MAC/IAID and the two short trailing
* columns keep the nowrap above. `.leases` vs `.lases` separates the v4 tables by release on its
* own. The v6 table carries the SAME class on both, so it is split on column COUNT — 24.10 has
* exactly five, 25.12+ has six or seven. That count test is wrapped in `:where()` so it costs
* NOTHING: `:has()` otherwise takes its argument's specificity, and `.tr.table-titles >
* :nth-child(6)` alone pushed the selector to [1,8,1], past the sheet's [1,7,0] ceiling. */
#view table.fs-dt.leases:not(.fs-stacked) .td:is(:nth-last-child(7), :nth-last-child(4)),
#view table.fs-dt.leases6:has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
.td:is(:nth-last-child(6), :nth-last-child(5), :nth-last-child(4)),
#view table.fs-dt.lases:not(.fs-stacked) .td:nth-last-child(5),
#view table.fs-dt.leases6:not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
.td:is(:nth-last-child(5), :nth-last-child(4), :nth-last-child(3)) {
/* `white-space` is the whole rule now: it is what re-permits wrapping for these columns
* against the nowrap above — and the nowrap is also what keeps IPv4/MAC unbroken, so the
* column plan does not rest on overflow-wrap at all. The break itself is base's default for
* every cell (base/40-tables.css); the copy that stood here moved nothing against 164 cells. */
white-space: normal;
}
}
@@ -0,0 +1,27 @@
@layer page {
/* Associated stations (Overview and Network → Wireless render the same `.assoclist` table).
*
* theme/30-tables.css lets ANY data cell break to fit (`overflow-wrap: anywhere`) — which is
* what reflows a wide table into the content column instead of scrolling it. On a MAC address
* that is wrong: `48:E1:5C:9F:26:20` split over two lines at whatever character happened to
* land on the edge (issue #5), and the column is only ~103px, so it split every time. Stock
* LuCI keeps it on one line.
*
* Exactly the shape pages/50-leases.css already uses, and for the same reason — see the note
* there about why the nowrap is also the table's floor, and why `:not(.fs-stacked)` is the
* whole guard: once fs-select.js has measured that the table no longer fits and carded it, the
* cells must be free to wrap again.
*
* Column 2, NOT `[data-title="MAC address"]` — that literal is a translated string and the rule
* was dead on every localised router, which is precisely how the bug was still being reported
* (#7) after it had been "fixed". See the note in pages/50-leases.css.
* Columns: 1 Network | 2 MAC address | 3 Host | 4 Signal/Noise | 5 RX/TX Rate | 6 Disconnect.
*
* ONLY the MAC. Signal/Noise (4) and RX/TX Rate (5) were nowrapped too (v0.9.1, #7) to stop
* `anywhere` splitting "-54/-90 dBm" mid-character — and that was worse: nowrap makes the
* modulation string ("229 Mbit/s, 20 MHz, HE-MCS 9, HE-NSS 2") an unbreakable ~300px block, so
* it ate the row and crushed the Network column to a few characters (reported on #7 against
* 0.9.1). It also raised the table's floor, carding the table on desktops that had room. A wide
* cell that CAN break is the lesser evil: the columns keep their share. */
#view table.fs-dt.assoclist:not(.fs-stacked) .td:nth-child(2) { white-space: nowrap; }
}
@@ -0,0 +1,22 @@
@layer page {
/* System Log / Kernel Log (luci-mod-status): the whole log is a readonly
* <textarea id="syslog"> in a bare div, NOT in a .cbi-value-field. Stock bootstrap pairs
* its generic `input, textarea { width: 210px }` with `#syslog { width: 100% }` and wins
* on SPECIFICITY — but this theme's generic field box lives in the `theme` LAYER, and a
* layer beats specificity, so the same pair in `base` lost and the log rendered as a
* 210px column (measured on 25.12). The rule is absorbed HERE, above the generic's layer;
* it is bootstrap's own coverage relocated, not a page hack. Mono at the data tables'
* size, `pre` because log lines are lines, and a vertical-only resize grip — a sideways
* grip would just re-create the squeeze. */
#syslog {
width: 100%;
min-height: 500px;
color: var(--fs-text);
margin-bottom: var(--fs-space-4);
font-family: var(--fs-font-mono);
font-size: var(--fs-type);
line-height: var(--fs-leading);
white-space: pre;
resize: vertical;
}
}
@@ -0,0 +1,202 @@
@layer page {
/* ==================================================================
* The Appearance controls, appended to System -> System by fs-appearance.js.
*
* This was a popover on <body>, placed against a chrome button by widgets.placePopover and
* capped to the viewport because eighteen axes plus an upload panel do not fit a floating card
* on a phone in landscape. As part of a page none of that applies: the sections are ordinary
* .cbi-section cards, the width is the content column's, and the scroll is the document's. The
* placement, the max-height clamp, the fade-in and the [hidden] pair went with the popover.
*
* The CONTROLS are still the theme's own primitives (fs-widgets.js), not LuCI form widgets:
* .fs-seg is a radiogroup with a roving tabindex, .fs-range is the Rounding slider. They live in
* the page layer rather than the theme layer because this is the only page that draws them — an
* axis control is not chrome any more.
* ================================================================== */
.fs-ap { display: flex; flex-direction: column; gap: var(--fs-space-5); }
/* Grouping headings INSIDE the tab pane, not cards: the pane is already inside the stock
* .cbi-section card, and a second border around each group is a box in a box. A hairline above
* each group does the same job — except for the first, which sits under the tab strip. */
/* the heading row. On a plain group it holds the title; on a FOLDED one the whole row is the
* disclosure button (fs-appearance.js) — full width, so the target is the heading rather than a
* chevron nobody can hit on a phone. */
/* the heading row spans the section on its own now that the rows stack */
button.fs-ap-fold {
display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-3);
inline-size: 100%; padding: 0; margin: 0;
background: none; border: 0; cursor: pointer; text-align: start; color: inherit;
}
button.fs-ap-fold:hover h4 { color: var(--fs-accent); }
button.fs-ap-fold:focus-visible { outline: none; box-shadow: var(--fs-focus-ring-solo); border-radius: var(--fs-radius-sm); }
/* The chevron: the same 26px box with a two-border ::after that the overview's card toggles
* use (styles/pages/20-overview.css) — one chevron for "this panel opens", drawn once here and
* once there rather than an SVG in one place and borders in the other.
*
* It turns on the SAME attribute that tells a screen reader the state, so the two cannot
* disagree. Rotated with `transform` and not the individual `rotate` property, because that is
* what the overview's copy does and the pair is meant to look identical mid-animation too. */
.fs-ap-chev {
flex: 0 0 auto; inline-size: 26px; block-size: 26px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--fs-dim); border-radius: var(--fs-radius-sm);
}
.fs-ap-chev::after {
content: "";
width: 7px; height: 7px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
border-radius: 1px;
transform: rotate(45deg); /* closed: chevron down */
margin-top: -2px; /* optical centring of the down chevron */
transition: transform var(--fs-dur-move);
}
button.fs-ap-fold[aria-expanded="true"] .fs-ap-chev::after {
transform: rotate(-135deg); /* open: chevron up */
margin-top: var(--fs-space-0-5);
}
button.fs-ap-fold:hover .fs-ap-chev { color: var(--fs-accent); }
/* Stock `.cbi-value` rows stack one per line and carry their own hairline, so a folded group needs
* no layout of its own — it lays its rows out exactly like an unfolded section by doing nothing. */
/* an explicit `display` DEFEATS the `hidden` attribute (the UA rule is a bare 0,0,1), and hidden
* is what keeps a closed group out of the tab order and the a11y tree */
.fs-ap-body[hidden] { display: none; }
/* the second heading inside the Colours fold — Surfaces shares the fold, not the heading. It
* wears the SAME .fs-ap-head h4 as every other section title now; a third heading style on one
* page (h4, folded h4, uppercase eyebrow h5) was a third of the raggedness. Only the space
* above it is its own. */
.fs-ap-sub { margin-top: var(--fs-space-4); }
.fs-ap-head h4 {
margin: 0;
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); color: var(--fs-text);
}
.fs-ap-section + .fs-ap-section { padding-top: var(--fs-space-4); border-top: 1px solid var(--fs-border); }
/* THE ROWS ARE STOCK `.cbi-value` NOW and this page states no layout for them at all — caption
* column, field column and the hairline between rows come from base/30-forms.css and
* theme/60-inputs.css, the same rules every other LuCI form is drawn by. What was here before was
* a responsive two-column grid of stacked cards with an uppercase eyebrow above each control: it
* fitted a floating popover, and beside General Settings / Language and Style it made the theme's
* own tab the one page in LuCI that did not look like LuCI. Deleting the grid is the change. */
/* The accordion switch is meaningless in the top layout — its sections are hover dropdowns,
* already exclusive. Hidden HERE, not by a branch in the JS: the page is built once, so a branch
* froze the control to whatever layout it loaded in and the live toggle could never change it. */
:root[data-layout="top"] .fs-ap-submenus { display: none; }
/* Save-as-default / Reset row: two equal buttons, no status text — the Save button's own
* label/disabled state is the status. They take their look from .btn / .cbi-button-action
* (base + theme/55-buttons.css); a disabled .cbi-button-action already greys via base. */
/* Buttons take the width of their own label, which is what every button on every other LuCI page
* does — they were `flex: 1 1 9em` and stretched to fill the field, so three of them made one
* long bar and "Choose SVG"/"Remove" each ran half the card. `wrap` keeps a narrow card honest:
* an item that cannot fit moves to the next line instead of hanging past the edge, which on a
* flex row is silent. */
.fs-ap-actrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
.fs-ap-actrow .btn { font-size: var(--fs-type); padding: var(--fs-space-2) var(--fs-space-2-5); cursor: pointer; }
.fs-ap-actrow .btn:disabled { cursor: default; }
/* the primed Reset (second click confirms) reads as the destructive action it is */
.fs-ap-armed { color: var(--fs-on-danger); background: var(--fs-danger); border-color: transparent; }
/* Save's only visible failure line (a rejected save rpc — usually a stale session) — shown in
* place of the absent status text, hidden until a save actually fails. */
.fs-ap-err { margin-top: var(--fs-space-2); font-size: var(--fs-type-xs); line-height: 1.35; color: var(--fs-danger); }
/* ---- the colour axes ----
* One row is a swatch, a hex field and the button back to the palette's own colour. */
/* the same ceiling every other stretching control in a form row takes (02-tokens.css), so the
* swatch + hex + Palette button end where the selects and the sliders end */
.fs-colorctl { display: flex; flex-direction: column; gap: var(--fs-space-1-5); max-width: var(--fs-field-max); }
.fs-color-row { display: flex; align-items: center; gap: var(--fs-space-2); }
/* The native colour swatch. type="color" is drawn by the browser and cannot be restyled beyond
* its box, so the box is what is styled: the inner well is stripped to the frame's edge on both
* engines (WebKit nests two elements, Firefox one) or it paints a white margin inside our
* border. Fixed 34px square so it lines up with the field beside it at every density. */
input[type="color"].fs-color-swatch {
flex: 0 0 auto; inline-size: 34px; block-size: 34px; padding: 0; cursor: pointer;
background: none; border: 1px solid var(--fs-border); border-radius: var(--fs-radius-sm);
}
input[type="color"].fs-color-swatch::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"].fs-color-swatch::-webkit-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
input[type="color"].fs-color-swatch::-moz-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
/* the hex field is a VALUE, so it is mono and tabular like every other value in the theme —
* and #0969da is exactly the kind of string a proportional face makes hard to read back */
input[type="text"].fs-color-hex {
flex: 1 1 auto; min-inline-size: 0;
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-variant-numeric: tabular-nums;
text-transform: lowercase;
}
.fs-color-clear { flex: 0 0 auto; font-size: var(--fs-type-xs); padding: var(--fs-space-1-5) var(--fs-space-2); cursor: pointer; }
.fs-color-clear:disabled { cursor: default; }
/* What the chosen colour COSTS, measured live against the surface it is read on (fs-widgets'
* contrastRatio). It is a readout, not a validation: the theme derives readable ink over a fill
* but never silently corrects a colour the admin asked for, so this says the number and grades
* it. Three grades, three colours — and the failing one is --fs-danger, which the admin may have
* just recoloured, which is exactly the honest outcome. */
/* It is LuCI's own help-text line now — `.cbi-value-description`, styled in base/95-luci.css,
* which is where a sentence under a field belongs on every other page. What stays here is the
* only thing stock has no opinion about: the GRADE's colour, and the tabular figures that keep
* the ratio from jittering as it updates. */
.fs-color-contrast { font-variant-numeric: tabular-nums; }
.fs-contrast-aa { color: var(--fs-good); }
.fs-contrast-aa-large { color: var(--fs-warn); }
.fs-contrast-low { color: var(--fs-danger); }
/* THE PRESET CHIPS' rules were here — a wrapping row of pills, each painted in the accent it
* would set, with ink derived from that colour's own lightness. The control is gone from
* fs-appearance.js: it was the one thing on the page that looked like nothing else in LuCI, it
* started at the card's edge instead of at the field column, and everything it did the Accent
* row does with any colour rather than eight. */
/* The uploaded image's PREVIEW and the two buttons under it. Both live in an ordinary
* `.cbi-value-field` now — the sub-panel that used to wrap them is gone, along with the second
* caption column it nested inside the first.
*
* The preview is a thumbnail, not a banner: capped to the same field ceiling as every other
* stretching control and to a fixed height, so the row keeps one shape whatever the image's
* aspect is. It ran the full width of the card before, which made an SVG tile look like a
* letterboxed photograph. */
.fs-ap-bgprev {
display: block; width: 100%; max-width: var(--fs-field-max); height: 84px; object-fit: cover;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
/* the preview follows the buttons in the field, so the gap belongs ABOVE it */
margin-top: var(--fs-space-2);
}
.fs-ap-bgrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
.fs-ap-bgrow .btn { font-size: var(--fs-type); padding: var(--fs-space-2) var(--fs-space-2-5); cursor: pointer; }
.fs-ap-bgrow .btn:disabled { cursor: default; }
/* an explicit `display` DEFEATS the `hidden` attribute (the UA's `[hidden]{display:none}` is a
* bare 0,0,1 rule these out-rank), so the JS's row.hidden / preview.hidden / removeBtn.hidden
* would not hide a row that does not apply to the chosen wallpaper, the empty preview, or a
* not-yet-uploaded Remove. Restate hidden at a specificity that wins — `.cbi-value` is the one
* that matters most here, since base gives it `display: flex`. */
.cbi-value[hidden], .fs-ap-bgprev[hidden], .fs-ap-bgrow .btn[hidden] { display: none; }
/* Under the File wallpaper only the photo Dim is relevant — the router-identity Tint colours the
* canvas the photo covers, so hide the Tint hue AND its strength there (fs-ap-tint marks both). */
:root[data-wallpaper="file"] .fs-ap-tint { display: none; }
/* and the strength is a multiplier on a chroma WE choose, so it only means anything in hue mode:
* with the tint off there is nothing to scale, and on a hex canvas the colour is the one the
* admin entered, with no chroma of ours in it. (0,3,0) out-ranks the stock row's own display. */
:root:not([data-tint="hue"]) .fs-ap-density { display: none; }
/* the version line at the foot of the page */
.fs-ap-footer {
display: flex; flex-direction: column; gap: var(--fs-space-2);
margin-top: var(--fs-space-0-5); padding-top: var(--fs-space-2-5); border-top: 1px solid var(--fs-border);
}
.fs-ap-verrow { display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-2-5); }
/* links to the project repo — reads as muted text until hovered */
.fs-ap-version { font-size: var(--fs-type-xs); color: var(--fs-dim); font-variant-numeric: tabular-nums; text-decoration: none; }
.fs-ap-version:hover { color: var(--fs-accent); text-decoration: underline; }
/* THE SEGMENTED CONTROL AND THE RANGE SLIDER had their rules here — .fs-seg, .fs-rangewrap,
* .fs-range and its readout. Both controls are gone from fs-widgets.js: the page draws its enums
* with `ui.Select` and its numbers with `ui.RangeSlider`, which this theme already styles for
* every other LuCI form (`select` in base/30-forms.css, `.cbi-range-slider` and
* `input[type="range"]` in theme/60-inputs.css). Two copies of a slider, one of them ours, was
* the thing to delete — not a third. */
/* THE HUE SLIDER'S rules were here — a rainbow track under a thumb carrying the axis's live
* colour. The slider is gone from the control (fs-widgets' colorControl): two controls for one
* value read as two settings, and the half that could not reach a grey was the slider. The axis
* still STORES a hue and 03-palettes.css still rotates by one, so a value saved before this goes
* on painting — but nothing draws a wheel any more, and CSS for a control nobody renders is dead
* CSS in our own namespace, which `npm run css-orphans` is there to keep out. */
}
@@ -0,0 +1,206 @@
@layer theme {
/* ---- the menu's floor: pin what a foreign rule can reach by INHERITANCE ----
*
* fs-sheets.js fences the chrome out of a third-party selector, so nothing foreign MATCHES a
* menu element any more. Inheritance is the way in that a fence cannot close: a rule on `html`
* or `body` is an ANCESTOR, and excluding it is meaningless — the value arrives from above.
* Measured on the router against a hostile rule on `html` with every declaration flagged:
* font-style reached 166 of the menu's 169 elements, word-spacing and text-align 157,
* letter-spacing and text-transform 156, cursor 46. font-family, color, font-size, line-height
* and font-weight reached NONE — because the chrome already states those itself. That is the
* whole fix: state the rest too.
*
* Inheritance is not a cascade competitor. It only supplies a value when NO declaration matches
* the element, so any declaration of our own — normal, unflagged, at zero specificity — beats an
* inherited `!important`. Hence no `!important` here, and none is needed.
*
* It keys off `[data-fs-chrome]` — the mark a chrome root carries in the markup (header.ut) — and
* not off `.fs-sidebar`, because the chrome is not one element: the skip link sits beside
* .fs-shell and the Appearance popover hangs off <body>, so a pin naming the menu left both of
* them inheriting straight from `html`. The fence (fs-sheets.js) reads the same mark, so the two
* halves of Zone 1's defence cannot cover different sets of elements. `npm run chrome-fence`
* derives the mark from the markup and holds both to it.
*
* It goes on the ROOT ALONE — and says so IN THE SELECTOR (`:not([data-fs-chrome] *)`: a marked
* element that is not inside a marked element), rather than trusting whoever adds the next marker
* to have read this comment. A nested marker is otherwise a direct declaration on a descendant,
* which is exactly the 302-element bug below, and no gate can see it: the markers live in a
* template with conditional blocks, so "is this one inside that one" is not a question a text
* scanner can answer. CSS can answer it, so let CSS answer it. That is the whole design.
* Inheritance
* flows from the nearest ancestor that has a value: pinning the root breaks the chain from `html`
* once, and every descendant then inherits OUR value — while the chrome's own inheritance keeps
* working. Pinning descendants too was measured and it broke exactly that: a direct declaration
* beats an inherited one even when the inherited one is ours, so `.fs-label` lost the `nowrap` it
* inherits (labels would wrap), `.fs-railtoggle` lost its centring, and `text-align` was forced
* from `start` to `left` on 302 elements — `start` follows the writing direction and `left` does
* not, so that alone would have broken every RTL language LuCI ships. Hence `text-align: start`,
* not `left`. Nothing below a root needs a pin: a foreign rule can no longer MATCH a chrome
* element (fs-sheets.js fences it), so `html`/`body` is the only way in and the root closes it.
*
* `:where()` keeps it at 0,0,0 so every existing chrome rule outranks it — the pin is a floor,
* never a ceiling. Only inherited properties belong here; a non-inherited one would be a plain
* style decision wearing a guard's coat. */
:where([data-fs-chrome]:not([data-fs-chrome] *)) {
font-style: normal;
font-variant: normal;
letter-spacing: normal;
word-spacing: normal;
text-transform: none;
text-indent: 0;
text-align: start;
cursor: auto;
}
/* Shared chrome — brand, logo, wordmark, logout, indicators. Same in both layouts;
* only their PLACEMENT differs. Written twice once (here and under `.fs-topnav`) and
* the copies had drifted: 32 vs 30px logo, 11 vs 10px brand gap, 10 vs 8px indicator
* gap. The top-bar values won. A layout file may set placement on these (flex, order,
* the rail collapse) — never their look. */
.fs-brand {
display: flex; align-items: center; gap: var(--fs-space-2-5);
color: var(--fs-text); font-weight: var(--fs-weight-bold);
}
.fs-logo {
width: var(--fs-btn-size); height: var(--fs-btn-size); border-radius: var(--fs-radius);
/* No accent fill — a bordered box, same look as the square buttons in the right
* cluster (.fs-logout, .fs-themerow: 1px var(--fs-border)). The glyph is the OpenWrt
* favicon mark (brand.ut), baked-colour, so color/currentColor is unused here. */
border: 1px solid var(--fs-border);
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto;
}
.fs-logo svg { width: 20px; height: 20px; }
/* The OpenWrt ring flips with the theme mode like the SVG favicon does under
* prefers-color-scheme — dark ring on the light UI, light ring on dark. The cyan arcs
* stay baked (read on both). */
.fs-logo-ring { fill: #002b49; }
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
/* 700, not 800: the theme ships no faces, so 800 is whatever the reader's Manrope or system
* stack has — 700 for most of them, synthesised for the rest. It already resolved to 700 back
* when the 18 KB face was bundled and dropped for drawing six elements. Say so. */
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
/* Skip link — the first tab stop: a keyboard/SR user jumps the whole menu instead of
* tabbing through every section on every page. Off-screen until focused, never
* display:none / visibility:hidden — that removes it from the tab order, which is the
* one thing it exists for. Sits above everything, incl. an open popover. */
.fs-skip {
position: fixed; top: 8px; inset-inline-start: 8px; z-index: var(--fs-z-dropdown);
padding: var(--fs-space-2-5) var(--fs-space-4); border-radius: var(--fs-radius);
background: var(--fs-accent); color: var(--fs-on-accent);
font-weight: var(--fs-weight-bold); font-size: var(--fs-type); text-decoration: none;
box-shadow: var(--fs-shadow-pop);
}
/* Clipped, not translated off-screen: --fs-shadow-pop is offset 12px / blur 34px, so a
* box parked at -68px still bled ~16px of DROP SHADOW into the viewport and smudged the
* top-left corner of every page. clip-path clips the shadow too. Never display:none /
* visibility:hidden — see above. */
.fs-skip:not(:focus) {
clip-path: inset(50%);
width: 1px; height: 1px;
padding: 0; overflow: hidden; white-space: nowrap;
}
.fs-skip:focus {
outline: 2px solid var(--fs-panel); outline-offset: 2px;
}
/* Visually hidden but PRESENT in the a11y tree — for the document <h1> and the live
* region. hidden/display:none/visibility:hidden take the element out of the a11y tree
* too: the h1 would fix no heading outline and the live region would never announce.
* Clipping leaves a node readable to assistive tech while occupying no space.
* nowrap: a long title clipped into a 1px column is read out line by line by some SRs. */
.fs-sr {
position: absolute;
clip-path: inset(50%);
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden; white-space: nowrap;
}
/* the skip link's target <main> carries tabindex="-1" so the browser actually MOVES
* focus there (Safari/Chrome refuse on a non-focusable target: they scroll but leave
* focus in the menu) — but it must not then ring the whole page. */
#maincontent:focus { outline: none; }
.fs-logout { display: flex; align-items: center; color: var(--fs-dim); }
.fs-logout:hover { color: var(--fs-danger); }
.fs-indicators { display: flex; align-items: center; gap: var(--fs-space-2); }
/* The 34px square icon button (.fs-themerow / .fs-logout collapsed) is NOT declared
* here: it only exists where the chrome is a bar or a rail, and those blocks
* (20-shell.css, 50-toplayout.css) already give it its border, surface and size — an
* unconditional copy here was a second definition they had to re-state anyway. */
/* Density steps for the main menu once it is a horizontal row —
* fs-chrome.js (fitTabStrips) adds .fs-dense1/2 when the row would wrap (and strips them
* in the vertical sidebar, so no media guard is needed). Padding is trimmed hard
* first, the font only at the d2 floor. */
.fs-sidebar ul.nav.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
.fs-sidebar ul.nav.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-1-5); font-size: var(--fs-type); }
/* Menu primitives, once for both layouts (both render the menu as `ul.nav > li > a`):
* the 19px line-height, `display: block` on the items and the `position: relative`
* every submenu popup anchors to — all three used to be inherited from a navbar rule
* set the theme no longer carries. */
ul.nav { position: relative; margin: 0; list-style: none; }
ul.nav > li { display: block; }
ul.nav a { line-height: 19px; text-decoration: none; }
/* `.dropdown-menu` is emitted by NOBODY here: not by this theme (its menu is a plain nested
* `ul`), and not by luci-base — grepped, zero hits outside stylesheets. It stays styled purely
* under the coverage contract: it is theme vocabulary (stock luci-theme-bootstrap carries 14
* rules for it), so a third-party luci-app-* is entitled to emit it and expect a themed result.
* A selector with no on-router example today is styled for the package that emits it tomorrow.
* Each item anchors its own nested popup, hence position: relative.
*
* This comment used to claim ui.js emits it for the split Save & Apply button. It does not —
* that button is a `.cbi-dropdown`, a different widget entirely. */
.dropdown-menu li { display: block; position: relative; }
/* wins over `ul.nav a` above: the popup sets its own denser rhythm */
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; }
/* Menu hover / active — one look, every layout. One markup (`ul.nav > li > a` + a
* nested `ul`) serves the main list, the submenu (accordion / bar dropdown / rail
* flyout), the mode menu and the Appearance row, so there are no per-layout copies to
* keep in step. */
.fs-sidebar ul.nav > li > a:hover,
.fs-sidebar ul.nav ul a:hover,
.fs-themerow:hover {
background: var(--fs-panel2); color: var(--fs-text); opacity: 1;
}
.fs-sidebar ul.nav > li.active > a,
.fs-sidebar ul.nav > li > a.active,
.fs-sidebar ul.nav ul a.active,
.fs-themerow[aria-expanded="true"],
.fs-modemenu li.active a {
background: var(--fs-accent-soft); color: var(--fs-accent);
}
/* Keyboard focus for the chrome controls. Covers the buttons/links the theme itself renders,
* which had only the UA default (menu links: base's thin dotted outline).
*
* --fs-focus-ring-solo, not --fs-focus-ring: none of these flips a border on focus, so the
* ring is the entire indicator, and the tinted ring measures 1.15-1.29:1 against the sidebar
* — i.e. tabbing through the WHOLE MENU showed nothing at 3:1. See the two-ring contract in
* 02-tokens.css: the tint is only legitimate beside a second channel. */
.fs-railtoggle:focus-visible,
.fs-themerow:focus-visible,
.fs-logout:focus-visible,
.fs-sidebar ul.nav a:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring-solo);
}
/* The page-wide accent signals no widget rule covers. All three follow the palette:
* ::selection — the UA default (opaque blue) ignores palette and dark mode;
* caret-color — inherits from :root into every field, one declaration;
* accent-color — the theme's own checkboxes are appearance:none drawings and never
* see it, but a third-party luci-app-*'s NATIVE checkbox/radio/
* progress does — outbound theming in the spirit of the export tier. */
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
}
@@ -0,0 +1,78 @@
@layer theme {
/* Wallpaper axis, value `pattern`: an SVG the ADMIN uploads, tiled behind the content and
* recoloured by the theme. It composes with any palette, and the recolour is what makes that
* true — the drawing takes --fs-text, so the same file reads correctly in light and in dark
* without the admin editing it.
*
* WHY A DEDICATED ELEMENT AND NOT A `background-image`. Recolouring an image the browser has
* already rasterised takes `mask-image`: the SVG supplies the ALPHA and the element supplies the
* COLOUR. A mask applies to the element's whole rendering, children included, so it can never go
* on .fs-shell or on <body> — those hold the page. Hence .fs-pattern, emitted as the first
* child of <body> by header.ut AND by sysauth.ut (the login page has no .fs-shell at all), whose
* only job is to be masked.
*
* WHY z-index: -1 AND A TRANSPARENT BODY. Inside the root stacking context the paint order is
* html's background, then negative-z children, then every block background. So the layer sits
* ABOVE the canvas and BELOW the content — but body's own opaque --fs-bg would bury it. The
* canvas therefore moves up to :root for this axis and body goes transparent; .fs-main and the
* footer follow for the same reason the doodles needed it. The sidebar is deliberately NOT
* excluded: the bar is translucent (--fs-bar-bg) and the pattern showing behind it is what makes
* the page read as one sheet.
*
* Cards sit on opaque --fs-panel and cover it, so the pattern shows through the gaps only. */
.fs-pattern { display: none; }
:root[data-wallpaper="pattern"] { background-color: var(--fs-bg); }
:root[data-wallpaper="pattern"] body,
:root[data-wallpaper="pattern"] .fs-main { background-color: transparent; }
:root[data-wallpaper="pattern"] footer.fs-footer { background: transparent; }
/* `fixed`, so the pattern is one calm backdrop the page scrolls under rather than a strip that
* slides with every table — the same argument the File photo's background-attachment makes.
* --fs-pattern-url is `none` until something is uploaded (head.ut / fs-prefs.js), so the axis
* selected with no file is a bare canvas rather than a broken tile. */
:root[data-wallpaper="pattern"] .fs-pattern {
display: block;
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: var(--fs-pattern-strength);
}
/* THEME INK (the default). The file's own colours are discarded and only its alpha is used, so
* an opaque background inside the SVG would mask as a solid sheet — which is exactly why the
* admin can switch to the file's own colours below. Line art on transparency is the shape this
* mode is for. */
:root[data-wallpaper="pattern"]:not([data-pattern-ink="original"]) .fs-pattern {
background-color: var(--fs-text);
-webkit-mask-image: var(--fs-pattern-url);
mask-image: var(--fs-pattern-url);
-webkit-mask-repeat: repeat;
mask-repeat: repeat;
-webkit-mask-size: var(--fs-pattern-size) auto;
mask-size: var(--fs-pattern-size) auto;
}
/* THE FILE'S OWN COLOURS: an ordinary tiled background, no mask. For artwork that carries its
* own palette and would be flattened to one colour by the mask. */
:root[data-wallpaper="pattern"][data-pattern-ink="original"] .fs-pattern {
background-image: var(--fs-pattern-url);
background-repeat: repeat;
background-size: var(--fs-pattern-size) auto;
}
/* A CBI form has dozens of .btn/.cbi-button; a backdrop-filter on each is a live
* backdrop-sampling layer over the pattern — costly on weak GPUs, and invisible anyway
* since every button is already opaque. Blur dropped from buttons entirely; closed
* dropdowns keep the frost (few of them, and their body can show the pattern through). */
/* var(--fs-blur), not a literal: one radius for every frosted surface (02-tokens.css), and
* the literal 6px was also invisible to prefers-reduced-transparency, which nulls the token
* (95-a11y-media.css) — the one frost that kept blurring for users who asked for opaque. */
:root[data-wallpaper="pattern"] .cbi-dropdown:not([open]) {
backdrop-filter: var(--fs-blur);
-webkit-backdrop-filter: var(--fs-blur);
}
/* No wash layer behind #view: the pattern shows through the gaps between the cards at
* full strength. Every block that carries text paints an opaque --fs-panel of its own,
* so nothing has to be dimmed globally. */
}
@@ -0,0 +1,31 @@
@layer theme {
/* The uploaded photo — the Wallpaper axis's third value, `file` (Appearance → Wallpaper → File).
* The bytes are router-side and shared; the SERVED file is referenced by CSS `url()`, never inlined
* (uhttpd has no gzip). Unlike the cats doodle a photo carries no bakeable opacity, so a scrim is
* mandatory for legibility: --fs-photo-scrim (a wash derived from --fs-bg, so it follows
* palette/mode/tint; 03-palettes.css) is painted as the TOP background layer OVER the photo on the
* SAME element. That keeps it between image and content with no ::before and no z-index, and no
* backdrop-filter: a blur on a full-viewport surface is the single most expensive paint on a phone
* GPU (15-wallpaper.css / 95-a11y-media.css), the scrim already carries the readability, and
* reduced-transparency turns the scrim opaque anyway.
*
* ONE-element rule, same as the cats wallpaper (15-wallpaper.css:20): .fs-shell where there is
* chrome, the bare <body> on the login page (blank_page → no .fs-shell). Content blocks paint their
* own opaque --fs-panel and float above the scrim, so it only has to carry the little text that sits
* directly on the canvas. --fs-login-bg-url is `none` until a photo is uploaded (head.ut /
* fs-prefs.js), so with File selected but nothing uploaded this is just the scrim over the base
* canvas — which is what the popover's Choose-image prompt is for. */
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) {
background-image: linear-gradient(var(--fs-photo-scrim), var(--fs-photo-scrim)), var(--fs-login-bg-url);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
/* fixed, so the photo is one calm backdrop the page scrolls under, not a strip that slides
* with every table. */
background-attachment: fixed;
}
/* .fs-main's opaque --fs-bg would cover the shell's single background layer — make it transparent
* so the photo+scrim shows through the whole content area (as the cats wallpaper does at 15:34). */
:root[data-wallpaper="file"] .fs-main { background-color: transparent; }
:root[data-wallpaper="file"] footer.fs-footer { background: transparent; }
}
@@ -0,0 +1,712 @@
@layer theme {
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
body {
padding: 0;
background-color: var(--fs-bg);
font-family: var(--fs-font-sans);
}
/* placement only — the look lives in 10-chrome.css */
.fs-brand { flex: 1 1 auto; min-width: 0; }
/* A hostname is user-supplied and can outgrow any bar or column. `overflow-wrap: anywhere`
* wraps it mid-word only when no better break exists (unlike word-break: break-all, a
* normal hostname still breaks at its dots), and it lets the flex item's min-content size
* shrink — which is what stops a long name pushing the menu out of the bar. It used to be
* nowrap + ellipsis, which truncated the one string that identifies the router. */
.fs-wordmark { overflow-wrap: anywhere; min-width: 0; }
/* collapse the sidebar to an icon rail. The toggle only EXISTS in the vertical sidebar,
* so its `display` is declared there, not here, or it would just be shadowed. */
.fs-railtoggle {
align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; flex: 0 0 auto;
border: 1px solid transparent; border-radius: var(--fs-radius-sm);
background: transparent; color: var(--fs-faint); cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
.fs-railtoggle:hover { color: var(--fs-text); background: var(--fs-panel2); border-color: var(--fs-border); }
.fs-railtoggle .fs-ico { width: 16px; height: 16px; transition: transform var(--fs-dur-move); }
.fs-sidebar ul.nav,
.fs-sidebar #modemenu {
list-style: none;
margin: 0; padding: 0;
display: flex;
}
.fs-sidebar ul.nav > li > a .fs-label { flex: 1 1 auto; }
/* collapse chevron for sections with children */
.fs-chevron {
width: 14px; height: 14px; flex: 0 0 auto;
color: var(--fs-dim);
transition: rotate var(--fs-dur-move);
}
/* `rotate`, not `transform: rotate()`: the INDIVIDUAL transform properties compose across
* rules, so the RTL mirror below can set `scale` without either rule having to restate the
* other's value. The fixed order is translate -> rotate -> scale, so a mirrored chevron still
* points down when it opens. */
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
/* ---- the two chevrons are DIRECTION, and direction is what mirrors ----
* A disclosure marker and the rail's collapse arrow both point ALONG the reading axis: `›` means
* "opens away from the menu" and the rail arrow means "the sidebar folds towards its own edge".
* With the document flipped (head.ut stamps dir), both pointed back at themselves. Everything
* else in the chrome mirrors from the logical properties; these two are glyphs, so they need the
* one transform. */
:root[dir="rtl"] .fs-chevron,
:root[dir="rtl"] .fs-railtoggle .fs-ico { scale: -1 1; }
.fs-sidebar ul.nav > li.has-sub > a { cursor: pointer; }
.fs-sidebar ul.nav ul {
list-style: none;
}
/* .fs-logout's display/align/colour are shared chrome (10-chrome.css) — do not restate
* them; only its LAYOUT (square button vs full-width row) belongs to a layout block. */
/* ==================================================================
* THE BAR — the default chrome, and the ONLY copy of it.
*
* The chrome is a bar unless something says otherwise: the TOP layout at every width,
* and the SIDEBAR layout collapsed onto a phone. The vertical sidebar is the exception,
* written once below as a single guarded override.
*
* Why this way round: the bar is needed when
* (viewport <= 767px) OR (:root[data-layout="top"])
* and CSS cannot OR a media query with an attribute selector in ONE selector — so
* writing the bar under both guards meant writing it TWICE (measured: 55 of ~75
* declarations identical, i.e. free to drift in silence). Inverted, each of the three
* chrome states is stated exactly once.
*
* The override wins on SPECIFICITY, never on source order:
* :root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar (0,4,0) beats .fs-sidebar (0,1,0)
* (:root, [data-layout], :not([data-narrow]) — a :not() contributes its argument's
* specificity — and .fs-sidebar.)
* ================================================================== */
.fs-shell {
display: flex; flex-direction: column;
min-height: 100dvh; width: 100%;
}
.fs-sidebar {
display: flex;
width: 100%; height: auto;
position: sticky; top: 0; z-index: var(--fs-z-sticky);
flex-direction: row; flex-wrap: wrap; align-items: center;
gap: var(--fs-space-2-5); padding: var(--fs-space-2-5) var(--fs-space-4);
background: var(--fs-bar-bg);
backdrop-filter: var(--fs-blur);
-webkit-backdrop-filter: var(--fs-blur);
border-inline-end: 0; border-bottom: 1px solid var(--fs-border);
/* the submenu popups anchor to this bar and must escape it */
overflow: visible;
}
/* A hostname can be enormous. Three things must be true at once:
* flex: 0 1 auto + min-width: 0 — the brand may SHRINK (0 0 auto would let it push the
* menu and the right cluster out of the bar);
* max-width — it must also be CAPPED: the bar is flex-wrap, so instead of squeezing the
* brand, flexbox wraps the OTHER items away and lets the brand sit alone on a 600px
* line. The cap is what actually forces the wrap onto .fs-wordmark;
* and the bar grows in height (min-height, 50-toplayout.css) to hold the extra line.
* 30ch tracks the text, not the viewport: a wide screen licenses more MENU, not a wider
* hostname. */
.fs-brandrow { flex: 0 1 auto; order: 1; min-width: 0; max-width: min(100%, 30ch); padding: 0; }
/* the bar has no section caption, no column to collapse, and its height follows its
* content — so the flex filler has nothing to fill */
.fs-navlabel, .fs-spacer, .fs-railtoggle, .fs-modemenu { display: none; }
/* the menu on its own full-width row beneath the brand. The DESKTOP bar lifts it back
* onto the brand's row whenever it fits — MEASURED, not a breakpoint
* (fs-chrome.js fitChrome adds .fs-bar-stack when it does NOT fit). */
.fs-sidebar ul.nav {
order: 3; flex: 1 1 100%;
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
}
/* the dropdown hangs off its OWN item — in EVERY bar state, which is what this used to get
* wrong (issue #19). `position: static` + a panel pinned at `left: 16px` was the bar's base,
* and only the top layout's delta re-anchored it per item; so the sidebar layout collapsed
* onto a bar (data-narrow, i.e. a desktop window narrowed past --fs-content-min) opened a
* hover panel at the bar's LEFT EDGE while its trigger sat mid-bar. Measured at 760px: item
* at x=253, panel at x=32 — travelling down to the panel leaves the <li>, nothing bridges
* the diagonal and the CSS hover closes it, so no submenu entry could be reached at all.
* A phone never showed it: `hover: none` disables the hover rule outright. */
.fs-sidebar ul.nav > li.has-sub { position: relative; }
.fs-sidebar ul.nav > li > a {
display: flex; align-items: center;
padding: var(--fs-space-1-5) var(--fs-space-3); border-radius: var(--fs-radius);
color: var(--fs-dim);
font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
}
/* a bar item is its label: no icon, no chevron */
.fs-sidebar ul.nav > li > a .fs-ico,
.fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
/* submenu = a popup card under its own item: sized to its content rather than stretched
* across the bar, and capped to the viewport so a long label wraps instead of overflowing.
* `left: 0` = the item's left edge (the <li> above is the containing block); an item near
* the right edge is nudged back inside by menu-footstrap.js clampDropdown, in every bar
* state — the JS gate is the same question this rule asks, not a layout test. */
.fs-sidebar ul.nav ul,
.fs-sidebar ul.nav > li.open > ul {
display: none;
position: absolute; inset-inline: 0 auto; top: calc(100% + 8px); z-index: var(--fs-z-flyout);
width: max-content; min-width: 200px; max-width: calc(100vw - 32px);
margin: 0; padding: var(--fs-space-2);
background: var(--fs-glass);
backdrop-filter: var(--fs-blur); -webkit-backdrop-filter: var(--fs-blur);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
}
/* an invisible bridge across the 8px gap, so travelling from the item down into the panel
* does not drop the hover and close it. Belongs to the BAR, not to one layout: the hover
* rule below is unguarded, so every state that can open a panel on hover needs it. */
.fs-sidebar ul.nav > li.has-sub > ul::before {
content: ""; position: absolute; inset-inline: 0; top: -10px; height: 10px;
}
/* hover only on a real pointer: a tap leaves :hover stuck on mobile browsers, which
* would block tap-to-close */
@media (hover: hover) {
.fs-sidebar ul.nav > li.has-sub:hover > ul { display: block; }
}
.fs-sidebar ul.nav > li.has-sub.open > ul { display: block; }
/* a submenu link is the same link in every layout — only its PADDING differs (denser
* inside a popup than in the accordion), which is all the vertical override restates. */
.fs-sidebar ul.nav ul a {
display: block; padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm);
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
}
/* right cluster: the poll pill, then Appearance and Log out as square icon buttons.
* LuCI's #indicators (poll "Refreshing", unsaved changes) collapses when empty, so it
* only shows when there is something to report. */
.fs-sidebar #indicators {
display: flex; flex-wrap: wrap; gap: var(--fs-space-1-5);
order: 2; margin-inline-start: auto; padding: 0;
}
.fs-themerow {
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto; order: 2; margin-inline-start: auto;
/* see the :not(:empty) rule below — two auto margins would SPLIT the free space between
* them instead of pushing one cluster to the bar's far edge */
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
background: var(--fs-panel2); color: var(--fs-dim);
font: inherit; font-weight: var(--fs-weight); font-size: var(--fs-type); line-height: 1.1;
text-align: start; cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
/* ==================================================================
* WHEN THE RIGHT CLUSTER RUNS OUT OF ROOM — .fs-ind-compact, then .fs-bar-actrow
*
* Both are set by fs-chrome.js (fitCluster) by MEASUREMENT, and both belong to the BAR rather
* than to a layout: the top layout is a bar at every width, and the sidebar layout becomes one
* on a phone (data-narrow). They used to sit in 50-toplayout.css under a data-layout="top"
* guard, so a phone in the DEFAULT sidebar layout — the common case — got neither, and its bar
* showed the full "Refreshing" and "Unsaved Changes: 1" pills with Log out wrapped alone onto a
* second row at the LEFT edge (measured at 380px).
*
* Step 1, .fs-ind-compact: the pills lose their prose and become icon squares matching
* Appearance / Log out — "Refreshing" alone is ~90px, "Unsaved Changes: 2" another ~110px, and
* on the top layout freeing that is often enough to keep the MENU on the brand's row as well
* (fitChrome's own escalation sets the same class for that reason).
* Step 2, .fs-bar-actrow: the cluster still does not fit, so the brand row takes the full width
* and the whole cluster moves down together. It keeps the right edge for free — the first
* rendered cluster member already carries the auto inline-start margin (above). max-width must be released
* along with the flex basis, or the 30ch cap leaves the brand short of the row it was just
* given and the cluster rides back up beside it.
* ================================================================== */
.fs-sidebar.fs-bar-actrow .fs-brandrow { flex: 1 1 100%; max-width: 100%; }
/* the poll pill: label off, masked glyph on. Static green arrows, grey when the poll is paused —
* the same --fs-icon-refresh as the rail, and the same reason it does not spin (a permanently
* spinning glyph makes an idle router look busy, and a moving target cannot be clicked). */
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"] {
width: var(--fs-btn-size); padding: 0; font-size: 0; justify-content: center; position: relative;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
background: var(--fs-dim);
}
/* every OTHER indicator collapses to the same box and shows the badge fs-chrome.js lifted out of
* its label — the count, or a dot when there is no number — exactly as the collapsed rail does.
* min-width rather than width: three digits must not be clipped, and here there is room to grow. */
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"]) {
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
* FIRST one actually rendered. #indicators is earlier in the DOM, so when the poll pill
* shows IT opens the gap and .fs-themerow's auto must stand down — otherwise flexbox
* SPLITS the free space between the two and the pill drifts away from the buttons.
* .fs-themerow keeps its own auto for when the pill is empty and collapsed. */
.fs-sidebar #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
/* …and there are TWO .fs-themerow buttons now (Search, then Appearance), so the same split
* would happen between THEM whenever the pill is collapsed: the second one stands down too. */
.fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
.fs-logout {
flex: 0 0 auto; order: 2; justify-content: center;
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
background: var(--fs-panel2);
}
/* on a square button the BORDER carries the state — the fill and the ink come from the
* shared hover/active groups in 10-chrome.css */
.fs-themerow:hover,
.fs-themerow[aria-expanded="true"] { border-color: var(--fs-accent); }
.fs-logout:hover { border-color: var(--fs-danger); }
.fs-themerow .fs-label, .fs-logout .fs-label { display: none; }
.fs-themerow .fs-ico,
.fs-logout .fs-ico { width: 18px; height: 18px; flex: 0 0 auto; }
.fs-sidebar ul.nav .fs-ico { flex: 0 0 auto; }
/* ==================================================================
* THE VERTICAL SIDEBAR — the single override, the ONLY thing that is not a bar. Guarded
* positively (never write a `:not([data-layout=…])` guard). Everything the bar declared
* above must be answered here — which is what makes it checkable: cssdiff proves the
* column is byte-identical to the pre-merge theme.
*
* WHEN it applies is NOT "viewport >= 768px": what matters is how much room is left for
* the CONTENT once the sidebar takes its cut, and that cut is not constant — 224px
* expanded, 68px as a rail — so the two must give way at DIFFERENT widths, which one
* breakpoint cannot express. `data-narrow` is set by fs-chrome.js (fitShell) when the
* content column would fall below --fs-content-min (02-tokens.css), measured from the
* sidebar's ACTUAL cut, so the rail keeps its column ~156px further down than the
* expanded sidebar does — exactly the room it gave back.
*
* `min-width: 521px` is not a second guess: it is a floor keeping the chrome correct
* with JS DISABLED (no JS = no data-narrow, so a phone would render the desktop
* sidebar). Below 521 no cut leaves 500px anyway — it states the measurement's
* conclusion in advance. */
@media (min-width: 521px) {
/* SCROLL MODEL for the desktop sidebar layout: the shell is pinned to the viewport and the
* CONTENT column scrolls inside itself, so the sidebar is a STATIC element that simply
* stays put — it is NOT position:sticky. This is the fix for issue #7: a sticky sidebar is
* a composited GPU layer, and with overflow:visible (needed so the rail flyouts escape
* sideways) every hover repaint of the column left a 1px seam of the flyout's
* buttons/headers at the layer's edge that only a full repaint cleared — visible on the
* live collapse and on hover, never on a fresh load. A static sidebar is not a layer, so
* there is nothing to leave stale. It also drops the z-index juggling: sticky ALWAYS makes
* a stacking context (which is why the sidebar needed z-index to sit above .fs-main at
* all), whereas a static sidebar is none, so the flyouts resolve their own --fs-z-flyout in
* the ROOT stacking context and sit above the content for free. Confined to the desktop
* sidebar: the top layout and the phone bar keep window scrolling and their sticky bar. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
flex-direction: row; height: 100dvh; overflow: hidden;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
height: 100dvh; overflow-y: auto;
/* `contain: paint` — the fix for #12. .fs-shell is height:100dvh + overflow:hidden and
* .fs-main is its own overflow:auto scroller, so Chrome never grows the document past the
* viewport. Firefox does: on Обзор (where the .fs-ovl grid makes the column ~2800px tall)
* it propagates ~158px of an out-of-flow descendant's scrollable overflow PAST both
* overflow ancestors up to the initial containing block, painting a SECOND, whole-page
* scrollbar beside .fs-main's own (measured: html.scrollHeight 1058 vs 900 viewport;
* System, a short page, stays 900). overflow:hidden on the shell/main/root does NOT stop
* it — it only hides the extra scrollbar while the document still scrolls 158px, dragging
* the fixed sidebar up off-screen. Paint containment makes .fs-main the containing block
* for that descendant and clips it, so the overflow is captured here and html.scrollHeight
* drops to 900 (proven in Firefox). LuCI modals are position:fixed on <body>, not inside
* .fs-main, so the new containing block does not re-base them. No-op in Chrome. */
contain: paint;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
width: var(--fs-sidebar-w); height: 100dvh;
flex: 0 0 auto;
position: static;
flex-direction: column; flex-wrap: nowrap; align-items: normal;
gap: normal; padding: var(--fs-space-5) var(--fs-space-3-5);
background: var(--fs-panel);
backdrop-filter: none; -webkit-backdrop-filter: none;
border-inline-end: 1px solid var(--fs-border); border-bottom: 0;
box-shadow: none;
/* `overflow: auto`, not overflow-y alone: the bar sets `overflow: visible` for its
* popups, and a lone overflow-y here would leave overflow-x visible — which the
* spec forces back to auto anyway. Say both. */
overflow: auto;
z-index: auto;
}
/* the column is already 224px wide — that IS the cap, so the bar's 30ch one must go
* or the brand is capped twice, at the wrong value */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-brandrow {
display: flex; align-items: center; gap: var(--fs-space-1-5);
flex: 0 0 auto; order: 0; min-width: auto; max-width: none; padding: 0 var(--fs-space-1) var(--fs-space-5) var(--fs-space-2);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-navlabel {
display: block;
color: var(--fs-dim);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); letter-spacing: .1em;
text-transform: uppercase;
padding: var(--fs-space-1-5) var(--fs-space-2-5) var(--fs-space-2);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-spacer { display: block; flex: 1 1 auto; min-height: 12px; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-railtoggle { display: inline-flex; }
/* :not(:empty):not(.single) in the SHOW rule, not a separate hide pair: the bar's base
* rule (top of this file) already hides .fs-modemenu, so an empty/single-mode strip
* simply never opts in. The hide pair existed twice (here and 50-toplayout.css),
* byte-identical and unpinned — the drift shape @mirror exists for, folded instead. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu:not(:empty):not(.single) { display: flex; margin-top: var(--fs-space-1); }
/* vertical main nav */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #modemenu {
order: 0; flex: 0 1 auto;
flex-direction: column; flex-wrap: nowrap; align-items: normal; gap: var(--fs-space-0-5);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub { position: relative; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
display: flex; align-items: center; gap: var(--fs-space-3);
padding: var(--fs-space-2-5) var(--fs-space-3);
font-size: var(--fs-type); white-space: normal;
line-height: 1.1;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-ico { display: block; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-chevron { display: block; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav .fs-ico,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-ico,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-ico { width: 17px; height: 17px; }
/* nested submenu -> an inline accordion, not a popup */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav ul {
display: none;
/* the bar anchors this panel under itself (absolute + top/left); an accordion is
* just the next block in the column, so the offsets must go back to auto or they
* linger in the computed style */
position: static; top: auto; inset-inline: auto; z-index: auto;
width: auto; min-width: 0; max-width: none;
flex-direction: column; gap: var(--fs-space-0-5);
margin: 0 0 0 var(--fs-space-7); padding: 0;
background: none; backdrop-filter: none; -webkit-backdrop-filter: none;
border: 0; border-radius: 0; box-shadow: none;
overflow: hidden;
}
/* and the bar's hover bridge goes with the popup. The panel is static here, so the
* bridge's containing block falls back to the <li> — an invisible 10px strip sitting
* ABOVE the section, i.e. over the previous menu item, swallowing its clicks. The rail
* (below) is a popup again but flies its panel out sideways, and brings its own
* horizontal bridge (li.has-sub::after). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul { display: flex; margin: var(--fs-space-0-5) 0 var(--fs-space-1-5) var(--fs-space-7); }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav ul a { padding: var(--fs-space-2) var(--fs-space-3); }
/* the Appearance trigger and Log out are full-width text rows down here */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout {
order: 0; margin-inline-start: 0;
width: 100%; height: auto;
justify-content: normal;
gap: var(--fs-space-3); padding: var(--fs-space-2-5) var(--fs-space-3);
border: 0; border-radius: var(--fs-radius);
background: transparent;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow { text-align: start; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout { font-weight: var(--fs-weight); font-size: var(--fs-type); }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout:hover { background: var(--fs-panel2); border-color: transparent; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow:hover,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow[aria-expanded="true"] { border-color: transparent; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-label,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-label { display: block; flex: 1 1 auto; }
/* the poll pill sits in the sidebar footer, above Appearance */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators {
order: 0; margin-inline-start: 0;
flex-wrap: wrap; gap: var(--fs-space-1-5);
padding: 0 var(--fs-space-1-5) var(--fs-space-1);
}
/* In the column the indicator is not one of a row of buttons but one of a stack of
* ROWS — Appearance and Log out below it run the sidebar's full width. A pill floating
* at 87px in a 195px column read as a stray chip; give it its neighbours' width. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators .label,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
flex: 1 1 100%;
justify-content: center;
}
}
/* THE APPEARANCE CONTROLS USED TO LIVE HERE, as a popover on <body> plus the segmented and
* slider controls it was built from. They are a PAGE now (System -> Appearance) and their CSS
* moved with them, to styles/pages/80-appearance.css — a page's styles belong in the page layer,
* and the shell no longer draws any of it. */
/* ==================================================================
* Collapsed sidebar (icon rail) — <html data-rail="true">, persisted in localStorage and
* re-applied before paint by head.ut. Labels collapse; a section's submenu becomes a
* flyout anchored right of its icon, opened on hover (desktop) or on focus — which is
* what a tap on the section link produces on touch.
*
* THE GUARD MUST BE EXACTLY THE VERTICAL SIDEBAR'S — same media floor, same
* :not([data-narrow]). It once said `min-width: 768px` against the vertical block's 521:
* between 521 and 767 with the rail collapsed the VERTICAL rules applied but the RAIL
* rules did not, so the sidebar sprang back to its full 224px — it EXPANDED as the window
* shrank — while the JS measurement was subtracting the rail's 68px and believed the
* content still fitted. The rail is a MODE of the vertical sidebar: it can never be
* visible under conditions the vertical sidebar is not.
* ================================================================== */
@media (min-width: 521px) {
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar {
width: var(--fs-rail-w); padding: var(--fs-space-5) var(--fs-space-2-5);
/* the default `overflow-y: auto` computes overflow-x to `auto` as well,
* which would clip the flyouts; the rail is short enough not to scroll */
overflow: visible;
/* NO z-index / no sticky: the sidebar is a static element (see the scroll-model note
* above), so it is not a composited layer and its overflow:visible flyouts leave no
* stale seam (issue #7). The flyouts still cover the content on their own --fs-z-flyout
* in the root stacking context. */
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brandrow { flex-direction: column; gap: var(--fs-space-2-5); padding: 0 0 var(--fs-space-5); }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brand { flex: 0 0 auto; justify-content: center; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-railtoggle .fs-ico { rotate: 180deg; }
/* only the rail's own labels collapse — a `.fs-label` also exists on every
* submenu link, and those live inside the flyout where they must stay */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-wordmark,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-navlabel,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-modemenu,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout > .fs-label,
/* The chevron is an affordance for a section you can UNFOLD; a rail item has no label
* and no accordion (its children fly out to the side), so it says nothing and crowds
* the icon.
*
* The selector must be this long: the vertical override turns the chevron back on at
* (0,6,3), so a plainer `.fs-sidebar .fs-chevron` here is only (0,6,0) and LOSES —
* which is exactly why the chevrons reappeared in the rail. Match its shape and add
* the rail attribute: (0,7,3). */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout {
justify-content: center; gap: 0; padding: var(--fs-space-2-5) 0;
}
/* leaf sections have no flyout — show their name as a tooltip bubble */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a::after {
content: attr(data-label);
position: absolute; inset-inline-start: 100%; top: 50%; transform: translateY(-50%);
margin-inline-start: var(--fs-space-2-5); z-index: var(--fs-z-flyout);
padding: var(--fs-space-1-5) var(--fs-space-2-5); border-radius: var(--fs-radius-sm);
background: var(--fs-panel); border: 1px solid var(--fs-border);
box-shadow: var(--fs-shadow-pop);
color: var(--fs-text); font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a:hover::after { opacity: 1; }
/* the flyout: opened by hover on a mouse, or by .open which the menu JS toggles on tap
* (exclusively — menu-footstrap.js flyoutMode). The hover rule is gated on a real
* pointer: mobile browsers leave :hover stuck on the tapped element, which would block
* tap-to-close. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub > ul {
display: none;
position: absolute; inset-inline-start: 100%; top: -8px; margin: 0; margin-inline-start: var(--fs-space-2-5);
min-width: 196px; padding: var(--fs-space-2); z-index: var(--fs-z-flyout);
background: var(--fs-panel); border: 1px solid var(--fs-border); border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
animation: fs-fade var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
@media (hover: hover) {
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub:hover > ul { display: flex; }
}
/* the flyout's heading, so the collapsed icon still names its section.
*
* IT MUST UNDO THE BAR'S HOVER BRIDGE, and forgetting to was issue #22. This same
* ::before is the bar's invisible 10px hover bridge above the panel (the unguarded rule
* near the top of this file): `position: absolute; left: 0; right: 0; top: -10px;
* height: 10px`. Re-pointing `content` at the title left all four of those in force, so
* the heading rendered as a 10px-tall absolute box ten pixels ABOVE the flyout — out of
* the flow, and clipped in half by the panel's own `overflow: hidden` (measured:
* `top: -10px`, `height: 12px`, and the top of every letter cut off). The rail brings its
* own bridge as `li.has-sub::after`, so nothing here needs the bar's. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
content: attr(data-title);
position: static; inset: auto; height: auto;
padding: var(--fs-space-1) var(--fs-space-3) var(--fs-space-2);
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
}
/* bridge the 10px gap so the pointer can travel icon -> flyout without a hover drop */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub::after {
content: ""; position: absolute; inset-inline-start: 100%; top: 0;
width: 12px; height: 100%;
}
/* Collapsed rail: swap the "Refreshing" word for a green refresh glyph — a masked SVG
* tinted with --fs-good, so it follows the palette. Only poll-status becomes an icon;
* other indicators (unsaved changes …) keep their pill.
*
* These two lived OUTSIDE this media block, carrying only the [data-rail] half of the
* guard — the surviving instance of the bug this block's header legislates against. It
* was reachable, not theoretical: head.ut stamps data-rail from localStorage inline and
* pre-paint, while data-narrow is written later by fitShell() in an async module. So on a
* <521px phone a rail user had every OTHER rail rule excluded by the floor while these
* two matched, and the "Refreshing" pill painted as a bare 30px green glyph inside the
* phone bar until the modules landed — permanently if they failed. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: 30px; height: 30px; padding: 0;
border: 0; box-shadow: none; background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
/* STATIC, deliberately. A spinner promises something you are WAITING for; this is a
* poll running quietly in the background forever, and a permanently spinning glyph
* makes an idle router look busy. The spin also broke the click that pauses the poll —
* a target that never stops moving cannot be hit. */
}
/* paused: the same glyph gone quiet. Without this the rail kept saying GREEN while the poll
* was stopped — the one thing the indicator exists to tell you, and it was lying. (The pill
* gets this for free: its --fs-good only applies to [data-style="active"].) */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
background: var(--fs-dim);
}
/* EVERY OTHER indicator — LuCI's "Unsaved Changes: N" and whatever a third-party app shows.
* Its label is prose and the rail is 68px: measured on the router, that pill wants 86px,
* wraps onto three lines and hangs 34px past the rail's edge over the content (issue #14).
* The pill is not ours to shorten, so it becomes a square in the poll glyph's box and shows
* the COUNT alone — the only part that changes, lifted into data-fs-count by fs-chrome.js
* because a text node cannot be reached by a selector. `font-size: 0` kills the prose
* without removing it: the label stays in the DOM for a screen reader, and in the `title`
* for the pointer.
*
* What the badge SAYS — the count, or a dot when the label has no number — is decided in the
* JS, so this stays one rule per box: written as two CSS rules it needed an extra attribute
* and a `:not()` to tell them apart, which pushed the sheet's widest selector past the
* css-metrics ratchet for a choice that is not a cascade question.
*
* `.fs-sidebar` is deliberately absent from these two: #indicators is an id, and the class
* beside it only widened the selector. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
/* min-width, not width: the count is unbounded (a big config can queue three digits) and
* a fixed box would clip it. It grows from the poll glyph's square and stops at the
* rail's inner width. */
font-size: 0; min-width: 30px; max-width: 100%; height: 30px;
box-sizing: border-box; padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
/* The top bar's compact step paints the same badge (50-toplayout.css) and CANNOT share this
* rule: that one applies at every width, while this one must stay inside the 521px floor —
* below it a rail user has data-rail stamped pre-paint but data-narrow not yet written, so a
* merged rule would strip the phone bar's indicator to a bare digit until the modules land. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
}
/* ---------- main column ---------- */
.fs-main {
flex: 1 1 auto;
min-width: 0;
display: flex; flex-direction: column;
background: var(--fs-bg);
overflow-x: clip;
}
.fs-sidebar #indicators:empty { display: none; }
.fs-indicators { flex-wrap: wrap; }
.fs-content { padding: var(--fs-space-5) var(--fs-content-pad); flex: 1 1 auto; width: 100%; max-width: var(--fs-content-max); margin: 0 auto; }
/* ---------- footer ----------
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright (absorbed
* from base, which keeps only the flex skeleton), so a third-party view emitting its own
* <footer> stays themed too. */
footer {
padding: var(--fs-space-3-5) var(--fs-content-pad); border-top: 1px solid var(--fs-border);
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
/* CENTRE THE FLEX LINE, not just the text: `text-align: center` alone did nothing.
* base makes the footer a flex container and used to add `justify-content:
* space-between` (a two-column-footer leftover), which parked the single <span> this
* theme emits at the START — and the span shrinks to its content, so text-align had
* nothing to centre inside it. (base's space-between and `footer span` margin are gone;
* this rule made them dead.) */
justify-content: center;
text-align: center;
}
footer a { color: var(--fs-dim); }
footer a:hover { color: var(--fs-accent); }
/* the phone bar is the base above; only the content gutter is width-driven */
@media (max-width: 767px) {
.fs-content { padding: var(--fs-space-4); }
}
/* Notifications — ui.addNotification prepends .alert-message as the first child of
* #maincontent (.fs-main). Order it above the content and give it card spacing. */
.fs-main > .alert-message {
order: 1; margin: var(--fs-space-4) auto 0;
width: calc(100% - var(--fs-content-pad) * 2);
max-width: calc(var(--fs-content-max) - var(--fs-content-pad) * 2);
}
.fs-main > .fs-content { order: 2; }
.fs-main > .fs-footer { order: 3; }
@media (max-width: 767px) {
/* width:auto, not the base width above: that calc subtracts the DESKTOP gutter
* (--fs-content-pad, 28px a side) while .fs-content's own gutter is 16px here — the
* alert came out 24px narrower than the content and hugged the left (measured at 700px:
* insets 16/40 where the content sits 16/16). auto + the side margins matches the
* content column exactly at any width in this tier. */
.fs-main > .alert-message { margin: var(--fs-space-3) var(--fs-space-4) 0; width: auto; }
}
/* Phone: alerts (e.g. the sysupgrade banner, injected by ui.addNotification as a
* `.fs-main > .alert-message` — a SIBLING of #view, not a child) lay their action buttons
* in a nowrap flex row, so on a ~344px screen the trailing Dismiss button was shoved past
* the panel and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main so it covers
* those top-level alerts and any inside #view. */
@media (max-width: 767px) {
.fs-main .alert-message { flex-wrap: wrap; }
.fs-main .alert-message > * { min-width: 0; margin-inline-start: 0; }
/* the action row is a nested flex that stayed nowrap, so the first button's label got
* clipped when Dismiss sat beside it. Wrap the inner rows; let labels break. */
.fs-main .alert-message div { flex-wrap: wrap; min-width: 0; }
.fs-main .alert-message .btn,
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
}
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
.fs-indicators .label,
.fs-indicators [data-indicator],
#indicators .label,
#indicators [data-indicator] {
/* An indicator sits in a ROW of controls — in the bar, followed by Appearance and Log
* out — so it must look like one of them or the cluster reads as three unrelated
* things. Same --fs-btn-size height (a box sized by its font's line-box came out 28px,
* visibly the odd one out) and the same --fs-radius corner, not --fs-radius-pill: a
* capsule beside two rounded squares announced itself as a different KIND of object,
* which it is not — it is a control you click to pause the poll. WIDTH stays content's. */
display: inline-flex; align-items: center;
min-height: var(--fs-btn-size);
text-transform: none;
font-weight: var(--fs-weight); font-size: var(--fs-type);
background: var(--fs-panel2); background-image: none;
border: 1px solid var(--fs-border);
color: var(--fs-dim);
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
}
.fs-indicators [data-indicator][data-style="active"],
#indicators [data-indicator][data-style="active"] { color: var(--fs-good); }
/* nothing else gives a clickable indicator a pointer cursor (base's `header
* [data-indicator]` rule carries geometry only, and this theme emits no <header> at all),
* so "Refreshing" showed a text caret. */
.fs-indicators [data-indicator][data-clickable],
#indicators [data-indicator][data-clickable] { cursor: pointer; }
}
@@ -0,0 +1,43 @@
@layer theme {
/* One thin meter for every .cbi-progressbar (overview Memory/Storage/Network, Software
* disk space, package manager …): slim rounded track, accent fill, and the LuCI title
* value floated just above the bar's right edge (mono/dim) instead of stuffed inside a fat
* pill. No per-page overrides. */
.cbi-progressbar {
position: relative;
height: 10px;
background: var(--fs-track);
border: 0;
border-radius: var(--fs-radius-pill);
overflow: visible;
min-width: 120px;
margin: 0;
}
.cbi-progressbar > div {
height: 100%;
background: var(--fs-accent);
border-radius: var(--fs-radius-pill);
transition: width var(--fs-dur-fill);
}
.cbi-progressbar::after {
content: attr(title);
position: absolute; inset-inline: auto 0; bottom: calc(100% + 4px); top: auto;
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-weight: var(--fs-weight-normal);
color: var(--fs-dim); white-space: nowrap; text-shadow: none;
}
/* Progressbar rows in a section table (luci-app-cpu-status): keep the native label | bar
* row, just centre the cells vertically. */
.cbi-section .table:not([id]):not(.cbi-section-table) .tr:has(.cbi-progressbar) .td { vertical-align: middle; }
/* Multi-column DATA tables with a progressbar column (cpu-status per-CPU load): the
* generic meter floats its value ABOVE the bar, which here lands on the row divider.
* Shrink the bar and put the value INLINE to its right, clear of the border. */
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
min-width: 60px; margin-inline-end: var(--fs-space-10);
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
inset-inline: calc(100% + 8px) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
}
}
@@ -0,0 +1,493 @@
@layer theme {
/* ---------- panels / cards (cbi) ---------- */
.cbi-section, .cbi-map > .cbi-section-node, fieldset.cbi-section, .panel {
background: var(--fs-panel);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow);
}
.cbi-section { padding: var(--fs-card-pad); margin-bottom: var(--fs-card-gap); }
/* a bare <legend>; the ones inside a .cbi-section are covered below. The ink is the heading
* ramp's (45-misc.css) — legend is kept out of that group so this rule stays its one owner;
* no line-height here: base's `fieldset legend` sets 1 and every real legend sits in a
* fieldset, so stating leading here would silently repaint them all through the layer. */
legend { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
/* overview (luci-mod-status) DOM:
* .cbi-section > .cbi-title>h3 + div > table.table (.tr/.td) */
/* section title + collapse control */
.cbi-section > .cbi-title { margin-bottom: var(--fs-space-3); }
.cbi-section > .cbi-title h3,
.cbi-section > h3:first-child,
.cbi-section > legend {
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text);
margin: 0; padding: 0; border: 0;
}
/* A bare section title (no .cbi-title wrapper) sat flush against the content below it —
* on System → System the "System Properties" h3 touched the tab strip. Give it the same
* 12px the .cbi-title wrapper already carries above. Excludes .cbi-title h3: that variant
* gets its gap from the wrapper's own margin-bottom, so flagging the inner h3 too would
* double it. */
.cbi-section > h3:first-child,
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
/* ---- tables in cards ----
* Discriminator: a key/value include (System, Memory) has NO id; a data table has one
* (status_leases) or the JS tag .fs-dt. Key/value is not itself a card — no frame, no clip;
* a data table keeps the `.table` frame below. Nested (`.table .table`, `.cbi-value .table`)
* and the config-diff dialog (`.cbi-modal .table`) go with key/value. */
.cbi-section .table:not([id]):not(.fs-dt),
.cbi-section-node .table:not([id]):not(.fs-dt),
.cbi-modal .table, .table .table, .cbi-value .table {
border: 0; border-radius: 0; overflow: visible; background: none;
}
.cbi-section .table { margin: 0; width: 100%; }
/* A data table straight in #view (no section: package-manager, Startup) misses the width above,
* so it sized to preferred width and overflowed the content column — which base already fixes
* for EVERY `.table` (`width: 100%`, base/40-tables.css), and has for as long as both rules have
* existed. Switched off against the 24 tables that match it across eight pages: no computed
* style moved, so the rule only restated a value one layer down. Removed. */
/* Config-table fields fill their cell — moved from base with the generic field box in
* theme/60-inputs.css (in base these beat its 210px in-layer; left behind they would lose
* to it by layer), together with the numeric-cell refinement below them. */
.table.cbi-section-table input[type="password"],
.table.cbi-section-table input[type="text"],
.table.cbi-section-table textarea,
.table.cbi-section-table select,
.table.cbi-section-table .cbi-select {
width: 100%;
min-width: auto;
}
.table.cbi-section-table .td.cbi-section-table-cell select,
.table.cbi-section-table .td.cbi-section-table-cell .cbi-select { width: inherit; }
/* THE FILTER ROW (form.js renderHeaderRows, `filterrow` + uci luci.main.tablefilters) is the
* one place a field cannot claim the width it wants: it lives in a table HEADER cell, so the
* column is as wide as the DATA under it, not as wide as the box. The rule above already makes
* it fill that cell — what did not fit was the TEXT. LuCI writes the placeholder as `Filter ` +
* the column title, and the theme's field is 13px type inside 11px of side padding, so on a
* Russian firewall page "Фильтр Внутризональная пересылка" ran out of box and Firefox sliced it
* mid-glyph (issue #12). The flag column is the worse half: form.js writes `width: 30px` INLINE
* there (its filter takes one character), of which the theme's padding ate 22 — six pixels for
* "0/1", which rendered as a single clipped digit.
*
* Denser box and denser type buy back ~20px per column and make the 30px one legible; the
* ellipsis is what changes the FAILURE: a placeholder too long for its column now ENDS, instead
* of being cut through a letter. Nothing is lost to it — the column title says the same word
* directly above, which is the only reason shortening is honest here. No !important, so the
* inline 30px still stands: it is upstream's sizing for a one-character field, and correct. */
.table.cbi-section-table .cbi-section-filter {
padding: var(--fs-space-1) var(--fs-space-1-5);
font-size: var(--fs-type-xs);
text-overflow: ellipsis;
}
/* width:100% cannot shrink a table below min-content — one long unbreakable value holds the
* floor. Breaking values reflows it into the ~984px column instead of scrolling. That break is
* now the DEFAULT for every cell (base/40-tables.css), so the copy that used to sit here —
* `#view .table[id] .td, table.fs-dt .td { overflow-wrap: anywhere }` — said nothing the
* cascade did not already say: switched off against 1941 matching cells across eight pages at
* three widths, not one computed style moved. It is gone rather than kept "for clarity",
* because a second copy of a value is the drift this sheet is written to avoid. DHCP leases
* still need finer control (only DUID/IPv6 may break, not IPv4/MAC) and get it from
* `white-space: nowrap`, not from this property: pages/50-leases.css. */
.cbi-section .table .td,
.cbi-section .table .th {
padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-2-5) 0;
border: 0; border-bottom: 1px solid var(--fs-border);
line-height: 1.45; vertical-align: middle;
text-align: start;
}
/* Row actions hug the far edge. Base says so too but loses to the `text-align: start` above (an
* earlier layer), so restate it. `.cbi-section-table-cell` is the discriminator:
* form.TableSection's action cells carry it (firewall zones, port forwards); the hand-built
* ones of Wireless (`.td.middle`) and DHCP leases do not, and stay left. */
.cbi-section .table .td.cbi-section-table-cell.cbi-section-actions { text-align: end; }
.cbi-section .table .tr:last-child .td { border-bottom: 0; }
.cbi-section .table .th {
color: var(--fs-eyebrow-color); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs);
text-transform: uppercase; letter-spacing: var(--fs-eyebrow-tracking);
}
/* --- DATA tables ([id] or JS-tagged .fs-dt): leases, wifi, processes, routes … ---
* Symmetric padding so cells clear the table's own contour (below).
*
* `:is([id], .fs-dt)` = ONE discriminator at ONE specificity. The two names for "data table" were
* two selector lists at DIFFERENT weights — `.cbi-section .table[id] .td` (0,4,0) vs
* `table.fs-dt .td` (0,2,1), the latter losing to the key/value default (0,3,0) — so a table
* known only by the JS tag kept the key/value padding (flush left) and got no mono face, no row
* hover, no header rule. Live: Status → Routing emits <table class="table"> with no id. */
.cbi-section .table:is([id], .fs-dt) .td,
.cbi-section .table:is([id], .fs-dt) .th,
table.fs-dt .td, table.fs-dt .th { padding: var(--fs-space-2-5) var(--fs-space-3); }
.cbi-section .table:is([id], .fs-dt) .td { font-family: var(--fs-font-mono); font-size: var(--fs-type); color: var(--fs-text); }
/* No horizontal-scroll wrapper: long values break (overflow-wrap above), so the table reflows
* into the column. Whether it cards instead is MEASURED (.fs-stacked, fs-select.js), any width. */
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
.cbi-section .table:is([id], .fs-dt) .tr.table-titles .th { border-bottom-color: var(--fs-border); }
/* Contour around the whole data table. A real <table> needs border-collapse:separate or the
* radius is silently dropped. Scoped to #view too: Startup renders a .table.fs-dt in a bare
* #view wrapper with no section, and drew frameless under a section-scoped selector. */
.cbi-section .table[id], #view .table[id],
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, table.fs-dt {
border-collapse: separate; border-spacing: 0;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius-lg);
/* EXPLICIT, not an omission: the generic `.table` rule below still says `overflow: hidden`
* — which clipped the last column of a wide table — and not repeating it here does not
* stop it applying, which is what kept the bug alive. The corner cells (below) round the
* frame instead, and long values wrap so the table shrinks to fit. */
overflow: visible;
}
/* The frame is overflow:visible, so nothing clips the square corner cells to its radius and
* they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill.
* `#view .table[id]` is listed because the frame rule draws the border there too. */
.cbi-section .table[id] .tr.table-titles .th:first-child,
#view .table[id] .tr.table-titles .th:first-child,
table.fs-dt .tr.table-titles .th:first-child { border-top-left-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr.table-titles .th:last-child,
#view .table[id] .tr.table-titles .th:last-child,
table.fs-dt .tr.table-titles .th:last-child { border-top-right-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:first-child,
#view .table[id] .tr:last-child .td:first-child,
table.fs-dt .tr:last-child .td:first-child { border-bottom-left-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:last-child,
#view .table[id] .tr:last-child .td:last-child,
table.fs-dt .tr:last-child .td:last-child { border-bottom-right-radius: var(--fs-radius-lg); }
/* Empty table: L.ui.Table adds a single-cell .tr.placeholder ("There are no active leases").
* In a display:table it spans only the first column, so the bottom-corner rounding drew a tiny
* rounded box on the left. Block it to full width, carrying the frame's bottom corners. */
.cbi-section .table[id] .tr.placeholder,
#view .table[id] .tr.placeholder,
table.fs-dt .tr.placeholder { display: block; }
.cbi-section .table[id] .tr.placeholder .td,
#view .table[id] .tr.placeholder .td,
table.fs-dt .tr.placeholder .td {
display: block; width: 100%; text-align: start;
color: var(--fs-dim); font-family: var(--fs-font-sans);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
}
/* --- KEY/VALUE tables: System, Memory ---
* "no id" is NOT enough: form.TableSection/GridSection also render `.table.cbi-section-table`
* with no id (ddns, firewall zones, port forwards), and width:40%/nowrap on their first column
* starved the data columns (dates broke mid-token). So exclude it and .fs-dt explicitly.
*
* Also exclude the realtime graph legend: `[style*="table-layout"]` is the one no-id `.table`
* LuCI hands an inline `table-layout:fixed` (status/realtime/*, see below), where a 40% first
* column would only starve the other five. A DDNS-style multi-column status table (no id, no
* inline layout) is NOT excluded — it keeps the key/value first column exactly as before. */
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child {
color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) {
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
}
/* --- The realtime graph legend (status/realtime/{bandwidth,connections,load,wireless}) ---
* The one no-id `.table` LuCI renders with inline `table-layout:fixed;width:100%` and SIX cells
* per row (label/value ×3: UDP/Average/Peak). `fixed` splits the row into six equal columns, so
* on a phone each is ~1/6 of the content width (~52px at 375px) and the `Average:`/`Peak:` labels
* overflow onto the value beside them (measured). Stock luci-theme-bootstrap folds the six cells
* into label/value PAIRS on a narrow screen (each cell 50%, three stacked rows) and the labels
* fit; do the same. `display:flex` on the row takes the cells out of the fixed table layout (no
* !important needed — a class rule in `theme` beats the UA `display:table-row`), so the inline
* `table-layout:fixed` no longer bites. Keyed on that inline attribute so ONLY this legend is
* touched — never System/Memory, never a DDNS-style multi-column status table. */
@media (max-width: 767px) {
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .tr {
display: flex; flex-wrap: wrap;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .td {
flex: 1 1 50%; min-width: 0; width: auto;
}
}
/* remove double card when a section nests a table-only include */
.cbi-section .cbi-section { border: 0; box-shadow: none; padding: 0; margin: 0; background: transparent; }
/* generic links/labels inside cards */
.cbi-section .ifacebox, .cbi-section .zonebadge { border-radius: var(--fs-radius); }
/* moved from base with the generic field box (same layer arithmetic as the cell rules above) */
.zonebadge input { width: 6em; }
/* The device/MAC badge inside the Network status box kept base's flat 4px, reading as square
* against the card's --fs-radius corners (issue #5.4). Scoped to the status box: the small
* firewall zone .ifacebadge chips want to stay tight. Follows the Rounding axis via the token. */
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
/* ...and the head has to round WITH the box. It sits inside the box's 1px border and carries the
* zone colour as an inline background, so its own corners are what you see: base pairs a 4px box
* with a 3px head, but the line above bumps only the BOX to --fs-radius (10px by default), which
* left a 3px head whose square corners cut straight through the rounding — the zone colour
* visibly spilling past the corner on Network → Interfaces (issue #7).
* Minus the 1px border the head is inset by; max() because the Rounding axis goes down to 0,
* where a bare subtraction is negative — an invalid declaration, silently dropped, which would
* put base's 3px back. */
.cbi-section .ifacebox > .ifacebox-head {
--fs-head-radius: max(0px, calc(var(--fs-radius) - 1px));
border-radius: var(--fs-head-radius) var(--fs-head-radius) 0 0;
}
/* Inactive tab panes. Base's [data-tab-title] rule sets height:0/overflow:hidden, collapsing only
* the CONTENT box — padding/border/margin still draw empty bars (podkop and Network → Interfaces
* put data-tab-active on a .cbi-section: 18px/22px padding + 16px margin of phantom strip), and a
* pane left in flow inflates scrollHeight (Interfaces scrolled ~585px past the footer, Network →
* DNS another ~750px). Zeroing the box does both jobs; measured on the router, hiding the pane
* with display:none buys nothing on top of it (Interfaces scrollHeight 1039 either way, DNS 1347).
*
* It is `visibility`, and NOT `display: none`, because a display:none pane has no width, and a
* view is entitled to measure one: stock channel_analysis.js builds its channel axis from
* `graph.offsetWidth` in a requestAnimationFrame, ONCE — for the pane that starts inactive it read
* 0, so every channel offset landed at x≈0 and the 5GHz graph stayed a collapsed blob at the left
* edge for the life of the page, since nothing ever re-measures it (grid x-span 0px vs the 1147px
* it needs; luci-theme-bootstrap has no such bug because it hides panes exactly this way).
* `visibility: hidden` keeps the pane laid out at full width while still taking it out of the
* a11y tree and the tab order, and height:0 + overflow:hidden keep it out of the scroll.
*
* Hence ANY [data-tab-title], not just .cbi-section ones (DNS renders each tab as a bare <div>).
* Tab-menu <li>s carry data-tab but never data-tab-title, so the bar is untouched; the :not() also
* covers first render, where LuCI has set active="true" on the shown tab only.
*
* `position: relative` is what makes the overflow above actually hold, and it is not cosmetic:
* an ABSOLUTELY positioned descendant is clipped by an overflow ancestor only if that ancestor
* is in its containing-block chain, and a collapsed pane full of ordinary markup contains no
* positioned ancestor at all — so those escape to the initial containing block, keep the static
* position they were laid out at deep inside the hidden pane, and grow the DOCUMENT scroll box
* that .fs-main's own scroller cannot even reach. The theme supplies them itself: the toggle
* switch parks the real checkbox at `position: absolute` (theme/60-inputs.css), so every hidden
* pane holding a flag leaked one. Measured on the router: DHCP and DNS scrolled 1841px against
* 1110px of content on 24.10 and 1925 vs 1439 on 25.12 — ~500-700px of empty page under the
* footer (issue #10); with this, the document is exactly the viewport and .fs-main's scroll is
* untouched. Nothing paints differently — the pane is `visibility: hidden`. */
[data-tab-title]:not([data-tab-active="true"]) {
visibility: hidden;
position: relative;
height: 0;
overflow: hidden;
padding: 0;
margin: 0;
border: 0;
}
/* 6%, not 12%. The list inside is bold --fs-danger on a tint OF --fs-danger, so the tint
* eats its own contrast: 12% measured 4.4:1 (axe-core). At 6% every palette clears 4.5
* (4.87 / 4.95 / 5.28 / 6.09) and the box still reads red. */
.cbi-section-error { border-color: var(--fs-danger); background: color-mix(in srgb, var(--fs-danger) 6%, var(--fs-panel)); color: var(--fs-text); }
.cbi-section-error ul li { color: var(--fs-danger); }
/* denser data tables (dhcp static leases, section-tables) */
.cbi-section-table .th, .cbi-section-table .td,
/* The `::before` is the GridSection name CELL on 24.10, where form.js renders none and base
* generates the column from `data-title` (styles/base/95-luci.css). It is not a `.th`/`.td`, so
* every cell rule below has to name it too, or the column it draws lines up with nothing. Listing
* `.tr::before` unqualified is safe and deliberate: base names exactly the rows that get one, and
* a pseudo-element whose content is `none` generates NO BOX — so on 25.12, which renders a real
* cell instead, these declarations have nothing to apply to. */
.table.cbi-section-table .th, .table.cbi-section-table .td,
.table.cbi-section-table .tr::before { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; }
/* The zero padding-LEFT above is right only INSIDE a card: the .cbi-section supplies that gutter
* itself, so the first column lines up with the card's own 16px. Outside one it is wrong —
* Status → Firewall renders `.table.cbi-section-table` in a bare <div>, so the table draws its
* OWN frame (`.table`, below) and the header text ended up glued to that frame's rounded corner
* (issue #7). A table carrying its own frame pads its own cells, like every other standalone
* data table here. */
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .th,
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .td { padding: var(--fs-space-2-5) var(--fs-space-3); }
.cbi-section-table .tr.cbi-section-table-titles .th,
.cbi-section-table .tr.table-titles .th { font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); line-height: 1.25; }
/* Standalone data tables (Startup, Processes, Routes) -> rounded card. Inside a CBI section
* the section already IS the card, so the frame is stripped there (above): no double border. */
.table {
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
overflow: hidden;
background: var(--fs-panel);
}
/* SEPARATORS GO ON THE CELLS, NOT ON THE ROWS, and that is not a preference: the frame above
* needs `border-collapse: separate` (a collapsed table silently drops the radius), and in the
* separated model a ROW's border is not painted at all. These two rules used to sit on the .tr,
* so on every framed data table — the apk package list, Processes, Routes — they painted
* NOTHING. What actually drew the lines was base's per-cell `border-top`, which nobody had asked
* for and which also ran along the table's TOP edge, straight across the frame's rounded corners
* (issue #7: "лишняя граница, не имеющая скругления"). base no longer sets it, and the lines are
* declared here, where they paint. Remove one and the other is not a fallback — you get a table
* with no separators at all, which is exactly what the first attempt at this shipped.
*
* `:not(:last-child)` lives IN the body-row selector: a trailing bottom border draws a straight
* line the full width and pokes past the frame's rounded bottom corners (overflow: visible). */
.table .tr.table-titles > .th,
.table .tr.cbi-section-table-titles > .th,
.table .tr.cbi-section-table-titles::before { border-bottom: 1px solid var(--fs-border); }
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td,
.table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: 1px solid var(--fs-border); }
/* descendant combinator (not >) so a real <table>'s browser-inserted <tbody> is covered
* too — one rule themes every data table, div- or table-based. */
.table .tr.table-titles, .table .tr.cbi-section-table-titles {
background: transparent; /* drop the base panel2 header bar — match the dashboard's faint-label + underline look */
}
.table .tr.table-titles .th, .table .tr.cbi-section-table-titles .th,
.table .tr.cbi-section-table-titles::before {
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
background: transparent;
}
/* The row's NAME, and it must read the same on both releases: 25.12 renders a real
* `.td.cbi-section-table-titles`, 24.10 base's `::before`. Both are a LABEL, not a value, so both
* take the UI face — the surrounding value cells are mono, and `.cbi-value-field`
* (theme/60-inputs.css) hands the 25.12 cell that mono while base's
* `.cbi-section-table-titles { font-weight: bold }` makes it bold: no 700 mono face ships, so
* every port-forward and NAT rule name was drawn in SYNTHETIC bold mono, smearing the grid on the
* one string in the row that is a name. Specificity (0,3,0), not source order, is what beats the
* (0,1,0) mono rule. */
.table.cbi-section-table .td.cbi-section-table-titles,
.table.cbi-section-table .tr.cbi-section-table-row::before {
font-family: var(--fs-font-sans); font-weight: var(--fs-weight-bold); color: var(--fs-text);
}
.table .tr:not(.table-titles):not(.cbi-section-table-titles) {
transition: background var(--fs-dur);
}
/* Row hover ONLY on real data/config tables. A plain layout `.table` — Diagnostics is one
* <tr> of input+button spanning the whole card, with no header row, so fs-select never tags
* it .fs-dt — lit the ENTIRE block on hover (issue #5.3), and a data table's placeholder row
* ("no active leases") lit up too (issue #5.2, plus :not(.placeholder) above). Data tables
* hover per-cell above; config tables (.cbi-section-table, never .fs-dt) hover data rows here. */
/* :not(.placeholder) — a config table's "This section contains no values yet" row carries
* .cbi-section-table-row too, so it lit up under the pointer exactly as the data tables' did
* before the rule above excluded theirs (issue #15: reported on UPnP, but every empty
* GridSection had it — firewall's NAT rules, IP sets, static routes). A hover cue promises a
* row you can act on; there is no row. */
.table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); }
/* keep section/modal tables flush (no forced hover-card look) */
.cbi-section .table > .tr, .cbi-modal .table > .tr { border-radius: 0; }
/* .fs-dt is set by fs-select.js on a #view .table that has a header row and is NOT a
* .cbi-section-table — replacing a live `:has(.tr.table-titles):not(.cbi-section-table)`
* the style engine re-evaluated on every mutation of these polled tables (processes,
* routes, leases). */
/* ---- THE CARD STACK: one copy, every DATA table, no @container threshold ----
*
* Was three thresholds (568 here, 780 DHCP leases, 800 apk list); the last two each carried
* their own COPY of these declarations, because CSS cannot share a block across two @container
* thresholds — 55 duplicated declarations, free to drift. All three really asked "does it
* OVERFLOW?", which the browser can compute: fs-select.js measures it and sets .fs-stacked, so
* every table (a third-party luci-app-*'s included, whose column count we cannot know) finds
* its own width. Only the judgement a measurement cannot make survives, in fs-select.js:
* 568 = "too cramped to be a table at all".
*
* A CONFIG table (.cbi-section-table) is deliberately NOT here and keeps its @container (960,
* theme/65-dropdown.css). Its rows hold widgets that bake in a width from the layout they were
* rendered in, so un-collapsing one to read it CHANGES what is read: on the router the firewall
* zone table then claimed it needed 1747px where it really needs 1190px, and overflowed its
* section by 557px. Do NOT "finish the job" by measuring it.
*
* Shape = stock LuCI's phone layout: label ABOVE its value, cell = half the row. Label:value on
* ONE line with the value right-flushed cost a leases row 7 full-width lines and gave the value
* ~40% of the width (a badge/SVG/DUID then wrapped under its own label anyway); half-width pairs
* fold it into 4. Values stay LEFT-aligned: MAC/DUID/rates are opaque strings.
*
* ONE CONTRACT — do not split it again. The stack is MEASURED, so it fires at ANY width, but
* `display: block` and the `.hide-xs`/`.hide-sm` drops used to live only inside
* `@media (max-width: 767px)` (theme/90-responsive.css), so a table stacked above the phone tier
* got the card with neither. The window is real: the sidebar layout's content column is
* (viewport - 224 - 56), so between ~768 and 860px it is already below CRAMPED while that query
* is off. Measured at 790/820/850px — status_leases stacked with `display: table` still in force
* (keeping the intrinsic min-width `display: block` exists to prevent) and wifi_assoclist_table
* rendered ALL FIVE of its .hide-xs cells; at 760px both were correct. So they key off the CLASS.
* The media query keeps its own copies, for the tables that do NOT stack on a phone and for the
* config tables (@container, never .fs-stacked). The `.col-N` weights need no guard — see below. */
.table.fs-stacked { display: block; width: 100%; }
.table.fs-stacked .td.hide-sm, .table.fs-stacked .th.hide-sm,
.table.fs-stacked .td.hide-xs, .table.fs-stacked .th.hide-xs { display: none; }
.table.fs-stacked .tr.table-titles,
.table.fs-stacked .thead,
.table.fs-stacked .tr.cbi-section-table-titles,
.table.fs-stacked .tr.cbi-section-table-descr { display: none; }
/* align-items: stretch (the default), NOT flex-start: the cells carry a per-cell
* border-bottom, so when one cell of a pair wraps (a 2-line hostname beside a 1-line MAC)
* a flex-start neighbour stays short and its separator paints 52px higher — a broken line
* mid-card (measured). Stretched, both cells take the flex line's height and the
* separators meet; content still sits at the top (the cells are blocks). */
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
.table.fs-stacked .td {
display: block; min-width: 0; width: auto;
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
overflow-wrap: anywhere; white-space: normal;
}
/* The column label, lifted above its value (LuCI writes it per cell as data-title).
*
* PINNED: the config table (theme/65-dropdown.css) needs these exact declarations under its
* @container guard, and CSS cannot share a block between a CLASS and a CONTAINER QUERY — the two
* guards do not OR. tools/mirror.mjs keeps the copies byte-identical; edit one and the build
* fails until you edit the other. (A duplicate detector goes QUIET once two copies differ —
* exactly when it should shout.) */
.table.fs-stacked .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
text-transform: uppercase; color: var(--fs-eyebrow-color);
/* @endmirror */
}
.table.fs-stacked .td.cbi-section-actions::before,
.table.fs-stacked .td:not([data-title])::before,
.table.fs-stacked .td[data-title=""]::before { content: none; }
/* The row's buttons are not a label/value pair — full width below the pairs.
* PINNED with the config table's copy — see above. */
.table.fs-stacked .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
/* LuCI groups a row's buttons in ONE inner <div> (startup.js wraps all five), so the td's own
* flex-wrap above never fires — the div is a single flex item, and .fs-main's overflow-x:clip
* CUT the tail button (Startup at 390px, measured: the div needs 350px in a 324px cell and
* "Stop" vanished). Same trap the config table hit on the interfaces row; PINNED to its copy
* in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > * {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* Nothing in a half-width cell may force the row wider than the viewport: .fs-main is
* overflow-x:clip, so an over-wide child is CUT, not scrolled to. */
.table.fs-stacked .td > *,
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
/* --- the DATA table's flavour (L.ui.Table, tagged .fs-dt) ---
* Clip rows to the frame's corners: zebra paints its background on the .tr and the frame is
* overflow:visible, so a striped row's SQUARE background poked past the 12px corners. Safe once
* stacked: a flex-wrap row fits the width, so there is no horizontal column left to clip — the
* only thing overflow:visible protected. `#view .table.fs-dt.fs-stacked` (1,3,0) outranks the
* frame's `#view .table[id]` (1,2,0) on specificity, not order. */
#view .table.fs-dt.fs-stacked { overflow: hidden; }
.table.fs-dt.fs-stacked .tr { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
/* `.fs-stacked .td`, not `.table.fs-dt.fs-stacked .td` — the same elements (the class is only
* ever written onto a .table.fs-dt), but at (0,2,0) instead of (0,4,0): low enough that the
* unguarded `.table .td.col-N` below, at (0,3,0), beats it on SPECIFICITY, not source order. */
.fs-stacked .td { flex: 1 1 50%; }
/* ---- COLUMN WEIGHTS: ONE unguarded block, unguarded ON PURPOSE ----
* A weight (1-10) is a share of a CARDED row — the JS says which cell is a narrow badge and which
* the wide status text (wireless.js col-2/3/4, connections.js col-7); without it every cell falls
* back to the flat 50% above and the badge gets as much room as the text.
*
* Do NOT copy them under a guard (a first pass did: twenty rules, two guards, free to drift).
* `flex` applies only to a FLEX ITEM, and a `.td` becomes one exactly when its `.tr` turns into a
* flex container — i.e. when the table cards, by EITHER mechanism (`.fs-stacked`, measured at any
* width; or the config table's `@container fs-content (max-width: 960px)`, which can card on a
* 1200px desktop). Anywhere else they are inert. So one copy serves the phone tier, the measured
* stack and the config table — which got its weights for the first time (cssdiff: nothing else
* moved). Basis N*30px, as in stock, so the weights compose the same way. */
.table .td.col-1, .table .th.col-1 { flex: 1 1 30px; }
.table .td.col-2, .table .th.col-2 { flex: 2 2 60px; }
.table .td.col-3, .table .th.col-3 { flex: 3 3 90px; }
.table .td.col-4, .table .th.col-4 { flex: 4 4 120px; }
.table .td.col-5, .table .th.col-5 { flex: 5 5 150px; }
.table .td.col-6, .table .th.col-6 { flex: 6 6 180px; }
.table .td.col-7, .table .th.col-7 { flex: 7 7 210px; }
.table .td.col-8, .table .th.col-8 { flex: 8 8 240px; }
.table .td.col-9, .table .th.col-9 { flex: 9 9 270px; }
.table .td.col-10, .table .th.col-10 { flex: 10 10 300px; }
/* general table cell padding — compact but readable (Startup/Processes/etc) */
.table .td { padding-top: var(--fs-space-2); padding-bottom: var(--fs-space-2); }
}
@@ -0,0 +1,205 @@
@layer theme {
/* Alert panel: a left accent stripe on a panel card; the type variants recolour the frame.
* `background: var(--fs-panel)` used to be repeated per variant to out-specify base's
* gradients — a layer does that for free (theme beats base whatever the specificity), so
* one rule does it. */
.alert-message {
padding: var(--fs-space-3-5) var(--fs-space-4);
border-radius: var(--fs-radius-lg);
border: 1px solid var(--fs-border);
/* logical: the stripe marks the READING start of the alert, so it swaps sides with the
* document (LuCI ships ar/fa/he catalogues) — physical, it sat on the far edge in RTL. */
border-inline-start: 3px solid var(--fs-accent);
/* THE SURFACE IS OPAQUE, THE STATUS TINT RIDES ON TOP OF IT. The variants below used to
* REPLACE this with `background: var(--fs-*-fill)` — and a `-fill` is a color-mix with
* TRANSPARENT (02-tokens.css), i.e. an 18% wash with nothing behind it. On a flat --fs-bg
* nobody could tell; under Appearance -> Wallpaper: Cats the doodles tile behind the
* content (theme/15-wallpaper.css leaves them at full strength precisely because "every
* block that carries text paints an opaque --fs-panel of its own") and read straight
* through all six status alerts. The plain .alert-message, the only one still on --fs-panel,
* was the one that looked right.
*
* So: --fs-panel stays the background-COLOUR for every variant, and the tint is a
* background-IMAGE layer over it. The named `-fill` step is unchanged — no fresh
* percentage, no second spelling of the tint — and the composite is what the 18% wash
* already looked like on a panel, so an untinted router sees the same pixels it did.
* The local `--fs-alert-tint` is what keeps this ONE rule instead of six `background:`
* overrides that each have to remember to re-state the panel. */
background-color: var(--fs-panel);
background-image: linear-gradient(var(--fs-alert-tint, transparent), var(--fs-alert-tint, transparent));
color: var(--fs-text);
box-shadow: var(--fs-shadow);
gap: var(--fs-space-3);
}
/* ---- STATUS LIVES IN THE STRIPE AND THE SURFACE; THE RING IS ALWAYS NEUTRAL ----
*
* It used to be split down the middle, and not on purpose: `.warning`/`.error` set
* `border-color`, i.e. ALL FOUR sides including the 3px stripe, while `.success`/`.notice` set
* only `border-left-color`. So four of the seven alerts in docs/gallery.html wore a coloured
* ring and three a grey one — that reads as a hierarchy nobody designed, and the difference
* between "not applied yet" and "this is about to fail" hung on one pixel of frame.
*
* The surface carries it instead, at the ladder's named `-fill` step (18%) — the one docs/design-system.md
* describes as the callout/diff surface, which is exactly what an alert is, and which
* 55-buttons.css already uses as a surface twice. NOT a fresh `color-mix(… 12%, --fs-panel)`:
* an unnamed level is how four spellings of one tint appeared here before.
*
* `:not(.btn)` is load-bearing. `admin/system/flash` renders its confirm control as
* `<label class="btn alert-message danger">` — a BUTTON wearing the alert classes. Without the
* guard the tint out-ranks `.btn.danger` on source order and the "Force upgrade" control fades
* from a danger fill to a pale wash: the one button on that page that must look dangerous.
* (Measured: rgb(234,122,116) -> the tint.) */
.alert-message.warning:not(.btn) {
border-color: var(--fs-border); border-inline-start-color: var(--fs-warn);
--fs-alert-tint: var(--fs-warn-fill);
}
.alert-message.error:not(.btn), .alert-message.danger:not(.btn) {
border-color: var(--fs-border); border-inline-start-color: var(--fs-danger);
--fs-alert-tint: var(--fs-danger-fill);
}
.alert-message.success:not(.btn) {
border-inline-start-color: var(--fs-good);
--fs-alert-tint: var(--fs-good-fill);
}
/* `.info` was styled by nothing — it fell through to the plain panel with an accent stripe,
* which is what a class-less .alert-message already looks like. It is a STATUS, so it tints
* like the others; a bare .alert-message stays a flat panel, because a note is not a status.
*
* `.notice` is grouped HERE, not with .success where styles/base had left it. Grouped there it
* was pixel-identical to success — same fill, same rail, no other channel — so one status word
* carried two meanings, while the theme's own `.label.notice` is accent (with `.important`) and
* `.cbi-tooltip.notice` is neutral: one word, three colours. What settles the direction is what
* LuCI means by it: ui.js emits `notice spinning` for the "applying configuration changes"
* banner, i.e. IN PROGRESS, not done — and stock luci-theme-bootstrap paints notice from
* `--background-color-*`, keeping green for success alone. Informational, so: accent. */
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
border-inline-start-color: var(--fs-accent);
--fs-alert-tint: var(--fs-accent-fill);
}
/* h2 is what the theme's own notices emit (the document had no heading above h4 at all); h4 is
* here because a third-party alert may use it, and coverage only grows. h5/h6 joined for the
* same reason — base styled them and the theme did not, so an alert heading looked like an
* alert heading down to h4 and then fell back to a bootstrap one. */
.alert-message :is(h2, h3, h4, h5, h6) { margin: 0 0 var(--fs-space-1); font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
/* the alert's own content reset — moved from base with the heading ramp (45-misc.css): its
* `line-height: inherit` beat the ramp's tight leading in-layer, and left behind it would
* lose to the ramp by layer (measured: alert headings dropped 21 -> 16.8px) */
.alert-message h4,
.alert-message h5,
.alert-message pre,
.alert-message ul,
.alert-message li,
.alert-message p {
border: none;
line-height: inherit;
background: transparent;
padding: 0;
}
/* --fs-text, not --fs-dim: the paragraph IS the message. The line below already gives every
* ul/ol/li/pre inside an alert full-strength ink, so a two-sentence alert had its first
* sentence muted and its bullet list not — and on the tinted surface above, dim is the value
* that measured 4.06:1 when the Reset button had it (55-buttons.css). */
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
/* …and the body elements an alert may carry. Absorbed from base/90-widgets.css, same values:
* the theme had claimed only <p> out of that rule's six selectors. */
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
/* inner table (firmware Current/Available) — flatten so it does not read as a nested card
* on the alert panel; subtle row dividers only */
.alert-message .table { margin: var(--fs-space-2-5) 0; background: transparent; border: 0; box-shadow: none; border-radius: 0; }
/* --fs-text, not --fs-eyebrow-color: everywhere else an eyebrow sits on --fs-panel, but here the
* surface is a status tint, and the muted ink does not survive it — measured on the firmware
* table over the warning fill, hicontrast/light: 3.35-3.36:1 against AA's 4.5. The eyebrow still
* reads as one from its case, tracking and size; only the ink changes. */
.alert-message .table .th,
/* The title-row selectors are not decoration: `.table .tr.table-titles .th` in 30-tables.css is
* FOUR classes (0,4,0) and out-ranks the plain one above (0,3,0), so without these the header
* kept the eyebrow ink no matter what this rule said — being later in the sheet buys nothing
* against a heavier selector, which is the point of the "never win on source order" rule. */
.alert-message .table .tr.table-titles .th,
.alert-message .table .tr.cbi-section-table-titles .th { color: var(--fs-text); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); text-transform: uppercase; letter-spacing: var(--fs-eyebrow-tracking); }
/* the column gap is --fs-space-3 like every other cell in the theme; --fs-space-3-5 here was the
* only 14px cell gap in the sheet, and nothing above defends it — the comment covers flattening
* the frame, not the rhythm. (The row density stays --fs-space-2: a table inside an alert is a
* dense readout, same as the config tables that state it deliberately.) */
.alert-message .table .th, .alert-message .table .td { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; background: transparent; border-color: color-mix(in srgb, var(--fs-border) 60%, transparent); }
.alert-message .table .td { color: var(--fs-text); }
/* The validation tooltip (ui.js pops it on a .cbi-tooltip-container). Its colour words were the
* ONE status surface the theme never claimed — base/60-modal.css said so in as many words —
* and nothing could see it: docs/gallery.html rendered a plain `.cbi-tooltip` only, so the
* absorption backlog for these could not be settled either way. An un-rendered widget shows no
* diff, and no diff reads as "already dead". The gallery renders all four now.
*
* The fill stays SOLID, unlike `.label` below: a tooltip floats over arbitrary content, so a
* border carrying the status would have to read against whatever happens to be behind it. The
* ink is the role's own `--fs-on-*`, which 03-palettes.css sizes per palette AND per mode — a
* global white failed AA on seven of the eight dark-palette fills. Same paint as base's, in the
* theme's flat notation rather than a `linear-gradient(C, C)`. */
/* ONE elevation for the widget. base/90-widgets.css lit it with `0 0 2px var(--fs-border)` — a
* raw 2px halo in the hairline colour, which measures 1.08-1.86:1 against the surfaces a
* tooltip lands on — while the theme already overrode the single instance somebody happened to
* look at (`.zonebadge .cbi-tooltip`, in 70-modal.css) to --fs-shadow-pop. A tooltip floats
* over ARBITRARY content, which is the same reason the colour variants below stay opaque, so it
* is the last surface that should be separated from the page by a hairline. The zonebadge
* override is gone with this: it now says the same thing as the general rule. */
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
/* base reveals it on :hover alone. The container is `cursor: help` and carries no tabindex in
* anything LuCI ships, so nothing can focus it today and this matches nothing — but the
* focusability belongs to ui.js, and a third-party app that gives its own tooltip container a
* tabindex gets a keyboard-reachable tooltip the day it does. */
.cbi-tooltip-container:focus-within .cbi-tooltip:not(:empty) { inset-inline-start: auto; opacity: 1; }
.cbi-tooltip.info { background: var(--fs-accent); border-color: var(--fs-accent); color: var(--fs-on-accent); }
.cbi-tooltip.success { background: var(--fs-good); border-color: var(--fs-good); color: var(--fs-on-good); }
.cbi-tooltip.error { background: var(--fs-danger); border-color: var(--fs-danger); color: var(--fs-on-danger); }
.cbi-tooltip.notice { background: var(--fs-panel2); border-color: var(--fs-border); color: var(--fs-text); }
/* The `.label` pill. LuCI core barely uses it, but any luci-app-* may reach for it, so the
* theme owns it in tokens (base's copy is absorbed). `.important` is the conventional name
* for what we call accent; `.danger` has no counterpart there and is added because
* packages use it. See docs/gallery.html. */
.label {
display: inline-block; padding: var(--fs-space-1) var(--fs-space-2); border-radius: var(--fs-radius-pill);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight); line-height: 1.4;
background: var(--fs-panel2); border: 1px solid var(--fs-border);
color: var(--fs-dim); text-shadow: none; text-transform: none;
/* carried over from the absorbed styles/base .label rule */
white-space: nowrap; margin: .125em 0 .125em .4em;
}
/* THE VARIANTS SET NO BACKGROUND, deliberately — they inherit the opaque --fs-panel2 from
* `.label` above. They used to paint a TRANSLUCENT tint of their own colour on it: text of
* colour C on a tint of C. The tint drags the background toward the text and eats the
* contrast the text needs, and being translucent it renders differently on every surface,
* so no single percentage is safe. Measured across palette x mode (axe-core): every variant
* landed at 3.4-4.4:1. On --fs-panel2 the palette guarantees >=4.5 by construction, and the
* coloured border carries the status just as loudly. */
.label.important, .label.notice { border-color: var(--fs-accent); color: var(--fs-accent); }
.label.warning { border-color: var(--fs-warn); color: var(--fs-warn); }
.label.success { border-color: var(--fs-good); color: var(--fs-good); }
.label.danger, .label.error { border-color: var(--fs-danger); color: var(--fs-danger); }
a.label:link, a.label:visited { color: var(--fs-dim); }
a.label:hover { text-decoration: none; color: var(--fs-text); }
/* The show/hide toggles in a section title are .label pills too, but clickable. SINGLE
* owner of the pill look: pages/20-overview.css used to restate it (page layer, unscoped —
* so it silently won everywhere) with drifted values; these are the ones that were actually
* rendering. The trailing overrides (text-transform … box-shadow) kill the uppercase /
* margin / min-width / badge decorations base used to put on these pills. */
.cbi-title .label {
font-size: var(--fs-type-xs); font-weight: var(--fs-weight);
padding: var(--fs-space-1) var(--fs-space-3); border-radius: var(--fs-radius-pill);
background: var(--fs-panel2); border: 1px solid var(--fs-border);
color: var(--fs-dim); cursor: pointer; line-height: 1.4;
text-transform: none; letter-spacing: 0;
margin: 0; min-width: 0; box-shadow: none;
}
.cbi-title .label:hover { color: var(--fs-text); border-color: var(--fs-dim); opacity: 1; }
/* These pills toggle section visibility — interactive, so keyboard focus must show. */
.cbi-title .label:focus-visible {
outline: none; border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring);
}
/* Surface stays OPAQUE (--fs-panel2 inherited from the base pill): accent text on
* --fs-accent-soft is the accent-on-its-own-tint AA failure measured at 4.21:1 in the open
* dropdown (theme/65-dropdown.css) — the border carries the accent instead. */
.cbi-title .label.notice { opacity: 1; color: var(--fs-accent); border-color: var(--fs-accent); }
}
@@ -0,0 +1,137 @@
@layer theme {
/* ==================================================================
* Tab strips — one component, four entry points. LuCI emits a strip under four selectors
* with no markup difference worth honouring:
* #tabmenu ul.tabs page tabs, rendered into the server shell
* ul.cbi-tabmenu.mode mode switcher (Available / Installed / Updates)
* .tabs view tabs (Routing -> IPv4 / IPv6)
* .cbi-tabmenu CBI form tabs (System -> General / Logging / …)
* They agree on everything except the active marker: `.active` for `ul.tabs`, `.cbi-tab`
* for every `.cbi-tabmenu` flavour. All four render as pills in a card.
* ================================================================== */
#tabmenu { margin-bottom: 0; }
/* the card */
#tabmenu ul.tabs,
ul.cbi-tabmenu.mode,
.tabs,
.cbi-tabmenu {
display: flex;
flex-wrap: wrap;
gap: var(--fs-space-1);
list-style: none;
margin: 0 0 var(--fs-card-gap);
padding: var(--fs-space-2);
background: var(--fs-panel);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow);
/* kills a folder-tab baseline gradient (the 1px x 29px background image the strip
* conventionally carries) by zeroing its size rather than restating it — base's copy is
* absorbed, but any gradient a view or third-party app sets is sized away too */
background-size: 0 0;
}
/* a strip nested inside a .cbi-section card -> soft inset row, no frame (a card inside a
* card reads as a bug) */
.cbi-section .tabs,
.cbi-section .cbi-tabmenu:not(.mode):not(.map) {
background: var(--fs-panel2);
padding: var(--fs-space-1-5);
box-shadow: none;
}
#tabmenu ul.tabs > li,
ul.cbi-tabmenu.mode > li,
.tabs > li,
.cbi-tabmenu > li {
height: auto; max-width: none;
margin: 0; padding: 0;
border: 0; background: none;
border-radius: var(--fs-radius);
color: var(--fs-dim);
}
#tabmenu ul.tabs > li > a,
ul.cbi-tabmenu.mode > li > a,
.tabs > li > a,
.cbi-tabmenu > li > a {
display: block;
margin: 0;
padding: var(--fs-space-2) var(--fs-space-3-5);
border: 0;
border-radius: var(--fs-radius);
line-height: 1.3;
font-size: var(--fs-type);
font-weight: var(--fs-weight);
color: var(--fs-dim);
transition: background var(--fs-dur), color var(--fs-dur);
}
#tabmenu ul.tabs > li > a:hover,
ul.cbi-tabmenu.mode > li > a:hover,
.tabs > li > a:hover,
.cbi-tabmenu > li > a:hover {
background: var(--fs-accent-soft);
color: var(--fs-text);
}
/* active pill */
#tabmenu ul.tabs > li.active,
ul.cbi-tabmenu.mode > li.cbi-tab,
.tabs > li.active,
.cbi-tabmenu > li.cbi-tab {
color: var(--fs-on-accent);
}
#tabmenu ul.tabs > li.active > a,
ul.cbi-tabmenu.mode > li.cbi-tab > a,
.tabs > li.active > a,
.cbi-tabmenu > li.cbi-tab > a {
background: var(--fs-accent);
color: var(--fs-on-accent);
font-weight: var(--fs-weight-bold);
}
/* ---- keyboard focus: a ring that survives the ACTIVE pill ----
*
* base/50-chrome.css drew it as `outline: 2px solid var(--fs-accent)` inset by -2px, which is
* invisible on exactly the tab most likely to be focused: the active pill's own background IS
* --fs-accent (rule above; measured on the router, both are rgb(86,157,245)). Tabbing across a
* strip, the ring vanished the moment it reached the current page's tab.
*
* ONE rule now, and the token is --fs-focus-ring-solo for both states: a tab flips nothing else
* on focus, so the ring IS the indicator, and the tinted --fs-focus-ring measures 1.15-1.29:1
* against the strip — the first version of this fix took it for the inactive pill and swapped
* one invisible indicator for another. -solo's 2px surface gap is also what lets the same
* value serve the ACTIVE pill, whose background is the accent the ring is drawn in.
* box-shadow, not outline, because the strip clips (which is why the outline was inset in the
* first place) and a shadow is not clipped by the scroll container. */
#tabmenu ul.tabs > li > a:focus-visible,
ul.cbi-tabmenu.mode > li > a:focus-visible,
.tabs > li > a:focus-visible,
.cbi-tabmenu > li > a:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring-solo);
}
/* Density steps for a crowded strip: fs-chrome.js (fitTabStrips) MEASURES whether the tabs
* wrap to a second row and, if so, adds .fs-dense1 then .fs-dense2. Padding is trimmed
* hard first (9/14 -> 5/9 -> 3/7), the font only at the d2 floor (13 -> 12) and the gap
* only there (4 -> 3), so a pill never gets tighter than its label; past the floor the
* strip may wrap. The id in the first selector matches the #tabmenu rule's specificity. */
#tabmenu ul.tabs.fs-dense1 > li > a,
.tabs.fs-dense1 > li > a,
.cbi-tabmenu.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
#tabmenu ul.tabs.fs-dense2,
.tabs.fs-dense2,
.cbi-tabmenu.fs-dense2 { gap: var(--fs-space-1); }
#tabmenu ul.tabs.fs-dense2 > li > a,
.tabs.fs-dense2 > li > a,
.cbi-tabmenu.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-2); font-size: var(--fs-type); }
/* map-level tabs keep the body font size instead of base's 16.5px/bold */
.cbi-tabmenu.map > li { font-size: var(--fs-type); font-weight: var(--fs-weight); }
/* the little "n errors" badge on a tab */
.cbi-tab-disabled[data-errors]::after { background: var(--fs-danger); }
}
@@ -0,0 +1,198 @@
@layer theme {
/* The realtime graphs (Status → Realtime *) size their drawing from #view — NOT from the box
* they draw into: `width = document.querySelector('#view').offsetWidth - 2`, in bandwidth.js,
* load.js, wireless.js and connections.js, and in the luci-app-*-status copies of them. That
* assumption only holds in a theme whose .cbi-section has no gutter (luci-theme-bootstrap's has
* none, which is why the page is whole there). Ours is a card: 16px padding + a 1px border, so
* the canvas came out 34px narrower than the drawing, and the views draw NEWEST-first from the
* right — the freshest samples were the ones falling off the edge. Measured on the router, on
* every realtime page and at every width: #view 1158 vs svg 1124. It reads as a phone bug only
* because the loss is absolute: 34px of a 1124px desktop plot is 3%, of a 322px phone plot 10%.
*
* So bleed the graph box back out to the card's border edge, making the canvas exactly
* #view - 2 again. The bleed is padding + border, and it is derived from --fs-card-pad rather
* than restated, or narrowing the gutter would silently re-clip every graph.
*
* Scoped by SHAPE, not by page: a graph is an inline-styled box wrapping an <svg>, one card
* deep. The luci-app-*-status graphs are third-party pages this theme has never seen, and they
* inherit the bug by copying the stock view. channel_analysis.js is deliberately NOT matched —
* it measures its OWN box, so it is already correct, and it hangs off a bare [data-tab] div
* rather than a .cbi-section. The !important fights the inline `width: 100%` on the box; nothing
* in a cascade layer can outrank that. */
#view .cbi-section > div[style]:has(> svg) {
width: auto !important;
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
}
/* WCAG 2.2 SC 1.4.1 (Use of Color, A): inside a block of text a link may not be identified
* by COLOUR ALONE — it needs a non-colour cue or 3:1 against the surrounding text, and
* --fs-accent on --fs-text measures 1.4:1 in dark mode. So: underline links in prose.
* Scoped to prose deliberately — nav links, buttons, pills and table cells are identified
* by position and shape, and underlining them would be noise. */
p a, dd a,
.cbi-value-description a,
.alert-message a:not(.btn):not(.cbi-button) {
text-decoration: underline;
}
/* ==================================================================
* A <strong>/<b> is a LABEL, and a label is UI text — so it takes the UI face even on a
* monospaced surface. LuCI builds every status readout as `<strong>MAC:</strong>
* ac:1f:6b:…`: the strong names the datum, the text after it IS the datum. The theme puts
* mono on those surfaces (.cbi-value-field, data-table cells, ifacebox bodies), the strong
* inherited it, and <strong> also means weight 700 — so the label asked for a BOLD MONO
* face that exists for no other reason. Measured on the live router: 227 elements across
* seven pages, 20 KB of jetbrains-mono-600 fetched to draw them — 30% of the theme's whole
* font payload, spent on the word "MAC:". Manrope 700 is already downloaded: zero bytes.
*
* It must SET the face, not merely be excluded from the mono rule — an excluded element
* still INHERITS its parent's. That was the first attempt and it changed nothing.
*
* pre/code/#syslog/textarea are exempt: there a <strong> is part of the monospaced CONTENT
* (a highlighted config line, a log level), not a label for it, and its bold resolves onto
* the mono 400 face, which is still shipped.
* ================================================================== */
:is(strong, b):not(pre *, code *, #syslog *, textarea *) {
font-family: var(--fs-font-sans);
}
/* Same argument, the last two holdouts — found by measuring:
* .ifacebadge is a BADGE — chrome, not a value. It sits in a mono cell and
* .ifacebadge-active makes it bold, so the four active firewall zone badges were the last
* thing on the router asking for a bold mono face. Sans keeps the active/inactive weight cue
* (WCAG 1.4.1 — that distinction is not carried by colour) with faces the page already has.
* <code>/<pre> hold a LITERAL, which does not inherit its container's emphasis:
* `<code>/etc/ethers</code>` inside a .cbi-value-title (600) was asking for bold mono.
*
* The second selector is not redundant: the mono rule in theme/60-inputs.css is
* `.cbi-value-field *:where(…)` = (0,1,0), a TIE with a bare `.ifacebadge` — and a tie is
* settled by SOURCE ORDER, which 60-inputs would win. Win on specificity (0,2,0) instead. */
.ifacebadge,
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
code, pre { font-weight: var(--fs-weight-normal); }
/* fade-in used by overview poll */
.fade-in { animation: fs-fade var(--fs-dur-fade); }
@keyframes fs-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.cbi-title-ref { color: var(--fs-accent); }
.close { color: var(--fs-text); text-shadow: none; opacity: .6; }
.close:hover { opacity: 1; }
.ifacebox { background: var(--fs-panel); border-color: var(--fs-border); }
.ifacebox .ifacebox-head { background: var(--fs-panel2); color: var(--fs-text); }
/* The active-interface highlight lives in THIS layer, next to the head it overrides: the
* same rule in base was dead — a layer beats specificity, and the plain `.ifacebox-head`
* above repainted `.active` along with the rest. */
.ifacebox .ifacebox-head.active { background: var(--fs-accent); color: var(--fs-on-accent); }
.ifacebadge { background: var(--fs-panel2); border-color: var(--fs-border); }
.ifacebadge-active { border-color: var(--fs-accent); }
.zonebadge-empty { border-color: var(--fs-border); color: var(--fs-dim); }
/* ==================================================================
* The @container PLUMBING for the responsive tables — only WHICH boxes are query containers,
* under what names. The card rules are NOT here: a CBI section table cards under `@container
* fs-content (max-width: 960px)` (theme/65-dropdown.css), a data table cards by MEASUREMENT
* (.fs-stacked, fs-select.js) at any width — which is why no data-table threshold appears in
* this file.
*
* Query the CONTENT width, not the viewport: the sidebar eats width, so a viewport breakpoint
* would stack too late (or too early) depending on layout.
*
* NAMED, deliberately — an unnamed @container binds to the nearest ancestor that happens to
* have a container-type, so giving container-type to any new component would silently
* re-target every unnamed query underneath it.
* fs-map this element specifically (a CBI form's content column)
* fs-view the overview's grid (pages/20-overview.css, @container 800)
* fs-content "whatever content column I am in" — carried by BOTH .cbi-map and #view, so a
* query resolves to the NEAREST of the two. That is the point of sharing it.
* ================================================================== */
.cbi-map { container-type: inline-size; container-name: fs-map fs-content; }
#view { container-type: inline-size; container-name: fs-view fs-content; }
/* A modal is NOT inside #view — ui.js appends #modal_overlay to <body> — so every
* @container query above resolved to nothing in there and no table stacked: on a phone the
* Edit-interface dialog rendered its form at desktop widths and cut the inputs off at the
* dialog's right edge. Carry BOTH names so a dialog stacks its .cbi-section-table
* (fs-content) and its data tables (fs-view) exactly like the page — including any
* third-party app's modal. */
.modal { container-type: inline-size; container-name: fs-view fs-content; }
/* pager (« prev · text · next ») — generic, not page-scoped: any .pager gets an inline
* centred row with compact square buttons. */
.pager.center { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2-5); padding: var(--fs-space-3-5) 0; }
.pager .text { flex: 0 0 auto; font-size: var(--fs-type); color: var(--fs-dim); }
/* The heading ramp — absorbed from base wholesale (docs/css.md, the "native elements and
* typography" category). Stock LuCI hands h2 to a page title and h3/legend to a section
* title, and the heading-card rule below + theme/30-tables.css repaint exactly those two —
* so most of the ramp is only ever rendered by a third-party luci-app-*; it is styled
* anyway, coverage is a contract. Every step is a scale token and the ramp is monotonic.
* `legend` is deliberately NOT in the group: theme/30-tables.css owns legend (and carries
* the ramp's ink), and a member here would fight it at equal specificity, i.e. on source
* order — the one thing a cascade must not rest on. */
h1, h2, h3, h4, h5, h6 {
font-weight: var(--fs-weight-bold);
color: var(--fs-text);
/* 1.5 is prose leading; a heading is a label and looks slack at it. */
line-height: var(--fs-leading-tight);
}
h1 { margin-bottom: var(--fs-space-4); font-size: var(--fs-type-2xl); }
h2 { font-size: var(--fs-type-xl); }
h3 { font-size: var(--fs-type-lg); }
h4 { font-size: var(--fs-type); }
h5 { font-size: var(--fs-type); }
h6 { font-size: var(--fs-type-xs); color: var(--fs-dim); text-transform: uppercase; }
/* Heading card (h2 + .cbi-map-descr): everything sitting directly on the page background
* gets a .cbi-section card's frame, so a page reads as a stack of panels instead of loose
* text floating over the (possibly patterned) background. */
.cbi-map > h2,
#view > h2 {
background: var(--fs-panel);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow);
margin: 0 0 var(--fs-card-gap);
padding: var(--fs-space-4);
font-size: var(--fs-type-xl);
font-weight: var(--fs-weight-bold);
color: var(--fs-text);
}
/* a heading followed by a description: split the card across the two siblings (no wrapper
* element exists to hold both) */
.cbi-map > h2:has(+ .cbi-map-descr),
#view > h2:has(+ .cbi-map-descr) {
border-bottom: 0;
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
padding-bottom: var(--fs-space-1);
margin-bottom: 0;
box-shadow: none;
}
.cbi-map > h2 + .cbi-map-descr,
#view > h2 + .cbi-map-descr {
background: var(--fs-panel);
border: 1px solid var(--fs-border);
border-top: 0;
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
box-shadow: var(--fs-shadow);
margin: 0 0 var(--fs-card-gap);
padding: 0 var(--fs-space-4) var(--fs-space-4);
color: var(--fs-dim);
font-size: var(--fs-type);
line-height: var(--fs-leading);
}
/* An action-button row (a `.right` wrapper holding Add/Save) sitting directly under a field
* had no gap above it — the apk "Repo Public Keys -> Add key" paste box, and every other
* paste-a-value-then-submit layout, pinned its button to the textarea. Scoped to a `.right`
* immediately following a form control, so it never touches right-aligned text or cells. */
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
margin-top: var(--fs-space-2-5);
}
/* A button row belonging to the PAGE TITLE rather than to any section (`.cbi-title-section >
* .cbi-title-buttons`; Status → Channel Analysis puts "Refresh Channels" there, a
* third-party app may put anything). Nothing gave it a bottom margin, so it sat flush
* against the first .cbi-section below and read as part of that card instead of as a
* page-level action. Same 16px every .cbi-section already uses. */
.cbi-title-buttons { margin-bottom: var(--fs-card-gap); }
}
@@ -0,0 +1,102 @@
@layer theme {
/* ==================================================================
* THE TOP-LAYOUT BAR — :root[data-layout="top"], EVERY width.
*
* A DELTA, not a layout. The bar itself (sticky, blurred, row, popup submenus, square icon
* buttons, right cluster) is the UNGUARDED base in 20-shell.css — the bar is what the chrome
* IS unless the vertical sidebar overrides it; see the note there. This file adds what the
* TOP-LAYOUT bar wants on top of that base:
* - a designed bar height, and padding aligned to the content column;
* - the menu on the brand's row instead of wrapped beneath it.
*
* NO `@media` floor. The top bar used to gate these deltas at `min-width: 768px` and fall back
* to the phone bar below it — but "does the menu fit on the brand's row?" is a property of the
* CONTENT (5 sections stock, eleven with a few luci-app-*), not of the viewport, so the whole
* bar is now MEASURED: fs-chrome.js (fitChrome) shrinks the pills, then collapses the poll pill
* to an icon, then stacks the menu onto its own row — at any width. The under-item dropdown and
* its clamp therefore apply at every width too (menu-footstrap.js clampDropdown, no longer
* gated on 768) — and, since issue #19, in every bar STATE as well, which is why they sit in
* the base rather than here. There is no distinct left-pinned phone bar any more, in either
* layout: one bar, one dropdown anchor.
* ================================================================== */
:root[data-layout="top"] .fs-sidebar {
/* min-height, not height: the bar has a designed height but must be able to GROW —
* a long hostname wraps onto a second line inside the capped brand (20-shell.css),
* and a fixed height would clip it. */
min-height: var(--fs-bar-h); height: auto;
gap: var(--fs-space-3);
/* nowrap while the menu rides the brand's row. Without it flexbox wraps the BAR,
* carrying the menu onto a second line by itself while its own pills stay in one
* row — a stacked bar in everything but the styling (the right cluster keeps its
* single-row ordering, and the bar silently grew to 120px). It also destroys the
* fits-on-one-row MEASUREMENT, because a menu handed a whole row of course fits.
* Forbidding the wrap forces the menu to share the row and to wrap its OWN pills
* when it cannot — which is exactly the signal fitChrome measures. */
flex-wrap: nowrap;
/* align the bar's contents to the .fs-content column, so the brand and the menu line
* up with the headings and the cards below */
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
box-shadow: var(--fs-shadow-bar);
}
/* the menu rides the brand's row; its flex:1 opens the gap that pushes the right-hand
* cluster to the content column's right edge */
:root[data-layout="top"] .fs-sidebar ul.nav {
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
}
:root[data-layout="top"] .fs-sidebar #indicators,
:root[data-layout="top"] .fs-themerow,
:root[data-layout="top"] .fs-logout { order: 3; margin-inline-start: 0; }
/* The under-item dropdown anchor, its hover bridge and the clamp are NOT here: they belong to
* the BAR and live unguarded in 20-shell.css. They used to be this file's, and that WAS issue
* #19 — the sidebar layout collapsed onto a bar is the same chrome, and it inherited a panel
* pinned to the bar's left edge with no bridge, unreachable by hover. This file is the top
* layout's delta; a rule every bar state needs is not a delta. */
/* the mode strip (only rendered when a router has more than one top-level mode) takes
* its own full-width row under the bar */
/* :not(:empty):not(.single) instead of a hide pair — the bar's base rule (20-shell.css)
* already hides the strip; see the sidebar twin there for why the pair was folded. */
:root[data-layout="top"] .fs-modemenu:not(:empty):not(.single) {
display: flex; order: 4; flex: 1 1 100%; margin: 0; gap: var(--fs-space-1);
}
/* the bar replaces the sidebar's 22px top padding with the top-nav's 26px */
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
/* ==================================================================
* THE MENU DOES NOT FIT ON THE BRAND'S ROW — .fs-bar-stack
*
* Set by fs-chrome.js (fitChrome), NOT by a media query. Whether the menu fits beside the
* brand depends on how many sections the router HAS — 5 on a stock install, eleven with a few
* luci-app-* packages — so the width at which the bar must stack is a property of the CONTENT,
* not of the device, and a hard-coded breakpoint gets it wrong in both directions. The JS
* shrinks the pills first (.fs-dense1/2, theme/10-chrome.css), then collapses the poll pill
* (.fs-ind-compact, below), and stacks only when even that still overflows.
* ================================================================== */
:root[data-layout="top"] .fs-sidebar.fs-bar-stack {
flex-wrap: wrap; height: auto; padding: var(--fs-space-2-5) var(--fs-space-4);
}
:root[data-layout="top"] .fs-sidebar.fs-bar-stack ul.nav {
order: 3; flex: 1 1 100%; margin-inline-start: 0;
}
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow,
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-logout { order: 2; }
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow { margin-inline-start: auto; }
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
/* Two .fs-themerow buttons (Search, then Appearance): exactly like the base bar (20-shell.css),
* only the FIRST rendered far-edge item may keep the auto inline-start margin, or flexbox SPLITS the
* free space between the two autos and Search drifts to mid-bar. The base `.fs-themerow ~
* .fs-themerow` guard is out-specified by the auto rule above (0,4,0 > 0,2,0), so it must be
* restated here. Bites when #indicators is :empty (display:none) — e.g. right after an SPA nav,
* before the poll pill returns. */
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
/* The right cluster's own escalation — .fs-ind-compact and .fs-bar-actrow — is NOT here: it
* belongs to the BAR, and the sidebar layout is a bar too once fitShell has stamped
* data-narrow. Both live with the bar in 20-shell.css; this file is only the top layout's
* delta, and the menu's .fs-bar-stack above is the part that really is top-only. */
}
@@ -0,0 +1,303 @@
@layer theme {
/* Buttons placed straight in a field, not in a .control-group (app action rows
* Enable/Disable, Start/Restart/Stop): inline-block buttons only get whitespace spacing
* horizontally, so on wrap the rows collapsed together. Wrapping flex with a real gap. */
.cbi-value-field:has(> .cbi-button) {
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
}
/* The button. The native `input` types are in the selector list on purpose: LuCI's own views
* always use <button class="cbi-button">, but a third-party luci-app-* may emit a bare
* <input type="submit">, and base only gives those width/height: auto — coverage is a
* contract (docs/gallery.html). `.btn` and `.cbi-button` are interchangeable in LuCI markup
* (a row-action button usually carries both), so every rule here names both. */
.cbi-button, .btn,
input[type="button"], input[type="submit"], input[type="reset"] {
display: inline-flex; align-items: center; justify-content: center;
/* auto, stated: theme/60-inputs.css's generic `input` box says 210px x 32px, and a base
* reset would lose to it by layer — a button sizes to its label */
width: auto; height: auto;
min-height: var(--fs-ctl-h);
padding: var(--fs-space-2) var(--fs-space-3-5);
font-family: var(--fs-font-sans); font-weight: var(--fs-weight); line-height: 1.1;
color: var(--fs-text);
background: var(--fs-panel2);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
box-shadow: none; text-shadow: none;
/* border-COLOR, not `border`: the shorthand animates border-width too — a layout
* animation on every hover instead of a paint one (absorbed from base with the ring) */
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* bare `button` rides along: a third-party app's unclassed <button> hovered the same border
* cue in base's old glow rule, and border-color is a no-op on its border: 0 box — harmless
* there, meaningful the moment the app gives it a border */
.cbi-button:hover, .btn:hover, button:hover,
input[type="button"]:hover, input[type="submit"]:hover, input[type="reset"]:hover { border-color: var(--fs-dim); }
/* Keyboard focus — not decoration: without this a button shows NOTHING on focus. The
* `box-shadow: none` above kills any stray focus glow (theme beats base regardless of
* specificity), and the UA outline only ever shows on :focus-visible, which this rule
* restyles — so Save, Save & Apply, Reset, every row action and the login submit would
* otherwise look untabbable. A plain mouse :focus deliberately shows nothing: base's old
* glow rang every clicked button, and the pressed cue below already answers the click.
* Same --fs-focus-ring as every field, so focus looks like one thing. */
.cbi-button:focus-visible, .btn:focus-visible,
input[type="button"]:focus-visible, input[type="submit"]:focus-visible, input[type="reset"]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
/* Solid variants sit ON --fs-accent/--fs-good/--fs-danger, so an accent-tinted ring is
* invisible against them: ring in the surface colour with an accent halo. That halo was
* written out here as a literal ending in --fs-accent-soft — a 10-15% tint, i.e. 1.15-1.29:1
* against the surface, so the only thing this rule really painted was the 2px gap. It is
* --fs-focus-ring-solo now: same geometry, solid accent, and one name for the four rules that
* had been spelling it out. */
.cbi-button-positive:focus-visible, .cbi-button-action:focus-visible,
.cbi-button-negative:focus-visible, .cbi-button-remove:focus-visible,
.cbi-button-apply:focus-visible, .cbi-button-save:focus-visible,
.btn.primary:focus-visible, .btn.danger:focus-visible,
.btn.error:focus-visible, .btn.success:focus-visible, .btn.info:focus-visible {
outline: none;
border-color: transparent;
box-shadow: var(--fs-focus-ring-solo);
}
/* Pressed state. The `box-shadow: none` above would erase any press cue, so the inset shadow
* is declared here — the sole copy (base's identical one was absorbed). */
.cbi-button:active, .btn:active, .btn.active,
input[type="button"]:active, input[type="submit"]:active, input[type="reset"]:active {
box-shadow: inset 0 2px 4px color-mix(in srgb, var(--fs-border) 40%, transparent);
}
/* Solid variants — THREE fills, not six. `.cbi-button-*` is what LuCI's own views emit;
* `.btn.<colour-word>` is what a third-party luci-app-* tends to emit, and base/60-modal.css
* paints those words with gradients that are DEAD — the plain `.btn` rule above sets
* background/border/color in a LATER layer, which wins regardless of specificity, so an
* unclaimed `.btn.danger` would render as a neutral grey button with no colour cue.
* Coverage is a contract, so the theme claims them — in the SAME rule as their
* .cbi-button-* twin, because written apart (which they were, twenty lines away with a
* byte-identical body) a recolour lands on one half and not the other. */
.cbi-button-positive, .btn.success {
background: var(--fs-good); border-color: transparent; color: var(--fs-on-good);
}
.cbi-button-action, .cbi-button-action.important, .btn.primary, .btn.info {
background: var(--fs-accent); border-color: transparent; color: var(--fs-on-accent);
}
.cbi-button-negative, .cbi-button-negative.important, .cbi-button-remove,
.btn.danger, .btn.error {
background: var(--fs-danger); border-color: transparent; color: var(--fs-on-danger);
}
/* This hover must stay AHEAD of the page-action Save rule below: at equal specificity its
* `border-color: transparent` would erase Save's outline on hover. (.cbi-button-remove is
* deliberately absent — it never had the lift, and the row-action rules colour it their
* own way.) */
.cbi-button-positive:hover, .cbi-button-action:hover, .cbi-button-apply:hover,
.cbi-button-save:hover, .cbi-button-negative:hover,
.btn.success:hover, .btn.info:hover, .btn.danger:hover, .btn.error:hover {
filter: var(--fs-hover-lift); border-color: transparent;
}
.cbi-button-reset, .cbi-button-link { background: transparent; border-color: var(--fs-border); color: var(--fs-dim); }
/* Size words. The `.btn` rule above re-sets padding and radius from the token scale in a
* later layer, so a size word that carried only a font-size (as base's absorbed copy did)
* came out with default-button padding. Theme owns .btn.large/.small in full now, geometry
* on the radius scale rather than base's hardcoded 6px. */
.btn.large { font-size: var(--fs-type-lg); min-height: var(--fs-ctl-h-lg); padding: var(--fs-space-2) var(--fs-space-4); border-radius: var(--fs-radius); }
.btn.small { font-size: var(--fs-type-xs); min-height: var(--fs-ctl-h-sm); padding: var(--fs-space-1) var(--fs-space-2-5); border-radius: var(--fs-radius-sm); }
.cbi-section .table[id] .td .btn,
.cbi-section .table[id] .td .cbi-button { padding: var(--fs-space-1) var(--fs-space-2-5); font-size: var(--fs-type); }
.alert-message .btn, .alert-message .cbi-button {
border-radius: var(--fs-radius); padding: var(--fs-space-2) var(--fs-space-3); font-weight: var(--fs-weight);
background: var(--fs-panel2); border: 1px solid var(--fs-border); color: var(--fs-text);
}
.alert-message .btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
/* Page action bar (.cbi-page-actions) — the Save/Apply footer. base renders a tall
* half-empty plate with bottom-only radius; make it a compact rounded action bar. */
.cbi-page-actions {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: var(--fs-space-2) 0;
margin: 0 0 var(--fs-card-gap);
text-align: end;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--fs-space-2);
}
.cbi-page-actions .cbi-button,
.cbi-page-actions .btn {
min-height: var(--fs-ctl-h);
padding: 0 var(--fs-space-5);
border-radius: var(--fs-radius);
font-weight: var(--fs-weight);
display: inline-flex; align-items: center; justify-content: center;
vertical-align: middle;
}
/* Reset takes the SAME STEP of the ladder as Save (-soft at rest, -fill on hover), so the two
* read as equally weighted secondary actions beside the solid Save & Apply — but off the
* DANGER role, not the accent one: Reset discards your unsaved changes. (An opaque
* --fs-panel2 was the first attempt and weighed MORE than Save's translucent 10-15% tint: a
* solid surface and a tint do not read as the same fill, whatever their lightness.)
* Label is --fs-text for the same reason as Save's: --fs-dim on --fs-danger-soft measured
* 4.06:1 and failed AA. The role lives in the fill and the border. */
.cbi-page-actions .cbi-button-reset {
background: var(--fs-danger-soft);
border: 1px solid var(--fs-danger-line);
color: var(--fs-text);
}
.cbi-page-actions .cbi-button-reset:hover {
background: var(--fs-danger-fill);
border-color: var(--fs-danger-line-hi);
}
/* THERE IS NO `.spinning` PADDING LADDER HERE ANY MORE, and it must not come back. It used to
* be seven selectors reserving room for the absolutely-positioned glyph base draws — each one
* climbing a class above whatever else padded that element. The ladder lost twice in the field
* (issues #15 and #22, the second to a 0,5,0 data-table rule), and every loss looks the same:
* the spinner draws on top of the label, silently. base/95-luci.css puts the glyph IN THE FLOW
* instead, so it reserves its own space and no host padding is involved. */
input[type="file"]::file-selector-button {
margin-inline-end: var(--fs-space-3); padding: var(--fs-space-2) var(--fs-space-3-5);
font-family: var(--fs-font-sans); font-size: var(--fs-type); font-weight: var(--fs-weight);
color: var(--fs-text); background: var(--fs-panel2);
border: 1px solid var(--fs-border); border-radius: var(--fs-radius-sm); cursor: pointer;
}
input[type="file"]::file-selector-button:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
/* ==================================================================
* Row-action buttons (Edit / Delete / Start-Stop) in a table or a .cbi-section-actions
* container. Compact so tables stay dense. Neutral outline by default; the variants colour
* the ones that carry meaning (Edit = accent, Delete = danger). The neutral :hover comes
* FIRST on purpose — it used to sit last and greyed the coloured variants back out on hover,
* which only an !important on their colours hid. Equal specificity, so a variant hover must
* be written after it.
*
* WCAG 2.2 SC 2.5.8 (Target Size, AA): a pointer target must be >= 24x24 px, or spaced so
* 24px circles centred on adjacent targets do not overlap. At 28px tall with a 1px margin
* either side the circles DID overlap, so the exemption did not apply. 32px clears it.
* ================================================================== */
.td .cbi-button, .td .btn, .cbi-section-actions .cbi-button, .cbi-section-actions .btn,
.cbi-section-table .cbi-button {
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); font-size: var(--fs-type);
}
.td .cbi-button, .cbi-section-actions .cbi-button, .cbi-section-table .cbi-button {
background: transparent; background-image: none;
border: 1px solid var(--fs-border); color: var(--fs-dim);
}
.td .cbi-button:hover { border-color: var(--fs-dim); color: var(--fs-text); }
.td .cbi-button-action, .td .cbi-button-edit, .td .cbi-button-apply,
.cbi-section-actions .cbi-button-edit, .cbi-section-actions .cbi-button-action {
color: var(--fs-accent); border-color: var(--fs-accent-line); background: transparent;
}
/* THE HOVER INK IS --fs-text, NOT THE ROLE COLOUR, and that is the rule this file already
* states twice with measurements: text of colour C on a translucent tint of C eats its own
* contrast (Save: accent on accent-soft = 4.25:1, an AA failure; the alert `.label` chip: 3.4-4.4
* across the palettes). The row actions were the last place it survived — the role still reads,
* from the fill and the border, which is exactly how Save and Reset carry theirs. */
.td .cbi-button-action:hover, .td .cbi-button-edit:hover, .td .cbi-button-apply:hover,
.cbi-section-actions .cbi-button-action:hover {
background: var(--fs-accent-soft); color: var(--fs-text);
border-color: var(--fs-accent-line-hi);
}
/* An enabled/positive toggle in a table cell (System -> Startup) rendered as blue text on a
* green fill = unreadable. Green outline instead, matching the red-outline negative button. */
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
}
/* …and the border-colour the other two variants already had. Without it the green outline stayed
* at -line while action and negative brightened to -line-hi, so hovering the positive toggle
* read as less responsive than its neighbours in the same row. */
.td .cbi-button-positive:hover, .cbi-section-actions .cbi-button-positive:hover {
background: var(--fs-good-soft); color: var(--fs-text);
border-color: var(--fs-good-line-hi);
}
.td .cbi-button-negative, .td .cbi-button-remove,
.cbi-section-actions .cbi-button-remove, .cbi-section-actions .cbi-button-negative {
color: var(--fs-danger); border-color: var(--fs-danger-line); background: transparent;
}
.td .cbi-button-negative:hover, .td .cbi-button-remove:hover,
.cbi-section-actions .cbi-button-negative:hover {
background: var(--fs-danger-soft); color: var(--fs-text);
border-color: var(--fs-danger-line-hi);
}
/* Page actions: Save & Apply is THE primary action, Save is demoted so only one button reads
* as primary. `--on-color` drives the spinner mask in styles/base. */
.cbi-page-actions .cbi-button-apply,
.cbi-page-actions .cbi-dropdown .cbi-button-apply {
background: var(--fs-accent);
background-image: none;
border: 1px solid transparent;
color: var(--fs-on-accent);
--on-color: var(--fs-on-accent);
text-shadow: none;
}
/* Save carries a TINT at rest, not a bare outline: beside the solid Save & Apply a fully
* transparent button reads as disabled rather than secondary. The steps come off the role
* ladder (02-tokens.css) instead of being invented — -soft (12%) at rest, -fill (18%) on
* hover: one step UP the same ladder.
*
* THE LABEL IS --fs-text, NOT --fs-accent: never put text of colour C on a translucent tint
* of C (docs/design-system.md). The tint drags the surface toward the text and eats its own contrast —
* accent-on-accent-soft measured 4.25:1, an AA failure axe caught the moment the tint went
* in. The role is carried by the fill and the border; the label only has to be legible.
*
* `filter: none` cancels the shared hover's --fs-hover-lift: on a translucent tint a
* brightness shift washes the fill out instead of deepening it. */
.cbi-page-actions .cbi-button-save,
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
background: var(--fs-accent-soft);
background-image: none;
border: 1px solid var(--fs-accent-line);
color: var(--fs-text);
--on-color: var(--fs-on-accent);
text-shadow: none;
}
.cbi-page-actions .cbi-button-save:hover {
background: var(--fs-accent-fill);
border-color: var(--fs-accent-line-hi);
filter: none;
}
/* Buttons that are part of a compound field widget. */
.cbi-dynlist > .add-item > button,
.cbi-dynlist > .add-item > .cbi-button-add {
/* min-width was a bare 38px — the same number --fs-ctl-h already states. A restated value
* is the drift this ladder exists to prevent, and it also sat out the Density axis. */
flex: 0 0 auto; width: auto; min-width: var(--fs-ctl-h);
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); margin: 0;
border: 0; border-inline-start: 1px solid var(--fs-border);
border-radius: 0;
background: transparent; color: var(--fs-dim);
}
.cbi-dynlist > .add-item > button:hover,
.cbi-dynlist > .add-item > .cbi-button-add:hover { color: var(--fs-accent); }
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral {
border: 0; border-radius: 0; margin: 0;
background: transparent; color: var(--fs-dim);
padding: 0 var(--fs-space-3); min-height: var(--fs-ctl-h);
border-inline-start: 1px solid var(--fs-border);
}
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral:hover { color: var(--fs-accent); }
/* SQUARE, ON THE LADDER: these were the only buttons in the theme set in bare pixels
* (40 wide x 34 high), and 34 was the one control height that appears in no scale — the ladder
* is --fs-ctl-h-sm/-h/-h-lg = 30/38/44, plus the derived FIELD height (--fs-control-h, 32).
* A literal also opts out of the Density axis: every other control shrinks with
* --fs-density-box and the pager did not. Square because the glyph is an arrow, not a label. */
.pager .btn.prev, .pager .btn.next {
flex: 0 0 auto; width: var(--fs-ctl-h); min-width: var(--fs-ctl-h); height: var(--fs-ctl-h); padding: 0;
border-radius: var(--fs-radius); border: 1px solid var(--fs-border);
background: var(--fs-panel2); color: var(--fs-text);
display: inline-flex; align-items: center; justify-content: center;
}
.pager .btn.prev:hover, .pager .btn.next:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
}
@@ -0,0 +1,796 @@
@layer theme {
/* value rows.
* display:flex + align-items:baseline come from base — do not restate, and do not switch to
* center: a row's field can be taller than its control because a `.cbi-value-description`
* sits under it, and center then pushed the label to the middle of control+description,
* visibly BELOW the control. baseline lines the label up with the field's FIRST line for
* every widget (select, dropdown, toggle, dynlist, textarea). */
.cbi-value { justify-content: space-between; gap: var(--fs-space-4); padding: var(--fs-space-2) 0; margin: 0; border-bottom: 1px solid var(--fs-border); }
/* stacked controls in a field (Local Time: date input + Sync buttons) need breathing room —
* the raw <br> collapses them. Exclude the password reveal group: it must stay seamless. */
.cbi-value-field > .control-group { margin-top: var(--fs-space-2); }
.cbi-value-field > .control-group:not(:has(.cbi-input-password)) {
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center;
}
.cbi-value:last-child { border-bottom: 0; }
/* A toggle/checkbox field has NO text baseline: `.cbi-checkbox` is inline-flex with no line
* box, so its synthesised baseline is the box's BOTTOM edge. Under align-items:baseline the row
* then hangs the toggle from the label's baseline and floats it ~5px ABOVE the label, on every
* toggle row across the UI. Centre those rows instead — but ONLY when no help sentence sits under
* the control: with a `.cbi-value-description` present, centring drops the label to the middle
* of control+description, the exact regression the align-items comment above warns about. */
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
align-items: center;
}
/* The generic control transition — absorbed from base's transition group. ONLY the
* transition: control typography stays in base on purpose (see the note there — as a
* bare-element theme rule it out-layered `.zonebadge-empty`'s smaller font, measured
* 10.8 -> 13px on the firewall). border-COLOR, not `border`: the shorthand animates
* border-width too — a layout animation on every hover instead of a paint one. */
input, button {
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* keep base's padding-top: 6px — it is tuned for base's align-items: baseline. */
.cbi-value-title {
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
/* stated, not inherited: the control rule above hands every label `line-height: normal`,
* and this one is TEXT — dropping it silently hands the label the font's own default
* (measured: 18px -> normal, off the scale entirely). Absorbed from base with the rule
* that made it necessary. */
line-height: var(--fs-leading);
}
/* Mono is for the *values* a field holds. The exclusions:
* BUTTONS (Sync with browser, Start/Restart/Stop) are chrome, not data — excluded here
* rather than relying on theme/55-buttons.css being concatenated later. :where() keeps
* the exclusion at zero specificity, so the <select> rule below still wins its own font.
* .cbi-value-description is LuCI's inline HELP SENTENCE and lives INSIDE .cbi-value-field —
* this rule was setting prose in JetBrains Mono.
* `strong`/`b` — the most expensive line in this theme. LuCI writes `<strong>MAC:</strong>
* ac:1f:6b:…`: the strong is the LABEL, the text after it the value. Feeding the label to
* the mono rule made it mono AND weight 700, a face of its own — measured on the live
* router, 227 elements over seven pages in bold mono, and 20 KB of jetbrains-mono-600
* fetched to draw them: 30% of the whole font payload. Manrope 700 costs zero bytes and
* label-vs-value now differs by TYPEFACE, not by weight (the 600 mono face is gone, and
* so is every other bundled face).
* The label rule itself is in theme/45-misc.css and must ASSIGN the sans face: a <strong>
* merely excluded here still INHERITS mono from .cbi-value-field. Measured — excluding alone
* changed nothing, all 227 still drew bold mono. */
.cbi-value-field,
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
/* text-wrap: the help sentence is a <div>, so base's prose rule (which reaches `p` and the
* section/map descriptions) never covered it — and this is the one that wraps most, since it
* sits in the field column. Same reasoning as there. */
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
/* A text field in a CBI form was a fixed 210px — base's width, straight out of bootstrap's
* cascade. The same 210px holds far LESS here: the field is monospaced (the rule above) and
* padded 11px a side instead of 4, so Attended Sysupgrade's server URL was clipped mid-domain
* — while the .cbi-dynlist directly under it on that same page is 440px wide, which is what
* makes the mismatch the thing you actually see (issue #5). Elastic now, with the dynlist's own
* ceiling so the two line up. Number/date/time keep the fixed width: they hold short values,
* and a 440px box for a port number reads as a mistake. */
.cbi-value-field input[type="text"],
.cbi-value-field input[type="url"],
.cbi-value-field input[type="search"] { width: 100%; max-width: var(--fs-field-max); }
/* A .cbi-value is designed to hold ONE title+field pair; base lays it out as a flex row —
* label (180px) + field. Some apps cram SEVERAL pairs into one .cbi-value
* (luci-app-3ginfo-lite's "SIM card menu" modal: three title/field pairs in one), and the
* flex row then flows them side by side until the fields overflow the card — the exact bug
* a user reported. Give each title and each field its own full-width row (the same stacked
* layout base already uses on a phone), left-aligned, so nothing overflows and the pairs read
* top to bottom. Scoped by :has() to the abnormal multi-PAIR case ONLY, so every normal
* single-field row (i.e. every real form on the router) keeps its label-beside-field layout.
*
* THE TEST IS THE SECOND TITLE, NOT THE SECOND FIELD, and that is the whole of this rule's
* safety. `:has()` is LIVE: it re-evaluates the moment the DOM changes, so keying on
* `.cbi-value-field ~ .cbi-value-field` meant any app that APPENDS a second field to a row at
* runtime — a status line, an output pane, a spinner holder, all ordinary shapes — flipped the
* whole row from "label beside field" to "everything stacked" under the user's finger. Measured
* on the stand: pressing a button in such a row moved that button 31px DOWN with this rule and
* 0px with it disabled, which is the "the buttons jump when I press them" report.
*
* What the rule exists for is several title/field PAIRS in one row, and a pair is announced by
* its TITLE (luci-app-3ginfo-lite's SIM-card row: 3 titles, 3 fields — still matched here).
* A second field with no second title is not a pair, it is an app adding something to the one
* field it already had, and nothing about that asks for a re-layout. Rendered markup is matched
* exactly as before; only the runtime append stops triggering it. */
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-title,
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-field {
flex: 1 1 100%;
margin-inline-start: 0;
text-align: start;
}
/* fields inside a table cell fill it — moved from base with the generic 210px box below:
* in base these beat the box in-layer, left behind they would lose to it by layer */
.td > input[type="text"],
.td > input[type="password"],
.td > select,
.td > .cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-dynlist > .add-item > .cbi-dropdown { width: 100%; }
.cbi-section-create > * > input { width: 100%; }
/* --- live components that bypassed the theme --- */
/* A CBI row is a <div class="cbi-value"> on a form and a <td class="cbi-value-field"> in a
* section table — same <var>, and the theme had claimed only the table half. */
div.cbi-value var, .td.cbi-value-field var { color: var(--fs-accent); }
.td.cbi-value-field var.cbi-tooltip-container { border-bottom-color: var(--fs-accent); }
.cbi-filebrowser { border-color: var(--fs-border); background: var(--fs-panel); border-radius: var(--fs-radius-lg); }
.cbi-filebrowser > ul > li:hover { background: var(--fs-panel2); }
.cbi-filebrowser .upload { border-top-color: var(--fs-border); }
/* A FILE-BROWSER ROW COULD NOT HOLD ITS OWN BUTTONS, and the row CLIPPED what did not fit
* instead of showing it — `Delete` came out sliced by the browser's border. base sizes the row
* by PROPORTION (name `flex: 10`, actions `flex: 3`), which was sound for base's own ~20px
* buttons sitting in a text-height row; this theme's button is 36px tall with 14px of side
* padding, and a file row carries up to three of them (Deselect, Download, Delete). At 23% of
* the row they do not fit, base gives the browser's children no padding, and
* `.cbi-filebrowser > *` is `overflow: hidden` — so a button touched the clip box on BOTH axes,
* right edge and top edge, and lost its rounded corners to it. A shaved corner reads as a broken
* button, not as a missing 8px.
*
* So: size the action column to its CONTENT and let the name give way (a name can ellipsize, a
* button cannot), give the children a real gutter, and space the rows — with none, one row's
* button ran into the next row's. It wraps rather than clips when even that is not enough.
*
* This widget takes two clicks inside luci-app-firewall's ipsets or the wireless page to reach
* on a router, which is why it went unseen; docs/gallery.html now renders it open. */
.cbi-filebrowser > * { border-bottom-color: var(--fs-border); padding: var(--fs-space-1-5) var(--fs-space-2); }
.cbi-filebrowser > ul { display: flex; flex-direction: column; gap: var(--fs-space-1); }
.cbi-filebrowser > ul > li { align-items: center; flex-wrap: wrap; gap: var(--fs-space-2); }
.cbi-filebrowser > ul > li > div:first-child { flex: 1 1 auto; min-width: 0; }
.cbi-filebrowser > ul > li > div:last-child {
flex: 0 0 auto;
display: flex; flex-wrap: wrap; gap: var(--fs-space-2); justify-content: flex-end;
}
.cbi-filebrowser > ul > li > div:last-child > button { margin: 0; }
/* moved from base with the generic 210px box (same layer arithmetic as the .td rules) */
.cbi-filebrowser .upload > div > input { width: 100%; }
/* Form widgets — toggles, file input. Applies to every CBI form. */
/* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the
* checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words
* (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox]
* + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its input is
* opacity:0, so this margin moves nothing there. */
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
/* checkbox -> toggle switch
*
* The pill, the knob and the knob's travel are all on --fs-density-box, and every one of them
* used to be a bare literal — so LuCI's most common control was the only one in the theme that
* ignored the Density axis outright: 40x22 at Compact, 40x22 at Large, while every button and
* field around it moved (38 -> 32.3 -> 43.7). 02-tokens.css states the rule the other way round:
* "add a size OUTSIDE these ladders and it silently stops responding to the axis". The switch is
* a box that holds a knob, which is the box tier by definition; the pager buttons were moved off
* bare pixels for exactly this reason (55-buttons.css). Local names, not new global tokens: one
* component needs one number and three things derived from it. */
.cbi-checkbox { display: inline-flex; align-items: center; }
.cbi-checkbox > input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
.cbi-checkbox > label {
--sw-w: calc(40px * var(--fs-density-box));
--sw-h: calc(22px * var(--fs-density-box));
--sw-knob: calc(16px * var(--fs-density-box));
--sw-pad: calc(2px * var(--fs-density-box));
display: inline-block; position: relative; margin: 0;
width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill);
background: var(--fs-track); border: 1px solid var(--fs-border);
cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle;
}
/* The input is opacity:0/0x0, so this label IS the whole hit box of LuCI's most common
* control — and at 22px tall it sits under the 24px floor of WCAG 2.5.8. The floor is about
* the CLICKABLE area, not the drawing: an invisible overlay stretches the target to 44x30
* without fattening the pill (the visual 44x24 version was tried and looked bloated).
* position:absolute, so it adds nothing to layout; the knob keeps ::after.
*
* The inset stays LITERAL while the pill above scales: this is an accessibility floor, and a
* floor that shrinks with a density setting is not one. At Compact the pill is 34x18.7, so the
* target is 38x26.7 — still over the 24px minimum (measured). */
.cbi-checkbox > label::before {
content: ""; position: absolute; inset: -4px -2px;
}
.cbi-checkbox > label::after {
content: ""; position: absolute; top: var(--sw-pad); inset-inline-start: var(--sw-pad);
width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%;
background: var(--fs-dim);
/* transform, not `left`: `left` is a layout property, so the knob re-laid out the row on
* every frame of the slide. translateX is composited. */
transform: translateX(0);
transition: transform var(--fs-dur-move), background var(--fs-dur);
}
.cbi-checkbox > input[type="checkbox"]:checked + label { background: var(--fs-accent); border-color: transparent; }
/* The travel is derived rather than hand-written (it was a literal 18px), so it cannot drift out
* of step with the three sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole
* subtlety: --sw-w is a border-box width, while an absolutely-positioned ::after is placed
* against the PADDING box, so the room the knob actually has is 2px less. Deriving it without
* that term overshoots by 2px and the knob lands off-centre against its own left inset
* (measured: travel 20px where symmetry wants 18). The border does not scale with density, so
* the term stays literal. */
.cbi-checkbox > input[type="checkbox"]:checked + label::after {
transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));
background: var(--fs-on-accent);
}
/* The real checkbox is opacity:0 / 0x0 above, so base's `input:focus::before` glow paints on
* a 0x0 box — invisible. The <label> IS the switch, so the ring goes there. Without it the
* single most common control in LuCI showed nothing under keyboard focus.
*
* -solo, because on the CHECKED switch — which is half the switches on any page — neither
* other channel exists: the rule above fills the track with --fs-accent and sets
* `border-color: transparent`, so this border flip is accent on accent (measured on the
* gallery: fill rgb(86,157,245), border rgba(86,157,245,.97)) and the tinted ring was
* accent at .15 alpha. A checked, keyboard-focused toggle showed literally nothing. */
.cbi-checkbox > input[type="checkbox"]:focus-visible + label {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring-solo);
}
.cbi-checkbox > input[type="checkbox"]:disabled + label { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* RAW checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input`
* box below (width/height/padding/background/border), and left behind in base every one of
* those overrides would lose to that rule by layer. The box is 16px on the grid; the
* tick/dot is inset FROM it rather than restating its own number — the three used to be
* 14/2/10 by hand, so resizing the box left the mark off-centre. LuCI's own forms wrap
* checkboxes in the .cbi-checkbox toggle above; the raw drawing is what a third-party
* app's bare markup gets. */
input[type="checkbox"],
input[type="radio"] {
--bd-color: var(--fs-border);
--fg-color: var(--fs-text);
/* 16px on the grid, but on the BOX tier, not the space tier. `var(--fs-space-4)` was the
* same 16px at Normal and the wrong multiplier everywhere else: space moves to .65 at
* Compact and stays 1 at Large, so the tick box went to 10.4px (smaller than any other
* control on the page — measured) and then refused to grow at Large. A checkbox is a box
* that has to HOLD an icon, which is what --fs-density-box is defined for. */
--box: calc(16px * var(--fs-density-box));
--mark-inset: 3px;
appearance: none;
width: var(--box);
height: var(--box);
color: var(--fg-color);
position: relative;
display: inline-block;
cursor: pointer;
background: none;
border: none;
/* padding/border-radius are NOT reset: the generic input box's values land on an
* appearance-none 16px box whose pseudo-elements are absolute — invisible, and what the
* widget has always computed */
}
input[type="checkbox"]::before,
input[type="checkbox"]::after,
input[type="radio"]::before,
input[type="radio"]::after {
position: absolute;
content: "";
}
input[type="checkbox"]::before,
input[type="radio"]::before {
top: 0;
inset-inline-start: 0;
width: var(--box);
height: var(--box);
background: linear-gradient(var(--fs-panel), var(--fs-panel2));
border: 1px solid var(--bd-color);
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* each box states its own radius — one shared 2px re-declared to 50% for the radio would
* be a same-selector shadow inside this layer */
input[type="checkbox"]::before { border-radius: 2px; }
input[type="radio"],
input[type="radio"]::before {
border-radius: 50%;
}
/* keyboard/click focus rings on the DRAWN box — the theme's one ring token, replacing
* base's old generic glow; :focus (not :focus-visible) keeps the pre-absorption behaviour
* of ringing a clicked box */
input[type="checkbox"]:focus::before,
input[type="radio"]:focus::before {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
input[type="checkbox"].cbi-input-invalid:focus::before,
input[type="radio"].cbi-input-invalid:focus::before {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
input[type="checkbox"]::after,
input[type="radio"]::after {
top: var(--mark-inset);
inset-inline-start: var(--mark-inset);
width: calc(var(--box) - var(--mark-inset) * 2);
height: calc(var(--box) - var(--mark-inset) * 2);
}
input[type="checkbox"]:checked::after,
input[type="radio"]:checked::after {
/* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in
* base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid).
* It replaces a solid filled path that was byte-identical to luci-theme-bootstrap's — and to
* luci-theme-openwrt-2020's, which is the same project's artwork, not evidence the drawing is
* generic. A mask keys on ALPHA, so a stroked path masks exactly as a filled one did. */
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");
-webkit-mask: var(--checkmark-icon) center/cover no-repeat;
mask: var(--checkmark-icon) center/cover no-repeat;
background: var(--fg-color);
}
input[type="radio"]:checked:after {
/* the radio's dot — filled, because a ring would read as "not selected". OURS: our grid,
* our radius (the fill is the one shape a stroke cannot express). */
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6.5' fill='%23000'/></svg>");
}
input[type="checkbox"].cbi-input-invalid,
input[type="radio"].cbi-input-invalid {
--bd-color: var(--fs-danger);
--fg-color: var(--fs-danger);
}
/* The generic field box — absorbed from base's `input, textarea, select, …` group; each other
* member lives with its component rule now (select/.cbi-select above, textarea/dropdown in
* theirs). Bare `input` keeps the historical 210px box as the fallback for every type the
* exhaustive text-input list below does not name — and for a typeless <input>, which IS a
* text input. The widget types that must NOT wear this box (checkbox/radio, file,
* button/submit/reset, range) carry their own theme rules: a base override would lose to
* this rule by layer no matter its specificity, which is why they were absorbed together. */
input {
display: inline-block;
width: 210px;
height: var(--fs-control-h);
padding: var(--fs-space-1) var(--fs-space-2);
background: var(--fs-panel);
color: var(--fs-text);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
}
/* base's old `#localtime { width: 300px }` media rule was NOT moved here: the elastic
* .cbi-value-field width above already out-layered it (measured on the live router: the
* field rendered 440px, not 300) — the rule was dead, and a dead rule is deleted, not
* relocated */
/* native file input — the `initial`/auto resets are absorbed from base: the generic box
* above would otherwise dress the native chooser in a 210px bordered field */
input[type="file"] {
color: var(--fs-dim); font-size: var(--fs-type);
padding: initial;
border: initial;
line-height: initial;
box-shadow: none;
/* no !important: this out-specifies the generic `input { width: 210px }` on its own.
* height/background are NOT reset: the chooser keeps the generic box's control height
* and panel tint, as it always has. */
width: auto;
}
/* native <select> — custom chevron + theme (matching the text inputs). The chevron data-URI
* lives in 03-palettes.css per palette/mode block, because a URI cannot read var(); it
* replaced a hardcoded grey that ignored palette and dark mode. */
select, select.cbi-input-select, .cbi-value-field select {
/* -webkit- only: Firefox has had the unprefixed property since 80. */
appearance: none; -webkit-appearance: none;
background-color: var(--fs-panel2);
background-image: var(--fs-select-chevron);
/* right inset from the scale, not a literal 12 — the .cbi-select shell below states the same
* position and two spellings of one number is how they would drift apart */
background-repeat: no-repeat; background-position: right var(--fs-space-3) center; background-size: 12px;
padding: var(--fs-space-2) var(--fs-space-8) var(--fs-space-2) var(--fs-space-3);
border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type);
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
/* width/display absorbed from base's generic field box: a select's natural width is its
* longest option, so without the fixed box two selects in one form column ragged-edge;
* display stated because Chromium's base-select UA default is inline-flex, not the
* inline-block the widget has always computed */
width: 210px;
display: inline-block;
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* group-then-refine, directly under its group: the listbox variant must NOT take the closed
* control's one-line height. A SURFACE token, not an ink one: this once read --fs-on-accent
* (the ink meant to go ON an accent fill), which resolves to plain white and painted a white
* listbox in dark mode. `height: inherit` absorbed from base with the width above — without
* it the fixed box would cap a multi-row listbox at one line. */
select[multiple] { background-color: var(--fs-panel2); color: var(--fs-text); height: inherit; }
select:focus { border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring); outline: none; }
/* absorbed from base's reset: 100% (= inherit) keeps the option on its select's size, and
* only <option> needs the 2em leading — the other controls carry line-height: normal above */
option { font-size: 100%; line-height: 2em; }
select option { background: var(--fs-panel); color: var(--fs-text); }
/* Native <select> drop list: the OS renders it, so the `select option` colours above are
* ignored on most platforms (a stock white list on a dark theme). Chromium's
* customisable-select (`appearance: base-select`) renders the picker in the DOM, so it can be
* themed like the .cbi-dropdown menu. @supports-gated: browsers without it keep the styled
* closed control + the native list.
*
* Scoped to `.cbi-value-field select` ONLY: a third-party app can mount a raw <select>
* outside the CBI form and drive it its own way (podkop-plus rebuilds its device filter via
* select.replaceChildren), and early Chrome builds (~135) mis-render such a select as a
* single-item list — the app's dropdown then looks broken. Those sit outside
* .cbi-value-field, so this scope leaves them native. */
@supports (appearance: base-select) {
.cbi-value-field select { appearance: base-select; }
.cbi-value-field select::picker-icon { display: none; }
::picker(select) {
appearance: base-select;
background: var(--fs-panel); border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
padding: var(--fs-space-1-5); margin-top: var(--fs-space-1-5);
}
.cbi-value-field select option {
padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm); font-size: var(--fs-type);
/* no `color` here: `select option` above already sets --fs-text, and this restated it
* (switched off against 174 options, nothing moved). `background: transparent` is NOT the
* same — it deliberately undoes that rule's --fs-panel so the row's own hover shows. */
background: transparent; min-height: 0;
}
.cbi-value-field select option::checkmark { display: none; }
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
/* Opaque surface + inset rail, same shape as the open .cbi-dropdown's selected row
* (theme/65-dropdown.css): accent text on --fs-accent-soft measured 4.21:1 in dark,
* under AA — the tint of the accent eats its own contrast. */
.cbi-value-field select option:checked {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: inset 2px 0 0 var(--fs-accent);
/* @endmirror */
}
}
/* .cbi-select — the legacy luci-compat shell (a div around a native <select>). Absorbed from
* base whole: the generic field box, the shell's own paint (padding 0 + the two-stop gradient
* + the ▾ plate) and a focus ring on the theme's one token. The shell is a DIV, so
* display/width must be stated — they do not fall out of any UA default.
* :not(select) because the class also appears ON a bare <select> (docs/gallery.html renders
* that shape): a replaced element cannot draw the ::before ▾, so shell paint there means a
* gradient with no glyph — that shape keeps the themed-select rules above instead. */
/* IT PAINTS LIKE THE THEMED <select>, because it is the same control wearing an older shell.
* It used to be the theme's only skeuomorphic surface: `linear-gradient(--fs-panel, --fs-border)`
* on the box AND again on the ▾ plate. In the dark palettes --fs-border is LIGHTER than the
* panel, so it was also the only control that got brighter towards the bottom. Nothing else in
* the theme has a gradient at all.
* The geometry below is not invented — it is the `select` rule above, property for property
* (panel2 fill, the palette-aware --fs-select-chevron at the same position and size, the same
* --fs-ctl-h floor, the same 210px box), so a legacy shell and a modern select line up in one
* form column. This is the non-JS fallback: the people who see it already have something
* broken, and it should not additionally look like a control from another theme. */
.cbi-select:not(select) {
display: inline-block;
width: 210px;
/* `height`, NOT `min-height` — the inner select fills the shell with `height: 100%`, and a
* percentage against an auto-height parent has no definite containing block to resolve
* against, so it falls back to content height: measured, the select collapsed 30px -> 15px
* inside a 38px shell, text at the top and half the hit area gone. The themed `select` can
* use a floor because it IS the control; a wrapper has to state a height for its child. */
height: var(--fs-ctl-h);
color: var(--fs-text);
font-size: var(--fs-type);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
padding: 0;
position: relative;
background-color: var(--fs-panel2);
background-image: var(--fs-select-chevron);
background-repeat: no-repeat;
background-position: right var(--fs-space-3) center;
background-size: 12px;
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* The disabled cue used to live ENTIRELY on the ▾ plate: base/30-forms.css dims
* `.cbi-select[disabled]::before` and never the shell itself. With the plate gone the state had
* no visual at all, so the shell carries it now. */
.cbi-select[disabled]:not(select) {
opacity: var(--fs-disabled-opacity);
pointer-events: none;
}
.cbi-select.focus {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
/* the alarm must survive focus: .focus above would repaint an invalid shell accent */
.cbi-select.cbi-input-invalid.focus {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
/* the native <select> inside the shell: invisible, filling it, clearing the ▾ plate on the
* right. The inset is a scale step; the right margin is the plate's width, so it stays
* em-relative to the glyph it clears. Every themed-select declaration above (chevron,
* padding, min-height) must be answered here, or the inner select paints like a free-standing
* one inside the 32px shell. */
.cbi-select select,
.cbi-select select:focus {
appearance: none; -webkit-appearance: none;
outline: none;
border: none;
background: transparent;
background-image: none;
box-shadow: none;
height: 100%;
min-height: 0;
width: 100%;
/* clears the CHEVRON, which the shell now paints on its own background — the same
* --fs-space-8 inset the themed select uses. `margin-right: .6em` used to clear the ▾ plate
* instead, and an em-relative margin against a px-positioned glyph is how the two drifted. */
padding: 0 var(--fs-space-8) 0 var(--fs-space-3);
font-size: var(--fs-type);
line-height: normal;
cursor: pointer;
}
/* The ▾ plate is gone: the chevron is a background on the shell, exactly as on `select`. Hidden
* rather than deleted because base/30-forms.css still names `.cbi-select[disabled]::before` in
* its disabled list — dropping the rule here would leave that one to paint a bare glyph. */
.cbi-select::before { display: none; }
/* spinner / loading */
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
/* Text inputs & other field widgets — round + theme to match the selects/dropdowns (base
* leaves them at radius 3 / white). Excludes checkbox/radio (toggles) and file (above).
*
* The type list must be EXHAUSTIVE: an omission does not fall back to "unstyled", it falls
* back to BASE, which still paints a stock white 3px-radius field. color/datetime-local/
* month/week were missing and rendered as visibly different widgets next to a themed input
* in the same form. */
.cbi-input-text, .cbi-input-password, .cbi-input-textarea,
.cbi-section-create-name, .cbi-value-field input.create-item-input,
input[type="text"], input[type="password"], input[type="number"],
input[type="email"], input[type="url"], input[type="search"],
input[type="tel"], input[type="date"], input[type="time"],
input[type="datetime-local"], input[type="month"], input[type="week"],
input[type="color"],
textarea {
background: var(--fs-panel2);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
color: var(--fs-text);
padding: var(--fs-space-2) var(--fs-space-3);
min-height: var(--fs-ctl-h);
box-shadow: none;
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* width/font-size absorbed from base's generic field box, same numbers — a textarea has
* always sat at the fixed 210px (the elastic rule above names only text/url/search) */
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; font-size: var(--fs-type); }
/* A textarea's native scrollbar is a square strip and a rounded corner does not clip it, so
* on a scrolling config field the grey bar poked past the radius — a square notch out of the
* frame. Fix: a slim self-rounded thumb floated off the edges (transparent border +
* background-clip: padding-box), a transparent track, and a track margin keeping the thumb
* clear of the corners — nothing square ever reaches one.
*
* Deliberately NO `scrollbar-width`/`scrollbar-color`: either makes Chromium switch to the
* STANDARD scrollbar and ignore this whole ::-webkit block — and that standard bar is the
* square, unclipped one that caused the bug. Firefox has no ::-webkit and clips natively.
*
* 16px wide so ::-webkit-resizer (sized off the scrollbar) gets a bigger grab area; the 5px
* transparent thumb border still leaves it 6px visible. */
textarea::-webkit-scrollbar { width: 16px; height: 16px; }
textarea::-webkit-scrollbar-track { background: transparent; margin: var(--fs-space-4) 0 var(--fs-space-2-5); }
textarea::-webkit-scrollbar-thumb {
background: var(--fs-dim); border-radius: var(--fs-radius-pill);
border: 5px solid transparent; background-clip: padding-box;
}
textarea::-webkit-scrollbar-thumb:hover { background: var(--fs-text); }
/* The bottom-right square where the vertical bar meets the resize grip defaulted to white
* and poked past the rounded corner — transparent, so the frame shows. */
textarea::-webkit-scrollbar-corner { background: transparent; }
/* The resize grip HIGHLIGHTS the frame's bottom-right corner ("this field is resizable") —
* an accent arc tracing the frame's own radius (= the Rounding setting), no straight legs.
* Two borders + the corner radius draw the arc; the legs vanish because the box is shrunk to
* exactly the radius: the resizer is the scrollbar's 16px, so a `16px − radius` margin
* leaves a radius-sized box whose whole right+bottom border IS the quarter curve. (A mask
* would give a shorter arc, but ::-webkit-resizer ignores mask — it filled a solid square —
* and a baked-in SVG colour would not follow the palette or the accent hue.) */
/* PHYSICAL on purpose, and mirrored by hand below: ::-webkit-resizer is a UA-drawn corner whose
* side follows the writing direction, and it does not accept logical properties — with only the
* LTR rule the arc drew on the wrong corner in an RTL document (a stray quarter-circle on the
* left of every textarea). */
textarea::-webkit-resizer {
background: transparent;
border-right: 2px solid var(--fs-accent);
border-bottom: 2px solid var(--fs-accent);
border-bottom-right-radius: var(--fs-radius);
margin: calc(16px - var(--fs-radius)) 0 0 calc(16px - var(--fs-radius));
}
:root[dir="rtl"] textarea::-webkit-resizer {
border-right: 0; border-left: 2px solid var(--fs-accent);
border-bottom-right-radius: 0; border-bottom-left-radius: var(--fs-radius);
margin: calc(16px - var(--fs-radius)) calc(16px - var(--fs-radius)) 0 0;
}
.cbi-input-text:hover, .cbi-input-password:hover, .cbi-input-textarea:hover,
input[type="text"]:hover, input[type="password"]:hover, input[type="number"]:hover,
input[type="email"]:hover, input[type="url"]:hover, input[type="search"]:hover,
textarea:hover { border-color: var(--fs-dim); }
/* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback
* for the types the list does not name and for a typeless <input>; on checkbox/radio it lands
* on the invisible element box UNDER the drawn ::before ring above — coincident boxes, one
* visible ring, as the old glow behaved */
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
.cbi-section-create-name:focus,
input:focus,
input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus,
input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus,
input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus,
textarea:focus {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
outline: none;
}
/* Invalid state keeps the danger cue. `.cbi-value-error input` is the LUA-CBI path and it
* belongs HERE, not in base: base/70-buttons.css does declare a red border-color for it, but
* the rule above sets the `border` SHORTHAND, and a shorthand in a later layer wipes the
* longhand out regardless of specificity. Probed on the gallery — the field rendered
* --fs-border grey, so every third-party app on luci-compat (which emits .cbi-value-error
* from full_valueheader.htm) had lost its field-error cue entirely, while the modern
* .cbi-input-invalid path worked. */
.cbi-input-invalid, input.cbi-input-invalid, textarea.cbi-input-invalid,
.cbi-value-error input, .cbi-value-error textarea {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
/* The same rejected value shown by a ui.Dropdown rather than an <input>: the widget itself and,
* when it is closed, the <li> that displays the value. base carried these two and the theme only
* the input, so half of one state lived in a layer the theme cannot reach. */
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
/* the alarm must survive focus: the generic `input:focus` above is (0,1,1) and would repaint
* a focused invalid field accent; (0,2,0) outranks it. The TYPED :focus rules (0,2,1) still
* outrank this — a typed invalid field focuses accent, exactly as before the absorption. */
.cbi-input-invalid:focus {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
/* readonly keeps its faded border — moved from base with the generic box (its border-color
* longhand would lose to the box's `border` shorthand by layer). The doubled [readonly] is
* a deliberate specificity step: at (0,1,1) this ties the typed input rules above and the
* cascade would rest on file order — in base the same tie made the fade silently LOSE to
* the typed border, so a readonly text field never faded at all; (0,2,1) states the intent. */
:is(input, select, textarea)[readonly][readonly] {
border-color: color-mix(in srgb, var(--fs-border) calc(var(--fs-disabled-opacity) * 100%), transparent);
pointer-events: auto;
cursor: auto;
}
/* dynamic list: keep base's layout (inline-flex column of chips + one add-item row) — do NOT
* set display/flex-wrap here or the add row breaks. Only colours, rounding, a compact +
* button, and no wrapping box: capped narrow so the chip's × sits near the text instead of
* floating across a full-width field. */
.cbi-dynlist {
gap: var(--fs-space-1-5);
background: transparent; border: 0; padding: 0;
min-height: 0; min-width: 240px; max-width: var(--fs-field-max); width: 100%;
vertical-align: top;
}
.cbi-dynlist > .item {
background: var(--fs-panel);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-sm);
color: var(--fs-text);
box-shadow: none;
margin: 0;
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* a chip is keyboard-removable (ui.js gives it a tabindex), so it needs the ring */
.cbi-dynlist > .item:focus {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
outline: none;
}
/* The chip's × plate is a BUTTON in base's paint group (`.item::after, .btn, .cbi-button`) —
* absorbed with it, bare as base had it (coverage: an .item outside a dynlist keeps its
* plate). The machinery — content/position/edges/align — stays in base with the widget. */
.item::after {
display: inline-block;
cursor: pointer;
background: linear-gradient(var(--fs-panel), var(--fs-panel) 25%, var(--fs-panel2));
padding: 0 var(--fs-space-3);
color: var(--fs-text);
line-height: 2em;
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* the hover cue is the lift, not a colour: the plate's fill is a gradient (see
* --fs-hover-lift in 02-tokens.css) — replaces base's old focus-glow-on-hover */
.item:hover::after { filter: var(--fs-hover-lift); }
.cbi-dynlist > .item::after {
/* display/padding absorbed from base's machinery rule: the bare plate above says
* inline-block / space-3, and by layer it would beat base's refinement */
display: inline-flex;
padding: 0 var(--fs-space-2);
color: var(--fs-danger);
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
}
/* text add-item = one bordered input group (input + + button attached) */
.cbi-dynlist > .add-item {
align-items: stretch;
background: var(--fs-panel2); border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-sm); overflow: hidden;
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
.cbi-dynlist > .add-item:focus-within { border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring); }
.cbi-dynlist > .add-item > input,
/* `width: auto` keeps the flex basis in charge: this input sits INSIDE .cbi-value-field, so the
* elastic width above would otherwise hand it 100% of the field on top of `flex: 1 1 auto`. */
.cbi-dynlist > .add-item > .cbi-input-text {
flex: 1 1 auto; min-width: 0; width: auto;
border: 0; background: transparent;
box-shadow: none; min-height: var(--fs-ctl-h-sm); border-radius: 0;
}
/* password reveal: input + ∗ button as ONE seamless rounded group.
* `gap: 0` is load-bearing: base gives every .control-group a gap, and this is the one group
* whose members must touch — the group draws the single border and clips them. */
.control-group:has(> .cbi-input-password) {
display: inline-flex; align-items: stretch; gap: 0;
background: var(--fs-panel2);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
overflow: hidden;
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
.control-group:has(> .cbi-input-password) > .cbi-input-password {
border: 0; border-radius: 0;
background: transparent; box-shadow: none;
min-height: var(--fs-ctl-h);
}
.control-group:has(> .cbi-input-password):focus-within {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
/* base pulls .cbi-section-create up 3px to hug a table border, which glues the "Add" button
* to the last row — give it a real gap instead. */
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
/* `.hidden` must win over every component rule in this layer — CBI dependency hiding was
* leaking podkop's conditional fields back onto the page, and the enumerated list that fixed
* that (`.cbi-value.hidden`, `.cbi-section.hidden`, `tr.hidden`, …) only ever covered the
* elements someone had already been bitten by. The one it did NOT cover is the Enter-key
* submit every legacy Lua page carries: luci-compat's cbi/header.htm opens each form with
* `<input type="submit" value="Save" class="hidden">`, and an ATTRIBUTE selector counts as a
* class — theme/55-buttons.css's `input[type="submit"]` is 0,1,1 against a bare `.hidden`'s
* 0,1,0, so it won, and a stray "Save" button floated above the page title on every
* luci-compat view (issue #12, luci-app-openvpn). Doubling the class states the invariant
* ONCE at 0,2,0 instead of chasing elements: hidden is hidden, whatever the element and
* whatever component rule this layer also matched. An `!important` would have done it too,
* and would have inverted the layer order for every future override — this does not. */
.hidden.hidden { display: none; }
/* RangeSlider (UIRangeSlider) — the one LuCI widget base leaves to native browser rendering.
* Token-driven track/thumb. */
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 6px; min-height: 0; padding: 0;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
outline: none; box-shadow: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel);
box-shadow: var(--fs-shadow); cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel); cursor: pointer;
/* same shadow the -webkit thumb carries — Firefox had silently drifted flat */
box-shadow: var(--fs-shadow);
}
input[type="range"]::-moz-range-track { background: var(--fs-track); height: 6px; border-radius: var(--fs-radius-pill); }
/* -solo: the track sets `border: 0` and `outline: none`, so there is no second channel here
* either — the tinted ring was the whole indicator on a 6px bar. */
input[type="range"]:focus { box-shadow: var(--fs-focus-ring-solo); }
/* THE SAME CEILING THE ELASTIC TEXT INPUTS TAKE, and the reason is the whole point of the
* token: a `.cbi-value-field` is `flex: 1`, so `width: 100%` here meant the slider ate the
* entire card while the select two rows above stopped at 210px and the readout chip was flung
* to the card's right edge. Every stretching control in a form row now ends at one x. */
.cbi-range-slider { display: inline-flex; align-items: center; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
.cbi-range-slider-value {
background: var(--fs-panel2); border: 1px solid var(--fs-border); border-radius: var(--fs-radius-sm);
padding: var(--fs-space-1) var(--fs-space-2); color: var(--fs-accent); font-family: var(--fs-font-mono);
/* NOT 600: no face is bundled, so 600 either drags a 20 KB file back in or is faked by
* synthetic emboldening, which smears a monospace grid.
* The chip already reads as emphatic from its surface, border and accent ink. */
font-weight: var(--fs-weight-normal); font-size: var(--fs-type); white-space: nowrap;
}
.cbi-range-slider-calc, .cbi-range-slider-calc-units { color: var(--fs-dim); font-size: var(--fs-type); }
}
@@ -0,0 +1,486 @@
@layer theme {
/* Generic .dropdown-menu popup — ui.js's split "Save & Apply" menu, plus whatever a third-party
* luci-app-* emits. NOT the theme's nav menu (that is a plain nested <ul>; see 10-chrome.css).
* Hover text is ACCENT to match the open cbi-dropdown list: this is a form control's menu. */
.dropdown-menu { background-color: var(--fs-panel); border-color: var(--fs-border); }
.dropdown-menu a { color: var(--fs-text); text-shadow: none; }
.dropdown-menu a:hover,
.dropdown-menu a:focus-visible { background: var(--fs-panel2); background-image: none; color: var(--fs-accent); }
/* Split "Save & Apply": a plain dropdown in the action bar carries no frame of its own.
*
* The guard is the same `:not(.btn):not(.cbi-button)` the widget rules below use, and it is
* load-bearing: the split control's outer div IS the button, and the class on it is the one
* ui.js picked for the SELECTED mode — `cbi-button-apply` for "Save & Apply", but
* `cbi-button-negative` the moment the user picks "Apply unchecked" (luci.js's ComboButton
* `classes` map). Unguarded, this rule (0,2,0) erased that variant's fill while its ink
* (--fs-on-danger) survived from the variant rule that sets no background — a button painted
* page-on-page: 1.13:1 in dark and 1.06:1 in light, measured on the footer. Only the mode
* repainted below was ever visible. */
.cbi-page-actions .cbi-dropdown:not(.btn):not(.cbi-button) {
border: 0; border-radius: var(--fs-radius); background: transparent;
}
/* Apply is the one variant with no fill of its own — `.cbi-button-action` and the rest carry
* theirs from theme/55-buttons.css, and `.cbi-button-apply` is filled only where it is THE
* primary action, which is this bar. Painted by SPECIFICITY — it used to be a rule written
* afterwards, i.e. a fix that depended on source order. */
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
.cbi-page-actions .cbi-dropdown > .cbi-button {
background: var(--fs-accent);
background-image: none;
border: 0;
color: var(--fs-on-accent);
}
.cbi-page-actions .cbi-dropdown.cbi-button-apply:hover,
.cbi-page-actions .cbi-dropdown > .cbi-button:hover { filter: var(--fs-hover-lift); }
/* ---- cbi-dropdown widget: the JS select used across all forms ----
* Restyled to the design dropdown (panel2, rounded, chevron, focus ring) + themed open list.
* A native <select> is styled separately.
*
* The widget's MACHINERY is here too — absorbed from base/80-dropdown.css whole. ui.js drives
* it purely through attributes ([open], [multiple], [empty], [more], [display], [selected]);
* the rules below turn those into a working combobox. The display state machine is UNFLAGGED:
* upstream resolved the states with !important, but flag-vs-flag falls back to
* specificity-then-order anyway, so the machine below states the SAME resolution openly (see
* the state-machine block). The `ul` margins were flagged too, on the stated grounds that ui.js
* writes `margin` INLINE on an open list. IT DOES NOT — grepped on both releases: ui.js touches
* only `top`/`left`/`right`/`maxHeight`/`bottom` on that `ul`, and only under `'ontouchstart' in
* window` (see 90-responsive.css, where the flags that fight THOSE live). The flags came in with
* the absorbed widget from luci-theme-bootstrap, which has no @layer and needed one to beat its
* own `ul { margin: 0 0 10px 25px }`; here the layer split does that for free. They were not
* idle, though — they were beating THIS FILE: `margin: 0 !important` on (0,1,1) out-ranked the
* open popover's own `margin-top` at (0,2,1) below, so the list sat 1px off the control instead
* of the designed 6px, and the two `.btn` rules then needed flags of their own purely to escape
* the first one. Measured on the gallery: dropping all three moves exactly one value (that gap,
* 1px -> 7px) and nothing else. */
.cbi-dropdown {
display: inline-flex;
cursor: pointer;
height: auto;
position: relative;
padding: 0;
}
.cbi-dropdown img {
/* constrain a vector icon of any intrinsic size to the text line */
max-height: 1.6em;
width: auto;
}
.cbi-dropdown > ul {
margin: 0;
padding: 0;
list-style: none;
overflow-x: hidden;
overflow-y: hidden;
display: flex;
width: 100%;
}
/* clears the button's own side inset so the label starts on its text, not on its border.
* Unflagged: (0,3,1) over the rule above's (0,1,1) — it only ever needed a flag to beat that
* rule's flag. */
.cbi-dropdown.btn > ul:not(.dropdown),
.cbi-dropdown.cbi-button > ul:not(.dropdown) {
margin: 0 0 0 var(--fs-space-3);
}
.cbi-dropdown.btn.spinning > ul:not(.dropdown),
.cbi-dropdown.cbi-button.spinning > ul:not(.dropdown) {
margin: 0;
}
.cbi-dropdown > ul.preview {
display: none;
}
/* no `display` here: `.open` states its own below, and `.more` is display: none until the
* [multiple][more]/[multiple][empty] showers flip it — one declaration per state, or the
* audit's same-selector shadow check fires on this very pair */
.cbi-dropdown > .open,
.cbi-dropdown > .more {
flex-grow: 0;
flex-shrink: 0;
flex-direction: column;
justify-content: center;
text-align: center;
}
/* margin only: base also gave the button's chevron a .5em padding and a border-left divider,
* but both were DEAD on arrival — the theme's `.open` rule below zeroes padding and border in
* a later layer. A dead declaration is deleted, not relocated. */
.cbi-dropdown.btn > .open,
.cbi-dropdown.cbi-button > .open {
margin-inline-start: .5em;
}
/* display only: the placeholder's ink is the theme's own `li[placeholder]` --fs-faint below
* (base's --fs-dim had already lost to it by layer — moving it would recolour the row) */
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
display: none;
}
.cbi-dropdown > ul > li {
display: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
flex-grow: 1;
align-items: center;
align-self: center;
color: inherit;
}
.cbi-dropdown > ul > li .hide-open { display: initial; }
.cbi-dropdown > ul > li .hide-close { display: none; }
/* the chip divider on a BUTTON dropdown's preview; the form dropdown zeroes it below */
.cbi-dropdown > ul > li[display]:not([display="0"]) {
border-inline-start: 1px solid var(--fs-border);
}
.cbi-dropdown[empty] > ul {
max-width: 1px;
}
.cbi-dropdown > ul > li > form {
display: none;
margin: 0;
padding: 0;
pointer-events: none;
}
.cbi-dropdown > ul > li img {
vertical-align: middle;
margin-inline-end: .25em;
}
.cbi-dropdown > ul > li > form > input[type="checkbox"] {
margin: 0 var(--fs-space-1) 0 0;
}
/* ---- the display state machine, on specificity ----
* Every shower OUT-SPECIFIES the hider it overrides: li[display] (0,2,2) > the li hider
* (0,1,2); [open] > ul.dropdown > li (0,3,2) likewise; the placeholder rules (0,5,2 and
* 0,6,2) > the placeholder hider (0,4,2); the form rule (0,4,3) > its hider (0,1,3).
* [empty]'s block (0,2,2) loses to [open]'s flex (0,3,2) deliberately — an open empty list
* still lays out as a popup — which is exactly the resolution the old flags produced among
* themselves (importance never separated them; specificity always did). */
.cbi-dropdown > ul > li[display],
.cbi-dropdown[open] > ul.preview,
.cbi-dropdown[open] > ul.dropdown > li,
.cbi-dropdown[multiple] > ul > li > label,
.cbi-dropdown[multiple][open] > ul.dropdown > li,
.cbi-dropdown[multiple][more] > .more,
.cbi-dropdown[multiple][empty] > .more {
flex-grow: 1;
display: flex;
}
/* The ONE state the old flags actually carried: a closed optional single dropdown with an
* empty value. ui.js hands the [display] attribute to the placeholder row itself
* (`sel[0] ?? items[0]`), and the placeholder hider (0,4,2) out-specifies the generic
* li[display] shower (0,2,2) — unflagged, the closed control rendered EMPTY (this is the
* "popup collapses" the old base comment measured). :not([open]) keeps this out of the open
* list, where the [optional][open] rule below owns the row — the states cannot overlap, so
* nothing here rests on source order. */
.cbi-dropdown:not([open]):not(.btn):not(.cbi-button) > ul > li[placeholder][display] {
flex-grow: 1;
display: flex;
}
.cbi-dropdown[empty] > ul > li,
.cbi-dropdown[optional][open] > ul.dropdown > li[placeholder] {
display: block;
}
.cbi-dropdown[multiple][open] > ul.dropdown > li > form {
display: flex;
}
.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; }
/* `min-width` because the thing that overflows an open row is a CHILD with an inline width, so
* wrapping the row (theme/65 below) cannot reach it: form.js's RichListValue writes the
* description block as `<div class="hide-close" style="min-width:25vw">`, which assumes the list
* grows to fit — true on a theme that leaves ul.dropdown unbounded, false here (capped at
* min(92vw, 420px), 90-responsive.css). The inline floor is a VIEWPORT fraction, so the wider the
* screen the worse it gets: measured on a live router at 2559px, 25vw = 640px inside a 406px row
* — scrollWidth 664 vs clientWidth 406, and the row's own `overflow: hidden` cut "…for routing
* with/withou" mid-word, losing the whole "Requires hardware NAT support" sentence (issue #15,
* reported again after 0.10.1, whose fix only addressed the row). At 1280px the same option fits,
* which is why it does not reproduce on a laptop. An author !important is the only thing that
* outranks an inline declaration; releasing the floor lets the text wrap inside its row. */
.cbi-dropdown[open] > ul.dropdown > li .hide-close { display: initial; min-width: 0 !important; }
.cbi-dropdown[open] > ul.dropdown > li:last-child {
margin-bottom: 0;
}
.cbi-dropdown[open] > ul.dropdown > li[unselectable] {
opacity: .7;
}
.cbi-dropdown:not(.btn):not(.cbi-button) {
background: var(--fs-panel2);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius);
color: var(--fs-text);
min-height: var(--fs-ctl-h);
/* absorbed from base's generic field box with the width below. The fixed height is
* load-bearing next to the min-height: without it a closed dropdown grows to its
* tallest child (the Save & Apply split control carries a 40px button — measured,
* 38 -> 42px on the gallery). */
height: var(--fs-control-h);
font-size: var(--fs-type);
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
/* Width absorbed from base/30-forms.css, where it was grouped with .cbi-dynlist's — so the
* theme sized the dynlist and base still sized the dropdown, two halves of the same
* decision in two layers. Same numbers, and they are NOT the dynlist's (240/440): a
* dropdown holds one value, a dynlist a column of them. */
min-width: 210px;
max-width: 400px;
width: auto;
/* The widget's own box. In base these two carried `!important` — not to beat an inline
* style, but to beat base's OWN generic form-field rule, which sets `display: inline-block`
* and `padding: 4px` on this very selector at a higher specificity (0,3,0 vs 0,1,0). A later
* LAYER beats it for free, which is exactly what the layer split is for. */
display: inline-flex;
padding: 0;
}
/* …and the button dropdown (the Save & Apply split control), where the flag was beating the
* theme's own `.cbi-button` padding. Specificity does it: 0,2,0 over 0,1,0. */
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { display: inline-flex; padding: 0; }
.cbi-dropdown:not(.btn):not(.cbi-button):hover { border-color: var(--fs-dim); }
.cbi-dropdown:focus, .cbi-dropdown[open] {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
outline: none;
}
/* `.cbi-dropdown > ul.dropdown > li` is the same menu row on a BUTTON dropdown (the Save & Apply
* split control), which the `:not(.btn):not(.cbi-button)` guard excludes — so that menu kept
* base's .25em rows while every other dropdown got these. One menu, one row. */
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li,
.cbi-dropdown > ul.dropdown > li {
padding: var(--fs-space-2) var(--fs-space-3); color: var(--fs-text);
font-family: var(--fs-font-sans);
/* one menu row: a grid step, which is what a line of text at this scale rounds to. NOT
* `calc(--fs-type * --fs-leading)` — that is 19.5px, and a half-pixel on a BOX edge is
* the thing the scale exists to stop. Absorbed with the machinery above. */
min-height: var(--fs-space-5);
}
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]:not([display="0"]) { border-inline-start: 0; }
.cbi-dropdown > ul > li[placeholder] { color: var(--fs-faint); }
/* the multiselect's inline filter/create inputs — moved from base with the generic `input`
* box in theme/60-inputs.css: in base these beat it in-layer, left behind they would lose
* to it by layer and render as free-standing 210px fields inside the open list */
.cbi-dropdown > ul > li input[type="text"] { height: var(--fs-space-5); }
.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child { width: 100%; }
/* Custom chevron in the toggle (stock text/svg arrow hidden). `.open` is the only hit target that
* opens the list — on a button dropdown (.cbi-dropdown.btn, e.g. Diagnostics) a click anywhere
* else RUNS the button. `font-size: 0` collapsed the stock `line-height: 2em`, so the span
* measured 28x0 and the chevron (an absolute ::after) sat outside its own box: aiming at it hit
* the button. Hence a real box, chevron centred in it. */
.cbi-dropdown > .open {
display: flex;
font-size: 0; line-height: 0; border: 0;
position: relative; padding: 0;
align-self: stretch;
min-width: 34px; min-height: var(--fs-ctl-h-sm);
align-items: center;
cursor: pointer;
}
/* `.more` is the "…" a multi-value dropdown shows when its chips overflow. base wrote it in ONE
* rule with `.open`; the theme took `.open` for the chevron above and left its twin behind, so
* the two halves of the same toggle strip sat in different layers. Same geometry, absorbed —
* and hidden by default here (the [multiple][more]/[multiple][empty] showers reveal it),
* with base's muted ink. */
.cbi-dropdown > .more { line-height: 2em; padding: 0 .3em; color: var(--fs-dim); display: none; }
.cbi-dropdown > .open > * { display: none; }
.cbi-dropdown > .open::after {
content: ""; position: absolute; inset: 0; margin: auto;
width: 7px; height: 7px;
border-right: 2px solid var(--fs-dim); border-bottom: 2px solid var(--fs-dim);
transform: translateY(-2px) rotate(45deg); /* optical centre of a "v" */
}
/* A BUTTON dropdown (Save & Apply, Diagnostics) draws that chevron on the button's own FILL, where
* --fs-dim is a grey nobody measured against it — on the accent-filled Save & Apply it read as a
* smudge, not an arrow. currentColor takes whatever ink the variant already set, so there is no
* second list of variants to keep in sync (the same reason base draws this widget's divider with
* a colourless `border-left`). The form-control dropdown keeps the muted chevron on purpose:
* there the arrow is an affordance beside the value, not the content. */
.cbi-dropdown.btn > .open::after,
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
/* open list -> design popover (this was once stated four times over; these are the values
* that were winning). */
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
.cbi-dropdown[open] > ul.dropdown {
/* what makes it a popup at all — out of flow, stacked from the --fs-z-* scale;
* absorbed with the machinery */
display: block;
position: absolute;
z-index: var(--fs-z-dropdown);
transition: max-height var(--fs-dur-move);
overflow-y: auto;
background: var(--fs-panel);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
padding: var(--fs-space-1-5);
/* the gap that detaches the popover from its control. It was DEAD until the `ul` margin
* flags above came out (they out-ranked it from a lower specificity), so the list sat 1px
* off the control — the one value that moved when they went. */
margin-top: var(--fs-space-1-5);
}
.cbi-dropdown[open] > ul.dropdown > li {
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2) var(--fs-space-3);
border-inline-start: 0; color: var(--fs-text);
position: relative;
/* An OPEN row wraps; only the closed preview ellipsizes. `.cbi-dropdown > ul > li` (0,1,2)
* sets nowrap + overflow:hidden + ellipsis, which is right for the collapsed control — one
* line showing the current value — and wrong for the menu: the list is capped at
* min(92vw, 420px) (90-responsive.css), so an option that carries a description ran out of
* box and was cut mid-word. Measured on Network → Firewall, "Hardware flow offloading":
* "Requires hardware NAT support…" ended at the box edge with no ellipsis, because the cut
* fell inside a nested element (issue #15). Raising the cap instead would let one verbose
* option decide the width of every menu on the page. */
white-space: normal; overflow-wrap: anywhere;
/* no border-bottom divider: on a rounded box it curls up at the corners. The straight
* inset strip below (::after) is the divider, so the hover pill stays rounded. */
border-bottom: 0;
}
.cbi-dropdown[open] > ul.dropdown > li:not(:last-child)::after {
content: ""; position: absolute; inset-inline: 11px; bottom: 0;
height: 1px; background: var(--fs-border);
}
.cbi-dropdown[open] > ul.dropdown > li:hover,
.cbi-dropdown[open] > ul.dropdown > li.focus { background: var(--fs-panel2); color: var(--fs-accent); }
/* After hover on purpose: hovering the selected row must still read as selected.
* The surface is OPAQUE (--fs-panel2) and the accent is carried by the inset rail, not by a
* tint of itself: accent text on --fs-accent-soft (a translucent tint of the accent) measured
* 4.21:1 in dark, under the 4.5:1 AA floor — the tint drags the background toward the text and
* eats its own contrast. axe never saw it because no gallery case rendered an OPEN dropdown
* with a chosen value; the widget shipped that way on every dark router. */
.cbi-dropdown[open] > ul.dropdown > li[selected] {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: inset 2px 0 0 var(--fs-accent);
/* @endmirror */
}
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: 1px solid var(--fs-border); margin-top: var(--fs-space-1); }
/* The dropdown's "add item" field is a text input and takes the same radius as every other
* one (theme/60-inputs.css). It said 8px here, and won or lost on source order. */
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: 1px solid var(--fs-border); border-radius: var(--fs-radius); color: var(--fs-text); }
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, so the
* wrapper stays transparent: no double frame. */
.cbi-dynlist > .add-item:has(> .cbi-dropdown) {
background: none; border: 0; overflow: visible; box-shadow: none;
}
.cbi-section-table .td .cbi-dropdown,
.cbi-section-table .td .cbi-input-select,
.cbi-section-table .td select,
.cbi-section-table .td input[type="text"] {
min-width: 0;
max-width: none;
width: 100%;
}
/* Below ~960px of available width a multi-dropdown row no longer fits — card each config row.
* Same shape as the fs-dt stack (theme/30-tables.css): label ABOVE its cell, a read-only cell
* half the row, a widget cell the whole width — so an OpenVPN instance (6 columns) folds into
* 3 short lines instead of 6 right-flushed label:value ones.
*
* A @container, NOT the measurement the data tables use, and it must stay one: these rows are
* full of widgets that bake in a width from the layout they were rendered in, so un-collapsing
* one to take a reading CHANGES what is read — on the router the firewall zone table then
* claimed 1747px where it really needs 1190px, and overflowed its section by 557px. Do NOT
* "finish the job" by measuring it. */
@container fs-content (max-width: 960px) {
.table.cbi-section-table,
.table.cbi-section-table .tbody,
.table.cbi-section-table .td { display: block; width: auto; }
/* Hide the header in BOTH markups — a card repeats every label, so the header is a duplicate
* that only ran off the right edge. form.js wraps titles+descr in a `.thead`; the Lua CBI
* half the third-party apps still render through (luci-app-openvpn) emits a bare
* `tr.cbi-section-table-titles` with no .thead, which is why hiding only `.thead` left the
* OpenVPN header on screen. `.tr.table-titles` is deliberately NOT listed: it comes from
* L.ui.Table, which never renders a .cbi-section-table — that header is hidden by the fs-dt
* stack in theme/30-tables.css. */
.table.cbi-section-table .thead,
.table.cbi-section-table .tr.cbi-section-table-titles,
.table.cbi-section-table .tr.cbi-section-table-descr { display: none; }
/* no align-items: flex-start — stretch keeps a pair's cells (and their separators) the
* same height when one wraps; see the data card's .tr in theme/30-tables.css. */
.table.cbi-section-table .tr {
display: flex; flex-wrap: wrap; width: auto;
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
background: var(--fs-panel2);
padding: var(--fs-space-2) var(--fs-space-3-5) var(--fs-space-3);
margin-bottom: var(--fs-space-3);
}
.table.cbi-section-table .tr:hover { background: var(--fs-panel2); }
/* `.table.cbi-section-table`, not `.cbi-section-table`: the desktop cell padding is written as
* `.table.cbi-section-table .td` (theme/30-tables.css) and outranks a class-lighter selector,
* so a plainer one here silently kept the desktop padding.
*
* margin: 0 is load-bearing. The Lua CBI gives a table CELL the same `.cbi-value-field` class
* a FORM field carries, and base indents that class by 20px. A stacked cell then measured
* 50% + 20px, two no longer fit on a line, and each pair-cell wrapped to its own — which is
* why the OpenVPN row stayed one column deep. */
.table.cbi-section-table .td {
flex: 1 1 100%; min-width: 0; margin: 0;
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
/* same as the data card's cells (theme/30-tables.css): a carded cell holds opaque
* tokens (a real-length IPv6 GUA is 39 chars with no break point — colons are not
* break opportunities), and without `anywhere` one ran 156px past a 360px viewport
* on the Interfaces page. overflow-wrap inherits, so the inner spans get it too;
* white-space beats the `.nowrap` class LuCI puts on some cells. */
overflow-wrap: anywhere; white-space: normal;
}
/* A cell that only READS a value (dummy text, a flag, one button) is short, so two fit on a
* line; anything with a real widget keeps the full width (the 100% basis above) — a dropdown
* at half a phone's width is unusable. Both spellings of each widget: `CBI.DummyValue` from
* form.js, `dvalue` from the Lua CBI's typename. */
.table.cbi-section-table .td[data-widget="CBI.DummyValue"],
.table.cbi-section-table .td[data-widget="CBI.FlagValue"],
.table.cbi-section-table .td[data-widget="CBI.Button"],
.table.cbi-section-table .td[data-widget="dvalue"],
.table.cbi-section-table .td[data-widget="fvalue"],
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
/* PINNED to the data table's copy in theme/30-tables.css: the same card, needed under a CLASS
* there (.fs-stacked, measured) and under this @container here, and CSS cannot OR the two
* guards. tools/mirror.mjs holds the copies byte-identical — a duplicate detector goes QUIET
* once they differ, exactly when it should shout. */
.table.cbi-section-table .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
text-transform: uppercase; color: var(--fs-eyebrow-color);
/* @endmirror */
}
/* The row's buttons are not a label/value pair: full width, each button stretching to
* share the line (Edit | Delete). PINNED — see above. (These six declarations were the
* data table's six in a DIFFERENT ORDER, so a grep could not tell they were one rule.) */
.table.cbi-section-table .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
/* flex-wrap: LuCI groups the row's buttons in ONE inner <div>, so the td's own wrap
* (above) never fires — the div is a single flex item. Four buttons are ~330px; on a
* 320px phone the carded interfaces row overflowed its section by 17px. PINNED to the
* data table's copy (theme/30-tables.css), which hit the same trap on Startup. */
.table.cbi-section-table .td.cbi-section-actions > * {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
.table.cbi-section-table .td .cbi-dropdown,
.table.cbi-section-table .td select,
.table.cbi-section-table .td input[type="text"] { width: 100%; min-width: 0; }
.table.cbi-section-table .td .zone-forwards { width: 100%; }
}
/* ---- widget popups must escape the table card ----
* The standalone-table card (theme/30-tables.css) clips row backgrounds to its rounded corners
* with `overflow: hidden`. But a form table (Diagnostics: three command widgets in one row) also
* holds .cbi-dropdown widgets whose open list is absolutely positioned — the clip cropped it at
* the table's edge and dragged the table's box around with it. So drop the clip where a widget
* lives; the `.cbi-dropdown[open]` z-index above lifts the list over the following (unpositioned)
* cards.
*
* Dropping it squares the frame off, though: a real <table> inherits `border-collapse: collapse`,
* which IGNORES border-radius — the card's corners came from the clip, not the border. The
* separated model restores them, and at zero spacing it lays out identically. */
.table:has(.cbi-dropdown),
.table:has(.cbi-dynlist) {
overflow: visible;
border-collapse: separate;
border-spacing: 0;
}
}
@@ -0,0 +1,90 @@
@layer theme {
/* base/60-modal.css draws the dialog's elevation as `0 0 3px var(--fs-panel2)`, and in
* footstrap light --fs-panel2 and --fs-bg are the SAME hex (#f6f8fa): the shadow under the
* top of the z-stack was drawn in the exact colour of the page behind it (measured 1.00:1;
* dark is 1.27, hicontrast 1.06/1.14 — a glow, never a shadow, since panel2 is LIGHTER than
* the canvas). Every other floating surface in the theme — the open dropdown, ::picker(select),
* the rail flyout, the search box, the appearance popover — already takes --fs-shadow-pop.
* Unscoped `.modal`, not `#modal_overlay .modal`: .modal is reachable outside the overlay, and
* the layer beats base at equal specificity without a specificity fight. */
.modal { box-shadow: var(--fs-shadow-pop); }
/* The uci-changes diff used to be painted TWICE: base gave it one set of tints and five
* #modal_overlay rules here gave it another — 30/60% against 18/45%, the same widget at two
* strengths depending on whether it was inside the modal (it always is). Both are the ladder
* in base now (--fs-*-fill for the block, --fs-*-line for its border); the duplicates are
* gone and only geometry is left here — small rounding on the diff boxes and legend swatches.
*
* The popup's own radius is NOT restated here any more. It used to be, under a note saying
* base rounds a modal to 3px — base rounds it to `var(--fs-radius-lg)` today, with a comment
* of its own saying a modal is a card, so `#modal_overlay .modal { border-radius:
* var(--fs-radius-lg) }` restated a value one layer down and a stale reason beside it.
* Switched off against the 21 modals that match it: no computed style moved. */
/* moved from base with the heading ramp (45-misc.css): the `line-height: normal` here beat
* the ramp's tight leading in-layer for a dialog's h4 title, and left behind it would lose
* to the ramp by layer (measured: modal headings dropped normal -> 15.6px) */
.modal > * {
line-height: normal;
margin-bottom: .5em;
max-width: 100%;
}
/* an alert dropped into a dialog keeps the alert's own rhythm. In base this resolved by
* FILE ORDER (90-widgets after 60-modal, both 0,1,0) — here it wins on specificity, so the
* cascade no longer rests on position. */
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
/* moved from base with the generic field box in theme/60-inputs.css: a dialog's log/output
* textarea fills the dialog, and base's width:100% would lose to the 210px box by layer */
.modal > pre,
.modal > textarea {
white-space: pre-wrap;
overflow: auto;
width: 100%;
}
/* A config-form modal (Bridge device options, firewall rule …) is only 600px wide by
* default — cramped for a form with a table (Bridge VLAN filtering, whose .cbi-map container
* then falls under the 960px card threshold and cards even on a big screen). Fluid via vw:
* widens toward 1100 on desktop (the container clears 960 and tables stay tables), stays
* narrow (cards) on tablet/phone. */
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
/* A modal must never gain a HORIZONTAL scroll: #modal_overlay is the scroll container
* (base/60-modal.css, `overflow: auto`) and the dialog is centred in it with `margin: auto`,
* so one over-wide child scrolls the modal's own left edge off the screen — the dialog is
* still there, just unreachable-looking (reported from a phone, with the flash-image dialog
* cut in half). Measured over the gallery's copy of it at a 360px viewport: the overlay
* wanted 634px of scroll against 530px of room, the `backup_pkgs` label ending 132px past
* the dialog's right edge. Both numbers are 0 now.
*
* Two children do it, and both are stock LuCI markup:
* - `<label class="btn">` — flash.js writes each checkbox row as one ("Keep settings and
* retain the current configuration", "Include in backup a list of current installed
* packages at /etc/backup/installed_packages.txt"). `.btn` is `white-space: pre`
* (base/70-buttons.css), which is right for a BUTTON's own label and wrong for a
* sentence: it cannot wrap at any width. Scoped to `label` — a real button keeps `pre`.
* - a `<li>` carrying a checksum ("SHA256: 5d27b68d…") — one unbreakable 64-char token,
* nothing to wrap at, so it needs `anywhere` rather than a normal break. */
#modal_overlay .modal label.btn { white-space: normal; }
#modal_overlay .modal li { overflow-wrap: anywhere; }
#modal_overlay .uci-dialog div > ins,
#modal_overlay .uci-dialog div > del,
#modal_overlay .uci-dialog div > var { border-radius: var(--fs-radius-sm); }
#modal_overlay .uci-change-legend-label > ins,
#modal_overlay .uci-change-legend-label > del,
/* same token as the diff blocks above — a literal 4px sat outside the Rounding axis */
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); overflow: hidden; }
/* A CHANGED option is `<var><ins>uci set …</ins></var>`: the outer plate is rounded above, the
* coloured fill is the child — and `overflow: hidden` does NOT round it, because the child never
* reaches the corner it would be clipped at (the parent holds it 3px in: 1px border + 2px
* padding). So the plate had rounded corners with a square green block sitting inside them, and
* the legend's own "Option changed"/"Option removed" swatches were squares inside circles
* (issue #14 — the two "Section" swatches, which have no child, were already round).
* The child takes the parent's radius minus that 3px inset, which is what makes the two curves
* concentric; `max()` keeps it at 0 rather than going negative when Rounding is turned down. */
#modal_overlay .uci-dialog var > ins,
#modal_overlay .uci-dialog var > del {
border-radius: max(0px, calc(var(--fs-radius-sm) - 3px));
}
}
@@ -0,0 +1,97 @@
@layer theme {
/* ==================================================================
* The page-search palette. Markup is built by fs-search.js and parented to <body>:
* the vertical .fs-sidebar is `overflow: auto`, which would clip a child of the trigger,
* and the overlay has to cover the whole viewport anyway.
* ================================================================== */
/* The scrim. --fs-scrim is the theme's one colour literal (plain black at .7) on purpose:
* it is the absence of light behind a dialog, not a tint of any palette token. */
.fs-search-ov {
position: fixed; inset: 0; z-index: var(--fs-z-popover);
display: flex; justify-content: center;
/* not centred vertically: the box grows DOWNWARD as results come in, and a centred one
* would slide the input out from under the user's eyes on every keystroke */
align-items: flex-start;
padding: max(8vh, var(--fs-space-6)) var(--fs-space-4) var(--fs-space-4);
background: var(--fs-scrim);
animation: fs-fade var(--fs-dur-fade);
}
.fs-search-ov[hidden] { display: none; }
/* The dialog itself. It is a SCROLL CONTAINER, and that is not decoration: without a cap the
* box grows past a short viewport (a phone in landscape is ~380px tall) and the rows at the
* bottom become unreachable — a `position: fixed` popup cannot be scrolled by the page. */
.fs-search-box {
display: flex; flex-direction: column; min-height: 0;
width: 100%; max-width: 560px; max-height: min(70dvh, 560px);
background: var(--fs-glass);
backdrop-filter: var(--fs-blur); -webkit-backdrop-filter: var(--fs-blur);
border: 1px solid var(--fs-border); border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
overflow: hidden;
}
.fs-search-row {
display: flex; align-items: center; gap: var(--fs-space-2-5); flex: 0 0 auto;
padding: var(--fs-space-3) var(--fs-space-3-5);
border-bottom: 1px solid var(--fs-border);
}
.fs-search-ico { display: inline-flex; color: var(--fs-faint); }
.fs-search-ico .fs-ico { width: 18px; height: 18px; }
/* The input carries no frame of its own — the ROW is the field, and the box around it is the
* dialog. Two classes (0,2,0) on purpose: the generic field box in 60-inputs.css is
* `input[type="text"], …` (0,1,1), which outranks a single class in the same layer whatever
* the source order — measured, it painted this input with the page-field border, panel2 fill,
* 8px/11px padding and a focus ring inside the dialog's own frame.
*
* 16px is not the theme's body size and is deliberate: a font-size below 16px makes iOS
* Safari zoom the page when the field takes focus, and this one takes focus the moment the
* palette opens. */
.fs-search-row .fs-search-input {
flex: 1 1 auto; min-width: 0;
margin: 0; padding: 0; border: 0; min-height: 0; background: transparent;
color: var(--fs-text);
font-family: inherit; font-size: var(--fs-type-lg); font-weight: var(--fs-weight); line-height: 1.4;
}
.fs-search-row .fs-search-input:focus { outline: none; box-shadow: none; }
.fs-search-row .fs-search-input::placeholder { color: var(--fs-faint); font-weight: var(--fs-weight); }
.fs-search-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--fs-space-1-5); }
.fs-search-opt {
display: flex; align-items: baseline; gap: var(--fs-space-2-5);
padding: var(--fs-space-2) var(--fs-space-2-5); border-radius: var(--fs-radius);
color: var(--fs-text); text-decoration: none; cursor: pointer;
}
/* One highlight for both pointer and keyboard: `.active` is set by fs-search.js for the
* aria-activedescendant row AND on pointermove, so the mouse and the arrow keys can never
* disagree about which row Enter would open. */
.fs-search-opt.active { background: var(--fs-accent-soft); color: var(--fs-accent); }
.fs-search-opt-title { flex: 0 1 auto; font-weight: var(--fs-weight-bold); font-size: var(--fs-type); }
/* the breadcrumb: which section owns the page — the whole point of searching for it */
.fs-search-opt-path {
flex: 1 1 auto; min-width: 0;
color: var(--fs-faint); font-size: var(--fs-type); text-align: end;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fs-search-opt.active .fs-search-opt-path { color: inherit; }
/* "Recently visited" — the caption over the rows */
.fs-search-note {
flex: 0 0 auto;
padding: var(--fs-space-2-5) var(--fs-space-3-5) var(--fs-space-0-5);
color: var(--fs-eyebrow-color);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
}
.fs-search-note[hidden] { display: none; }
/* …and the box's EMPTY STATE when there are no rows at all ("No pages found"), which reads as
* a sentence, not as an eyebrow label. :has() rather than a sibling combinator: the note sits
* ABOVE the list it describes, so `+` cannot reach back to it. */
.fs-search-box:has(.fs-search-list:empty) .fs-search-note {
padding: var(--fs-space-6) var(--fs-space-3-5) var(--fs-space-7);
text-align: center; text-transform: none; letter-spacing: 0;
font-size: var(--fs-type); font-weight: var(--fs-weight);
}
}
@@ -0,0 +1,188 @@
@layer theme {
/* ==================================================================
* Phone overflow safety net (<=767px). .fs-main uses overflow-x: clip, so anything wider
* than the viewport is silently CUT on the right rather than scrolled to. A device sweep
* (iPhone/Samsung/Pixel/iPad presets) found the offenders below; each fix lets the control
* or table shrink or wrap. Tablets (>=768px) were already clean.
* ================================================================== */
@media (max-width: 767px) {
/* A FOREIGN <table> — one with none of LuCI's classes — scrolls instead of being cut off.
*
* The theme cards a data table when it stops fitting (.fs-stacked, fs-select.js), but that
* only reaches `.table` markup carrying a header row: `.tr.table-titles` or
* `.tr.cbi-section-table-titles`. A third-party dashboard that emits a plain
* `<table><tr><th>` matches none of it, so nothing measured it, nothing carded it, and
* `.fs-main`'s `overflow-x: clip` silently CUT the right-hand columns — reported on a phone
* against a wifi-clients dashboard whose last column was simply not there. Measured here at
* 390px: the table wanted 388px inside a 356px card and overflowed by 31px, with the card at
* `overflow-x: visible` so there was nothing to scroll.
*
* `display: block` + `overflow-x: auto` on the table itself, not a wrapper: the theme cannot
* add markup around someone else's element, and the block box is what gives it a scroll
* container. The rows still lay out as a table — a `display: block` <table> wraps its own
* children in an anonymous table box — so the columns keep lining up.
*
* Scoped away from every table the theme DOES understand: `.table` (LuCI's div-tables and
* the ones fs-select tags), `.cbi-section-table` (config forms, which card by container)
* and `.fs-dt`. Those have their own answer and must not get a scrollbar instead of it. */
#view table:not(.table):not(.cbi-section-table):not(.fs-dt) {
display: block;
max-width: 100%;
overflow-x: auto;
/* the scroll gesture stops at this table instead of dragging the page sideways */
overscroll-behavior-x: contain;
}
/* Must stay INSIDE this @media: written unscoped it never took effect (base's
* .cbi-dropdown:not(.btn):not(.cbi-button) is more specific and kept its 400px cap), and
* with base in its own layer it would now cap every dropdown on the desktop too. A
* dropdown must never exceed its field, on a page or in a dialog. */
.cbi-dropdown { min-width: 0; max-width: 100%; }
/* Software page (luci-app-package-manager): stack each labeled control; base gives its
* labels a min-width: 250px that overflowed a ~360px screen. `.controls` is a flex-ROW,
* so its Filter/Download/Actions columns crammed side-by-side and overlapped.
* Scoped by data-page, NOT by #disk-space-label: on 24.10 the "Disk space:" label ships
* with no id (25.12 added one), so a :has(#disk-space-label) scope silently missed the
* whole page there.
* !important because package-manager.js injects an UNLAYERED inline \3c style>
* (`.controls{display:flex}` + `.controls > *{…}`), and unlayered outranks every layer. */
body[data-page="admin-system-package-manager"] #view .controls { display: block !important; }
/* `:not(.pager)` — the page reuses `.controls` for the « / "Displaying 1-100 of 7677" / »
* pager, and its .pager is a <div> too. Blocking it stacked those three onto three lines
* (reported). Only the LABELLED control groups want stacking; the pager is a row. */
body[data-page="admin-system-package-manager"] #view .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
/* …and the pager itself: a centred row that stays one. `.text` is the only part that may
* give way, so the two buttons keep their size instead of being squeezed. */
body[data-page="admin-system-package-manager"] #view .controls > .pager {
display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2);
}
body[data-page="admin-system-package-manager"] #view .controls > .pager > .btn { flex: 0 0 auto; }
body[data-page="admin-system-package-manager"] #view .controls > .pager > .text { flex: 0 1 auto; min-width: 0; }
/* the bar's ::after value ("63% used …") normally floats above the bar, but the "Disk
* space:" label already sits there — on a phone the long value text overlapped label +
* bar. Drop it BELOW. The bar is a fixed 10px box, so a static ::after cannot push the
* next control: reserve space with margin-bottom and place the value absolutely in it.
* The disk section is the only `label` immediately followed by a `.cbi-progressbar`. */
body[data-page="admin-system-package-manager"] #view label + .cbi-progressbar {
width: 100%; margin-bottom: 3em;
}
body[data-page="admin-system-package-manager"] #view label + .cbi-progressbar::after {
top: calc(100% + 5px); inset-inline: 0 auto; bottom: auto;
text-align: start; white-space: normal; max-width: 100%;
}
/* `> div > label` — the group's TITLE label ("Filter:", "Actions:", "Display LuCI
* translation packages:"), not every label on the page. Written as plain `.controls label`
* it also blocked the three radio labels nested a level deeper, and `filtered / all / none`
* came out one per line. They are a set of three short choices: they belong on one row. */
body[data-page="admin-system-package-manager"] #view .controls > div > label { display: block; min-width: 0; white-space: normal; margin-bottom: var(--fs-space-1); }
/* …and the row they sit in. Stock separates them with &nbsp; text nodes, which is enough to
* keep them apart but not to keep them from splitting mid-choice — a flex row with a real
* gap wraps between labels instead of inside one. */
body[data-page="admin-system-package-manager"] #view .controls > div > div:has(> label > input[type="radio"]) {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-1) var(--fs-space-5);
}
body[data-page="admin-system-package-manager"] #view .controls .control-group { display: flex; flex-wrap: wrap; min-width: 0; gap: var(--fs-space-2); }
body[data-page="admin-system-package-manager"] #view .controls .control-group input { flex: 1 1 140px; min-width: 0; max-width: 100%; }
/* Diagnostics tool (input + method select + button in one <table> row) has no id and no
* .cbi-section-table, so nothing stacks it — a fixed table layout stayed ~365px wide. */
body[data-page="admin-network-diagnostics"] #view .table:not([id]):not(.cbi-section-table),
body[data-page="admin-network-diagnostics"] #view .table:not([id]):not(.cbi-section-table) .tr,
body[data-page="admin-network-diagnostics"] #view .table:not([id]):not(.cbi-section-table) .td { display: block; width: auto; }
/* fs-dt data tables (Startup init.d list, leases, processes) that do NOT card on a phone:
* the row stacking is MEASURED (.fs-stacked, fs-select.js) and carries its own
* `display: block` (theme/30-tables.css), but a table that still fits as a table keeps
* display: table and with it an intrinsic min-width. Block it so its width cannot exceed
* the column. */
table.fs-dt { display: block; width: 100%; }
/* generic caps: an input/select must never exceed its field, and long key/value strings
* in the Overview cards must break. */
.cbi-value-field input, .cbi-value-field select, .cbi-value-field .cbi-dropdown { max-width: 100%; }
/* …and once the label is on its own line the control takes the line it was given — a
* field sized to its content left half the row empty and made the form read as a ragged
* column (stock does the same on a phone). checkbox/radio/file keep their intrinsic size:
* a full-width checkbox is a hit target the size of the screen. */
.cbi-value-field select,
.cbi-value-field input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
.cbi-value-field .cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-value-field .cbi-dynlist,
.cbi-value-field .cbi-select { width: 100%; }
/* min-width only: breaking a long value is base/40-tables.css's default for every cell now,
* and this copy moved nothing when switched off against 126 matching cells. `min-width: 0` is
* NOT redundant — switching that off moved 12 — so it stays. */
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
/* Stack each field: label on its own line, control + description full-width below. Side
* by side (space-between) on the desktop squeezes the field — and any
* .cbi-value-description inside it — into a ~120px column on a phone, so one sentence of
* help wrapped to 8 lines. The row already flex-wraps (base .cbi-value); these two
* full-width children are what actually stack it.
*
* NOT scoped to #view, and that is the point: ui.js appends a modal to <body>, so an
* `#view .cbi-value` rule left every DIALOG in the desktop side-by-side layout and on a
* phone the Edit-interface form pushed its inputs past the dialog's right edge, where
* they were cut off. A .cbi-value only ever appears in a form, page or dialog alike. */
/* `start` undoes base's `text-align: end` (the label hugs its field when the two share a
* row); stacked, it goes back to the reading edge. Logical for the same reason base's is. */
.cbi-value > .cbi-value-title { flex: 1 1 100%; text-align: start; padding-top: 0; }
.cbi-value > .cbi-value-field { flex: 1 1 100%; min-width: 0; }
/* ==============================================================
* The stock phone contract every luci-app-* is written against. These classes are emitted
* by LuCI's own JS (wireless.js, connections.js, package-manager.js, channel_analysis.js,
* ui.js) and by third-party apps that copied them — and they are styled NOWHERE but
* bootstrap's mobile.css, so a theme that ignores them renders a phone layout its own app
* JS believes it has already handled.
* ============================================================== */
/* A column the emitting JS asks to DROP on a phone: the wireless Associated Stations table
* marks its MAC column .hide-xs — stock LuCI shows Network/Host/Signal/Rate and no MAC; we
* showed the MAC and paid a whole half-row for it.
*
* The `.table .td.hide-xs` half is not redundant: the stacking rules select the cell as
* `table.fs-dt .td` (0,2,1) / `.cbi-section-table .td` (0,2,0), both of which outrank a
* bare `.hide-xs` (0,1,0), so the cell kept their `display: block` and stayed visible. Stock LuCI wins
* this with !important; one class more (`.table`, which every such table carries) wins it
* without one. */
.hide-sm, .hide-xs,
.table .td.hide-sm, .table .td.hide-xs,
.table .th.hide-sm, .table .th.hide-xs { display: none; }
/* The `.col-N` weights used to live here, inside the phone tier. They are UNGUARDED now,
* in theme/30-tables.css: `flex` is inert on anything that is not a flex item, and a `.td`
* becomes one exactly when its table cards — so one copy covers the phone tier, the
* measured stack at any width and the config table's @container, with no second copy to
* keep in step. See the block there. */
/* iOS Safari zooms the page in when a focused control's text is under 16px and never zooms
* back out — the theme's 13px inputs did that on every form. Stock LuCI needs !important
* for it; here the theme layer is enough. */
input, textarea, select, .cbi-dropdown > ul > li { font-size: var(--fs-type-lg); }
}
/* ==================================================================
* Dropdown list: anchored to its widget, never full-bleed.
*
* ui.js has a touch branch (`'ontouchstart' in window`) that stretches the open list across
* the viewport with INLINE styles:
* ul.style.left = `${-rect.left}px`
* ul.style.right = `${rect.right - vpWidth}px`
* Those offsets assume the list's containing block is the viewport. It is not: .cbi-dropdown
* is position: relative, so a negative `left` drags the list off to the side and clips the
* labels — which is what a phone actually rendered. An author !important is the only thing
* that outranks an inline declaration, so pin it back under the widget. Harmless on desktop,
* where ui.js sets only top/bottom/max-height.
*
* Only left/right carry the flag — those are the two properties ui.js writes inline. The
* widths never appear in an inline style (ui.js only CLEARS `ul.style.width`, on close), so
* they win from this layer on their own.
* ================================================================== */
.cbi-dropdown[open] > ul.dropdown {
inset-inline-start: 0 !important;
inset-inline-end: auto !important;
min-width: 100%;
width: max-content;
max-width: min(92vw, 420px);
}
}
@@ -0,0 +1,124 @@
@layer theme {
/* ==================================================================
* prefers-reduced-motion — a medical accommodation: vestibular disorders make sliding,
* fading and spinning UI genuinely nauseating. Honoured globally rather than per-component;
* every transition and keyframe in this theme is decoration, none carries information.
*
* WHY !important HERE — the documented exception, and NOT the same kind as the 14 in theme/
* and pages/ that fight an inline `style=` or an unlayered \3c style>. A cascade layer cannot
* help: styles/base sets transitions and animations too, and !important INVERTS the layer
* order (an important declaration in base would beat a normal one here). Only an important
* declaration reaches all of them at once. This is the canonical form of the rule as
* published by the WCAG working group. DO NOT COPY THE FLAG OUT OF THIS BLOCK.
*
* .01ms rather than 0s: a zero duration means `transitionend`/`animationend` never fire at
* all, and JS that waits for one (LuCI's modal teardown does) would hang.
* scroll-behavior: smooth scrolling is motion too — a jump is what this user asked for.
* ================================================================== */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
/* ==================================================================
* prefers-reduced-transparency — the user asked the OS for opaque surfaces (visual clutter,
* low vision, or plain preference). Every frosted surface in this theme is a translucent
* fill + backdrop-filter blur; both go: the fill becomes the opaque panel, and the blur —
* the single most expensive paint on a phone GPU (the sticky bar's backdrop is
* resampled on every scrolled frame) — has nothing left to show through anyway. This also
* gives a measured escape hatch on devices where the bar blur is too costly, without
* taking the frosted design away from everyone else.
* ================================================================== */
@media (prefers-reduced-transparency: reduce) {
:root {
--fs-bar-bg: var(--fs-panel);
--fs-glass: var(--fs-panel);
--fs-blur: none;
/* the uploaded background is a translucent wash over a photo — exactly the effect this
* user asked the OS to drop. Make the scrim opaque so the canvas is flat, not a photo
* ghosting through (16-login-bg.css lays it over the image as one background layer). */
--fs-photo-scrim: var(--fs-bg);
}
}
/* ==================================================================
* prefers-contrast — the user asked the OS for MORE contrast, but (unlike forced-colors)
* left the palette to us. Same mechanism as reduced-transparency above: re-state the tokens,
* and every rule that reads them follows. What moves and why:
* - the hairlines: --fs-border is the most-read line in the theme and sits at ~20% of the
* text's contrast; pull it toward --fs-text. The role hairlines (-line/-line-hi, a 40/55%
* tint of their role) go to the FULL role colour — a tint exists to be quiet, which is
* the opposite of what this user asked for.
* - secondary text: --fs-dim toward --fs-text, and --fs-faint (10-11px eyebrow labels)
* up to dim's level — small + faint is where AA erodes first.
* - the focus ring: --fs-focus-ring is a tint of the accent (--fs-accent-soft: 10% light,
* 15% dark — the one ladder member whose strength is per MODE, so not the ladder's 12%);
* at "more contrast" it carries the accent at 60%. Its sibling --fs-focus-ring-solo is
* already the SOLID accent and needs no restatement here.
* ================================================================== */
@media (prefers-contrast: more) {
:root {
--fs-border: color-mix(in srgb, var(--fs-text) 45%, var(--fs-bg));
--fs-dim: color-mix(in srgb, var(--fs-text) 85%, var(--fs-bg));
--fs-faint: var(--fs-dim);
--fs-focus-ring: 0 0 0 3px color-mix(in srgb, var(--fs-accent) 60%, transparent);
--fs-accent-line: var(--fs-accent); --fs-accent-line-hi: var(--fs-accent);
--fs-good-line: var(--fs-good); --fs-good-line-hi: var(--fs-good);
--fs-warn-line: var(--fs-warn); --fs-warn-line-hi: var(--fs-warn);
--fs-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
}
}
/* ==================================================================
* forced-colors (Windows High Contrast) — the OS replaces every colour the theme picked, and
* background-color on most elements is forced to the system canvas. Any state carried by
* BACKGROUND ALONE therefore vanishes: the active tab pill, the checked toggle and the
* selected dropdown row all looked exactly like their inactive siblings — a correctness bug,
* not a cosmetic one. Re-state those states with a BORDER/OUTLINE, which forced-colors keeps,
* in system colour keywords so they follow the user's scheme.
* ================================================================== */
@media (forced-colors: active) {
/* active tab / selected dropdown row / checked toggle: outline the state.
*
* `.ifacebox-head.active` and Apply joined the list rather than getting a rule of their own:
* they are the same KIND of state — carried by a background, with no word to fall back on —
* and it is the same repair. "This port is up" was indistinguishable from a dead port, and
* Save & Apply did not read as a BUTTON at all: it is a .cbi-dropdown wrapper, so with its
* fill forced to Canvas only the label inside the <li> kept a highlight, leaving what looks
* like selected text beside a bare chevron (see tmp/decisions/4-forcedcolors-actionbar).
* NOT extended to the alert and .label variants: the audit's proposal there was to encode
* severity in border-style (dashed/dotted/double), and a border style is not a scale anyone
* can learn — the honest repair is a word or an icon in the markup, which is ui.js's. */
#tabmenu ul.tabs > li.active > a,
ul.cbi-tabmenu.mode > li.cbi-tab > a,
.tabs > li.active > a,
.cbi-tabmenu > li.cbi-tab > a,
.cbi-dropdown > ul > li[selected],
.ifacebox .ifacebox-head.active,
.cbi-page-actions .cbi-button-apply {
outline: 2px solid Highlight;
outline-offset: -2px;
color: HighlightText;
background: Highlight;
forced-color-adjust: none; /* keep the fill: it IS the state */
}
/* the toggle switch is a bare rounded box in forced colors — give the knob and
* the track an edge so on/off is distinguishable at all */
.cbi-checkbox > label { border: 1px solid ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked + label {
background: Highlight;
forced-color-adjust: none;
}
.cbi-checkbox > label::after { background: ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked + label::after { background: HighlightText; }
/* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
/* the focus ring is a box-shadow everywhere in this theme, and box-shadow is
* dropped entirely in forced-colors — so keyboard focus would be invisible */
:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
}
}
@@ -0,0 +1,46 @@
@layer theme {
/* ==================================================================
* Print. A LuCI page ends up on paper/PDF as documentation — a firewall ruleset in a
* change ticket, an overview in a handover — and without this block it printed the dark
* chrome, the sticky bar, and (in the sidebar layout) ONE page: the shell is
* height:100dvh + overflow:hidden there, so everything past the first viewport was cut.
*
* No `!important` (tools/audit.py allows the flag only in its listed files, and nothing
* here fights an inline style). Where a rule must beat a same-layer chrome rule it wins
* on SPECIFICITY — a repeated class, `.x.x.x.x` at (0,4,0), one step over the layout
* guards' (0,3,1) — never on this file sorting after 20-shell.css.
* ================================================================== */
@media print {
/* Ink-friendly surfaces whatever palette/mode is active: this block sits in the
* theme layer, so it outranks every tokens-layer palette block without naming
* data-darkmode or the palettes. Every rule reads the tokens, so one re-statement
* re-paints the whole page. */
:root {
--fs-bg: #fff; --fs-panel: #fff; --fs-panel2: #f4f4f4;
--fs-text: #000; --fs-dim: #333; --fs-faint: #555;
--fs-border: #bbb; --fs-track: #ddd;
--fs-shadow: none; --fs-shadow-pop: none; --fs-shadow-bar: none;
--fs-emboss: none; --fs-text-emboss: none;
--fs-bar-bg: #fff; --fs-glass: #fff; --fs-blur: none;
}
/* Navigation, the search overlay and the action buttons cannot be used on paper. The
* Appearance popover was on this list; it is a PAGE now, and a page prints — an admin
* printing System -> Appearance wants what is on it. */
.fs-sidebar.fs-sidebar.fs-sidebar.fs-sidebar,
.fs-skip.fs-skip,
.fs-search-ov.fs-search-ov,
.cbi-page-actions.cbi-page-actions,
.cbi-tabmenu.cbi-tabmenu,
footer { display: none; }
/* Undo the sidebar layout's scroll frame — the ONE-page bug above. */
.fs-shell.fs-shell.fs-shell.fs-shell { height: auto; overflow: visible; }
.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
.fs-content.fs-content { max-width: none; padding: 0; }
/* same element as 15-wallpaper.css, later in the layer — the pattern stays off the paper */
.fs-pattern.fs-pattern { display: none; }
/* and the uploaded photo — ink, not decoration, on paper (16-login-bg.css) */
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) { background-image: none; }
/* keep a card in one piece where the page allows it */
.cbi-section { break-inside: avoid; }
}
}
@@ -0,0 +1 @@
{% include('themes/footstrap/partials/footer') %}
@@ -0,0 +1,184 @@
{#
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2012 David Menting <david@nut-bolt.nl>
Copyright 2008-2022 Jo-Philipp Wich <jo@mein.io>
Copyright 2026 footstrap theme
Licensed to the public under the Apache License 2.0.
i18n, AND THE RULE THE OTHER TEMPLATES POINT AT. A msgid is a GLOBAL name: LuCI serves ONE
merged catalogue — load_catalog() loads every *.<lang>.lmo in /usr/lib/lua/luci/i18n and a lookup
returns the first archive holding the hash — so readdir order decides who owns a string. The
Appearance layout toggle rendered "Максимум" on a Russian router (issue #6), because somebody
else's catalogue translates the msgid `Top` as "maximum". Anything we leave BARE is a name anyone
may take, and contexting cannot be selective.
So every label in the Appearance page carries the `footstrap` msgctxt (fs-appearance.js), while
the CHROME's own strings — Skip to content, Menu, Collapse menu, Search, Log out — are
deliberately msgctxt-FREE: inheriting luci-base's translation is a feature in the ~40 languages
this theme ships no catalogue for. Same for the login/notice sentences and for
System/Memory/Storage in fs-overview.js, which MATCH the stock headings on purpose.
-#}
{%
import { getuid, getspnam } from 'luci.core';
const boardinfo = ubus.call('system', 'board') ?? {};
const empty_password = (getuid() == 0 && getspnam('root')?.pwdp === '');
/* The ROUTER's saved Appearance defaults (Appearance -> Save as default, in /etc/config/footstrap):
head.ut stamps them before paint and the client's own localStorage overrides every one — the
browser choice always wins. Layout falls back to the legacy migration seed
luci.main.footstrap_layout=top, which uci-defaults sets for a router upgraded from the old
top-nav theme (a shell script cannot write localStorage). Absent = the built-in defaults.
`uci` and `config` come from the render scope, which dispatcher.uc builds per request: `uci` is
already a cursor (dispatcher.uc `let uci = cursor()`) and `config.main` is already
`uci.get_all('luci','main')`. Importing cursor from 'uci' and opening a second one allocated a
cursor per page render and re-read a package the dispatcher had loaded before calling us —
and left this template reaching for UCI a different way than sysauth.ut does. */
const fsd = uci.get_all('footstrap', 'settings') ?? {};
/* EVERY option saveAsDefault() writes, listed ONCE and read back in a loop.
*
* This used to be a hand-written object literal with one line per axis, and the shape of that
* mistake is on record twice: `density` was saved by saveAsDefault() and then never read back
* here, so the router-wide Density default reached no browser — and when the colour axes landed,
* the same thing happened again to all seven of them at once. The symptom is the worst kind:
* Save as default reports success, the file on disk is correct, and "Reset to saved" drops the
* browser to the BUILT-IN default instead of the saved one, because the value the server hands
* the client for that axis was never there. Nothing else notices — tools/axes.mjs held the
* pre-paint against the live applier and both were right; the gap was the SERVER read that feeds
* them, which is now what tools/axes.mjs checks against snapshotAxes().
*
* '' (not the raw undefined) when unset, so head.ut's `!= ''` guards fire — otherwise
* int(undefined) is 0, a VALID value, and an axis whose 0 is a real choice (rounding, the tint
* strength, the photo dim) would default to 0 instead of to its own default. */
const FS_AXES = [
'layout', 'darkmode', 'palette', 'wallpaper', 'density',
'tint', 'accent', 'good', 'warn', 'danger',
'card', 'control', 'bar', 'line',
'rounding', 'autocollapse', 'tint_strength', 'photo_dim',
'pattern_size', 'pattern_strength', 'pattern_ink',
/* the two uploaded images' cache-bust tokens (head.ut whitelists both to hex) — the files
themselves are served, only these tokens travel in the page. NOT axes: they have no
browser layer at all, but they reach the client the same way. */
'login_bg', 'pattern',
/* the fonts, same shape and for the same reason: no browser layer, no Appearance control,
no entry in snapshotAxes(). font_sans/font_mono are font-family stacks (head.ut
whitelists the charset and prints them into a style element); `fonts` is the cache-bust
token of the generated @font-face sheet, hex like the two above. All three are written by
fonts/set-font.sh or by hand with `uci set`, never by Save as default. */
'font_sans', 'font_mono', 'fonts'
];
const fs_defaults = {};
for (let k in FS_AXES)
fs_defaults[k] = fsd[k] || '';
/* the one value with a fallback of its own: the legacy migration seed
luci.main.footstrap_layout=top, which uci-defaults sets for a router upgraded from the old
top-nav theme (a shell script cannot write localStorage). */
fs_defaults.layout = fsd.layout || config.main.footstrap_layout || '';
http.prepare_content('text/html; charset=UTF-8');
-%}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %}
{#
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
/admin/status renders the overview while request_path is only ['admin','status'], so
data-page came out "admin-status" and every `body[data-page='admin-status-overview']`
rule — and the overview layout include, which gates on it — silently did not apply
there. The SPA router stamps the resolved leaf path, so both ways of arriving agree.
#}
<body class="lang_{{ dispatcher.lang }} {{ entityencode(striptags(node?.title ?? ''), true) }}" data-page="{{ entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) }}">
{#
The wallpaper PATTERN's paint surface, and nothing else: 15-wallpaper.css masks this
element so an uploaded SVG takes the theme's ink. A mask applies to an element's whole
rendering, children included, so it cannot go on .fs-shell or on <body> — it needs a
layer of its own with nothing in it.
OUTSIDE the blank_page guard on purpose. The login page renders with blank_page: true
(no .fs-shell, no chrome at all), and it is the first page anyone sees: a router whose
saved default is Pattern has to show it there too, exactly as the File photo does.
Inert unless the axis is on — .fs-pattern is display:none by default, so on every other
setting this is one empty div that paints nothing.
data-fs-chrome: Zone 1, ours — see the <nav> below.
#}
<div class="fs-pattern" data-fs-chrome aria-hidden="true"></div>
{% if (!blank_page): %}
{#
Skip link: jump the menu instead of tabbing through every section on every page.
Visually hidden until focused (.fs-skip, theme/10-chrome.css).
data-fs-chrome: this element and its subtree are Zone 1 — see the <nav> below.
#}
<a class="fs-skip" data-fs-chrome href="#maincontent">{{ _('Skip to content') }}</a>
<div class="fs-shell">
{#
<nav>, not <aside>: this column IS the site navigation, and an <aside> gets
role="complementary" — a screen reader could not reach the menu by landmark, and
the shell had no navigation landmark at all. CSS keys off .fs-sidebar.
data-fs-chrome marks a Zone 1 ROOT: everything from here down is ours, and no
third-party rule may match it (the fence, fs-sheets.js) or reach it from html/body
by inheritance (the pin, theme/10-chrome.css). Both key off the ATTRIBUTE, never off
a class list, because the alternative rotted: chrome is not one element — the skip
link and the Appearance popover are not inside this <nav> — so a fence naming
`.fs-sidebar` protected the menu and left them out, and nothing said so. Marking the
element is what a new chrome root cannot forget to do somewhere else.
It marks ROOTS ONLY: never nest one marker inside another. The pin states inherited
properties on the marked element, and a direct declaration beats an inherited one
EVEN WHEN the inherited one is ours — measured, that cost .fs-label its nowrap and
forced text-align from start to left on 302 elements (RTL). `npm run chrome-fence`
fails on a nested marker.
#}
<nav class="fs-sidebar" data-fs-chrome aria-label="{{ _('Menu') }}">
<div class="fs-brandrow">
{% include('themes/footstrap/partials/brand', { boardinfo }) %}
<button type="button" class="fs-railtoggle" id="fs-rail-toggle" aria-label="{{ _('Collapse menu') }}" title="{{ _('Collapse menu') }}">
{% include('themes/footstrap/partials/icon', { body: '<path d="M15 6l-6 6 6 6"/>' }) %}
</button>
</div>
{# LuCI's poll status ("Refreshing") and other indicators land here by id. #}
<div id="indicators" class="fs-indicators"></div>
<div class="fs-navlabel">{{ _('Menu') }}</div>
<ul class="nav" id="topmenu"></ul>
<ul id="modemenu" class="fs-modemenu"></ul>
<div class="fs-spacer"></div>
{# right cluster, in visual order: the poll pill above, then Search, Log out.
APPEARANCE USED TO SIT HERE, a button opening a popover on <body>. It is a tab
now — System -> System (admin/system/system), appended to the stock page by
fs-appearance.js's MutationObserver, because a THEME may not own a dispatcher
node. The axes had outgrown a floating panel; fs-appearance.js's header has the
reasoning. So the chrome carries one control fewer. #}
{% include('themes/footstrap/partials/search') %}
{% include('themes/footstrap/partials/logout') %}
</nav>
<main class="fs-main" id="maincontent" tabindex="-1">
{# The document's <h1>: LuCI views start at <h2>, so without it the heading outline
begins mid-way down. It must EXIST for a screen reader without being seen, and
`hidden` cannot do that — it is display:none, which strips the element from the
accessibility tree too, so the h1 was never there and the SPA router's
.fs-title-main sync updated a node no assistive tech could read. .fs-sr clips
instead (like .fs-skip): clipped stays in the tree — and what keeps it clipped is
a class a foreign `*` outranks with one `!important`, so it is marked Zone 1 too.
Inside .fs-main, which is NOT marked: that is where app pages render. #}
<div class="fs-title fs-sr" data-fs-chrome>
<h1 class="fs-title-main">{{ entityencode(striptags(dispatched?.title ? _(dispatched.title) : (boardinfo.hostname ?? 'OpenWrt')), true) }}</h1>
</div>
{# The SPA router swaps the page with no reload, and a screen reader cannot notice:
no load event, focus dies with the clicked <a> (renderChrome() empties
#topmenu), the new <title> is not announced. The router writes the incoming
title here after every nav; empty on a full load — that announces itself. #}
<div id="fs-nav-status" class="fs-sr" data-fs-chrome role="status" aria-live="polite"></div>
<div class="fs-content">
{% include('themes/footstrap/partials/notices', { boardinfo, empty_password }) %}
{% endif %}
@@ -0,0 +1,16 @@
{#
Shared brand block (logo + hostname wordmark). Included as
include('themes/footstrap/partials/brand', { boardinfo }); the look lives in
styles/theme/10-chrome.css.
Licensed to the public under the Apache License 2.0.
-#}
<a class="fs-brand" href="{{ dispatcher.build_url() }}">
<span class="fs-logo">
{# The OpenWrt mark, same as the tab favicon (logo.svg): cyan arcs are baked, the ring
flips with the theme mode via the .fs-logo-ring rule in styles/theme/10-chrome.css —
dark ring on the light UI, light ring on dark. It sits in a bordered box (no accent
fill), matching the square buttons in the right cluster. #}
<svg aria-hidden="true" viewBox="-9 -1 100 100"><path d="m40.5 50.8a6.6 6.6 0 1 0 0 13.2 6.6 6.6 0 0 0 0-13.2m-40.5-33.9 7 6.8a47.5 47.5 0 0 1 67.2 0l7-6.8a57.2 57.2 0 0 0-81.2 0" fill="#00b5e2"/><path d="m12.5 29.2 6.8 7a30 30 0 0 1 42.6 0l6.8-7a39.7 39.7 0 0 0-56.3 0" fill="#00b5e2"/><path d="m24.8 41.6 6.8 6.9a12.6 12.6 0 0 1 18 0l6.8-6.9a22.3 22.3 0 0 0-31.6 0" fill="#00b5e2"/><path class="fs-logo-ring" d="m64.9 39.7a30.2 30.2 0 1 1-48.7 0l-6.9-7a39.9 39.9 0 1 0 62.5 0z"/></svg>
</span>
<span class="fs-wordmark">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</span>
</a>
@@ -0,0 +1,37 @@
{#
Footer body. ONE layout template and ONE client menu renderer — sidebar and top bar are
the same markup, morphed by :root[data-layout] — so the old `menu_module` parameter that
picked between two renderers is gone with the second one.
Licensed to the public under the Apache License 2.0.
-#}
{% if (!blank_page): %}
</div>{# /.fs-content #}
{#
role="contentinfo" is explicit because a <footer> only gets it IMPLICITLY
when its nearest ancestor is <body>, and this one lives inside <main> —
where it must stay: both layouts lay .fs-main out as a flex column and order
the footer within it. The explicit role escapes that scoping rule.
The version strings are entityencode()d: /etc/openwrt_release is root-owned,
but an unescaped `&` in a distname would still break the markup.
#}
<footer class="fs-footer" role="contentinfo">
<span>
Powered by
<a href="https://github.com/openwrt/luci" target="_blank" rel="noreferrer">
{{ entityencode(version.luciname, true) }} ({{ entityencode(version.luciversion, true) }})</a>
/
<a href="{{ entityencode(version.disturl ?? '#', true) }}" target="_blank" rel="noreferrer">
{{ entityencode(version.distname, true) }} {{ entityencode(version.distversion, true) }} ({{ entityencode(version.distrevision, true) }})</a>
{% if (lua_active): %}
/ {{ _('Lua compatibility mode active') }}
{% endif %}
</span>
</footer>
</main>{# /.fs-main #}
</div>{# /.fs-shell #}
<script>L.require('menu-footstrap')</script>
<script>L.require('fs-select')</script>
{% endif %}
</body>
</html>
@@ -0,0 +1,417 @@
{#
<head> for the ONE template. include(…, { boardinfo, fs_defaults }); everything else comes from
the render scope. fs_defaults = the ROUTER's saved Appearance defaults (luci-theme-footstrap
/etc/config/footstrap), which the client's own localStorage overrides before paint.
Licensed to the public under the Apache License 2.0.
-#}
<!DOCTYPE html>
{%
/* Sanitise the ROUTER's saved Appearance defaults (Appearance -> Save as default, stored in
/etc/config/footstrap) into the window.__fsSD object the pre-paint below reads. Enums are
whitelisted and the two hues + rounding clamped to range, so a hand-edited config can neither
break the inline script nor inject markup (defence in depth — writing that file already needs
root). Absent/invalid becomes '' (or 0, or -1 for rounding whose 0 is a real value), which the
JS reads as "no router default". The client's own localStorage overrides every one of these
before paint — the browser choice always wins (see fs-prefs.js). */
fs_defaults = fs_defaults ?? {};
let _sd_lay = index([ 'sidebar', 'top' ], fs_defaults.layout) >= 0 ? fs_defaults.layout : '';
let _sd_dark = index([ 'auto', 'light', 'dark' ], fs_defaults.darkmode) >= 0 ? fs_defaults.darkmode : '';
let _sd_pal = index([ 'footstrap', 'hicontrast', 'bootstrap' ], fs_defaults.palette) >= 0 ? fs_defaults.palette : '';
let _sd_wall = index([ 'off', 'pattern', 'file' ], fs_defaults.wallpaper) >= 0 ? fs_defaults.wallpaper : '';
let _sd_pink = index([ 'theme', 'original' ], fs_defaults.pattern_ink) >= 0 ? fs_defaults.pattern_ink : '';
let _sd_ac = index([ 'on', 'off' ], fs_defaults.autocollapse) >= 0 ? fs_defaults.autocollapse : '';
let _sd_dens = index([ 'compact', 'normal', 'large' ], fs_defaults.density) >= 0 ? fs_defaults.density : '';
/* The five COLOUR axes (tint, accent, good, warn, danger) each hold one of three things: '' or
'0' for off, '1'..'360' for a hue, or '#rrggbb' for a colour typed into the Appearance page.
Both live forms reach the client as a STRING — the pre-paint below re-reads which it is, the
way fs-prefs.js's normColor() does — so this sanitiser passes through exactly the two shapes
and turns everything else into '' (= no router default). The hex charset is whitelisted for
the same reason login_bg's is: the value is interpolated into the inline data block below, and
writing this file already needs root, so this is defence in depth rather than the only line.
(Do not write the tag name for that block in a comment: tools/lib/ut-scripts.mjs finds the
inline scripts by matching the opening tag, and a mention in prose is one it will match.)
A config written before the axes took colours holds a bare number, which parses identically. */
function _sd_color(v) {
if (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/))
return lc(v);
let n = int(v);
return (n >= 1 && n <= 360) ? `${n}` : '';
}
let _sd_tint = _sd_color(fs_defaults.tint);
let _sd_acc = _sd_color(fs_defaults.accent);
let _sd_good = _sd_color(fs_defaults.good);
let _sd_warn = _sd_color(fs_defaults.warn);
let _sd_dang = _sd_color(fs_defaults.danger);
/* the four SURFACE axes (cards, inset controls, the chrome bar, hairlines). Hex or nothing —
* they have no hue mode, so _sd_color's number branch would let a bare '0' through as a value;
* these read '' for off. */
function _sd_hex(v) {
return (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) ? lc(v) : '';
}
let _sd_card = _sd_hex(fs_defaults.card);
let _sd_ctrl = _sd_hex(fs_defaults.control);
let _sd_bar = _sd_hex(fs_defaults.bar);
let _sd_line = _sd_hex(fs_defaults.line);
let _rr = int(fs_defaults.rounding);
/* rounding 0 is SHARP corners, a real choice, so '' (unset) must map to -1, not 0 */
let _sd_round = (fs_defaults.rounding != '' && _rr >= 0 && _rr <= 20) ? _rr : -1;
/* login_bg is the CACHE-BUST TOKEN for the uploaded background (the cgi-upload checksum, an
md5), not the image and not a path — the served path is FIXED (/luci-static/footstrap/bg).
Whitelist it to hex so a hand-edited config cannot inject markup into the inline script that
builds the url() below; anything else becomes '' = "no background". */
let _lb = fs_defaults.login_bg;
let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : '';
/* the router-identity Tint's DENSITY 0-200 (% of the designed chroma); '' (unset) -> -1, like
rounding, because 0 is a real value (no tint). */
let _ts = int(fs_defaults.tint_strength);
let _sd_tstr = (fs_defaults.tint_strength != '' && _ts >= 0 && _ts <= 200) ? _ts : -1;
/* the File photo's scrim opacity 0-100 (Wallpaper -> File -> Dim), a SHARED router value; unset
-> -1, because 0 is a real value (a fully transparent scrim). */
let _pd = int(fs_defaults.photo_dim);
let _sd_pdim = (fs_defaults.photo_dim != '' && _pd >= 0 && _pd <= 100) ? _pd : -1;
/* the uploaded PATTERN: its cache-bust token, whitelisted to the same hex charset as login_bg
and for the same reason — it is interpolated into the inline data block below and then into a
url(); the served path is FIXED (/luci-static/footstrap/pattern.svg). */
let _pt = fs_defaults.pattern;
let _sd_pat = (type(_pt) == 'string' && match(_pt, /^[a-f0-9]{6,64}$/)) ? _pt : '';
/* the pattern's tile edge in px and the layer's opacity 0-100. Unset -> -1 like rounding: 0 is a
real strength (invisible), and 0 px is not a tile. */
let _ps = int(fs_defaults.pattern_size);
let _sd_psize = (fs_defaults.pattern_size != '' && _ps >= 40 && _ps <= 1600) ? _ps : -1;
let _pst = int(fs_defaults.pattern_strength);
let _sd_pstr = (fs_defaults.pattern_strength != '' && _pst >= 0 && _pst <= 100) ? _pst : -1;
/* ---- THE FONTS ----
The theme carries no faces (see the note by the stylesheet link below), so the two stacks in
styles/02-tokens.css are what everyone gets unless the ROUTER names something else here.
Two independent things, either usable alone:
font_sans / font_mono a font-family stack, printed into a style element further down.
fonts the cache-bust token of the @font-face sheet fonts/set-font.sh
generates when an admin also installs the FILE. Hex, exactly like
login_bg and pattern, and for the same reason.
Neither is an axis: there is no browser layer, no Appearance control and no entry in
snapshotAxes() — a font is a router-wide decision here, not a per-visitor one.
THE CHARSET IS THE WHOLE DEFENCE. A family stack is free text and it is printed UNESCAPED,
so the whitelist has to exclude everything that could end the declaration or the element:
< > ( ) { } ; and the backslash are all absent, which leaves no way to close a style element,
start a second declaration or write a url(). Anything else becomes '' = "no router font".
A Cyrillic or CJK family name is refused by this too — those faces carry a Latin name as well,
and fonts/README.md says to use it. */
function _sd_family(v) {
return (type(v) == 'string' && length(v) >= 1 && length(v) <= 120 &&
match(v, /^[A-Za-z0-9 ,._"'-]+$/)) ? v : '';
}
let _sd_fsans = _sd_family(fs_defaults.font_sans);
let _sd_fmono = _sd_family(fs_defaults.font_mono);
let _ft = fs_defaults.fonts;
let _sd_ftok = (type(_ft) == 'string' && match(_ft, /^[a-f0-9]{6,64}$/)) ? _ft : '';
/* ---- WRITING DIRECTION ----
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops there, and no stock theme
adds one — so a router in Arabic, Persian or Hebrew renders right-to-left text inside a
left-to-right document. That is not a font problem the browser fixes on its own: with `dir`
absent the whole document is `ltr`, so paragraphs align to the wrong edge, a line's trailing
punctuation jumps to the wrong end, and every mirrored affordance (the sidebar, the flyouts,
the chevrons, the tab strip) points the wrong way. The theme owns the <html> element, so this
is the one place that can say it.
Matched on the BASE subtag, so a future `ar-EG` or `fa_IR` is covered; the list is the standard
RTL set rather than only the three LuCI ships catalogues for today (ar, fa, he), because the
cost of a language never installed is one array entry. `ltr` is written explicitly for the same
reason data-layout is: every rule then matches positively. */
let _lang = dispatcher.lang ?? 'en';
let _lbase = lc(split(_lang, /[-_]/)[0]);
let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ps', 'sd', 'ur', 'yi' ], _lbase) >= 0;
%}
{# The SERVER always stamps data-layout with an EXPLICIT value (sidebar|top): every layout rule
then matches POSITIVELY, so a future third layout must opt in rather than inherit the other's
rules by not being it — never write a :not([data-layout=…]) guard. And the chrome is right with
JS off; the script below only overrides it.
fs_defaults.layout: the router-wide default (Appearance -> Save as default, or uci-defaults'
`top` seed when migrating an old top-nav install). ABSENT = TOP: the bar is the theme's default
layout and the sidebar is the opt-in. Deliberate — the bar is what the theme is built around
(every layout rule starts from it, with the vertical column as the one guarded override) and it
is what a phone gets at any setting. #}
<html lang="{{ dispatcher.lang }}" dir="{{ _rtl ? 'rtl' : 'ltr' }}" data-layout="{{ fs_defaults.layout == 'sidebar' ? 'sidebar' : 'top' }}">
<head>
<meta charset="utf-8">
{# entityencode AROUND striptags: striptags drops tags but escapes neither `&` nor quotes,
so a hostname with `&` broke entity parsing. No XSS (the tags are gone), but the markup
must be well-formed. #}
<title>{{ entityencode(striptags(`${boardinfo.hostname ?? 'OpenWrt'}${dispatched?.title ? ` | ${_(dispatched.title)}` : ''}`), true) }}</title>
{# The router-wide Appearance defaults, read by BOTH the pre-paint below and fs-prefs.js's
current*() (so the Appearance page's controls show the effective default when this browser
has no localStorage of its own). Enums are '', the colours '' (=off), rounding -1 (=unset)
— see the sanitiser above. #}
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}"};</script>
<script>
/* Each block below pre-paints one Appearance axis before the first frame, duplicating
a live applier in fs-prefs.js. They cannot share code — this runs before the module
loader exists — so tools/axes.mjs derives the contract (keys, attrs, properties,
ranges, default, ORDER) from the JS and holds this template to it.
PRECEDENCE, every axis: this browser's localStorage wins; absent -> the router default
(window.__fsSD); absent -> the built-in default. So the effective value is
localStorage ?? router-default ?? built-in, and the browser choice always overrides.
Dark mode: a saved preference wins, else the router default, else the OS. */
(() => {
const root = document.querySelector(':root'),
sd = (window.__fsSD || {}).darkmode;
let saved = null;
try { saved = localStorage.getItem('fs-darkmode'); } catch (e) {}
const mq = window.matchMedia('(prefers-color-scheme: dark)');
/* data-darkmode is what THIS theme's CSS reads. data-theme / data-bs-theme are
outbound compat for apps that sniff them (justclash keys 21 rules off
data-theme, ssclash reads data-bs-theme first); never read from styles/. Must
stay identical to stampDark() — axes.mjs proves it. */
function set(dark) {
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
root.setAttribute('data-theme', dark ? 'dark' : 'light');
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
}
/* localStorage stores 'true'/'false' (explicit) or 'auto' (explicitly follow OS);
absent falls to the router default, then the OS. */
const explicit = (saved === 'true') ? true
: (saved === 'false') ? false
: (saved === 'auto') ? 'auto'
: (sd === 'dark') ? true
: (sd === 'light') ? false
: 'auto';
set(explicit === true || (explicit === 'auto' && mq.matches));
/* ALWAYS registered (it re-checks storage): the user can switch to Auto after
loading with a saved value, and OS flips must track live from then on. An
explicit browser choice or an explicit router default does NOT follow the OS. */
mq.addEventListener('change', (m) => {
try {
const s = localStorage.getItem('fs-darkmode');
if (s === 'true' || s === 'false') return;
if (s === null && (sd === 'dark' || sd === 'light')) return;
} catch (e) {}
set(m.matches);
});
})();
</script>
<script>
/* palette, wallpaper, density, rounding and the five colour axes. Each: localStorage
(this browser) wins; absent -> the router default in window.__fsSD; absent -> the
built-in. */
(() => {
try {
const root = document.querySelector(':root'),
sd = window.__fsSD || {};
let p = localStorage.getItem('fs-palette');
/* legacy 'rvht'/'roman' = default colours + a doodle wallpaper, and legacy
'github' = the footstrap default, now stored explicitly. Migrate to the current
explicit value, do NOT clear (an absent key means "inherit the router default",
which is no longer the same as "the built-in default"). The doodles they used to
turn on are gone — the Wallpaper axis now paints an SVG the admin uploads — so
the wallpaper half of that migration has nothing to point at and is dropped;
`fs-wallpaper` keeps whatever it holds and the whitelist below reads a doodle
name as off. */
if (p === 'rvht' || p === 'roman' || p === 'github') {
localStorage.setItem('fs-palette', 'footstrap'); p = 'footstrap';
}
/* the default palette is a bare :root; every other colourway is opt-in and
carries its own value. The list is the same one fs-prefs.js validates
against — a name in one and not the other paints here and is taken away by
the first live change. */
if (p === null) p = sd.palette || null;
if (p === 'hicontrast' || p === 'bootstrap') root.setAttribute('data-palette', p);
else root.removeAttribute('data-palette');
/* Wallpaper: off | pattern (the uploaded SVG, tiled and recoloured) | file (the
uploaded photo). The url() and the two pattern numbers are set BEFORE the
attribute for the reason every axis here is: the other order paints one frame
with the tile at the previous size. sd.pattern is the cache-bust token, already
whitelisted to hex above, so nothing user-controlled reaches the url(). */
const pu = sd.pattern;
if (pu) root.style.setProperty('--fs-pattern-url', 'url("/luci-static/footstrap/pattern.svg?v=' + pu + '")');
const psRaw = localStorage.getItem('fs-pattern-size');
const ps = (psRaw !== null) ? parseInt(psRaw, 10) : sd.pattern_size;
if (ps >= 40 && ps <= 1600 && ps !== 440) root.style.setProperty('--fs-pattern-size', ps + 'px');
const pstRaw = localStorage.getItem('fs-pattern-strength');
const pst = (pstRaw !== null) ? parseInt(pstRaw, 10) : sd.pattern_strength;
if (pst >= 0 && pst <= 100 && pst !== 20) root.style.setProperty('--fs-pattern-strength', String(pst / 100));
let pi = localStorage.getItem('fs-pattern-ink');
if (pi === null) pi = sd.pattern_ink || null;
if (pi === 'original') root.setAttribute('data-pattern-ink', 'original');
else root.removeAttribute('data-pattern-ink');
let w = localStorage.getItem('fs-wallpaper');
if (w === null) w = sd.wallpaper || null;
if (w === 'pattern' || w === 'file') root.setAttribute('data-wallpaper', w);
else root.removeAttribute('data-wallpaper');
/* Density: Compact/Normal/Large, a pure token axis (02-tokens multiplies the
type and space ladders). Normal is a bare :root, so the removeAttribute
half matters as much as the set — without it a browser that switched back
to Normal would paint one frame still compressed. */
let dn = localStorage.getItem('fs-density');
if (dn === null) dn = sd.density || null;
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
else root.removeAttribute('data-density');
/* Rounding 0-20px (02-tokens derives the control/chip radii from it). The
default is a LITERAL: this runs before the stylesheet and cannot read the
CSS token, so axes.mjs is the only thing checking the two agree. sd.rounding
is -1 when the router has no default. */
const rRaw = localStorage.getItem('fs-radius');
let r = parseInt(rRaw, 10);
if (rRaw === null) r = (sd.rounding >= 0 ? sd.rounding : 12);
if (r >= 0 && r <= 20 && r !== 12)
root.style.setProperty('--fs-radius-base', r + 'px');
/* The five COLOUR axes: Tint (the page CANVAS, so this router is recognisable
at a glance), Accent (the UI colour) and the three status colours. Each is
off, a HUE 1-360 that rotates the palette's own colour, or a '#rrggbb' that
replaces it outright — mirrors colorAxis()/normColor() in fs-prefs.js.
Set the PROPERTY BEFORE THE ATTRIBUTE that switches the rotation on, or a
reload paints one frame in the previous colour.
The two modes CLEAR each other's property: this script runs once per load, so
nothing here is stale — but the live applier calls the same sequence on a
browser where the other mode was in effect, and axes.mjs holds the two copies
to one shape. */
const hex = /^#[0-9a-f]{6}$/i;
const colour = (key, sdVal, attr, hueProp, colorProp) => {
const raw = localStorage.getItem(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim();
if (hex.test(v)) {
root.style.removeProperty(hueProp);
root.style.setProperty(colorProp, v.toLowerCase());
root.setAttribute(attr, 'hex');
return;
}
const h = parseInt(v, 10);
if (h >= 1 && h <= 360) {
root.style.removeProperty(colorProp);
root.style.setProperty(hueProp, String(h));
root.setAttribute(attr, 'hue');
}
};
colour('fs-tint', sd.tint, 'data-tint', '--fs-tint-h', '--fs-bg');
colour('fs-accent', sd.accent, 'data-accent', '--fs-accent-h', '--fs-accent');
colour('fs-good', sd.good, 'data-good', '--fs-good-h', '--fs-good');
colour('fs-warn', sd.warn, 'data-warn', '--fs-warn-h', '--fs-warn');
colour('fs-danger', sd.danger, 'data-danger', '--fs-danger-h', '--fs-danger');
/* The four SURFACE axes — the cards, the inset controls, the chrome bar and the
hairlines. Simpler than the colour axes above and deliberately so: no hue
mode and therefore no attribute, just the token or nothing. Mirrors
surfaceAxis() in fs-prefs.js. */
const surface = (key, sdVal, prop) => {
const raw = localStorage.getItem(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim().toLowerCase();
if (hex.test(v)) root.style.setProperty(prop, v);
};
surface('fs-card', sd.card, '--fs-panel-base');
surface('fs-control', sd.control, '--fs-panel2-base');
surface('fs-bar', sd.bar, '--fs-bar-bg');
surface('fs-line', sd.line, '--fs-border-base');
/* The uploaded photo's URL: ROUTER-SIDE (the token comes purely from
window.__fsSD — no localStorage). Set the property whenever a valid token
exists; whether it PAINTS is the wallpaper axis above (data-wallpaper="file"),
so a router whose default wallpaper is 'file' shows it here, pre-login included.
Re-validate the token (defence in depth, same charset as the server sanitiser). */
const lb = sd.login_bg;
if (lb && (/^[a-f0-9]{6,64}$/).test(lb))
root.style.setProperty('--fs-login-bg-url', "url('/luci-static/footstrap/bg?v=" + lb + "')");
/* the router-identity Tint's density (default 100% = the designed chroma). */
const tsRaw = localStorage.getItem('fs-tint-strength');
let ts = parseInt(tsRaw, 10);
if (tsRaw === null) ts = (sd.tint_strength >= 0 ? sd.tint_strength : 100);
if (ts >= 0 && ts <= 200 && ts !== 100)
root.style.setProperty('--fs-tint-strength', String(ts / 100));
/* the File photo's scrim opacity — an ordinary per-browser axis, same shape as
the tint strength above; the 74% default lives in the CSS token. */
const pdRaw = localStorage.getItem('fs-photo-dim');
let pd = parseInt(pdRaw, 10);
if (pdRaw === null) pd = (sd.photo_dim >= 0 ? sd.photo_dim : 74);
if (pd >= 0 && pd <= 100 && pd !== 74)
root.style.setProperty('--fs-photo-dim', pd + '%');
} catch (e) {}
})();
</script>
<script>
/* the icon-rail preference, so a reload doesn't flash the full sidebar first. Not a
router-wide default (it is a transient chrome collapse, not an appearance choice). */
(() => {
try {
if (localStorage.getItem('fs-rail') === 'true')
document.querySelector(':root').setAttribute('data-rail', 'true');
} catch (e) {}
})();
</script>
<script>
/* the CLIENT's saved layout overrides the server's stamp above, before paint. Only a
KNOWN value is honoured — a corrupt one leaves the stamp standing rather than
blanking the attribute every layout rule matches on. */
(() => {
try {
const saved = localStorage.getItem('fs-layout');
if (saved === 'top' || saved === 'sidebar')
document.querySelector(':root').setAttribute('data-layout', saved);
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="darkreader-lock">
{# TWO <link rel=preload> FOR THE CHROME FACES USED TO SIT HERE, and the package still carries
no webfonts: --fs-font-sans and --fs-font-mono name Manrope and JetBrains Mono FIRST and
the system stack after, so a browser that has either installed locally uses it and one
that does not falls through silently — no request, no 404, no flash. What follows the
stylesheet below is the OPT-IN: a router whose admin ran fonts/set-font.sh.
The preload pair is why both tags down there are written the way they are. A preload is
not in the stylesheet, so deleting the @font-face rules alone left these two behind and
every page went on asking the router for files that were gone: SIX 404s per page, and
nothing in CSS could have told anyone. So the sheet link is emitted only when the token
says the sheet exists, and the token is written by set-font.sh only after the file is. #}
{# `data-fs-shell` marks the SHELL's own CSS: on every nav the SPA router asks whether a
VIEW put CSS in the document, and this is how it tells its own sheet from an app's. #}
<link rel="stylesheet" data-fs-shell href="{{ media }}/cascade.css?v={{ pkgs_update_time }}">
{% if (_sd_ftok != ''): %}
{# The generated @font-face sheet, and ONLY the faces: the families themselves come from the
element below, so `uci set footstrap.settings.font_sans=…` by hand takes effect without
regenerating anything. The token is the sheet's md5 — uhttpd sends no Cache-Control, so
without it a browser applies heuristic freshness and keeps the previous face for days. #}
<link rel="stylesheet" data-fs-shell href="{{ media }}/fonts/fonts.css?v={{ _sd_ftok }}">
{% endif %}
{% if (_sd_fsans != '' || _sd_fmono != ''): %}
{# The router's own font stacks. Unlayered, so it beats the `tokens` layer that declares the
defaults no matter what order the two sheets arrive in — a layered rule always loses to an
unlayered one, which is the same reason a theme rule never needs !important to outrank
base. Values are whitelisted at the top of this file; nothing here escapes them. #}
<style data-fs-shell>:root { {% if (_sd_fsans != ''): %}--fs-font-sans: {{ _sd_fsans }};{% endif %}{% if (_sd_fmono != ''): %} --fs-font-mono: {{ _sd_fmono }};{% endif %} }</style>
{% endif %}
<link rel="icon" href="{{ media }}/logo_48.png" sizes="48x48">
<link rel="icon" href="{{ media }}/logo.svg" sizes="any">
{% if (node?.css): %}
{# uhttpd sends no Cache-Control, so without a version key the browser applies HEURISTIC
freshness and can serve a stale app stylesheet for days after an upgrade. #}
<link rel="stylesheet" href="{{ resource }}/{{ node.css }}?v={{ pkgs_update_time }}">
{% endif %}
{% if (css): %}
{# no `title` attribute: on a <style> it names a STYLE SHEET SET, making this a "preferred"
alternate sheet the user can switch off in Firefox's Page Style menu. `data-fs-shell`
as above — every OTHER sheet in <head> came from an app, and the SPA router treats an
invasive one as a page it must not hand on (filemanager, banip, adblock inject one). #}
<style data-fs-shell>{{ css }}</style>
{% endif %}
{# The catalogue is a CGI SPAWN on the critical path — 30 ms measured on the router — and at
lang=en it spends them to deliver 13 bytes (`window.TR={};`): there is no en catalogue,
because the msgids already ARE English. The process is the cost, not the data. So emit
those 13 bytes inline when this language has no catalogue, and keep the tag when it has.
The probe MIRRORS the server: action_translations calls load_catalog(lang,
'/usr/lib/lua/luci/i18n'), which loads every `*.<lang>.lmo` there — so a router that does
ship an en catalogue still gets the tag. Deciding by language NAME would silently drop it.
Fails OPEN (a throwing probe keeps the tag): a missing catalogue makes every `_()` render
English and report nothing, which is the one failure this must never risk. #}
{%- let has_catalog = true;
try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %}
{% if (has_catalog): %}
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}"></script>
{% else %}
<script>window.TR={};</script>
{% endif %}
{# the one asset stock LuCI leaves without a cache key. Must stay SYNCHRONOUS and
un-deferred: luci.js captures `window.cbi_init` in its constructor. `defer` is not an
option for the catalogue above either — footer.ut runs `L.require('menu-footstrap')`
inline while the parser is still running, so a module's `_()` would race a deferred
window.TR and win silently. #}
<script src="{{ resource }}/cbi.js?v={{ pkgs_update_time }}"></script>
</head>
@@ -0,0 +1,18 @@
{#
The chrome's ONE inline-SVG wrapper, server side. Every icon the templates draw is the same 24x24
stroked outline and differs only in its path data, but the wrapper was written out at each call
site — header.ut's rail toggle plus search and logout — so `stroke-width` and the two
linecap attributes were independent copies of a value that is meant to make one icon SET.
fs-widgets.js's svgIcon() is the same wrapper for the icons the JS builds; the two are the server
and client halves of one look.
`body` is the shape, and it is emitted RAW on purpose: it is author-written markup from the caller
(paths and circles), never anything a request can influence, and `{{ }}` in a ucode template does
not escape — escaping here would print the path data as visible text. `cls` is optional and
defaults to the icon class every chrome control uses.
aria-hidden: each of these sits beside its own label (or in a control that carries one), and an
unlabelled <svg> would otherwise be announced as a graphic in its own right.
Licensed to the public under the Apache License 2.0.
-#}
<svg class="{{ cls ?? 'fs-ico' }}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">{{ body }}</svg>
@@ -0,0 +1,15 @@
{#
The logout control (guarded on the node being dispatchable). ONE markup for every layout,
which CSS renders as a square icon button in the bar and the rail: ONE markup for every layout,
because a layout is a presentation choice and must not fork the markup. So the old `with_label`
parameter and .fs-top-logout are gone.
It is also the ONLY logout the theme renders: menu-footstrap.js drops the menu tree's own
admin/logout node, so the bar carries no duplicate "Log out" item beside this one.
Licensed to the public under the Apache License 2.0.
-#}
{% if (dispatcher.lookup("admin/logout")): %}
<a class="fs-logout" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ _('Logout') }}" aria-label="{{ _('Logout') }}">
{% include('themes/footstrap/partials/icon', { body: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 12H3m0 0 3-3m-3 3 3 3"/>' }) %}
<span class="fs-label">{{ _('Logout') }}</span>
</a>
{% endif %}
@@ -0,0 +1,21 @@
{#
ONE top-of-content warning notice: heading, sentence, and an optional "go fix it" button. The
three notices in notices.ut were three copies of this markup — and the button half was written
out twice down to the `dispatcher.lookup` guard around it, which is the part that must not drift:
the guard is what keeps the button from pointing at a page this user's ACLs do not grant.
include(…, { title, text }) for a plain notice; add { path, label } for the button. `path` is a
dispatcher path ("admin/system/admin"); it is looked up before it is linked, so a build with that
page absent — or a user without rights to it — gets the warning without a dead link.
title/text are emitted with `{{ }}`, i.e. unescaped, exactly as the three copies did: they are
`_()` results over strings this theme ships, not request data.
Licensed to the public under the Apache License 2.0.
-#}
<div class="alert-message warning">
<h2>{{ title }}</h2>
<p>{{ text }}</p>
{% if (path && dispatcher.lookup(path)): %}
<div class="right"><a class="btn" href="{{ dispatcher.build_url(path) }}">{{ label }}</a></div>
{% endif %}
</div>
@@ -0,0 +1,36 @@
{#
Top-of-content notices: no-root-password warning, initramfs recovery warning, the <noscript>
fallback, the #tabmenu container. include(…, { boardinfo, empty_password }) — empty_password
is computed in the header, which already imports luci.core.
The markup of a notice lives ONCE, in partials/notice.ut; this file decides only WHICH notices
there are and what they say.
The sentences carry no msgctxt on purpose: inheriting luci-base's translation is a feature in
the ~40 languages this theme has no catalogue for (see header.ut).
Licensed to the public under the Apache License 2.0.
-#}
{% if (empty_password): %}
{% include('themes/footstrap/partials/notice', {
title: _('No password set!'),
text: _('There is no password set on this router. Please configure a root password to protect the web interface.'),
path: "admin/system/admin",
label: _('Go to password configuration...')
}) %}
{% endif %}
{% if (boardinfo.rootfs_type == "initramfs"): %}
{% include('themes/footstrap/partials/notice', {
title: _('System running in recovery (initramfs) mode.'),
text: _('No changes to settings will be stored and are lost after rebooting. This mode should only be used to install a firmware upgrade'),
path: "admin/system/flash",
label: _('Go to firmware upgrade...')
}) %}
{% endif %}
<noscript>
{% include('themes/footstrap/partials/notice', {
title: _('JavaScript required!'),
text: _('You must enable JavaScript in your browser or LuCI will not work properly.')
}) %}
</noscript>
<div id="tabmenu" style="display:none"></div>
@@ -0,0 +1,17 @@
{#
The page-search trigger (#fs-search-btn), wired by fs-search.js. It is a `.fs-themerow` — the
SAME class the Appearance trigger uses, not a look of its own — so it is one of the right
cluster's square icon buttons in the bar and the rail, and a full-width labelled row in the
vertical sidebar, with no new CSS to keep in step. It sits between the poll pill (#indicators)
and Appearance: those three share `order: 2`, so the DOM order here IS the visual order.
i18n: 'Search' is CHROME and deliberately carries no msgctxt — inheriting luci-base's translation
is a feature in the ~40 languages this theme ships no catalogue for. The palette's OWN strings
(fs-search.js: 'Search pages', 'Recently visited', …) do carry the `footstrap` context, because a
msgid is a GLOBAL name shared with every luci-app on the router — see header.ut.
Licensed to the public under the Apache License 2.0.
-#}
<button type="button" class="fs-themerow" id="fs-search-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="fs-search-ov" title="{{ _('Search') }}" aria-label="{{ _('Search') }}">
{% include('themes/footstrap/partials/icon', { body: '<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>' }) %}
<span class="fs-label">{{ _('Search') }}</span>
</button>
@@ -0,0 +1,172 @@
{#
The LOGIN page.
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2008-2012 Jo-Philipp Wich <jow@openwrt.org>
Licensed to the public under the Apache License 2.0.
It exists to pass `blank_page: true`, the only thing it changes about the generic template:
without a theme-local sysauth the dispatcher falls back to that one, which includes the header
WITHOUT blank_page — so the whole chrome (sidebar, menu, footer) rendered around the login
form, every control on it dead.
It is NOT a copy of bootstrap's sysauth.ut, which hides the form in a `<section hidden>` and
needs a view module to reveal it: tried here once and it gave a BLANK PAGE WITH NO WAY TO LOG
IN — the view bootstraps before a session exists, its RPCs answer "Access denied", the promise
rejects and render() never runs. Do not re-introduce it. The SERVER renders the form: works
with JS off, cannot be broken by a rejected promise, needs no luci-theme-bootstrap installed.
styles/pages/10-login.css makes it the centred card, keyed off `.fs-login` below. The strings
carry no msgctxt on purpose — luci-base translates them in the ~40 languages this theme has no
catalogue for.
-#}
{% include('header', { blank_page: true }) %}
{# The class is what 10-login.css hooks: the form used to be found by
`form:has(> .cbi-map input[name="luci_username"])` — 49 characters written 17 times, ~0.8 KB —
because the markup was assumed to be stock LuCI's. It is OURS: name the form instead. #}
<form method="post" class="fs-login">
{#
THE BACKEND'S MESSAGE WINS OVER THE CREDENTIALS ONE, and the order matters more than it looks.
`fuser` is not "the password was wrong" — it is the USERNAME the form posted, set in BOTH
branches of dispatcher.uc that render a sysauth. On the second step of a pluggable login (the
OTP prompt, dispatcher.uc:1018) the credentials were CORRECT and `fuser` is still set, with
`auth_message` carrying what actually happened. The generic template guards its message with
`&& !fuser`, so that step tells the user "Invalid username and/or password!" — about a password
that was right — and swallows "Too many failed attempts" / "System time is not calibrated"
entirely. luci-theme-bootstrap does not make that trade, and neither does this: message first,
credentials as the fallback. `auth_plugin` names the challenge that stopped the login, and it
is the one signal separating "the backend is asking for something" from an ordinary notice.
-#}
{% if (auth_message): %}
<div class="alert-message{% if (auth_plugin): %} warning{% endif %}">
<p>{{ auth_message }}</p>
</div>
{% elif (fuser): %}
<div class="alert-message warning">
<p>{{ _('Invalid username and/or password! Please try again.') }}</p>
</div>
{% endif %}
<div class="cbi-map">
<h2 name="content">{{ _('Authorization Required') }}</h2>
<div class="cbi-map-descr">
{{ _('Please enter your username and password.') }}
</div>
<div class="cbi-section"><div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="luci_username">{{ _('Username') }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text" type="text" name="luci_username" id="luci_username" autocomplete="username" value="{{ entityencode(duser, true) }}" />
</div>
</div>
<div class="cbi-value cbi-value-last">
<label class="cbi-value-title" for="luci_password">{{ _('Password') }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text" type="password" name="luci_password" id="luci_password" autocomplete="current-password" />
</div>
</div>
{#
THE LOGIN BACKEND'S OWN FIELDS. The dispatcher hands `auth_fields`, `auth_message`,
`auth_html` and `auth_assets` to whichever sysauth template renders, and a pluggable
backend (luci-plugin-2fa is the in-tree one) puts its OTP input in there. A theme-local
sysauth that drops them is not a login form with a missing extra: the field is never
posted, so on a router with 2FA enabled NOBODY CAN LOG IN while this theme is active,
and the backend's "Too many failed attempts" / "System time is not calibrated" messages
never reach the screen either. This template exists only to pass `blank_page: true`, so
it must stay a superset of the generic one.
The leading space inside each conditional attribute is load-bearing. ucode's template
lexer eats the whitespace immediately BEFORE a `{%` tag and the newline immediately
AFTER a `%}` — plain tags, no `{%-`/`-%}` needed — so the indentation these lines are
written with never reaches the output. Rendered by a router's own ucode, the generic
template's spelling gives `inputmode="numeric"pattern="[0-9]*"maxlength="6"required`:
an HTML parse error browsers recover from and a validator does not. Measured, not
assumed — `A\n\t{% if (1): %}B{% endif %}\n\t{% if (1): %}C{% endif %}\nD` renders as
`A\nBCD`.
-#}
{% if (auth_fields): %}
{% for (let field in auth_fields): %}
<div class="cbi-value">
<label class="cbi-value-title" for="{{ entityencode(field.name, true) }}">{{ _(field.label ?? field.name) }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text"
type="{{ field.type ?? 'text' }}"
name="{{ field.name }}"
id="{{ entityencode(field.name, true) }}"
{% if (field.placeholder): %} placeholder="{{ field.placeholder }}"{% endif %}
{% if (field.inputmode): %} inputmode="{{ field.inputmode }}"{% endif %}
{% if (field.pattern): %} pattern="{{ field.pattern }}"{% endif %}
{% if (field.maxlength): %} maxlength="{{ field.maxlength }}"{% endif %}
{% if (field.autocomplete): %} autocomplete="{{ field.autocomplete }}"{% endif %}
{% if (field.required): %} required{% endif %}
/>
</div>
</div>
{% endfor %}
{% endif %}
{% if (auth_html): %}
<div class="cbi-value">
{{ auth_html }}
</div>
{% endif %}
{% if (auth_assets): %}
{% for (let asset in auth_assets): %}
{% if (asset.type == 'script'): %}
<script src="{{ asset.src }}"></script>
{% endif %}
{% endfor %}
{% endif %}
</div></div>
</div>
<div class="cbi-page-actions">
<input type="submit" value="{{ _('Log in') }}" class="btn cbi-button cbi-button-apply" />
</div>
</form>
{%
let https_ports = uci.get('uhttpd', 'main', 'listen_https') ?? [];
https_ports = uniq(filter(
map(
(type(https_ports) == 'string') ? split(https_ports, /\s+/) : https_ports,
e => +match(e, /\d+$/)?.[0]
),
p => (p >= 0 && p <= 65535)
));
%}
{# The two server values the script below needs, handed over as DATA. It is the one shape the
linter cannot read — a `{{ … }}` block is not JS until the server renders it — so it carries no
logic at all, and eslint's .ut processor enforces exactly that (tools/lib/ut-scripts.mjs). The
script itself is then plain JS and gets linted like every other file in the theme. #}
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
<script>
(() => {
const input = document.getElementsByName('luci_password')[0];
if (input)
input.focus();
/* If the router also serves HTTPS, hop over to it — the password is about to go over this
* connection. The <img> probe is upstream's trick: it proves the TLS port actually answers
* before we send the user there. */
if (document.location.protocol === 'https:') return;
const { hostname, pathname } = window.location;
(window.__fsHttps.ports || []).forEach((port) => {
const url = `https://${hostname}:${port}${pathname}`;
const img = new Image();
img.onload = () => { window.location = url; };
img.src = `https://${hostname}:${port}${window.__fsHttps.resource}/icons/loading.svg?${Math.random()}`;
setTimeout(() => { img.src = ''; }, 5000);
});
})();
</script>
{% include('footer', { blank_page: true }) %}
+114
View File
@@ -0,0 +1,114 @@
#!/bin/sh
# Rescan the theme sources into po/templates/footstrap.pot and merge it into every
# po/<lang>/footstrap.po. Run after adding or changing ANY _('…') string.
#
# ./update-po.sh rescan, merge, report what is still untranslated
# ./update-po.sh --check change nothing; fail if the .pot is stale or a string is
# untranslated. The CI gate.
#
# A missing translation CANNOT fail loudly — an uncompiled _() falls through to its English
# msgid and nothing reports it (the popover said "Palette"/"Rounding"/"Cats" on a Russian
# LuCI). Hence --check is a gate, not a suggestion.
#
# THE DIRECTORY IS `po/`, which is what LUCI_LANGUAGES globs and what Weblate translates. It was
# `i18n/` for a while so luci.mk would NOT emit a luci-i18n-footstrap-<lang> package per language
# (issue #6: a self-updater resolving the theme by name took head -1 and installed the catalogue);
# that updater is retired and owfeed builds the release as one artifact per format either way, so
# the rename bought nothing and cost the catalogue its visibility to the translation platform.
#
# Nothing here runs on the buildbot — luci.mk calls po2lmo itself. This needs perl + gettext
# (xgettext, msgmerge, msgfmt), which the OpenWrt build does not.
#
# The scanner is LuCI's OWN build/i18n-scan.pl, not a grep: it lexes a .ut (rewriting the
# template into JS before xgettext) and covers the rpcd acl.d/*.json title. A grep for _('…')
# would miss the ACL string and choke on any apostrophe.
set -eu
cd "$(dirname "$0")"
# Pinned to a COMMIT, not `master`, and checksummed: a perl script we fetch over the network
# and then EXECUTE, and the gate deciding whether the catalogue is complete. Off a moving
# branch, the gate is whatever upstream pushed last. luci-upstream.pin is the single source of
# the commit and both checksums (CI sources it too) — they were once written out separately
# here and in the workflow, with nothing holding them together.
. ./luci-upstream.pin
SCANNER_URL="https://raw.githubusercontent.com/openwrt/luci/${LUCI_PIN}/build/i18n-scan.pl"
SCANNER_SHA256="$I18N_SCAN_SHA256"
POT='po/templates/footstrap.pot'
CHECK=0
[ "${1:-}" = '--check' ] && CHECK=1
for tool in perl xgettext msgmerge msgfmt; do
command -v "$tool" >/dev/null || { echo "update-po: $tool not found (install perl + gettext)" >&2; exit 1; }
done
# Prefer a scanner from a local LuCI checkout ($LUCI_SRC) so the gate is not hostage to the
# network; fall back to fetching it. jsmin.c is pinned the same way in CI.
#
# ONE trap, covering every temp and every exit. It used to be installed inside the fetch branch
# only — so the LUCI_SRC branch had none at all — and the three mktemps below were cleaned by hand
# at each success path. With `set -eu`, any failure in between (perl choking on a template, i.e.
# exactly the stale-.pot session this script exists for) leaked them. `fetched` is what the trap
# removes, so the LUCI_SRC path never deletes the user's own checkout.
fetched=''; fresh=''; old_ids=''; new_ids=''
# shellcheck disable=SC2064 # expand nothing now: the names are assigned as the script proceeds
trap 'rm -f "$fetched" "$fresh" "$old_ids" "$new_ids"' EXIT INT TERM
scanner=''
if [ -n "${LUCI_SRC:-}" ] && [ -f "$LUCI_SRC/build/i18n-scan.pl" ]; then
scanner="$LUCI_SRC/build/i18n-scan.pl"
else
scanner="$(mktemp)"; fetched="$scanner"
curl -sfL --proto '=https' --proto-redir '=https' "$SCANNER_URL" -o "$scanner" || {
echo "update-po: cannot fetch $SCANNER_URL — set LUCI_SRC to a luci checkout" >&2
exit 1
}
# the pin says WHICH script; this says it is that script and nothing else
echo "$SCANNER_SHA256 $scanner" | sha256sum -c - >/dev/null || {
echo "update-po: i18n-scan.pl checksum mismatch — refusing to run it" >&2
exit 1
}
fi
mkdir -p po/templates
fresh="$(mktemp)"
# htdocs = the theme JS, ucode = the templates, root = the rpcd ACL title
perl "$scanner" htdocs ucode root > "$fresh"
if [ "$CHECK" = 1 ]; then
# Compare msgid AND msgctxt (line-number comments churn on every edit and say nothing).
# The CONTEXT is part of the key — po2lmo hashes "ctxt\1msgid" — so a .pot carrying the
# same msgids with the context dropped describes a different catalogue, which comparing
# msgid alone waved through.
old_ids="$(mktemp)"; new_ids="$(mktemp)"
grep '^msgid\|^msgctxt' "$POT" | sort > "$old_ids"
grep '^msgid\|^msgctxt' "$fresh" | sort > "$new_ids"
if ! cmp -s "$old_ids" "$new_ids"; then
echo "update-po: $POT is STALE — a string was added or removed without rerunning ./update-po.sh" >&2
diff "$old_ids" "$new_ids" | grep '^[<>]' >&2 || true
exit 1
fi
rc=0
for po in po/*/*.po; do
[ -e "$po" ] || continue
# an empty msgstr means the string renders in English for that language
missing="$(msgfmt --statistics -o /dev/null "$po" 2>&1 | grep -o '[0-9]* untranslated' || true)"
if [ -n "$missing" ]; then
echo "update-po: $po has $missing message(s) — they will silently render in English" >&2
rc=1
fi
msgfmt --check -o /dev/null "$po" || rc=1
done
[ "$rc" = 0 ] && echo "i18n: .pot current, every string translated"
exit "$rc"
fi
mv "$fresh" "$POT"
echo "scanned -> $POT ($(grep -c '^msgid' "$POT") strings)"
for po in po/*/*.po; do
[ -e "$po" ] || continue
msgmerge --quiet --update --backup=none "$po" "$POT"
echo "merged -> $po: $(msgfmt --statistics -o /dev/null "$po" 2>&1 | tr '\n' ' ')"
done
+1 -1
View File
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=sqm-autorate
PKG_VERSION:=0.1
PKG_RELEASE:=3
PKG_RELEASE:=4
include $(INCLUDE_DIR)/package.mk
@@ -622,7 +622,7 @@ parse_tsping()
printf "SET_PROC_PID proc_pids %s %s\n" "${parse_id}_preprocessor" "${parse_preprocessor_pid}" >&"${main_fd}"
# accommodate present tsping interval/sleep handling to prevent ping flood with only one pinger
tsping_sleep_time=$(( no_pingers == 1 ? ping_response_interval_ms : 0 ))
${ping_prefix_string} tsping ${ping_extra_args} --print-timestamps --machine-readable=, --sleep-time "${tsping_sleep_time}" --target-spacing "${ping_response_interval_ms}" "${reflectors[@]:0:${no_pingers}}" 2>/dev/null >&"${parse_preprocessor_fd}" &
${ping_prefix_string} tsping ${ping_extra_args} --print-timestamps --machine-readable=, --sleep-time "${tsping_sleep_time}" --target-spacing "${ping_response_interval_ms}" "${reflectors[@]:0:${no_pingers}}" 1>&"${parse_preprocessor_fd}" 2>/dev/null &
pinger_pid="${!}"
printf "SET_PROC_PID proc_pids %s %s\n" "${parse_id}_pinger" "${pinger_pid}" >&"${main_fd}"
continue
@@ -792,7 +792,7 @@ parse_fping()
exec {parse_preprocessor_fd}> >(parse_preprocessor)
parse_preprocessor_pid="${!}"
printf "SET_PROC_PID proc_pids %s %s\n" "${parse_id}_preprocessor" "${parse_preprocessor_pid}" >&"${main_fd}"
${ping_prefix_string} fping ${ping_extra_args} --timestamp --loop --period "${reflector_ping_interval_ms}" --interval "${ping_response_interval_ms}" --timeout 10000 "${reflectors[@]:0:${no_pingers}}" 2> /dev/null >&"${parse_preprocessor_fd}" &
${ping_prefix_string} fping ${ping_extra_args} --timestamp --loop --period "${reflector_ping_interval_ms}" --interval "${ping_response_interval_ms}" --timeout 10000 "${reflectors[@]:0:${no_pingers}}" 1>&"${parse_preprocessor_fd}" 2>/dev/null &
pinger_pid="${!}"
printf "SET_PROC_PID proc_pids %s %s\n" "${parse_id}_pinger" "${pinger_pid}" >&"${main_fd}"
continue
@@ -937,7 +937,7 @@ parse_ping()
exec {parse_preprocessor_fd}> >(parse_preprocessor)
parse_preprocessor_pid="${!}"
printf "SET_PROC_PID %s %s\n" "proc_pids ${parse_id}_preprocessor" "${parse_preprocessor_pid}" >&"${main_fd}"
${ping_prefix_string} ping ${ping_extra_args} -D -i "${reflector_ping_interval_s}" "${reflector}" 2> /dev/null >&"${parse_preprocessor_fd}" &
${ping_prefix_string} ping ${ping_extra_args} -D -i "${reflector_ping_interval_s}" "${reflector}" 1>&"${parse_preprocessor_fd}" 2>/dev/null &
pinger_pid="${!}"
printf "SET_PROC_PID proc_pids %s %s\n" "${parse_id}_pinger" "${pinger_pid}" >&"${main_fd}"
continue