mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 07:55:13 +08:00
🌴 Sync 2026-08-05 03:17:09
This commit is contained in:
1 parent
2f3ffbb7d9
commit
fffafa4359
91 files changed
+18955
-44
No files matched your search
@@ -4,4 +4,5 @@ config airconnect 'config'
|
||||
option 'interface' 'br-lan'
|
||||
option 'aircast' '1'
|
||||
option 'airupnp' '1'
|
||||
option 'stream_type' 'broadcast'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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 "曲目"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
Executable
+117
@@ -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
|
||||
Executable
+255
@@ -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"
|
||||
Executable
+125
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
Executable
+129
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+57
@@ -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)))"
|
||||
Executable
+71
@@ -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)))"
|
||||
@@ -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;
|
||||
@@ -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). */
|
||||
}
|
||||
@@ -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 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 }) %}
|
||||
Executable
+114
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user