mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-31 20:25:13 +08:00
update 2026-09-27 02:55:36
This commit is contained in:
1 parent
384296452c
commit
cd9aae293c
165 files changed
+41478
-6
No files matched your search
@@ -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,257 @@
|
||||
#
|
||||
# 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. luci.mk falls back to PKG_SRC_VERSION,
|
||||
# which it always defines, when nothing here sets it — see docs/package.md for why no override
|
||||
# lives here.
|
||||
LUCI_TITLE:=Footstrap Theme
|
||||
# +luci-base is the WHOLE list, and keeping it that way is the constraint. Nothing this package ships
|
||||
# fetches anything at run time, so there is no second name to justify — and the rule that got it here
|
||||
# stands: the one convenience tool that ever wanted a dep was `curl`, which is NOT in OpenWrt's
|
||||
# default set, on an 8 MB device, for one API call. Fall back to what the base image has
|
||||
# (uclient-fetch, jsonfilter, sha256sum) or do without.
|
||||
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:=Ivan Kvashonkin <vizzlef@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() and nested calc() and break the layout. build-css.sh minifies instead.
|
||||
LUCI_MINIFY_CSS:=0
|
||||
#
|
||||
# JS: the released package is built by owfeed (tools/stage.sh), never by this Makefile through
|
||||
# the SDK — so LUCI_MINIFY_JS keeps luci.mk's default jsmin here. 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.
|
||||
|
||||
# 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.
|
||||
#
|
||||
# No OFL-1.1: the theme ships no Font Software, so declaring it 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. Without this define
|
||||
# the package manager owns it as an ordinary file and REPLACES it on upgrade, so the admin's saved
|
||||
# defaults are wiped by the theme's own one-click Update, silently and reported as success.
|
||||
# OpenWrt honours the define for BOTH formats (include/package-pack.mk), so one 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
|
||||
|
||||
# NO `rpcd reload` HERE, on install or removal — it used to be, and it was the more dangerous of
|
||||
# the two rpcd calls this package could make. Measured on an owrt2512 stand:
|
||||
#
|
||||
# 1. RELOAD IS A RE-EXEC, NOT A REFRESH. `/etc/init.d/rpcd reload` is `procd_send_signal rpcd` ->
|
||||
# SIGHUP -> rpcd freezes sessions and calls exec_self(): a full re-exec that re-scans the plugin
|
||||
# dirs and dlopen()s every .so. A plugin file that is absent or mid-write at that instant is
|
||||
# DROPPED, past one stderr line, and does NOT come back on its own:
|
||||
#
|
||||
# mv /usr/lib/rpcd/file.so /tmp/ ; rpcd reload -> `ubus list` loses `file`
|
||||
# file restored, no reload -> still missing
|
||||
# reload (or restart) -> back
|
||||
#
|
||||
# First measured this way on the `luci` ucode plugin (24.10/25.12: /usr/libexec/rpcd/luci) — a
|
||||
# reload racing another package's file replacement left every luci/getFeatures,
|
||||
# luci/getTimezones and luci/getMountPoints call answering `-32000 Object not found`, reported
|
||||
# from the field on a SNAPSHOT router, cleared only by a reboot. The SAME race hits `file`, and
|
||||
# losing `file` is worse: it is what makes System -> Backup/Flash Firmware's "Reset to
|
||||
# defaults" row silently disappear. view/system/flash.js computes has_rootfs_data from
|
||||
# /proc/mtd and /proc/mounts read through fs.trimmed(), wrapped in L.resolveDefault(..., '') —
|
||||
# so the -32000 `file` answers with reads as "no overlay" instead of as an error (OpenWrt forum
|
||||
# thread 251930, posts 97 and 109 — "fixed" by installing an unrelated package whose OWN
|
||||
# postinst reload happened to re-register `file`).
|
||||
#
|
||||
# 2. THIS PACKAGE HAS NOTHING FOR A RELOAD TO REFRESH. It registers no rpcd object at all — no
|
||||
# /usr/libexec/rpcd/* script, no /usr/lib/rpcd/*.so, only root/usr/share/rpcd/acl.d/*.json — so
|
||||
# a reload here only runs the dlopen risk above for every OTHER plugin, for zero gain of its own.
|
||||
#
|
||||
# 3. rpcd READS acl.d/*.json AT LOGIN, not only at start or on SIGHUP. Measured: narrow
|
||||
# rpcd.@login[0].read/write from '*' to an explicit group list, remove the ACL file with rpcd
|
||||
# already running -> `session access` for uci footstrap write answers false; put the file back
|
||||
# with NO reload, log in again -> true. A fresh login already sees a just-installed ACL.
|
||||
#
|
||||
# 4. On a stock router, root's rpcd login grants read='*' write='*' — blanket. `session access`
|
||||
# answers true even for an INVENTED object (measured against `uci totally_made_up_pkg_xyz`), so
|
||||
# the theme's ACL group is irrelevant to a root session from the start.
|
||||
#
|
||||
# The one case #3 does not cover: a session that logged in BEFORE this install, on a RESTRICTED
|
||||
# (non-'*') rpcd login — its in-memory ACL set predates the new grant, and only a fresh login
|
||||
# re-reads acl.d. Measured: that session stays false until it is forced to re-authenticate.
|
||||
# Ending the session (not reloading rpcd) fixes exactly that, with a narrower blast radius: the
|
||||
# postinst below ends a session only when a fresh login would actually gain the scope — denied
|
||||
# uci/footstrap/write right now, AND its own username's rpcd login lists this theme's ACL group —
|
||||
# so a restricted login the theme's grant was never added to (a second admin account, a monitoring
|
||||
# script's login) is left running: re-authenticating would deny it exactly the same as before.
|
||||
# Verified verbatim on the stand: a blanket-root session survived, a stale restricted session was
|
||||
# destroyed, the unauthenticated all-zero session was left untouched, and a fresh login answered
|
||||
# true. The one visible price: an admin on a restricted account WHOSE LOGIN ALREADY LISTS the
|
||||
# theme's group is logged out by their own install action and must log back in — the narrowest set
|
||||
# this can be, since a session whose login never grants the group gains nothing from re-logging in.
|
||||
define Package/luci-theme-footstrap/postinst
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] || {
|
||||
# uci-defaults registers the theme; drop the LuCI caches so it is seen without a reboot.
|
||||
# default_postinst also runs it; the second pass is idempotent.
|
||||
[ -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
|
||||
# See the note above the define: no reload. Instead, end a session only when re-authenticating
|
||||
# would actually hand it the theme's scope: it must be denied uci/footstrap/write right now
|
||||
# AND its own rpcd login entry must list this theme's ACL group, in either read or write — a
|
||||
# denied session whose login never grants that group (a second admin account or a monitoring
|
||||
# script's login the theme was never added to) stays denied after a fresh login too, so ending
|
||||
# it would only cost someone a session for nothing. Guarded on the `session` object existing at
|
||||
# all (a router with rpcd built without ubus session support just does nothing here).
|
||||
ubus list 2>/dev/null | grep -qx session && {
|
||||
for s in $$(ubus call session list 2>/dev/null | grep -o '"ubus_rpc_session": "[a-f0-9]*"' | cut -d'"' -f4); do
|
||||
# skips the all-zero session: that one is the UNAUTHENTICATED default and is never
|
||||
# denied anything by an ACL, so it is not this sweep's business.
|
||||
case "$$s" in *[!0]*) ;; *) continue ;; esac
|
||||
ubus call session access "{\"ubus_rpc_session\":\"$$s\",\"scope\":\"uci\",\"object\":\"footstrap\",\"function\":\"write\"}" 2>/dev/null | grep -q true && continue
|
||||
# A '*' login already answered true above and never reaches here. What is left is a
|
||||
# denied session: only destroy it if ITS OWN username's rpcd login actually lists the
|
||||
# theme's ACL group, so a fresh login would gain the scope this sweep is chasing.
|
||||
u=$$(ubus call session get "{\"ubus_rpc_session\":\"$$s\"}" 2>/dev/null | grep -o '"username": *"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$u" ] || continue
|
||||
# Enumerate every login section from `uci show`, not a `uci -q get` index count: the
|
||||
# latter stops at the FIRST section with no username option (a login block missing that
|
||||
# option is legal UCI) and never looks at any section past it — fails safe (a session is
|
||||
# kept, never wrongly destroyed) but skips real sections. `while read` takes one line at
|
||||
# a time instead of `for`'s word-splitting, because a username may itself contain a
|
||||
# space, which would otherwise split one section's line into two and skip it. The loop
|
||||
# runs in a subshell, so its answer cannot survive in a variable — it is echoed out and
|
||||
# captured instead. A show line's value comes single-quoted
|
||||
# (`rpcd.@login[0].username='root'`), stripped below.
|
||||
grant=$$(uci show rpcd 2>/dev/null | grep -F .username= | while read -r kv; do
|
||||
sect=$${kv%%.username=*}
|
||||
un=$${kv#*.username=}
|
||||
un=$${un#\'}
|
||||
un=$${un%\'}
|
||||
[ "$$un" = "$$u" ] || continue
|
||||
for grp in $$(uci -q get "$$sect.read") $$(uci -q get "$$sect.write"); do
|
||||
[ "$$grp" = luci-theme-footstrap ] && { echo 1; break 2; }
|
||||
done
|
||||
done)
|
||||
[ -n "$$grant" ] || continue
|
||||
ubus call session destroy "{\"ubus_rpc_session\":\"$$s\"}" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
}
|
||||
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 and "remove" on a
|
||||
# real removal. On upgrade this script MUST change nothing: reverting mediaurlbase and wiping the
|
||||
# theme registration here is what flipped every updating 24.10 user back to bootstrap. 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.
|
||||
case "$$1" in *upgrade*) exit 0 ;; esac
|
||||
[ -n "$${IPKG_INSTROOT}" ] || {
|
||||
uci -q delete luci.themes.Footstrap
|
||||
# Don't leave the active theme pointing at the media dir we just removed: a theme needs its
|
||||
# media dir AND its ucode template to render, so both are checked before handing the UI over.
|
||||
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 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
|
||||
# No rpcd reload here either — see the postinst note. A removal drops nothing rpcd needs to
|
||||
# be told about (no plugin object, ever); a session still holding the now-gone ACL group is
|
||||
# simply denied uci/footstrap/write on its next call, which is the correct answer anyway.
|
||||
}
|
||||
endef
|
||||
|
||||
# Runs right after luci.mk copies luasrc/ucode/htdocs/root/src into PKG_BUILD_DIR, so it rewrites
|
||||
# the build copy and 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 and awk only, which
|
||||
# is why it runs on an OpenWrt buildbot.
|
||||
# 2. Stamp the git-derived version into fs-version.js. Unconditional: with PKG_VERSION empty it
|
||||
# falls back to PKG_SRC_VERSION, which luci.mk always defines, so only an un-built source
|
||||
# checkout keeps the literal '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.
|
||||
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 mostly 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. -22 KB of 60, 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
|
||||
# The gate-only exports (`/* fs:probe */`). A handful of module-private functions are also
|
||||
# listed in a module's baseclass so a gate in the source repository can call them — nothing on a
|
||||
# router does. The functions stay; the export line goes, so the surface a package carries is the
|
||||
# one the theme itself uses.
|
||||
$(SHELL) $(CURDIR)/strip-probes.sh $(PKG_BUILD_DIR)/htdocs/luci-static/resources
|
||||
# …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
|
||||
# The two static assets luci.mk has no step for: the SVG favicon carries a 753-byte comment and
|
||||
# the manifest its indentation, and both are fetched by every browser over a link uhttpd does
|
||||
# not compress. awk only, so this runs on a buildbot with no node.
|
||||
$(SHELL) $(CURDIR)/strip-assets.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap
|
||||
$(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 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, and 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. 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 the disposable routers `../owlab.yaml` defines, driven by `owlab` — 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
+120
@@ -0,0 +1,120 @@
|
||||
#!/bin/sh
|
||||
# Build luci-theme-footstrap as an OpenWrt .apk via the SDK. The theme is noarch, 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 — `./tools/stage.sh && owfeed build` is, and it produces both
|
||||
# formats in seconds without a toolchain. What this exercises is the OTHER path: the Makefile,
|
||||
# luci.mk, jsmin and the SDK's own packaging, which has to keep working for anyone building the
|
||||
# theme in an SDK or a feed without owfeed.
|
||||
set -e
|
||||
|
||||
# The point release and the compiler are both baked into the SDK's filename, so this default goes
|
||||
# stale twice over: downloads.openwrt.org keeps only the CURRENT point release of a branch, and a
|
||||
# toolchain bump changes the gcc string. Kept in step with owfeed.lock's `point:` for the 25.12
|
||||
# branch, which is what the release builds against. Override with OPENWRT_RELEASE=… and
|
||||
# OPENWRT_SDK_FILE=… for any other.
|
||||
REL="${OPENWRT_RELEASE:-25.12.5}"
|
||||
SDK_BASE="https://downloads.openwrt.org/releases/${REL}/targets/mediatek/filogic"
|
||||
SDK_FILE="${OPENWRT_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
+274
@@ -0,0 +1,274 @@
|
||||
#!/bin/sh
|
||||
# Concatenate the styles/ tree into a single cascade.css.
|
||||
#
|
||||
# ./build-css.sh [outfile] [--dev]
|
||||
#
|
||||
# Directory order is layer order (styles/, base/, theme/, pages/); filename order is source order
|
||||
# within a directory. A later layer beats an earlier one whatever the specificity, so nothing needs
|
||||
# !important to override base.
|
||||
#
|
||||
# Minified unless --dev: ~468 KB of source to ~136 KB. 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() and color-mix()). Needs only cat and 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" "$TMP.layer"' EXIT
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
|
||||
# One `@layer X{` per LAYER, not one per FILE. Every source file carries its own wrapper so it can
|
||||
# be read and edited alone; concatenated, that is 37 copies of the same six-to-eleven bytes, 542 of
|
||||
# them.
|
||||
#
|
||||
# The wrapper now comes from the DIRECTORY, so a file filed under the wrong layer would be silently
|
||||
# re-layered instead of just being wrong — hence the check that each file opens with the layer its
|
||||
# directory means. A file with no wrapper at all (00-header.css: the banner and the layer-order
|
||||
# statement) is copied through, and must come before the wrapped ones or it would land inside the
|
||||
# block.
|
||||
emit_layer() {
|
||||
layer="$1"; shift
|
||||
body="$TMP.layer"
|
||||
: > "$body"
|
||||
for f in "$@"; do
|
||||
if head -1 "$f" | grep -q '^@layer '; then
|
||||
head -1 "$f" | grep -q "^@layer $layer {\$" || {
|
||||
echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2
|
||||
exit 1; }
|
||||
# the file's own wrapper: its first line, and its last line, which is that wrapper's `}`
|
||||
sed '1d;$d' "$f" >> "$body"
|
||||
elif [ -s "$body" ]; then
|
||||
echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2
|
||||
echo "build-css: swallowed into the $layer block instead of staying above it." >&2
|
||||
exit 1
|
||||
else
|
||||
cat "$f"
|
||||
fi
|
||||
done
|
||||
if [ -s "$body" ]; then
|
||||
printf '@layer %s {\n' "$layer"
|
||||
cat "$body"
|
||||
printf '}\n'
|
||||
fi
|
||||
# not covered by the EXIT trap: the success path runs `trap - EXIT` first
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
# glob expands in filename order
|
||||
{
|
||||
emit_layer tokens "$D"/styles/*.css
|
||||
emit_layer base "$D"/styles/base/*.css
|
||||
emit_layer theme "$D"/styles/theme/*.css
|
||||
emit_layer page "$D"/styles/pages/*.css
|
||||
} > "$TMP"
|
||||
|
||||
# Comments, and — unless told not to — the whitespace CSS ignores anyway, in one string-aware
|
||||
# pass rather than two chained ones. squeeze=0 (the pre-compression rule count below, and every
|
||||
# `--dev` build) strips comments and trims each line; squeeze=1 also does the ~9.5 KB whitespace
|
||||
# pass. Why a scan has to be string-aware, what the whitespace pass leaves alone and why, and the
|
||||
# sed bug this replaced: docs/css.md, "The build".
|
||||
squeeze() {
|
||||
awk -v SQUEEZE="$2" '
|
||||
BEGIN { q = ""; inc = 0; banner = 0; lastc = ""; buf = ""; lastreal = "" }
|
||||
{
|
||||
line = $0
|
||||
# a banner already open on an earlier line: nothing on a pure continuation line can
|
||||
# change q or inc, so the whole line is banner content until its own closing "*/".
|
||||
if (banner) {
|
||||
trimmed = line
|
||||
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
|
||||
print trimmed
|
||||
lastc = ""; lastreal = ""
|
||||
if (index(line, "*/")) banner = 0
|
||||
next
|
||||
}
|
||||
# The line BREAK a squeeze pass is 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 and is dropped next to { } ; , : —
|
||||
# lastc == "" means output is already at the start of a line, with nothing to glue to.
|
||||
if (SQUEEZE && lastc != "" && q == "" && !inc) line = " " line
|
||||
out = ""; i = 1; n = length(line); opened = 0
|
||||
while (i <= n) {
|
||||
c = substr(line, i, 1)
|
||||
if (inc) { # inside a stripped /* ... */
|
||||
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 = ""
|
||||
lastreal = ""
|
||||
i++; continue
|
||||
}
|
||||
if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue }
|
||||
if (c == "/" && substr(line, i + 1, 1) == "*") {
|
||||
if (substr(line, i + 2, 1) == "!") {
|
||||
# a genuine banner open: reached only with q=="" and inc==0 AT THIS
|
||||
# CHARACTER, the same guard strip_comments used before this pass absorbed
|
||||
# it — so a "/*!" that is really bytes inside a string or an already-open
|
||||
# ordinary comment never lands here. Copied raw from the marker on, byte
|
||||
# for byte: it is an Apache-2.0 attribution, not formatting.
|
||||
banner = 1
|
||||
rest = substr(line, i)
|
||||
out = out rest
|
||||
if (index(rest, "*/")) banner = 0
|
||||
opened = 1
|
||||
break
|
||||
}
|
||||
inc = 1; i += 2; continue
|
||||
}
|
||||
if (SQUEEZE) {
|
||||
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
|
||||
#
|
||||
# `>` is a delimiter too, and the only one that is itself a combinator: a
|
||||
# space either side of it is decoration. Safe because a `>` outside a
|
||||
# string can only be the child combinator — the sheet has no media range
|
||||
# syntax (`@media (width > 600px)`) — and a `>` inside a string never
|
||||
# reaches here, the scanner having copied it verbatim above. `~` and `+`
|
||||
# are deliberately NOT joined: `[attr~=v]` and `calc(100% - 10px)` make
|
||||
# them ambiguous without tracking bracket depth.
|
||||
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
|
||||
continue
|
||||
if (nxt == "{" || 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++
|
||||
} else {
|
||||
out = out c; i++
|
||||
}
|
||||
}
|
||||
if (opened) {
|
||||
trimmed = out
|
||||
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
|
||||
print trimmed
|
||||
lastc = ""; lastreal = ""
|
||||
next
|
||||
}
|
||||
if (SQUEEZE) {
|
||||
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 = "" }
|
||||
} else {
|
||||
sub(/^[ \t]+/, "", out)
|
||||
sub(/[ \t]+$/, "", out)
|
||||
if (length(out)) print out
|
||||
}
|
||||
}
|
||||
END { if (SQUEEZE) { 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, so a counter that gsub()s over the raw line makes a perfectly valid rule fail the build.
|
||||
# Measured, all three shapes: `content: ";}"`, `content: "{"`, and a data-URI carrying `;}`. It
|
||||
# fails closed, so nothing is ever corrupted; what it costs is a hunt for an imbalance that is not
|
||||
# there.
|
||||
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, UNSQUEEZED copy, --dev included: counting braces in the
|
||||
# raw file made a stray "{" in prose fail the build on perfectly valid CSS.
|
||||
squeeze "$TMP" 0 > "$TMP.min"
|
||||
RULES_BEFORE=$(brace_count "$TMP.min") || exit 1
|
||||
rm -f "$TMP.min"
|
||||
|
||||
if [ "$DEV" -eq 0 ]; then
|
||||
squeeze "$TMP" 1 > "$TMP.min"
|
||||
mv "$TMP.min" "$TMP"
|
||||
|
||||
# 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.
|
||||
#
|
||||
# That equality — not brace balance on its own — is also why the 80 KB FS_CSS_FLOOR size
|
||||
# guard once here is gone. Balance alone misses a cut right after any of the four
|
||||
# `@layer NAME { … }` wrappers closes: everything up to that point is still a matched set, so
|
||||
# a truncated write — a full disk, a compression that ate the tail — that stops exactly there
|
||||
# reads as a valid, complete-looking file. Fed the real sheet, a cut right after the base
|
||||
# layer's own close counts a balanced 283 rules against the file's true 1057. What catches
|
||||
# that is the COUNT: RULES_BEFORE is measured from the untruncated concatenation before this
|
||||
# pass runs, so a write that stops early can only ever come up short against it.
|
||||
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
|
||||
fi
|
||||
# --dev: $TMP is still the raw concatenation — comments and formatting intact, for reading on a
|
||||
# router rather than shipping.
|
||||
|
||||
SIZE=$(wc -c < "$TMP" | tr -d ' ')
|
||||
mv "$TMP" "$OUT"
|
||||
trap - EXIT
|
||||
|
||||
echo "build-css: $SIZE bytes -> $OUT"
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/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 and the two logo files, which is all this directory holds. No
|
||||
# wallpapers — the doodles this theme once downloaded from GitHub are gone, and nothing in the
|
||||
# package fetches anything at run time; the Background axis paints an SVG the admin uploads.
|
||||
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 bare `&&`-list whose LAST command fails is what
|
||||
# aborts, so `[ -n "$FS_V" ] && ssh …` would take the sync down whenever git describe yielded
|
||||
# nothing (a copied tree without .git). An `&&` guarding an ASSIGNMENT is a different shape and is
|
||||
# safe. expr refuses a tag with sed- or 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. Nothing in Build/Prepare compiles it, and the release does not come from luci.mk
|
||||
# either — owfeed bundles the catalogues inside the theme package — so a dev router gets them from
|
||||
# nowhere unless this sync makes them. 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 checkout: make -C modules/luci-base/src po2lmo —
|
||||
# plural_formula.c is GENERATED by contrib/lemon, so a bare cc over the .c files cannot work.)
|
||||
#
|
||||
# The basename is `footstrap-theme`, matching owfeed.yml and NOT the po file's own name:
|
||||
# lmo_load_catalog globs *.<lang>.lmo so any basename loads, and a v0.8.4 router still owns
|
||||
# footstrap.ru.lmo through the old luci-i18n package, where the same path is a file conflict.
|
||||
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
|
||||
# No rpcd reload here — same race as the Makefile's postinst note (Package/luci-theme-
|
||||
# footstrap/postinst): this copies files onto a router where the theme registers no rpcd object,
|
||||
# rpcd re-reads acl.d/*.json at LOGIN (not only on reload), and a developer's root session already
|
||||
# carries read='*' write='*', so the theme's ACL group is irrelevant to it regardless.
|
||||
ssh "$R" "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 rather than its target). \`rm -f\` refuses a directory, 2>/dev/null swallows the error, and
|
||||
# this block runs without set -e — so the sweep silently does 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 must not be left in luci-mod-status's global include dir, where LuCI loads
|
||||
# every *.js under any theme: it is a chrome module now. A package upgrade drops the old file, but
|
||||
# dev-sync copies rather than installs, so it has to be swept by name — otherwise the dev router
|
||||
# runs 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*"
|
||||
|
||||
|
||||
# ---- bust the browser cache, or a dev deploy is invisible to the device you are testing on ----
|
||||
#
|
||||
# head.ut versions every asset with `?v={{ pkgs_update_time }}`, and luci-base derives that from the
|
||||
# mtime of the PACKAGE DATABASE. Installing a package moves it; scp'ing files over an existing
|
||||
# install does not — so a dev deploy leaves the URL identical while the bytes change, and a browser
|
||||
# is entitled to keep serving what it has. On a phone that is not a theory: an afternoon of "still
|
||||
# shakes" reports were taken against a stylesheet several deploys old.
|
||||
#
|
||||
# Touching the database file is what an install would have done to it, so the next page carries a
|
||||
# new `?v=` for every asset. Router-only, dev-only: nothing in the package does this.
|
||||
ssh "$R" 'touch /lib/apk/db/installed 2>/dev/null || touch /usr/lib/opkg/status 2>/dev/null || true'
|
||||
|
||||
echo "synced to $R (single Footstrap theme registered, active theme unchanged)"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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,18 @@
|
||||
{
|
||||
"id": "/cgi-bin/luci/",
|
||||
"name": "LuCI",
|
||||
"short_name": "LuCI",
|
||||
"start_url": "/cgi-bin/luci/",
|
||||
"scope": "/cgi-bin/luci/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f6f8fa",
|
||||
"theme_color": "#f6f8fa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/luci-static/footstrap/app-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-axes as axes';
|
||||
'require fs-assets as assets';
|
||||
'require fs-version as ver';
|
||||
|
||||
/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js
|
||||
* holds the axes and fs-version.js the version string; this file is the form they are shown in.
|
||||
*
|
||||
* Built once, for the dispatched page: `view/footstrap/appearance.js` — a menu entry, an ordinary
|
||||
* route — calls `renderStandalone()` below, the whole surface this file exposes. Before the page
|
||||
* existed the same form was stapled onto a tab on System -> System instead, watching for the stock
|
||||
* form's own tab strip with a MutationObserver, a 5 s deadline, a `body[data-page]` tracker, a
|
||||
* stale-group WeakSet disqualifying the outgoing page's own DOM from a false match, and a
|
||||
* sessionStorage flag so a reset's reload landed back on the tab rather than whichever one LuCI
|
||||
* remembered. A Save & Apply on a real router once lost that tab outright, never reproduced on
|
||||
* either stand — a mount stapled onto another app's own render is a timing dependency on that
|
||||
* render, by construction, and the most likely account of a user report that could never be
|
||||
* reproduced either way. All of it is gone now that the page owns a route of its own. Twenty-one
|
||||
* axes, nine of them with a colour field, a swatch and a contrast readout, do not fit a floating
|
||||
* popover that has to trap Tab and stay inside a 320px column, and keeping both containers would
|
||||
* render every axis twice.
|
||||
*
|
||||
* The version line makes no request and must not grow one: which version is INSTALLED is what this
|
||||
* page answers, and which is available is the package manager's question. */
|
||||
|
||||
/* ---- colour: reading what the page is actually painted ----
|
||||
*
|
||||
* This lived in fs-widgets.js, which the menu and the search palette also require — so the whole
|
||||
* colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe,
|
||||
* canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was
|
||||
* fs-widgets' only colour export and this file its only consumer. */
|
||||
|
||||
/* ---- colour: reading what the page is actually painted ----
|
||||
*
|
||||
* Two questions no stored value answers: what colour a role is right now (the palette's own while
|
||||
* the axis is off — there is deliberately no copy of the palette in JS), and what contrast the
|
||||
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
|
||||
*
|
||||
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
|
||||
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
|
||||
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
|
||||
* resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of.
|
||||
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
|
||||
let _probe = null;
|
||||
function probeColor(expr) {
|
||||
if (!_probe) {
|
||||
/* Off-screen rather than display:none, so the reading does not depend on a display:none
|
||||
* element computing `color` in every engine. It has no text and no size, so it paints
|
||||
* nothing.
|
||||
*
|
||||
* Every declaration is !important (issue #19): this is an unmarked element in a document
|
||||
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
|
||||
* a layer and a plain inline style alike. A probe that loses its own colour reports the
|
||||
* app's, which then becomes the admin's saved axis on the next confirm. */
|
||||
/* the id is this probe's escape from fs-router.js's strayBodyNode() (issue #56): it is
|
||||
* appended once and never removed, so without an fs-* id it would read as body litter and
|
||||
* force every navigation away from this page into a full load. */
|
||||
_probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' });
|
||||
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
|
||||
+ 'width:0!important;height:0!important;overflow:hidden!important;'
|
||||
+ 'pointer-events:none!important;';
|
||||
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.setProperty('color', '');
|
||||
_probe.style.setProperty('color', expr, 'important');
|
||||
return getComputedStyle(_probe).color;
|
||||
}
|
||||
|
||||
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
|
||||
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
|
||||
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
|
||||
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
|
||||
* convert instead (tools/export-tier.mjs uses the same method). Canvas 2D is universal on every
|
||||
* engine 24.10 ships against, so there is no second path. */
|
||||
let _cx = null;
|
||||
function rasterCtx() {
|
||||
if (!_cx) {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = 1;
|
||||
_cx = cv.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
return _cx;
|
||||
}
|
||||
function parseColor(s) {
|
||||
const cx = rasterCtx();
|
||||
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
|
||||
* report the previous one as a fresh reading — the trap probeColor() clears for */
|
||||
cx.fillStyle = '#000';
|
||||
cx.fillStyle = String(s || '');
|
||||
cx.clearRect(0, 0, 1, 1);
|
||||
cx.fillRect(0, 0, 1, 1);
|
||||
const d = cx.getImageData(0, 0, 1, 1).data;
|
||||
/* translucent: composite over nothing is meaningless for a readout */
|
||||
return d[3] === 255 ? [ d[0], d[1], d[2] ] : null;
|
||||
}
|
||||
|
||||
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
|
||||
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
|
||||
* derives the ink over a fill, which is a different question). */
|
||||
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, because <input type="color"> accepts nothing else. An unparseable colour becomes black
|
||||
* rather than throwing: the text field beside the swatch is the authoritative one. */
|
||||
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, a hex field and a button back to the palette's own colour.
|
||||
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
|
||||
* caller hands straight to fs-prefs.js's colorAxis.
|
||||
*
|
||||
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
|
||||
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
|
||||
* still rotates the palette by one, so a saved value goes on working.
|
||||
*
|
||||
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
|
||||
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
|
||||
* the pair whose ratio is reported under the row. */
|
||||
function colorControl(onPick, label, opts) {
|
||||
const o = opts;
|
||||
|
||||
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
|
||||
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
|
||||
* fallback where the browser draws no picker. */
|
||||
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;
|
||||
|
||||
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
|
||||
* refresh() after a preset, palette switch or dark-mode flip — each changes 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 prefix, 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 doubles as the axis state readout: enabled means the axis
|
||||
* holds a colour of its own, disabled means the field shows 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; }
|
||||
/* The readout states what the ratio means; the number itself stays in the title.
|
||||
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
|
||||
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
|
||||
* border is a legitimate choice.
|
||||
*
|
||||
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
|
||||
* fs-* tokens in the source, and a concatenated name is invisible to it. */
|
||||
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;
|
||||
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 Enter, not per keystroke: a half-typed `#0096` would repaint the page
|
||||
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
|
||||
* cannot 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);
|
||||
/* re-read rather than a cached copy: the page can change what the axis holds behind this
|
||||
* control (a preset, Reset to default), which a snapshot taken at build time would miss */
|
||||
else reflect(o.read());
|
||||
};
|
||||
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 decides when this runs: probeColor() needs the document, and this control is not
|
||||
* in it yet */
|
||||
wrap.fsRefresh = () => reflect(o.read());
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* Build the whole form. Returns the root element, for renderStandalone() below to hand to the
|
||||
* page's own render().
|
||||
*
|
||||
* Everything applies immediately and there is nothing to save: every axis is this browser's, in
|
||||
* localStorage, and the page repaints under the control as it moves. Only "Save to router" writes
|
||||
* anything, pushing the current look to the ROUTER for other browsers. That distinction is the
|
||||
* model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own —
|
||||
* view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, which is what actually
|
||||
* drops it: view.js's own default footer (Save & Apply | Apply unchecked | Save | Reset, wired to
|
||||
* apply_rollback) renders and runs otherwise, POSTing on a form with nothing staged (measured,
|
||||
* live, before that null). */
|
||||
function build() {
|
||||
/* every saved axis re-checks the Save button after applying, so it greys the moment this
|
||||
* browser matches the saved default and un-greys when it diverges. Wrapped around the appliers
|
||||
* because the controls call them directly and have no other seam back here. */
|
||||
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 or dark-mode flip moves
|
||||
* all of it under controls nobody touched, so they refresh together. */
|
||||
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 */
|
||||
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
|
||||
|
||||
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
|
||||
* `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and
|
||||
* theme/60-inputs.css already lay them out — so this page inherits every future fix to the form
|
||||
* layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2,
|
||||
* where an app may win on specificity), which is right for a page inside #view.
|
||||
*
|
||||
* `make` is handed the same label string the caption renders, because every control needs it a
|
||||
* second time as its aria-label; stating it twice is how the visible caption and what a screen
|
||||
* reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks
|
||||
* the rows CSS has 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 three literals every colour row repeats; a string literal survives minification intact */
|
||||
const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap');
|
||||
|
||||
/* ---- the controls are LuCI's own ----
|
||||
*
|
||||
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other
|
||||
* tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css,
|
||||
* `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports
|
||||
* — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further
|
||||
* back.
|
||||
*
|
||||
* Listen for `widget-change` from UIElement, not for an event on the inner element: that is the
|
||||
* seam the widget publishes, and reaching past it ties this to how the widget happens to be
|
||||
* built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes
|
||||
* the page under the drag; the appliers are idempotent, so wiring both 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
|
||||
});
|
||||
const node = w.render();
|
||||
node.setAttribute('aria-label', label);
|
||||
const push = () => apply(parseInt(w.getValue(), 10));
|
||||
/* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column
|
||||
* the Appearance page is drawn in, so applying mid-drag slides the handle out from under
|
||||
* the pointer, the pointer catches up, and one small drag runs the value to the maximum —
|
||||
* reported from a router, and not a thing any other slider here can do: rounding and the
|
||||
* tint sliders repaint around a control that stays where it is. On release the page still
|
||||
* follows, and a keyboard user is unaffected either way: an arrow key on a range input
|
||||
* fires `change` as well as `input`, so the value applies at once. */
|
||||
if (o.live !== false) node.addEventListener('widget-update', push);
|
||||
node.addEventListener('widget-change', push);
|
||||
return node;
|
||||
};
|
||||
|
||||
/* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `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 = colorControl(bump(axis.apply), lbl, {
|
||||
probe: probe,
|
||||
read: axis.current,
|
||||
contrast: contrast,
|
||||
cls: opts.cls
|
||||
});
|
||||
colourCtls.push(ctl);
|
||||
return ctl;
|
||||
}, opts);
|
||||
|
||||
/* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves
|
||||
* one merged catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first
|
||||
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
|
||||
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
|
||||
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
|
||||
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
|
||||
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
|
||||
* has no catalogue for. */
|
||||
|
||||
/* ---- 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(axes.palette.current(), {
|
||||
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',
|
||||
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
|
||||
'2020': 'OpenWrt 2020',
|
||||
/* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is —
|
||||
* a proper noun like the three above it, not the English common noun "forum" */
|
||||
forum: 'Forum'
|
||||
}, bump(repaint(axes.palette.apply)), label)),
|
||||
|
||||
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
|
||||
compact: _('Compact', 'footstrap'),
|
||||
normal: _('Normal', 'footstrap'),
|
||||
large: _('Large', 'footstrap')
|
||||
}, bump(prefs.applyDensity), label)),
|
||||
|
||||
/* issue #44: the content column's own cap, separate from Density (which moves type and
|
||||
* air, not the column's ceiling). The slider STARTS at the 1280px the theme has always
|
||||
* shipped and only ever widens — there is no reason to offer a column narrower than the
|
||||
* one every page was designed against, and keeping 1280 as the left end also keeps
|
||||
* --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach
|
||||
* by construction rather than by a rule someone has to remember. */
|
||||
group(_('Content width', 'footstrap'),
|
||||
(label) => sliderCtl(axes.contentWidth.current(), 1280, 3840,
|
||||
bump(axes.contentWidth.apply), label, { step: 40, live: false })),
|
||||
|
||||
group(_('Rounding', 'footstrap'),
|
||||
(label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)),
|
||||
|
||||
/* The top layout has no accordion, so this switch is meaningless there: always built,
|
||||
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
|
||||
* `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze
|
||||
* the control to the layout the page loaded in while CSS morphs the chrome live. */
|
||||
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'), axes.tint, '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, meaningful only 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.
|
||||
*
|
||||
* Not called "Density": that is the select above, and this string is both the caption and
|
||||
* the aria-label, so a screen reader would announce two rows under one name. */
|
||||
group(_('Tint strength', 'footstrap'),
|
||||
(label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, {
|
||||
step: 5
|
||||
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
|
||||
|
||||
/* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas.
|
||||
* Measured as text on a card, the use that fails first: as a fill it carries derived ink,
|
||||
* as a link or status label it carries only itself. It is also what answers #20 ("sometimes
|
||||
* you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here
|
||||
* are not coming back. */
|
||||
/* Four status roles, one shape: the role's own colour read against a card. Written out
|
||||
* eight times between here and the surfaces below, they cost their repeated literals in
|
||||
* full — a string is not mangled — so the rows are data and the row is stated once. */
|
||||
...[
|
||||
[ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ],
|
||||
[ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ],
|
||||
[ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ],
|
||||
[ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ]
|
||||
].map(([ label, axis, ink ]) =>
|
||||
colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
|
||||
];
|
||||
|
||||
/* ---- the surfaces: the sheet the UI is drawn on ----
|
||||
* Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on
|
||||
* each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the
|
||||
* palette's, and moving it would recolour the very thing being measured against.
|
||||
*
|
||||
* The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label
|
||||
* — and below that it is decoration, which a hairline is entitled to be, so the readout states
|
||||
* the number and leaves the call to the admin. */
|
||||
const surfaces = [
|
||||
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
|
||||
* for the three that carry body text and the hairline itself for the border. */
|
||||
...[
|
||||
[ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ],
|
||||
[ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
|
||||
[ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
|
||||
[ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
|
||||
].map(([ label, axis, probe, fg, bg, where, kind ]) =>
|
||||
colourGroup(label, axis, probe, { fg, bg, label: where, kind }))
|
||||
];
|
||||
|
||||
/* ---- section 3: the wallpaper and the rows each value brings ----
|
||||
*
|
||||
* Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
|
||||
* uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of
|
||||
* its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column
|
||||
* inside the first, and those controls started 216px right of every other one on the page,
|
||||
* measured on the router. Flat rows hidden as a group is what the stock pages do with a
|
||||
* dependent field.
|
||||
*
|
||||
* The select is the per-browser switch deciding whether to paint an image, so it is what keeps
|
||||
* the Save button honest; Choose/Remove only swap the picture and never touch the axis. The
|
||||
* 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 `hidden`: a bare `hidden=""` still renders the native
|
||||
* "Choose File / No file chosen" control */
|
||||
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 snapshotAxes(), so it moves this browser toward or away from the router default and
|
||||
* must be bump()-ed like every other saved axis, or the Save button misreports its own
|
||||
* status. Separate from the Tint 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 resizes and fades under the drag. Colours decides whether the
|
||||
* file's own palette is kept or replaced by the theme's — a mask uses the alpha only,
|
||||
* which is right for line art and wrong for artwork that carries its own colours. */
|
||||
const patRows = [
|
||||
group(_('Pattern', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
|
||||
{ extra: [ patInput, patPreview, patErr ] }),
|
||||
group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600,
|
||||
bump(axes.patternSize.apply), lbl, { step: 20 })),
|
||||
group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100,
|
||||
bump(axes.patternStrength.apply), lbl, { step: 5 })),
|
||||
group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), {
|
||||
theme: _('Theme', 'footstrap'),
|
||||
original: _('As in file', 'footstrap')
|
||||
}, bump(axes.patternInk.apply), 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(axes.photoDim.current(), 0, 100,
|
||||
bump(axes.photoDim.apply), lbl, { step: 5 }))
|
||||
];
|
||||
|
||||
function reflect(tok) {
|
||||
if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; }
|
||||
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
|
||||
}
|
||||
function reflectPattern(tok) {
|
||||
if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; }
|
||||
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
|
||||
}
|
||||
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
|
||||
* `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not
|
||||
* removed: each row holds a live control, so rebuilding on every switch would freeze it to
|
||||
* the state it was constructed in. */
|
||||
function togglePanel(v) {
|
||||
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
|
||||
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
|
||||
}
|
||||
reflect(axes.loginBg.current());
|
||||
reflectPattern(axes.pattern.current());
|
||||
togglePanel(axes.wallpaper.current());
|
||||
|
||||
const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); };
|
||||
|
||||
/* Both uploads present the same three controls and the same four states — pick, upload,
|
||||
* report, remove — so the wiring is stated once. What differs is `after`: the pattern also
|
||||
* has to move the Wallpaper dropdown, because the upload switched this browser onto the
|
||||
* tile and the page would otherwise paint it while the control still read Off.
|
||||
*
|
||||
* The file input is cleared on every change so re-picking the SAME file fires `change`
|
||||
* again, and the button carries its own busy state: the label is restored in `finally`, or
|
||||
* a failed upload leaves "Uploading…" standing for the life of the form. */
|
||||
const wireUploader = (u) => {
|
||||
const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; };
|
||||
u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); });
|
||||
u.input.addEventListener('change', () => {
|
||||
const f = u.input.files && u.input.files[0];
|
||||
u.input.value = '';
|
||||
if (!f) return;
|
||||
u.err.hidden = true; u.choose.disabled = true;
|
||||
u.choose.textContent = _('Uploading…', 'footstrap');
|
||||
u.upload(f)
|
||||
.then((tok) => { u.reflect(tok); if (u.after) u.after(tok); })
|
||||
.catch(fail)
|
||||
.finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; });
|
||||
});
|
||||
u.remove.addEventListener('click', () => {
|
||||
u.err.hidden = true; u.remove.disabled = true;
|
||||
u.drop().then(() => u.reflect('')).catch(fail)
|
||||
.finally(() => { u.remove.disabled = false; });
|
||||
});
|
||||
};
|
||||
|
||||
wireUploader({
|
||||
choose: patChoose, remove: patRemove, input: patInput, err: patErr,
|
||||
label: patChooseLabel, reflect: reflectPattern,
|
||||
upload: assets.uploadPattern, drop: assets.removePattern,
|
||||
/* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is
|
||||
* then called directly, because a programmatic setValue emits no `widget-change`. */
|
||||
after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); }
|
||||
});
|
||||
|
||||
wireUploader({
|
||||
choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
|
||||
label: chooseLabel, reflect: reflect,
|
||||
upload: assets.uploadLoginBg, drop: assets.removeLoginBg
|
||||
});
|
||||
|
||||
let seg;
|
||||
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
|
||||
seg = selectCtl(axes.wallpaper.current(), {
|
||||
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 ----
|
||||
*
|
||||
* Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over
|
||||
* the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the
|
||||
* saved default only shows on a fresh browser. The two Reset buttons below are the escape
|
||||
* hatches, and they do not land in the same place. */
|
||||
/* the button/status label said three times below, inside this one build() call — hoisting the
|
||||
* RESULT, not the msgid, so update-po.sh still sees the literal `_()` argument elsewhere
|
||||
* (measured: 22 B x3 -> 27 B, 39 B saved) */
|
||||
const SAVE_TO_ROUTER = _('Save to router', 'footstrap');
|
||||
/* said twice below, same build() call (measured: 24 B x2 -> 30 B, 18 B saved) */
|
||||
const RESET_TO_ROUTER = _('Reset to router', 'footstrap');
|
||||
const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]);
|
||||
/* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to
|
||||
* router" clears them and lets every axis fall back to whatever the router holds, while "Reset
|
||||
* to built-in" writes the theme's built-ins explicitly — the only way to say "as the theme
|
||||
* ships" on a router that has a look of its own. Neither touches /etc/config/footstrap.
|
||||
*
|
||||
* ONE word per state, and "default" is not one of them: it used to name the router's look in
|
||||
* "Save as default" and the theme's in "Reset to default", while the router's look also
|
||||
* answered to "saved" — three names for two states, asked about on the forum (topic 251930,
|
||||
* post 92). The row now reads as one save and two resets, over `router` and `built-in`. */
|
||||
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]);
|
||||
/* the stock destructive class, so the button discarding every local tweak is the red one
|
||||
* (theme/55-buttons.css). "Reset to router" stays neutral: it steps back to the shared state
|
||||
* rather than discarding. */
|
||||
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to built-in', 'footstrap') ]);
|
||||
/* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired
|
||||
* session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set
|
||||
* in the session and commit then 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. */
|
||||
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||||
|
||||
/* The Save button is the status: matching disables it, diverging enables it. Called after every
|
||||
* axis change (via bump).
|
||||
*
|
||||
* Unless the browser refuses storage, where the comparison would always be true — nothing was
|
||||
* written, so every current*() reads the router default back however far the page has been
|
||||
* dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to
|
||||
* the router is the one thing that still works. */
|
||||
function refreshSave() {
|
||||
if (prefs.storageBroken()) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = SAVE_TO_ROUTER;
|
||||
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap');
|
||||
saveErr.hidden = false;
|
||||
return;
|
||||
}
|
||||
const saved = axes.matchesSavedDefault();
|
||||
saveBtn.disabled = saved;
|
||||
saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER;
|
||||
}
|
||||
saveBtn.addEventListener('click', () => {
|
||||
saveBtn.disabled = true;
|
||||
saveErr.hidden = true;
|
||||
axes.saveAsDefault()
|
||||
.then(() => { saveErr.hidden = true; })
|
||||
/* on failure refreshSave re-enables the button so the user can retry; the usual cause
|
||||
* is a stale session, which a reload fixes. The raw rpc error stays in a title
|
||||
* tooltip. */
|
||||
.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: discarding local tweaks is destructive and a native confirm() is
|
||||
* banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a
|
||||
* click meant for its neighbour. Each reload lands back on this same page — an ordinary route,
|
||||
* unlike the tab this once needed a sessionStorage flag to return to. */
|
||||
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();
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved);
|
||||
twoClick(resetBtn, _('Reset to built-in', 'footstrap'), axes.resetToBuiltin);
|
||||
refreshSave(); /* correct label and enabled state before the first paint */
|
||||
|
||||
const versionLink = E('a', {
|
||||
'class': 'fs-ap-version',
|
||||
'href': ver.REPO_URL,
|
||||
'target': '_blank',
|
||||
/* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it
|
||||
* that way */
|
||||
'rel': 'noreferrer'
|
||||
}, [ ver.label() ]);
|
||||
|
||||
/* One clause per button, in LuCI's own help-sentence idiom and under the buttons themselves,
|
||||
* because that is where the question is asked: with all three named "save"/"reset" and two of
|
||||
* them resets, the row does not say which state each lands in (forum topic 251930, post 90 —
|
||||
* "what is the difference on 'reset to saved' and 'reset to default'?"). Three lines rather
|
||||
* than a paragraph: three parts want a list, and each is read on its own.
|
||||
*
|
||||
* Text nodes separated by <br>, NOT a child per line: `.cbi-value-field *` in
|
||||
* theme/60-inputs.css hands mono to every descendant and excludes `.cbi-value-description`
|
||||
* itself, so a wrapped line becomes a descendant that the exclusion does not reach — measured,
|
||||
* all three lines came out monospace. Text nodes inherit the sans face from the container, and
|
||||
* one container also means one `?` glyph with every line aligned under it. */
|
||||
const buttonHelp = E('div', { 'class': 'cbi-value-description' }, [
|
||||
_('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap'),
|
||||
E('br'),
|
||||
_('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap'),
|
||||
E('br'),
|
||||
_('Reset to built-in — go back to the look the theme ships with.', 'footstrap')
|
||||
]);
|
||||
|
||||
const defaults = [
|
||||
/* the one row whose control is a pair of buttons, each named by its own text, so `make`
|
||||
* ignores the caption rather than re-using it as an aria-label */
|
||||
group(_('Saved look', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]),
|
||||
{ extra: [ buttonHelp, saveErr ] })
|
||||
];
|
||||
|
||||
|
||||
/* ---- the catalogue this router has not got ----
|
||||
*
|
||||
* The theme's translations are their own packages since 0.14.4, the way every `luci-app-*`
|
||||
* ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right
|
||||
* one: apk learns it from `install-if` against `luci-i18n-base-<lang>` (owfeed.yml), opkg has no
|
||||
* conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply
|
||||
* loses the catalogue that used to ride inside the theme (issue #41). `install.sh` covers its
|
||||
* own path; this covers the one nobody ran a script on.
|
||||
*
|
||||
* ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not
|
||||
* grow one for this. `_()` returns its argument unchanged when no catalogue answers, so asking
|
||||
* for a string the catalogue certainly carries is the whole test.
|
||||
*
|
||||
* `Layout` is that string — a caption this very form renders, present in every catalogue under `po/`. A word
|
||||
* that only LOOKS certain is worse than no check: `Appearance` is in the source but obsolete in
|
||||
* the catalogues (`#~ msgid`), so testing it reported "not translated" on a router whose
|
||||
* Russian catalogue was installed and working.
|
||||
*
|
||||
* The language comes from the document, not from `L.env`, which carries no language field at
|
||||
* all — the dispatcher stamps `<html lang>` and that is what the page knows.
|
||||
*
|
||||
* Nothing is shown on an English or `auto` router, where there is no catalogue to miss. */
|
||||
const lang = (document.documentElement.getAttribute('lang') || '').trim();
|
||||
const untranslated = lang && lang !== 'en' && lang !== 'auto' &&
|
||||
_('Layout', 'footstrap') === 'Layout';
|
||||
const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [
|
||||
E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]),
|
||||
E('code', {}, [ 'luci-i18n-footstrap-' + lang ])
|
||||
]) : '';
|
||||
|
||||
defaults.push(E('div', { 'class': 'fs-ap-footer' }, [
|
||||
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]),
|
||||
missing
|
||||
]));
|
||||
|
||||
/* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put
|
||||
* their rows straight into the pane. These are grouping headings within one pane, styled by
|
||||
* 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 ----
|
||||
* Nine colour fields and an uploader are the widest rows on the page and most admins never
|
||||
* touch them, so each group is a disclosure, closed by default.
|
||||
*
|
||||
* A disclosure and not a switch: a switch answers "is this feature on", and turning it off
|
||||
* would either revert nine colours or change nothing at all. Opening or closing a fold applies,
|
||||
* un-applies and disables nothing.
|
||||
*
|
||||
* W3C APG disclosure pattern, as the menu's sections use: a <button> owning the region,
|
||||
* `aria-expanded` on it and `aria-controls` pointing at the panel. `hidden` on the panel rather
|
||||
* than a class, so a closed group leaves the tab order and 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, snapshotAxes() and the pre-paint. */
|
||||
let foldSeq = 0;
|
||||
function foldable(title, rows, key) {
|
||||
const id = 'fs-ap-fold-' + (++foldSeq);
|
||||
let open = (prefs.lsGet(key) === 'on');
|
||||
/* id only: `aria-controls` needs one, and no rule has ever styled the panel itself */
|
||||
const body = E('div', { 'id': id }, rows);
|
||||
const btn = E('button', {
|
||||
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
|
||||
}, [
|
||||
E('h4', {}, [ title ]),
|
||||
/* the same chevron the overview's card toggles draw: an empty box whose ::after is two
|
||||
* borders rotated 45° (pages/20-overview.css), not a second <svg> to keep in step.
|
||||
* Empty and aria-hidden — the state is the button's aria-expanded, which is also what
|
||||
* CSS rotates it off. */
|
||||
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();
|
||||
/* refreshed on open because the axes below may have moved while it was collapsed;
|
||||
* skipped while closed, where nothing is on screen to be wrong */
|
||||
if (open) refreshColours();
|
||||
});
|
||||
paint();
|
||||
return E('div', { 'class': 'fs-ap-section' }, [
|
||||
E('div', { 'class': 'fs-ap-head' }, [ btn ]), body
|
||||
]);
|
||||
}
|
||||
|
||||
/* Above the first section, because the misreading this once caused (the tab sat inside the stock
|
||||
* System form, so LuCI's own Save & Apply footer sat under a page it did not save — forum topic
|
||||
* 251930) happened before anyone scrolled to Defaults. The dispatched page has no such footer to
|
||||
* misread: view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, so
|
||||
* view.js's addFooter() renders nothing here, and the caveat sentence that once named it is
|
||||
* gone with the tab. A bare `.alert-message`: theme/35-alerts.css keeps the tinted variants for
|
||||
* a STATUS and a flat panel for a note. */
|
||||
const note = E('div', { 'class': 'alert-message fs-ap-note' }, [
|
||||
E('p', {}, [ _('Footstrap theme settings apply at once and are stored permanently in this browser.', 'footstrap') ])
|
||||
]);
|
||||
|
||||
/* Colours and Surfaces are one fold: the same job, split into two headings only because a
|
||||
* figure and the sheet it sits on are read differently */
|
||||
const page = E('div', { 'class': 'fs-ap' }, [
|
||||
note,
|
||||
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(_('Saving', 'footstrap'), defaults)
|
||||
]);
|
||||
|
||||
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for
|
||||
* the form to be in the document: every readout resolves inside fs-widgets against a hidden
|
||||
* probe attached to <body>, so a detached form still reads the live palette. */
|
||||
Promise.resolve().then(refreshColours);
|
||||
return page;
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
/* the dispatched page's whole render(): view/footstrap/appearance.js calls this and nothing
|
||||
* else in this file — no mount, no observer, no deadline, no tab. A menu entry and a route did
|
||||
* that job instead; see the file's own header comment for what used to live here. */
|
||||
renderStandalone: () => Promise.resolve().then(build)
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require fs-axes as axes';
|
||||
|
||||
/* fs-assets — putting a file ON THE ROUTER, and taking it off again.
|
||||
*
|
||||
* Two uploads live here, the pattern tile and the login photo, and everything they need that the
|
||||
* rest of the theme does not: a DOMParser pass over an SVG, a canvas re-encode of a photo, the
|
||||
* chmod that makes a freshly written 0600 file servable, and the rollback that runs when the token
|
||||
* write fails after the bytes have landed.
|
||||
*
|
||||
* It is a module of its own because of WHERE it is needed: this machinery is reached only from the
|
||||
* Appearance tab, one page out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
|
||||
* plumbing downloaded to a router's browser on the way to the DHCP page.
|
||||
*
|
||||
* The token accessors and the two live appliers live in `fs-axes` beside the axes themselves — the
|
||||
* Appearance previews and head.ut's pre-paint read the same fields — so this file requires that one
|
||||
* and nothing else of the theme's. */
|
||||
|
||||
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
|
||||
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
|
||||
* the resolved value — measured on the router, a per-config ACL refusal resolves with 6
|
||||
* (permission denied) and every `.then()` below runs as if the file had been written, greying the
|
||||
* Save button over a write that never happened. */
|
||||
/* The four messages each said twice or three times below. Hoisted because a string literal is not
|
||||
* mangled, so every repeat is paid in full on flash — and because a message with two spellings is a
|
||||
* message that gets fixed in one of them. The msgid and its 'footstrap' context stay literal
|
||||
* arguments here, which is what update-po.sh's extractor reads. */
|
||||
const MSG_UPLOAD_FAILED = _('Upload failed.', 'footstrap');
|
||||
const MSG_NOT_SVG = _('That file is not an SVG image.', 'footstrap');
|
||||
const MSG_BAD_IMAGE = _('Could not process the image.', 'footstrap');
|
||||
const MSG_PICK_SVG = _('Please choose an SVG file.', 'footstrap');
|
||||
/* One template, two numbers, rather than composing "1 script, 3 external references" out of
|
||||
* pluralised fragments at run time: a translator gets one sentence to place the two %d's in,
|
||||
* instead of a jigsaw of "script"/"scripts" and "reference"/"references" that has to agree in
|
||||
* every language's own plural rule. */
|
||||
const MSG_SANITIZED = _('Cleaned before upload: removed %d unsafe element(s) and %d external reference(s).', 'footstrap');
|
||||
const MSG_NOTHING_LEFT = _('That SVG had nothing left once the unsafe parts were removed.', 'footstrap');
|
||||
|
||||
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
|
||||
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
|
||||
|
||||
/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
|
||||
*
|
||||
* The bytes come from the admin, never from a third-party host: a theme in a package feed does not
|
||||
* reach out at run time.
|
||||
*
|
||||
* Router-side, like the login photo and for the same reason — a file cannot live in localStorage,
|
||||
* and a pattern is something a router wears. The path is a fixed server-side constant matched
|
||||
* exactly by the rpcd ACL, so nothing user-controlled reaches a path. It lives under /etc so a
|
||||
* package upgrade cannot delete it (keep.d carries it across a sysupgrade). It keeps the .svg name
|
||||
* for its own sake — the CGI handler that now serves it (root/www/cgi-bin) sets Content-Type
|
||||
* itself and does not rely on uhttpd's by-extension typing the way the login photo's /www symlink
|
||||
* still does.
|
||||
*
|
||||
* How it is made to fit is 15-wallpaper.css's mask, not anything done to the bytes: the file
|
||||
* supplies the alpha and the theme the colour, so one upload reads correctly in both modes and
|
||||
* under every palette.
|
||||
*
|
||||
* What is cleaned, and why cleaning is honest here: an SVG is a document, not a picture, and while
|
||||
* a masked or background image never executes script, the same file fetched from its own URL would
|
||||
* (OpenWrt forum thread 251930). THIS FILE IS NOT THE SECURITY BOUNDARY and none of what follows
|
||||
* changes that: the rpcd ACL authorises the cgi-upload POST regardless of what wrote it, so a `curl`
|
||||
* straight to the endpoint skips every check below, and what actually stops the file executing if
|
||||
* it is ever opened directly is the server's own response headers — the CGI handler
|
||||
* (root/www/cgi-bin/luci-theme-footstrap-pattern) sends `Content-Security-Policy: default-src
|
||||
* 'none'; sandbox` on every request for it, upload path or not. That is true of the bytes this
|
||||
* module hands to `fetch()` whether they are the admin's original file, a cleaned copy, or (before
|
||||
* this pass existed) a refusal that never reached the server at all. So sanitizing changes
|
||||
* CONVENIENCE, not the threat model: an admin whose export carries a stray handler or an external
|
||||
* `url()` — an editor artefact, not a deliberate payload in the overwhelming case — gets a working
|
||||
* tile and a note about what was taken out, instead of a refusal they have to go and hand-edit
|
||||
* their way around. */
|
||||
const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */
|
||||
const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */
|
||||
/* What a cleaned SVG has had taken out of it, decided on the PARSED document and not on its text: a
|
||||
* regex over the source guesses at a grammar the browser already implements, and guesses in both
|
||||
* directions — a handler pattern also matches an ordinary `only_selected="false"`, while an entity
|
||||
* or odd whitespace hides a real handler from it.
|
||||
*
|
||||
* DOMParser is the parser the file will actually be read by, and parsing is inert: no script runs,
|
||||
* no subresource is fetched, no handler is bound. So the questions are exact ones about nodes, and
|
||||
* `_sanitizeSvg` below asks all of them in the SAME walk that removes what fails one:
|
||||
*
|
||||
* - is it an SVG at all (a parsererror, or a root that is not <svg>, is not an image — refused,
|
||||
* not cleanable: there is no document to clean)
|
||||
* - does it carry an element that executes or embeds (script, foreignObject, iframe, style, …) —
|
||||
* the element is removed
|
||||
* - does it carry a real event-handler attribute — `^on[a-z]+$` — the attribute is removed
|
||||
* - does any value start a `javascript:` url — the attribute is removed
|
||||
* - does any href point off this router — a leading `//`, or a `data:`/`blob:`/`about:` payload —
|
||||
* the attribute is removed
|
||||
* - does an element carry `xml:base` — it moves where every relative reference under it resolves,
|
||||
* so it is removed regardless of what it points to
|
||||
* - does a `style` attribute reach off this document — `url(` naming anything but a same-document
|
||||
* fragment, `@import`, or `expression(` — that ONE DECLARATION is dropped, the rest of the
|
||||
* value kept
|
||||
*
|
||||
* The check is for the way the file can be reached that a mask does not cover: its own URL, opened
|
||||
* directly, same-origin with the session.
|
||||
*
|
||||
* `animate`/`set` are listed for a second reason as well: they can retarget an attribute at run
|
||||
* time (`<set attributename="href" to="javascript:…">`), and a tile that animates repaints a
|
||||
* full-viewport layer behind every page.
|
||||
*
|
||||
* `<style>` is removed whole, as an ELEMENT (PAT_BAD_TAGS): `<style>@import
|
||||
* url("//attacker/x.css")</style>` reaches off-router with no script and no href at all, a tile has
|
||||
* no legitimate use for a stylesheet, and parsing its CSS to tell a safe one from a dangerous one
|
||||
* would be exactly the grammar-guessing this file avoids everywhere else — so the element is taken
|
||||
* out without being read.
|
||||
*
|
||||
* A `style` ATTRIBUTE is narrower, because it is common where the element is not: a vector editor
|
||||
* writes one on every shape it draws, and an Inkscape export measured for this fix carried 278 of
|
||||
* them — `style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none"` — not one containing a
|
||||
* reference of any kind. Removing the attribute outright would touch nearly every real upload for
|
||||
* nothing a mask does not already stop, so `_sanitizeStyleValue` below splits it on `;` and drops
|
||||
* only the declaration that reaches off this document: `url(` naming anything but a `#fragment`
|
||||
* inside the SAME svg, `@import`, or a CSS `expression(` (legacy IE, parsed by nothing shipping
|
||||
* today, dropped anyway for the same reason `data:`/`blob:`/`about:` are below — the grammar to
|
||||
* tell a dead syntax from a live one in every future engine is not worth having). `url(#gradient)`
|
||||
* is how ordinary SVG gradients and clip-paths are written and is kept; a relative or absolute
|
||||
* `url(…)` is not, because it is a second fetch and not "into this document" either way. The match
|
||||
* is case-insensitive and tolerant of whitespace before the `(` — CSS itself is stricter about the
|
||||
* second one, but a check a differently-spaced upload slips past is not a check, and the cost of
|
||||
* dropping a declaration that could not have run anyway is one line fewer of `style`, not a hole.
|
||||
*
|
||||
* `data:`/`blob:`/`about:` were allowed
|
||||
* through `href`/`xlink:href` before this pass, on the theory that a tile only ever uses `data:` to
|
||||
* embed its own bitmap — closed anyway, because the same scheme admits `data:text/html` and
|
||||
* `data:image/svg+xml`, and telling those apart from a `data:image/png` bitmap means parsing the
|
||||
* URI's own MIME token, which is exactly the kind of grammar-guessing this file avoids everywhere
|
||||
* else. The cost is real: an admin embedding a bitmap inside the tile now needs a second uploaded
|
||||
* asset referenced by a same-origin URL instead of a `data:` href — the tile itself is unaffected,
|
||||
* since 15-wallpaper.css never used one. */
|
||||
const PAT_BAD_TAGS = [ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'audio', 'video', 'animate', 'set', 'style' ];
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/* CSS Syntax Level 3 §4.3.7: `\` + 1-6 hex digits (+ one optional trailing whitespace that ends the
|
||||
* escape) decodes to that code point, and `\` + any other character is that character literally.
|
||||
* Security review finding (LOW): `_externalStyleRef` used to match the literal word `url(`, so a
|
||||
* hex-escaped function name — `u\72l(//evil.example/x)`, `\72` being 'r' — reached a real CSS
|
||||
* tokenizer as an ordinary `url()` while the literal match never saw it. Decoding first closes it
|
||||
* without a second grammar to maintain: once the escape is gone, the same literal match is exactly
|
||||
* what a browser would have resolved. */
|
||||
function _cssUnescape(v) {
|
||||
return v.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?|\\([^\r\n\f])/g,
|
||||
(m, hex, lit) => (hex !== undefined ? String.fromCodePoint(parseInt(hex, 16)) : lit));
|
||||
}
|
||||
|
||||
/* True if a CSS declaration reaches off this document — see the comment above PAT_BAD_TAGS for
|
||||
* what is and is not admitted and why. `url(` is matched everywhere it occurs, because one
|
||||
* declaration can itself carry more than one (a shorthand, or a fallback list), and a single
|
||||
* external reference among same-document ones is still external. Case-insensitive and tolerant of
|
||||
* whitespace before `(` on purpose: this is the removing side of the check, so matching MORE than
|
||||
* the CSS grammar strictly allows costs a dropped declaration that could not have run anyway, while
|
||||
* matching less would let a spacing or casing trick back through. */
|
||||
function _externalStyleRef(v) {
|
||||
v = _cssUnescape(v);
|
||||
if ((/@import\b/i).test(v) || (/expression\s*\(/i).test(v)) return true;
|
||||
const urlRe = /url\s*\(\s*(['"]?)\s*([^)\s'"]*)/gi;
|
||||
let m;
|
||||
while ((m = urlRe.exec(v)) !== null)
|
||||
if (m[2].charAt(0) !== '#') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* A `style` value with every off-document DECLARATION dropped and the rest kept verbatim — see the
|
||||
* comment above PAT_BAD_TAGS for the `style` ATTRIBUTE's narrower treatment. Splitting and
|
||||
* rejoining on `;` is only ever done when something is actually being removed: a clean value never
|
||||
* reaches this function's caller with `removed` counted, so it never gets rewritten either (point 2
|
||||
* of the sanitize pass — see `_sanitizeSvg`). */
|
||||
function _sanitizeStyleValue(v) {
|
||||
let removed = 0;
|
||||
const kept = v.split(';').filter((raw) => {
|
||||
const d = raw.trim();
|
||||
if (!d) return true;
|
||||
if (_externalStyleRef(d)) { removed++; return false; }
|
||||
return true;
|
||||
});
|
||||
return { value: kept.join(';'), removed };
|
||||
}
|
||||
|
||||
/* One walk that both judges the document's shape and cleans its content — a second traversal that
|
||||
* could disagree with this one is exactly the risk a duplicate check runs (openwrt/luci#8981's
|
||||
* lesson applied here). Returns `{ error }` for the four things that cannot be cleaned honestly
|
||||
* (not an SVG, a parser error, the wrong namespace, nothing left once the unsafe parts are gone),
|
||||
* or `{ text, elements, refs }` — `text` is the ORIGINAL string, untouched, when `elements` and
|
||||
* `refs` are both 0: a round-trip through XMLSerializer can rewrite namespaces, entities and
|
||||
* whitespace, which on a 138 KB tile is silent corruption nobody notices until it is on a router, so
|
||||
* serializing only happens when something was actually removed. */
|
||||
function _sanitizeSvg(text) {
|
||||
let doc;
|
||||
try { doc = new DOMParser().parseFromString(text, 'image/svg+xml'); }
|
||||
catch (e) { return { error: MSG_NOT_SVG }; }
|
||||
const root = doc && doc.documentElement;
|
||||
/* An SVG is its ROOT'S NAMESPACE, not its root's spelling. `nodeName` is the qualified name, so
|
||||
* it answers both questions wrong at once: `<svg xmlns="http://www.w3.org/1999/xhtml">` reads as
|
||||
* `svg` and is admitted although it is an XHTML document that executes on all three engines,
|
||||
* while `<s:svg xmlns:s="http://www.w3.org/2000/svg">` reads as `s:svg` and is turned away
|
||||
* although it is an ordinary picture. */
|
||||
if (!root || doc.querySelector('parsererror') ||
|
||||
root.localName.toLowerCase() !== 'svg' || root.namespaceURI !== SVG_NS)
|
||||
return { error: MSG_NOT_SVG };
|
||||
|
||||
let elements = 0, refs = 0;
|
||||
|
||||
/* A processing instruction can attach an XSLT stylesheet carried INSIDE this same document, and
|
||||
* the transform's output is a document this walk never sees: `<xsl:element name="script">`
|
||||
* builds the element by name, so nothing here is called script. Measured executing on Firefox
|
||||
* (Chromium and WebKit decline to run XSLT on an image/svg+xml document). A tile has no use for
|
||||
* one, and without the PI the embedded stylesheet is never applied. */
|
||||
|
||||
/* An external-DTD DOCTYPE prologue is left unvisited on purpose: verified-safe residual finding,
|
||||
* docs/architecture.md "The SVG sanitizer's residual, verified-safe findings". */
|
||||
for (const n of [ ...doc.childNodes ])
|
||||
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
|
||||
|
||||
/* Snapshot before mutating: querySelectorAll answers once, and removing a bad element also
|
||||
* detaches everything under it — `isConnected` is how the loop tells a still-live descendant
|
||||
* from one whose parent already left in an earlier turn of this same loop. */
|
||||
const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
|
||||
for (const el of els) {
|
||||
if (el !== root && !el.isConnected) continue;
|
||||
/* localName, never nodeName: in an XML document nodeName carries the namespace PREFIX, so
|
||||
* `<s:script xmlns:s="http://www.w3.org/2000/svg">` reads as `s:script` and walks straight
|
||||
* past a list of names — measured executing on all three engines, as does the same element
|
||||
* put in the xhtml namespace. localName is `script` for every one of those spellings. */
|
||||
if (PAT_BAD_TAGS.indexOf((el.localName || el.nodeName).toLowerCase()) >= 0) {
|
||||
el.remove();
|
||||
elements++;
|
||||
continue;
|
||||
}
|
||||
/* A snapshot, not a live walk: removing an attribute while indexing the live NamedNodeMap
|
||||
* shifts every index after it, the same trap a live NodeList sets for element removal above. */
|
||||
const attrs = [];
|
||||
for (let i = 0; el.attributes && i < el.attributes.length; i++)
|
||||
attrs.push({ name: el.attributes[i].name, value: el.attributes[i].value });
|
||||
for (const a of attrs) {
|
||||
const n = a.name.toLowerCase();
|
||||
const v = String(a.value || '').trim();
|
||||
/* a REAL handler is `on` + letters and nothing else; `only_selected` is not one. The
|
||||
* qualified name is right here, unlike on the element above: a prefixed `s:onload` or
|
||||
* `xlink:onload` fires on none of the three engines, so matching localName would only
|
||||
* strip attributes that do nothing.
|
||||
* Verified in the 2026-09 security review, including the case where the prefix is bound
|
||||
* right back to the SVG namespace itself (`s:onload` under `xmlns:s="…/2000/svg"`): SVG
|
||||
* defines its event-handler attributes in NO namespace at all, not "the SVG namespace",
|
||||
* so a prefixed spelling never binds as a listener regardless of what the prefix
|
||||
* resolves to. That is a platform guarantee, not this file's, and would stop holding only
|
||||
* if some future consumer bound SVG event attributes by namespace instead of the empty
|
||||
* one the spec gives them — a change to how SVG itself is interpreted, not something a
|
||||
* check here could see coming or catch. */
|
||||
if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* A TAB-obfuscated `javascript:` (`href="javas	cript:alert(1)"`) passes this regex:
|
||||
* verified-safe residual finding, docs/architecture.md "The SVG sanitizer's residual,
|
||||
* verified-safe findings". */
|
||||
if ((/^javascript:/i).test(v)) { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* Security review finding (LOW): `xml:base` (XML Base, W3C) is not itself a reference —
|
||||
* it changes what every RELATIVE one in the subtree UNDER IT resolves against. A perfectly
|
||||
* relative `href="x.png"` inside `<svg xml:base="http://evil.example/">` fetches from
|
||||
* evil.example, and the href check below never sees an absolute URL to catch, because
|
||||
* there isn't one in that attribute — the base moved instead. Removing the ONE attribute
|
||||
* that redirects resolution closes href, xlink:href and a `style` url()'s base alike at
|
||||
* once, the same way `<style>` above is removed whole rather than parsed for a safe half:
|
||||
* a tile has no legitimate use for an alternate base either. Checked on EVERY element via
|
||||
* this same walk, not only the root — XML Base is re-settable at any depth. Matched on the
|
||||
* literal `xml:` prefix, not a resolved namespace, same as every other attribute check
|
||||
* here: `xml` needs no `xmlns:xml` declaration to mean this (Namespaces in XML §4), so the
|
||||
* qualified name alone is the realistic case; an export that goes on to alias some OTHER
|
||||
* prefix to the XML namespace just to carry it is a narrower attack this does not chase,
|
||||
* consistent with this file avoiding a second grammar it would have to maintain. */
|
||||
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* A presentation attribute's `url(` (`fill`, `stroke`, `filter`, …) is only checked below
|
||||
* for `style`, not here: verified-safe residual finding, docs/architecture.md "The SVG
|
||||
* sanitizer's residual, verified-safe findings". */
|
||||
if (n === 'style') {
|
||||
const cleaned = _sanitizeStyleValue(v);
|
||||
if (cleaned.removed) {
|
||||
if (cleaned.value.trim()) el.setAttribute(a.name, cleaned.value);
|
||||
else el.removeAttribute(a.name);
|
||||
refs += cleaned.removed;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((/(?:^|:)href$/).test(n)) {
|
||||
/* off-router reference (a leading `//` is protocol-relative and just as external), or
|
||||
* a scheme that can carry a second document behind what looks like a same-document
|
||||
* reference — see the comment above PAT_BAD_TAGS for why this is a blanket removal
|
||||
* and not a MIME-token check. */
|
||||
if ((/^(?:[a-z][a-z0-9+.-]*:)?\/\//i).test(v) || (/^(?:data|blob|about):/i).test(v))
|
||||
{ el.removeAttribute(a.name); refs++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(elements + refs)) return { text }; /* untouched: hand back the ORIGINAL bytes */
|
||||
if (!root.children || root.children.length === 0) return { error: MSG_NOTHING_LEFT };
|
||||
return { text: new XMLSerializer().serializeToString(doc), elements, refs };
|
||||
}
|
||||
|
||||
/* ---- login/page background upload: router-side, and deliberately not an axis ----
|
||||
* The other axes are per-browser with a router default; this one has no browser layer. An admin
|
||||
* uploads an image once, it becomes the router-wide background for every device and shows
|
||||
* pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() — it must not
|
||||
* move the Save button — and needs no factory, so tools/axes.mjs never sees it.
|
||||
*
|
||||
* The image is a served file, uhttpd having no gzip to make inlining it in every <head> viable;
|
||||
* only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
|
||||
* a fixed server-side constant matched exactly by the rpcd ACL, so nothing user-controlled reaches
|
||||
* a path. */
|
||||
const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
|
||||
const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
|
||||
const BG_QUALITY = 0.9;
|
||||
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
|
||||
/* No `reject: true` here, unlike every other declare in this file: with it, "the file was already
|
||||
* gone" and "the router refused to delete it" arrive as the same Error. Without it the promise
|
||||
* resolves with the ubus status as a number, which this code can branch on. */
|
||||
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
|
||||
|
||||
/* Delete, treating "not found" as done. Anything else is a real refusal (a read-only or full
|
||||
* overlay, an immutable flag, a path replaced by a directory) and must not be reported as a
|
||||
* removal: the file stays on flash and stays fetchable WITHOUT a session through the /www symlink,
|
||||
* which is what an admin removing a background believes they have stopped. */
|
||||
const UBUS_NOT_FOUND = 4;
|
||||
function _removeServed(path) {
|
||||
return _fileRemoveStatus(path).then((res) => {
|
||||
const code = (typeof res === 'number') ? res : parseInt(res, 10);
|
||||
if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
|
||||
return Promise.reject(new Error(
|
||||
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
|
||||
});
|
||||
}
|
||||
/* cgi-upload writes the file 0600 and uhttpd refuses to serve a file that is not world-readable
|
||||
* (0600 -> 403, 0644 -> 200), so make it 0644 first. The rpcd ACL grants exec on exactly two fixed
|
||||
* commands — chmod 644 on the two files this module uploads — with no caller-controlled
|
||||
* argument. */
|
||||
const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
|
||||
/* …and the ubus status is only half of it: `file.exec` reports the command's exit status inside the
|
||||
* payload, so a chmod that ran and failed still comes back as a successful call — and the upload
|
||||
* then reports success for a file uhttpd will 403, leaving every device a scrim over nothing. */
|
||||
function _chmodServeable(path) {
|
||||
return _fileExec('/bin/chmod', [ '644', path ]).then((res) => {
|
||||
if (res && res.code)
|
||||
throw new Error(MSG_UPLOAD_FAILED + ' (chmod ' + res.code + ')');
|
||||
return res;
|
||||
});
|
||||
}
|
||||
/* Re-encode the picked image to a bounded JPEG on a canvas. A security step as much as a size one:
|
||||
* the canvas keeps only the decoded pixels, so EXIF and any bytes appended past the image are
|
||||
* dropped and the uploaded blob is exactly what the browser drew.
|
||||
*
|
||||
* The whole body is guarded, because a throw inside an event handler does not reject the promise it
|
||||
* sits in — it escapes as an uncaught error and leaves the promise pending forever. Two real ways
|
||||
* out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed, and
|
||||
* drawImage/toBlob can throw. A pending promise leaves the caller's "Uploading…" button disabled
|
||||
* and lying until the form is rebuilt on a later arrival at the page. */
|
||||
function _downscale(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
try {
|
||||
const scale = Math.min(1, BG_MAX_SIDE / Math.max(img.width, img.height));
|
||||
const w = Math.max(1, Math.round(img.width * scale));
|
||||
const h = Math.max(1, Math.round(img.height * scale));
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w; cv.height = h;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) throw new Error('no 2d context');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
cv.toBlob((blob) => blob ? resolve(blob) : reject(new Error(MSG_BAD_IMAGE)),
|
||||
'image/jpeg', BG_QUALITY);
|
||||
} catch (e) { reject(new Error(MSG_BAD_IMAGE)); }
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(_('That file is not a readable image.', 'footstrap'))); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
|
||||
* below write the file first and the token second, and the second half can fail on its own (no
|
||||
* `settings` section, a narrowed uci ACL, ubus busy) — the image then sits at mode 0644 and is
|
||||
* served to anyone through the /www symlink, which does not depend on the token, while Remove is
|
||||
* hidden precisely because the token is empty. Roll the file back and report the failure that
|
||||
* started it; a rollback that itself fails is appended, because the admin has to know the file is
|
||||
* there. */
|
||||
function _rollbackUpload(path, cause) {
|
||||
return _removeServed(path).then(
|
||||
() => Promise.reject(cause),
|
||||
() => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
|
||||
+ _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- one upload, two assets ----
|
||||
*
|
||||
* Both wallpapers travel the same road: refuse what should not be sent, turn the picked file into
|
||||
* the bytes that will actually be stored, POST them to cgi-upload, take the md5 `checksum` back as
|
||||
* the cache-bust token, make the file servable, write the token to uci, and only then paint it.
|
||||
* Every step of that was written out twice, and the two copies had already drifted — one quoted
|
||||
* the url() it wrote with `"` and the other with `'`.
|
||||
*
|
||||
* What genuinely differs is one function: what `prepare` hands back to be uploaded. The SVG is read
|
||||
* as text and inspected, because an SVG is a document and the check has to see the parsed tree; the
|
||||
* photo is redrawn on a canvas, which both bounds it and drops EXIF, because a raster has nothing
|
||||
* to inspect. Everything either side of that is the same road.
|
||||
*
|
||||
* `rollback` is the reason the order matters. The bytes land before the token does, and the second
|
||||
* half can fail on its own — no `settings` section, a narrowed uci ACL, ubus busy — leaving a file
|
||||
* at 0644 served through the /www symlink while Remove stays hidden, because Remove keys off the
|
||||
* token being non-empty. So a failure after the write takes the file away again. */
|
||||
function assetAxis(o) {
|
||||
const upload = (file) => Promise.resolve()
|
||||
.then(() => o.prepare(file))
|
||||
.then((blob) => {
|
||||
const fd = new FormData();
|
||||
fd.append('sessionid', rpc.getSessionID());
|
||||
fd.append('filename', o.path);
|
||||
fd.append('filedata', blob, o.filename);
|
||||
return fetch(L.env.cgi_base + '/cgi-upload',
|
||||
{ method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))));
|
||||
})
|
||||
.then((reply) => {
|
||||
/* cgi-upload answers { name, size, checksum, sha256sum } or { failure: [code, msg] } */
|
||||
if (!reply || reply.failure)
|
||||
return Promise.reject(new Error((reply && reply.failure && reply.failure[1])
|
||||
|| MSG_UPLOAD_FAILED));
|
||||
const tok = String(reply.checksum || '').toLowerCase();
|
||||
if (!axes.tokenOk(tok)) return Promise.reject(new Error(MSG_UPLOAD_FAILED));
|
||||
/* cgi-upload writes 0600 and uhttpd refuses to serve a file that is not world-readable
|
||||
* (0600 -> 403, 0644 -> 200); _chmodServeable checks the command's exit status, not
|
||||
* just the ubus call's */
|
||||
return _chmodServeable(o.path)
|
||||
/* uci gets the token and nothing else: putting a file on the router is not the same
|
||||
* act as making every other device paint it */
|
||||
.then(() => _uciSet('footstrap', 'settings', { [o.field]: tok }))
|
||||
.then(() => _uciCommit('footstrap'))
|
||||
.catch((e) => _rollbackUpload(o.path, e))
|
||||
.then(() => {
|
||||
/* switch this browser onto it: the ordinary axis path, localStorage only */
|
||||
axes.wallpaper.apply(o.wallpaper);
|
||||
o.apply(tok);
|
||||
return tok;
|
||||
});
|
||||
});
|
||||
|
||||
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
|
||||
* set/commit only), clear the url() live. */
|
||||
const remove = () => _removeServed(o.path)
|
||||
.then(() => _uciSet('footstrap', 'settings', { [o.field]: '' }))
|
||||
.then(() => _uciCommit('footstrap'))
|
||||
.then(() => { o.apply(''); });
|
||||
|
||||
return { upload, remove };
|
||||
}
|
||||
|
||||
/* The tile. No canvas step, which is what strips a photo's EXIF: an SVG redrawn to a canvas comes
|
||||
* back a raster, so the parsed-document check above stands in for it. */
|
||||
const PATTERN = assetAxis({
|
||||
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern', wallpaper: 'pattern',
|
||||
apply: (tok) => axes.pattern.apply(tok),
|
||||
prepare: (file) => {
|
||||
if (!file) return Promise.reject(new Error(MSG_PICK_SVG));
|
||||
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
|
||||
if (!isSvg) return Promise.reject(new Error(MSG_PICK_SVG));
|
||||
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
|
||||
/* the catch keeps the same message, so what reaches the router is still exactly the bytes
|
||||
* that were checked */
|
||||
const readErr = () => Promise.reject(new Error(_('That file could not be read.', 'footstrap')));
|
||||
return file.text().catch(readErr).then((text) => {
|
||||
const cleaned = _sanitizeSvg(text);
|
||||
if (cleaned.error) return Promise.reject(new Error(cleaned.error));
|
||||
/* Told once, here, regardless of whether the upload that follows succeeds — what left
|
||||
* the file is true independently of the network call, and `prepare()` has no channel
|
||||
* back to the caller past the Blob it returns. `ui.addNotification` is the stock banner
|
||||
* every other runtime notice in the theme already uses (fs-router.js). */
|
||||
if (cleaned.elements || cleaned.refs)
|
||||
ui.addNotification(null, E('p', {}, [ MSG_SANITIZED.format(cleaned.elements, cleaned.refs) ]), 'info');
|
||||
return new Blob([ cleaned.text ], { type: 'image/svg+xml' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* The photo. cgi-upload is the endpoint L.ui.uploadFile uses — session in the `sessionid` field,
|
||||
* path in `filename`, bytes in `filedata` — and it authorises the write against the ACL's `file`
|
||||
* grant for BG_PATH. */
|
||||
const LOGIN_BG = assetAxis({
|
||||
path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file',
|
||||
apply: (tok) => axes.loginBg.apply(tok),
|
||||
prepare: (file) => {
|
||||
if (!file || !(/^image\//).test(file.type || ''))
|
||||
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
|
||||
if (file.size > BG_SRC_MAX)
|
||||
return Promise.reject(new Error(_('That image is too large.', 'footstrap')));
|
||||
return _downscale(file);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return baseclass.extend({
|
||||
uploadPattern: PATTERN.upload,
|
||||
removePattern: PATTERN.remove,
|
||||
uploadLoginBg: LOGIN_BG.upload,
|
||||
removeLoginBg: LOGIN_BG.remove,
|
||||
_sanitizeSvg, /* fs:probe */
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-fit as fit';
|
||||
|
||||
/* fs-axes — the nineteen Appearance axes and the Save-as-default machinery.
|
||||
*
|
||||
* Split out of fs-prefs.js for one reason: WHERE it is needed. `fs-prefs` is required by the
|
||||
* chrome, the menu and the search palette, so it is fetched on every admin page — and of its
|
||||
* sixty-one exports the cold path called eight. Everything here is reached only from
|
||||
* `fs-appearance` (the form) and `fs-assets` (the two uploads), both page modules, so the router
|
||||
* fetches it on the Appearance tab and nowhere else. Measured: 6.6 KB off what every page
|
||||
* downloads.
|
||||
*
|
||||
* What stayed behind in `fs-prefs`, and why:
|
||||
* - the localStorage wrappers and `sd()`, which everything here calls through `prefs.`
|
||||
* - dark mode, because `guardDarkStamp` defends against a third-party app on every page and
|
||||
* `tools/chrome-fence.mjs` holds `stampDark()` to that file by path
|
||||
* - layout, density, rail and auto-collapse, because the chrome and the menu apply them live —
|
||||
* and because `tools/scroll-anchor.mjs` and `tools/scroll-jank.mjs` stamp layout and density
|
||||
* through `L.require('fs-prefs')` to sweep their matrix
|
||||
*
|
||||
* The pre-paint in head.ut has already stamped every axis before the first frame, so nothing here
|
||||
* is needed to PAINT a page correctly — only to change one from the form. tools/axes.mjs reads the
|
||||
* whole resources directory rather than a path, precisely so an axis may live in a second file. */
|
||||
|
||||
const FS_RADIUS_DEFAULT = 12;
|
||||
|
||||
const FS_HEX_RE = /^#[0-9a-f]{6}$/i;
|
||||
/* 0 | 1..360 | '#rrggbb', from anything: localStorage (always a string), the router default (a uci
|
||||
* string, or a number from a config written before the axes took colours) or a caller. Anything
|
||||
* unrecognised reads as off, the built-in default. */
|
||||
function normColor(v) {
|
||||
if (typeof v === 'number') return (v >= 1 && v <= 360) ? v : 0;
|
||||
if (typeof v !== 'string') return 0;
|
||||
const s = v.trim();
|
||||
if (FS_HEX_RE.test(s)) return s.toLowerCase();
|
||||
const h = parseInt(s, 10);
|
||||
return (h >= 1 && h <= 360) ? h : 0;
|
||||
}
|
||||
function colorAxis(key, attr, hueProp, colorProp) {
|
||||
/* 'fs-tint' -> 'tint', the window.__fsSD field. Every colour key is one word today, so the
|
||||
* hyphen fold changes nothing; it is here because the failure when one is not would be silent
|
||||
* (see enumAxis). */
|
||||
const sdKey = key.slice(3).replace(/-/g, '_');
|
||||
const def = () => normColor(prefs.sd(sdKey));
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const raw = prefs.lsGet(key);
|
||||
return (raw !== null) ? normColor(raw) : def();
|
||||
},
|
||||
apply(val) {
|
||||
const root = document.documentElement;
|
||||
const v = normColor(val);
|
||||
prefs.lsSet(key, String(v));
|
||||
if (!v) {
|
||||
root.removeAttribute(attr);
|
||||
root.style.removeProperty(hueProp);
|
||||
root.style.removeProperty(colorProp);
|
||||
} else if (typeof v === 'number') {
|
||||
root.style.removeProperty(colorProp);
|
||||
/* the hue first, then the attribute that switches the rotation on: the other
|
||||
* order paints one frame in the previous colour on a fresh load */
|
||||
root.style.setProperty(hueProp, String(v));
|
||||
root.setAttribute(attr, 'hue');
|
||||
} else {
|
||||
root.style.removeProperty(hueProp);
|
||||
root.style.setProperty(colorProp, v);
|
||||
root.setAttribute(attr, 'hex');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* A numeric slider axis that sets an inline custom property and no attribute. Each validates to
|
||||
* [min,max], stores the choice explicitly (including the default, so it overrides a router default)
|
||||
* and removes the property AT the default, so 02-tokens' own value shows through; they differ only
|
||||
* in how the number formats onto the property, which is the one varying argument. The prefs.sd() field
|
||||
* name is passed explicitly because one instance needs a rename rather than a spelling
|
||||
* ('fs-radius' -> rounding), and a factory right for four keys out of five is the trap enumAxis and
|
||||
* colorAxis name above.
|
||||
*
|
||||
* `after`, like listAxis's, is optional and runs once the property is set: every propAxis instance
|
||||
* but Content width only repaints a rule that reads the token, so none of the other five pass it.
|
||||
* Content width moves --fs-content-max, which shellGeometry() (fs-chrome.js) reads to decide
|
||||
* data-narrow, so its applier must re-run the fit or the chrome goes on deciding against the width
|
||||
* the column had a moment ago. */
|
||||
function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) {
|
||||
const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
|
||||
const def = () => { const d = prefs.sd(sdKey); return inRange(d) ? d : dfl; };
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const raw = prefs.lsGet(key);
|
||||
if (raw !== null) { const v = parseInt(raw, 10); return inRange(v) ? v : dfl; }
|
||||
return def();
|
||||
},
|
||||
apply(n) {
|
||||
const root = document.documentElement;
|
||||
const v = Math.max(min, Math.min(max, n | 0));
|
||||
prefs.lsSet(key, String(v));
|
||||
if (v === dfl) root.style.removeProperty(prop);
|
||||
else root.style.setProperty(prop, fmt(v));
|
||||
if (after) after();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Palette: footstrap is the default (bare :root); every other colourway is an opt-in data-palette
|
||||
* value, defined in styles/03-palettes.css.
|
||||
*
|
||||
* Not the enumAxis shape, which has one `on` name and reads every other stored string — including a
|
||||
* real palette — as the default. The array is what VALIDATES a stored value: a name added to the
|
||||
* CSS and not here is one head.ut pre-paints and the live applier then rejects, so the page paints
|
||||
* it and the first touch of any other control takes it away.
|
||||
*
|
||||
* Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
|
||||
* currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
|
||||
|
||||
const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* the non-default values; 'footstrap' = bare :root */
|
||||
const PALETTE = prefs.listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
|
||||
|
||||
/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
|
||||
* recoloured — 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
|
||||
* data-wallpaper carries the value, or is absent for 'off'. Both images are router-side; this axis
|
||||
* only decides whether THIS browser paints one, so a router-wide backdrop comes from
|
||||
* Save-as-default, including the pre-login page.
|
||||
*
|
||||
* The list validates a stored value, so adding one means this line, the head.ut whitelist, the
|
||||
* Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css. A value that is no longer
|
||||
* in the list falls back to 'off'. */
|
||||
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
|
||||
const WALLPAPER = prefs.listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
|
||||
|
||||
/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis —
|
||||
* 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
|
||||
* and no re-render.
|
||||
*
|
||||
* Beyond stamping the attribute it must re-run the measured decisions (fitChrome, fitTables,
|
||||
* fitShell), which were taken against the old metrics: Compact makes more fit and Large less, so
|
||||
* otherwise the bar stays stacked — or stays unstacked and overflows — until the next resize. */
|
||||
|
||||
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
|
||||
|
||||
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
|
||||
* while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
|
||||
* palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
|
||||
* recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
|
||||
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
|
||||
|
||||
/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
|
||||
* separate because they carry separate meanings and every derived tint is a color-mix() of the
|
||||
* role, so each follows its own axis. They are not protected from recolouring: a status colour is
|
||||
* information, and an admin who paints Danger green has said so. What the theme owes them is
|
||||
* readable ink over the fill (03-palettes.css) and the contrast readout beside each field. */
|
||||
/* ---- the surface axes: the sheet the UI is drawn on, rather than the marks on it ----
|
||||
*
|
||||
* The cards, the chrome, the inset controls and the hairlines. Their own factory rather than four
|
||||
* more colorAxis instances, because:
|
||||
*
|
||||
* - there is no hue mode — rotating the hue of a near-white card keeps its chroma (~0.003), so
|
||||
* every angle produces the same white. The Tint axis colours a surface by SETTING a chroma;
|
||||
* - there is no derived ink — what reads on these is --fs-text, a palette token these axes must
|
||||
* not move, so the Appearance page reports the contrast instead;
|
||||
* - they therefore need no attribute: an inline custom property on :root is the whole mechanism,
|
||||
* and every derived token follows because each is a color-mix() of the one this sets. That is
|
||||
* why --fs-bar-bg is a surface of its own — an admin who wants a dark chrome over light cards
|
||||
* has to be able to say so.
|
||||
*
|
||||
* Off is prefs.lsSet('0'), not a deleted key: once a router default exists, clearing means "inherit
|
||||
* it". */
|
||||
|
||||
function surfaceAxis(key, sdKey, prop) {
|
||||
const norm = (v) => {
|
||||
const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
|
||||
return FS_HEX_RE.test(s) ? s : 0;
|
||||
};
|
||||
const def = () => norm(prefs.sd(sdKey));
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const raw = prefs.lsGet(key);
|
||||
return (raw !== null) ? norm(raw) : def();
|
||||
},
|
||||
apply(val) {
|
||||
const v = norm(val);
|
||||
prefs.lsSet(key, String(v));
|
||||
if (v) document.documentElement.style.setProperty(prop, v);
|
||||
else document.documentElement.style.removeProperty(prop);
|
||||
}
|
||||
};
|
||||
}
|
||||
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
|
||||
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
|
||||
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
|
||||
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
|
||||
|
||||
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
|
||||
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
|
||||
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
|
||||
|
||||
/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
|
||||
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
|
||||
|
||||
/* Content width (issue #44): how far the reader lets the column grow past --fs-content-max's own
|
||||
* 1280px. A propAxis like Rounding, pointed at that one token — the range comment lives in
|
||||
* 02-tokens.css, next to the token it bounds, and is not restated here.
|
||||
*
|
||||
* The one propAxis instance that moves chrome geometry: shellGeometry() (fs-chrome.js) reads
|
||||
* --fs-content-max on every fit, so dragging the slider must re-run it through the SAME
|
||||
* fit.schedule() every other geometry-affecting axis calls, or the sidebar goes on folding at the
|
||||
* width the column had before the drag. */
|
||||
const FS_CWIDTH_DEFAULT = 1280;
|
||||
const CWIDTH = propAxis('fs-content-width', 'content_width', '--fs-content-max', 1280, 3840,
|
||||
FS_CWIDTH_DEFAULT, (v) => (v + 'px'), () => fit.schedule());
|
||||
|
||||
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer —
|
||||
* CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
|
||||
* observes the attribute and folds the accordion into dropdowns or restores it.
|
||||
*
|
||||
* Read the ATTRIBUTE, not localStorage: head.ut stamps it server-side from the router default and
|
||||
* the pre-paint script overrides it, so it always carries an explicit value. localStorage would
|
||||
* report 'sidebar' on a router defaulting to 'top' until the user first touched the toggle. */
|
||||
|
||||
const AXIS_KEYS = [
|
||||
'fs-layout', 'fs-darkmode', 'fs-palette', 'fs-wallpaper', 'fs-tint',
|
||||
'fs-accent', 'fs-good', 'fs-warn', 'fs-danger', 'fs-card', 'fs-control',
|
||||
'fs-bar', 'fs-line', 'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength',
|
||||
'fs-density', 'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength',
|
||||
'fs-pattern-ink', 'fs-content-width'
|
||||
];
|
||||
/* Tint strength: a multiplier on the tint chroma (03-palettes.css), 100% being the designed
|
||||
* strength and 200% the cap. 0 is not quite "no tint" — the relative colour that applies the tint
|
||||
* replaces chroma outright, so 0 leaves a neutral canvas at the same lightness rather than the
|
||||
* untinted one; clearing the Tint hue is the real off. It only bites while a Tint hue is set, and
|
||||
* is moot under the File wallpaper, where the photo covers the canvas.
|
||||
*
|
||||
* This axis and its default live above _resolvedDefault()'s module-init call below: a propAxis
|
||||
* instance is a `const`, so declaring it further down leaves it in the TDZ at init and the whole
|
||||
* module throws, taking the chrome and the menu with it. */
|
||||
const FS_TSTR_DEFAULT = 100;
|
||||
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
|
||||
|
||||
/* Photo dim: the scrim opacity over the FILE photo (0–100%). The photo is shared; how strongly
|
||||
* this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
|
||||
* the wallpaper is 'file'. Declared up here for the TDZ reason above. */
|
||||
const FS_PDIM_DEFAULT = 74;
|
||||
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
|
||||
|
||||
/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
|
||||
* wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
|
||||
*
|
||||
* Size is the tile's edge in px, with a wide range because "how big is one repeat" is a property of
|
||||
* the artwork. Strength is the layer's opacity 0-100, which is the knob a `<g opacity>` baked into
|
||||
* the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
|
||||
const FS_PSIZE_DEFAULT = 440;
|
||||
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
|
||||
const FS_PSTR_DEFAULT = 20;
|
||||
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
|
||||
/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
|
||||
* mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
|
||||
const PINK = prefs.enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
|
||||
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
|
||||
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
|
||||
* the resolved value — measured on the router, a per-config ACL refusal resolves with 6
|
||||
* (permission denied) and every `.then()` below runs as if the file had been written, greying the
|
||||
* Save button over a write that never happened. */
|
||||
|
||||
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
|
||||
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
|
||||
|
||||
function snapshotAxes() {
|
||||
return {
|
||||
layout: prefs.currentLayout(),
|
||||
darkmode: prefs.currentMode(),
|
||||
palette: PALETTE.current(),
|
||||
wallpaper: WALLPAPER.current(),
|
||||
tint: String(TINT.current()),
|
||||
accent: String(ACCENT.current()),
|
||||
good: String(GOOD.current()),
|
||||
warn: String(WARN.current()),
|
||||
danger: String(DANGER.current()),
|
||||
card: String(CARD.current()),
|
||||
control: String(CONTROL.current()),
|
||||
bar: String(BAR.current()),
|
||||
line: String(LINE.current()),
|
||||
rounding: String(RADIUS.current()),
|
||||
autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
|
||||
tint_strength: String(TSTR.current()),
|
||||
density: prefs.currentDensity(),
|
||||
photo_dim: String(PDIM.current()),
|
||||
pattern_size: String(PSIZE.current()),
|
||||
pattern_strength: String(PSTR.current()),
|
||||
pattern_ink: PINK.current(),
|
||||
content_width: String(CWIDTH.current())
|
||||
};
|
||||
}
|
||||
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
|
||||
* form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
|
||||
* Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
|
||||
* match without a reload.
|
||||
*
|
||||
* Every field is the axis's own def(): a second copy of a validation drifts with no symptom beyond
|
||||
* matchesSavedDefault() lying, which is the one thing the Save button is. `layout` is the exception,
|
||||
* since prefs.currentLayout() reads the attribute — its fallback must stay `top`, matching head.ut's
|
||||
* stamp and resetToBuiltin(), or a fresh install shows dirty before anything is touched and
|
||||
* resetToSaved() lands on the wrong layout. */
|
||||
function _resolvedDefault() {
|
||||
return {
|
||||
layout: prefs.sd('layout') || 'top',
|
||||
darkmode: prefs.modeDefault(),
|
||||
palette: PALETTE.def(),
|
||||
wallpaper: WALLPAPER.def(),
|
||||
tint: String(TINT.def()),
|
||||
accent: String(ACCENT.def()),
|
||||
good: String(GOOD.def()),
|
||||
warn: String(WARN.def()),
|
||||
danger: String(DANGER.def()),
|
||||
card: String(CARD.def()),
|
||||
control: String(CONTROL.def()),
|
||||
bar: String(BAR.def()),
|
||||
line: String(LINE.def()),
|
||||
rounding: String(RADIUS.def()),
|
||||
autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
|
||||
tint_strength: String(TSTR.def()),
|
||||
density: prefs.densityDefault(),
|
||||
photo_dim: String(PDIM.def()),
|
||||
pattern_size: String(PSIZE.def()),
|
||||
pattern_strength: String(PSTR.def()),
|
||||
pattern_ink: PINK.def(),
|
||||
content_width: String(CWIDTH.def())
|
||||
};
|
||||
}
|
||||
let _savedDefault = _resolvedDefault();
|
||||
function matchesSavedDefault() {
|
||||
const cur = snapshotAxes();
|
||||
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
|
||||
}
|
||||
|
||||
/* ---- no axis reaches /etc/config/footstrap except through Save-as-default ----
|
||||
* Every axis is per-browser. An axis that wrote through on change — on the argument that the photo
|
||||
* it relates to is router-side — re-pointed the router-wide default for every other device from one
|
||||
* browser, and moved the Save baseline with it, so the button did not even light up. A per-browser
|
||||
* preference must never mutate shared state invisibly.
|
||||
*
|
||||
* Only the photo's bytes and its cache-bust token are router-side. Whether a browser paints it is
|
||||
* `fs-wallpaper` and how dim is `fs-photo-dim`: ordinary axes, saved with the rest or not at
|
||||
* all. */
|
||||
function saveAsDefault() {
|
||||
const snap = snapshotAxes();
|
||||
return _uciSet('footstrap', 'settings', snap)
|
||||
.then(() => _uciCommit('footstrap'))
|
||||
.then(() => { _savedDefault = snap; });
|
||||
}
|
||||
/* ---- the two resets, which are not the same escape hatch ----
|
||||
*
|
||||
* Both drop this browser's tweaks and differ in what is underneath:
|
||||
*
|
||||
* resetToSaved() clears the keys, so every axis falls back to the router default where one is
|
||||
* set and to the built-in where it is not. The browser goes back to inheriting.
|
||||
* resetToBuiltin() writes the theme's own defaults explicitly, the only way to say "as the theme
|
||||
* ships" — clearing the keys is the sentence that means "inherit the router
|
||||
* default".
|
||||
*
|
||||
* Both leave /etc/config/footstrap alone: neither un-saves a router default.
|
||||
*
|
||||
* The caller reloads so head.ut re-applies everything in one pass — the appliers repaint correctly,
|
||||
* but the controls on the page were built from the values they had at render time. */
|
||||
function resetToSaved() {
|
||||
AXIS_KEYS.forEach(prefs.lsDel);
|
||||
}
|
||||
|
||||
/* The built-in defaults, written through the ordinary appliers so each validates its own value and
|
||||
* stamps :root as usual. Stated rather than derived: a default is a default because it is what a
|
||||
* bare :root paints, and the five with a named const use it, so the numbers cannot drift from the
|
||||
* CSS. */
|
||||
function resetToBuiltin() {
|
||||
/* `top` for layout, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci
|
||||
* says nothing), so it is what "as the theme ships" means. The colour and surface axes reset to
|
||||
* 0, which is "the palette's own".
|
||||
*
|
||||
* Stated as calls rather than carried in AXES: a fourth column of thunks measured 297 B against
|
||||
* this list, and a wrong value here is what the Save button's "Reset to built-in" shows on the
|
||||
* first click — the one state no static gate can see and the live check does. */
|
||||
prefs.applyLayout('top');
|
||||
prefs.applyMode('auto');
|
||||
PALETTE.apply('footstrap');
|
||||
WALLPAPER.apply('off');
|
||||
TINT.apply(0);
|
||||
ACCENT.apply(0);
|
||||
GOOD.apply(0);
|
||||
WARN.apply(0);
|
||||
DANGER.apply(0);
|
||||
CARD.apply(0);
|
||||
CONTROL.apply(0);
|
||||
BAR.apply(0);
|
||||
LINE.apply(0);
|
||||
RADIUS.apply(FS_RADIUS_DEFAULT);
|
||||
prefs.applyAutoCollapse('off');
|
||||
TSTR.apply(FS_TSTR_DEFAULT);
|
||||
prefs.applyDensity('normal');
|
||||
PDIM.apply(FS_PDIM_DEFAULT);
|
||||
PSIZE.apply(FS_PSIZE_DEFAULT);
|
||||
PSTR.apply(FS_PSTR_DEFAULT);
|
||||
PINK.apply('theme');
|
||||
CWIDTH.apply(FS_CWIDTH_DEFAULT);
|
||||
}
|
||||
|
||||
/* ---- the two uploaded wallpapers, browser side ----
|
||||
*
|
||||
* What the router last saved, what URL that is, and how to paint it. Putting the file THERE is
|
||||
* fs-assets.js: a DOMParser pass, a canvas re-encode, a chmod and a rollback, reached only from the
|
||||
* Appearance tab and so not worth downloading on every admin page. The token accessors stay here
|
||||
* because `prefs.sd()` is private to this module and because head.ut's pre-paint reads the same fields.
|
||||
*
|
||||
* Neither is an axis: an axis is per-browser with a router default, and these have no browser
|
||||
* layer — one admin uploads once and every device sees it, pre-login included. So they are absent
|
||||
|
||||
* from AXIS_KEYS, snapshotAxes() and matchesSavedDefault(), and must not move the Save button. */
|
||||
/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
|
||||
* and the pre-paint inline script keep their own identical copies unavoidably, running before this
|
||||
* module — see the axes contract in head.ut. */
|
||||
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
|
||||
/* the same question asked from fs-assets.js, which validates the checksum an upload replies with.
|
||||
* A predicate rather than the pattern itself, so the charset stays stated once. */
|
||||
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
|
||||
|
||||
/* The pattern and the login-bg photo are the same shape twice: a router-saved cache-bust TOKEN,
|
||||
* read back through prefs.sd(), that sets or clears one url() custom property. Neither is an axis
|
||||
* (see the comment above) — this factory is what a bare url() setter shares with a real axis's
|
||||
* apply(), not membership in AXIS_KEYS. `serve` differs for a reason stated at each call below. */
|
||||
function tokenAsset(sdKey, serve, prop) {
|
||||
const current = () => {
|
||||
const t = prefs.sd(sdKey);
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
};
|
||||
const url = (tok) => serve + '?v=' + tok;
|
||||
/* set/clear the url() live; whether it PAINTS is the Wallpaper axis (data-wallpaper="pattern"
|
||||
* or "file"). Exported because fs-assets.js applies the token it just wrote. */
|
||||
const apply = (tok) => {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty(prop, 'url("' + url(tok) + '")');
|
||||
else root.style.removeProperty(prop);
|
||||
prefs.setSD(sdKey, tok || '');
|
||||
};
|
||||
return { current, url, apply };
|
||||
}
|
||||
|
||||
/* NOT a /www symlink, unlike LOGIN_BG's plain static path below: an SVG served as a plain static
|
||||
* file is a same-origin document that runs script opened directly (OpenWrt forum thread 251930).
|
||||
* This CGI handler reads the fixed /etc/footstrap/pattern.svg path itself and answers with CSP
|
||||
* 'none' + sandbox and nosniff, headers uhttpd cannot attach to a static file — see the uci-default
|
||||
* and the handler for the rest of the reasoning. Painting stays exactly as before: a URL is a URL
|
||||
* to a mask-image. */
|
||||
const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '--fs-pattern-url');
|
||||
|
||||
/* Still the plain uhttpd symlink the uci-default makes, unlike PATTERN's CGI handler above:
|
||||
* fs-assets.js re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the
|
||||
* stored bytes are pixels only — there is no script grammar left in a raster for a direct hit to
|
||||
* run. */
|
||||
const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url');
|
||||
|
||||
/* Every axis exported as the OBJECT itself — `{ current, apply, def? }` — not a flat
|
||||
* currentX()/applyX() pair per axis: a caller wanting both already had to carry them as a pair
|
||||
* (fs-appearance.js's colourGroup() takes `{ current, apply }`, built ad hoc from two separate
|
||||
* lookups before this), and a flat name is one more thing every consumer of THIS FILE has to keep
|
||||
* spelled the same way fs-axes.js does — 18 axis names doubled to 36 lookups, now 18. `pattern`
|
||||
* and `loginBg` are not axes (no AXIS_KEYS entry, no router-default `def()`) but the same
|
||||
* `tokenAsset()` shape, so they export the same way. `tokenOk`, `matchesSavedDefault`,
|
||||
* `saveAsDefault`, `resetToSaved` and `resetToBuiltin` stay flat: none of them is a
|
||||
* `{current, apply}` pair. `snapshotAxes` stays module-private: fs-appearance.js, its only would-be
|
||||
* caller, builds the Appearance form from the per-axis exports above, not from the router-default
|
||||
* snapshot matchesSavedDefault()/saveAsDefault() read internally.
|
||||
*
|
||||
* No flat `currentX`/`applyX` fallback kept: grepped `../luci-app-footstrap-*` (the sibling
|
||||
* checkouts) and this repo's docs for either spelling — nothing outside fs-appearance.js and
|
||||
* fs-assets.js, both updated alongside this file, ever called one. */
|
||||
return baseclass.extend({
|
||||
palette: PALETTE,
|
||||
wallpaper: WALLPAPER,
|
||||
tint: TINT,
|
||||
accent: ACCENT,
|
||||
good: GOOD,
|
||||
warn: WARN,
|
||||
danger: DANGER,
|
||||
card: CARD,
|
||||
control: CONTROL,
|
||||
bar: BAR,
|
||||
line: LINE,
|
||||
radius: RADIUS,
|
||||
tintStrength: TSTR,
|
||||
photoDim: PDIM,
|
||||
patternSize: PSIZE,
|
||||
patternStrength: PSTR,
|
||||
patternInk: PINK,
|
||||
contentWidth: CWIDTH,
|
||||
pattern: PATTERN,
|
||||
loginBg: LOGIN_BG,
|
||||
|
||||
tokenOk,
|
||||
|
||||
matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
|
||||
});
|
||||
@@ -0,0 +1,614 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require fs-fit as fit';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-menutree as tree';
|
||||
'require fs-widgets as widgets';
|
||||
|
||||
/* The chrome around the content: the mode menu, the section tabs, the rail toggle and the
|
||||
* measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as
|
||||
* a callback (renderMainMenu), since a required LuCI module is a singleton and a renderer cannot
|
||||
* subclass 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) {
|
||||
/* #tabmenu is emitted whenever this module loads (notices.ut, !blank_page) */
|
||||
const container = document.querySelector('#tabmenu');
|
||||
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, not just the `active` class, which is paint 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;
|
||||
|
||||
container.appendChild(ul);
|
||||
container.style.display = '';
|
||||
|
||||
if (activeNode)
|
||||
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
|
||||
}
|
||||
|
||||
/* ---- 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 (theme/40-tabs.css) trim
|
||||
* padding, then gap+font. Floored so a pill never gets tighter than its label; past the floor the
|
||||
* strip may wrap. */
|
||||
function stripFitsOneRow(ul) {
|
||||
/* only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
|
||||
* reads as "one row" on a strip that has wrapped */
|
||||
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, 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 */
|
||||
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 below forces a reflow per strip, on every tick */
|
||||
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 cannot say that: the cut is 224px expanded and 68px as a rail, so one
|
||||
* breakpoint gives both states the same answer and the rail folds away at the same width as the
|
||||
* full sidebar. Do not measure the RENDERED sidebar either — the answer would depend on the state
|
||||
* it is deciding (as a bar there is no cut, so the content fits, so it un-narrows) and oscillate.
|
||||
*
|
||||
* The widths come from the stylesheet (02-tokens.css) and are never restated here, or narrowing
|
||||
* the rail in CSS leaves this subtracting the old width with no gate able to see it.
|
||||
*
|
||||
* A custom property is untyped, so `parseFloat(getComputedStyle(root).getPropertyValue(…))` reads
|
||||
* `calc(224px * 1)` and returns NaN — silently, since `NaN < NaN` is false and the sidebar simply
|
||||
* never yields. Assign the token to a real length property on a throwaway element and read the
|
||||
* used value back instead. */
|
||||
let _probe = null;
|
||||
/* The probe is a plain <div> in the shared document, so a third-party app's CSS can style it —
|
||||
* it carries no chrome mark and the fence deliberately does not spare it. Every declaration is
|
||||
* therefore !important, which a style-attribute wins outright: an app carrying
|
||||
* `div { min-width: 500px !important }` otherwise wins every read and the sidebar folds into a bar
|
||||
* on a 1857px desktop (issue #19) — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
|
||||
* was reported as a zoom bug: Chrome at 90% gives 2063 CSS px and passes, 100% gives 1857 and
|
||||
* fails. 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. */
|
||||
function resolveLen(token, dflt) {
|
||||
if (!_probe) {
|
||||
_probe = document.createElement('div');
|
||||
/* the id is fs-router.js's strayBodyNode() (issue #56) reading an fs-* id as "ours", which is
|
||||
* a different question from the chrome mark above: this stays unmarked (no data-fs-chrome,
|
||||
* so the fence still does not spare it from a hostile !important) but still needs SOME fs-*
|
||||
* name, or an element appended once and never removed would read as body litter and force
|
||||
* every navigation off this page into a full load. */
|
||||
_probe.id = 'fs-chrome-geom-probe';
|
||||
_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: that is the one thing changing these widths at runtime
|
||||
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() for exactly that).
|
||||
*
|
||||
* The defaults are stated once so the fallbacks and the sanity net below cannot restate the
|
||||
* stylesheet's widths in two places. Reaching for them means the measurement failed. */
|
||||
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56, contentMax: 1280 };
|
||||
/* the class name toggled below by fitShell's own escalation and by fitCluster's (measured: 16 B x3
|
||||
* -> 25 B, 23 B saved) */
|
||||
const CLASS_IND_COMPACT = 'fs-ind-compact';
|
||||
|
||||
let _geom = null, _geomDensity = null, _geomWarned = false;
|
||||
function shellGeometry() {
|
||||
const density = document.documentElement.getAttribute('data-density') || '';
|
||||
/* contentMax moves with data-content-width (02-tokens.css), the other three widths do not, but
|
||||
* they are one memo and the axis's own applier calls fit.schedule() same as Density's — folded
|
||||
* into the same key rather than a second one, or a content-width change alone would hit the
|
||||
* density-only key and go on answering the previous contentMax until density also changed */
|
||||
const cwidth = document.documentElement.getAttribute('data-content-width') || '';
|
||||
const key = density + '|' + cwidth;
|
||||
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
|
||||
if (_geom && _geomDensity === key) return _geom;
|
||||
_geomDensity = key;
|
||||
const g = {
|
||||
contentMin: resolveLen('--fs-content-min', GEOM_DFLT.contentMin),
|
||||
sidebarW: resolveLen('--fs-sidebar-w', GEOM_DFLT.sidebarW),
|
||||
railW: resolveLen('--fs-rail-w', GEOM_DFLT.railW),
|
||||
/* the token is one side's padding; the column loses it twice. It is only the fallback —
|
||||
* measureShell() overwrites this with the gutter the column actually got, which nothing
|
||||
* has measured before the first fitter (and the login page has no `.fs-content`). */
|
||||
contentPad: resolveLen('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
|
||||
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
|
||||
contentMax: resolveLen('--fs-content-max', GEOM_DFLT.contentMax)
|
||||
};
|
||||
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
|
||||
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — 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 shrinks to 0, which is finite, so the per-read fallback
|
||||
* never fires). Only the relation between the numbers gives either away. */
|
||||
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
|
||||
/* The fallback keeps the chrome laid out, but on built-in literals, so the sidebar folds at a
|
||||
* width nobody chose while the page still looks correct. Say so, or a renamed token ships
|
||||
* green. Once per document — this runs on every resize and mutation. */
|
||||
if (!sane && !_geomWarned) {
|
||||
_geomWarned = true;
|
||||
console.error('footstrap: the chrome widths did not read back from the stylesheet (got '
|
||||
+ JSON.stringify(g) + ') — falling back to the built-in defaults. A --fs-* width token was '
|
||||
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
|
||||
}
|
||||
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
|
||||
if (_shellPad != null) _geom.contentPad = _shellPad;
|
||||
return _geom;
|
||||
}
|
||||
|
||||
/* The window's width and the column's gutter are read here, from a fitter or from
|
||||
* `contentWidth()`'s own staleness check below — nowhere else.
|
||||
*
|
||||
* The gutter is measured where it is applied rather than read off `--fs-content-pad`: below 767px
|
||||
* `theme/20-shell.css` re-pads `.fs-content` to `var(--fs-space-4)`, so the token says 28px a side
|
||||
* while the real gutter is 16px. The breakpoint may not be restated here — a width literal in JS is
|
||||
* what these reads exist to avoid — so the element is asked what it actually got.
|
||||
*
|
||||
* `clientWidth` is a layout read and `getComputedStyle` resolves style, so a fitter is the one
|
||||
* caller allowed to reach BOTH unconditionally: it defers the whole pass during a flick
|
||||
* (`fit.scrolling()`, fs-fit.js) rather than pay either mid-scroll. `contentWidth()` reaches only
|
||||
* the first (comparison, not the write) on every call, and this function's own body only when
|
||||
* that comparison says the width moved — its answer is otherwise the geometry as of the last still
|
||||
* moment, exactly what a fitter last measured.
|
||||
*
|
||||
* A hostile declaration is no threat here: `.fs-content` carries no chrome mark, so if an app
|
||||
* re-pads it then that padding IS the column's gutter. Before any fitter has run — the login page
|
||||
* has no `.fs-content` — the token stands in. */
|
||||
let _shellOuter = 0, _shellPad = null, _padAt = null;
|
||||
function measureShell() {
|
||||
/* the window's own width, every time: everything downstream is measured against it */
|
||||
_shellOuter = document.documentElement.clientWidth;
|
||||
/* The gutter is resolved style and this runs on every mutation batch, so it is memoised on the
|
||||
* three things that move it: the width (a media query re-pads the column below 767px), the
|
||||
* density (the token is a calc over it) and the page. The page is the third term because a
|
||||
* foreign sheet may re-pad `.fs-content`, and `sheets.scopeToCurrentPage()` enables and
|
||||
* disables those sheets per navigation with no width or density change to notice it by;
|
||||
* `body[data-page]` is the one attribute a navigation always restamps. */
|
||||
const key = (document.documentElement.getAttribute('data-density') || '') + '|' + _shellOuter +
|
||||
'|' + (document.body ? document.body.getAttribute('data-page') || '' : '');
|
||||
if (_padAt === key && _shellPad != null) return;
|
||||
const host = document.querySelector('.fs-content');
|
||||
const cs = host ? getComputedStyle(host) : null;
|
||||
const v = cs ? parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) : NaN;
|
||||
/* a failed read leaves the key unset, so the next pass retries rather than caching a miss */
|
||||
if (Number.isFinite(v) && v >= 0) {
|
||||
_shellPad = v;
|
||||
_padAt = key;
|
||||
if (_geom) _geom.contentPad = v;
|
||||
}
|
||||
}
|
||||
|
||||
function columnWidth(g, state) {
|
||||
/* narrow OR top: in both the chrome is above the content, not beside it */
|
||||
const cut = (state.narrow || state.top) ? 0 : (state.rail ? g.railW : g.sidebarW);
|
||||
/* The column stops growing: `.fs-content` is `max-width: var(--fs-content-max); margin: 0
|
||||
* auto`, so past ~1500px the surplus becomes margin, and without the cap this answers ~2280 on
|
||||
* a 2560px window for a column that is 1224 wide. No caller can reach that today (both ask a
|
||||
* lower bound), but this is the exported answer to "how wide is the content column". */
|
||||
const room = Math.min(state.outerW - cut, g.contentMax);
|
||||
return Math.max(0, room - g.contentPad);
|
||||
}
|
||||
|
||||
function fitShell() {
|
||||
const root = document.documentElement;
|
||||
/* runs in both branches: the bar layout decides nothing here, but `contentWidth()` still
|
||||
* answers in it */
|
||||
measureShell();
|
||||
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
|
||||
root.removeAttribute('data-narrow');
|
||||
return;
|
||||
}
|
||||
const g = shellGeometry();
|
||||
/* asked uncollapsed: this measurement decides `data-narrow`, so it may not read it */
|
||||
const content = columnWidth(g, { outerW: _shellOuter, rail: prefs.currentRail() });
|
||||
/* toggleAttribute, not setAttribute: a same-value setAttribute still queues a mutation record,
|
||||
* while toggleAttribute on an already-present attribute queues none. fitShell runs on every
|
||||
* mutation batch inside #view, and menu-footstrap treats each data-narrow record as a mode
|
||||
* change, so on a phone (where the attribute is permanently set) every poll tick would re-fire
|
||||
* closeFlyouts() and snap shut the section the user just opened. */
|
||||
root.toggleAttribute('data-narrow', content < g.contentMin);
|
||||
}
|
||||
|
||||
function fitChrome() {
|
||||
/* nothing asked here — bar width, menu width, room beside the brand — can change while the
|
||||
* reader scrolls, and each is a layout read landing mid-flick: defer (fs-fit.js) */
|
||||
if (fit.scrolling()) {
|
||||
fit.deferMeasurement();
|
||||
return;
|
||||
}
|
||||
|
||||
fitShell();
|
||||
|
||||
const bar = document.querySelector('.fs-sidebar');
|
||||
const menu = document.getElementById('topmenu');
|
||||
/* The top bar is measured at every width, with no breakpoint floor: the shrink/compact/stack
|
||||
* escalation below runs at any width. (The sidebar layout has its own phone bar, decided by
|
||||
* fitShell's data-narrow, and is untouched here.) */
|
||||
const topBar = !!bar && !!menu && prefs.isTopLayout();
|
||||
|
||||
/* THE BAR MAY NOT CHANGE HEIGHT WHILE IT IS BEING MEASURED, IN EITHER DIRECTION. The three
|
||||
* classes below are taken off so the menu can be asked whether it fits on one row (fs-fit
|
||||
* rule 1), and while they are off the bar's OWN box is free to answer any height its content
|
||||
* currently needs — not only shorter. A `min-height` floor alone stops the shrink but not the
|
||||
* grow: with the classes off and `fs-dense1`/`fs-dense2` stripped by `fitTabStrips()`, this pass
|
||||
* measured the bar walking 230 -> 202 -> 164 -> 144 -> 123 -> 131 -> 123px against a settled
|
||||
* 123px on owrt2512 at 767px — 107px of growth a floor never sees, on top of the shrink it does
|
||||
* — each step landing between two of the poll's own separate section refreshes, so the browser
|
||||
* paints in between and the reader is moved by exactly as much, on Chromium and Firefox as well
|
||||
* as Safari (`tools/fit-quiet.mjs`, `../tmp/task-toplayout/pass-probe.mjs`). `min-height` alone
|
||||
* was measured to `accc451`'s WebKit-only diagnosis instead — WebKit's own scroll anchoring
|
||||
* looked like the whole story only because it is the one engine with no anchoring at all to hide
|
||||
* this walk behind; Chromium and Firefox absorb it the same way they absorb any other layout
|
||||
* change, which is not the same as not producing it.
|
||||
*
|
||||
* So both `min-height` AND `height` are pinned to the SAME value for the whole decision — a hard
|
||||
* pin, not a floor — because nothing this pass measures (`stripFitsOneRow()`'s `offsetTop`,
|
||||
* `clusterFitsBrandRow()`'s widths) reads the BAR's own height; `overflow: visible`
|
||||
* (`theme/20-shell.css`) means a row the pin is too short for still lays out and measures
|
||||
* correctly, it only paints past the pinned box's edge, which is invisible for the one
|
||||
* synchronous pass before the pin comes off. The pin is released only once the final class set
|
||||
* is decided — after `fitCluster()`, before `publishBarHeight()` — which must measure the bar
|
||||
* the reader actually gets. */
|
||||
const pinned = bar ? Math.round(bar.getBoundingClientRect().height) : 0;
|
||||
const hadMinH = bar ? bar.style.minHeight : '';
|
||||
const hadH = bar ? bar.style.height : '';
|
||||
if (pinned > 0) { bar.style.minHeight = pinned + 'px'; bar.style.height = pinned + 'px'; }
|
||||
|
||||
if (bar) bar.classList.remove('fs-bar-stack', CLASS_IND_COMPACT, 'fs-bar-actrow');
|
||||
fitTabStrips();
|
||||
/* ---- does the main menu fit on the brand's row? ----
|
||||
* It depends on how many sections THIS router has (stock 5, a loaded box 11), not on the
|
||||
* viewport, so it is measured — a `max-width: 1199px` breakpoint stacked it on every laptop.
|
||||
* Measured unstacked (the remove above), because a stacked menu owns a whole row, would "fit"
|
||||
* and flip straight back.
|
||||
*
|
||||
* The menu's own pills wrapping is the "does not fit" signal, and only works because the
|
||||
* unstacked top bar is flex-wrap: nowrap (50-toplayout.css) — otherwise the bar wraps, hands
|
||||
* the menu a row and it always fits. Do not measure the bar's children by offsetTop instead:
|
||||
* the bar is align-items:center over children of differing heights, so offsetTop differs even
|
||||
* on one row. */
|
||||
if (topBar && !stripFitsOneRow(menu)) {
|
||||
/* first step before stacking: collapse the poll pill (~90px) to an icon square and
|
||||
* re-measure — often enough to keep the menu on the brand's row
|
||||
* (theme/50-toplayout.css) */
|
||||
bar.classList.add(CLASS_IND_COMPACT);
|
||||
fitTabStrips();
|
||||
if (!stripFitsOneRow(menu)) {
|
||||
bar.classList.add('fs-bar-stack');
|
||||
fitTabStrips();
|
||||
}
|
||||
}
|
||||
|
||||
/* the cluster's own escalation, for every bar; after the menu's, so a menu that did not fit
|
||||
* has already been given a row of its own by .fs-bar-stack */
|
||||
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
|
||||
fitCluster(bar, menu);
|
||||
|
||||
if (pinned > 0) { bar.style.minHeight = hadMinH; bar.style.height = hadH; }
|
||||
publishBarHeight(bar);
|
||||
}
|
||||
|
||||
/* ---- how tall the bar actually is, for whoever sticks underneath it ----
|
||||
*
|
||||
* A data table's header row sticks while its rows scroll past (theme/30-tables.css), and where the
|
||||
* document scrolls it has to clear the sticky bar. `--fs-bar-h` is only the bar's DESIGNED height:
|
||||
* it grows when the brand wraps, when the menu takes a row (.fs-bar-stack) or when the cluster does
|
||||
* (.fs-bar-actrow), each decided by measurement above. So the measurement is published and the CSS
|
||||
* falls back to the token.
|
||||
*
|
||||
* Written only on a change and rounded to the pixel: this runs on every fit pass, and a custom
|
||||
* property write on :root invalidates style for the whole document. */
|
||||
let _barH = 0;
|
||||
function publishBarHeight(bar) {
|
||||
const root = document.documentElement;
|
||||
if (!bar) return;
|
||||
const h = Math.round(bar.getBoundingClientRect().height);
|
||||
if (!(h > 0) || h === _barH) return;
|
||||
_barH = h;
|
||||
root.style.setProperty('--fs-bar-live', `${h}px`);
|
||||
}
|
||||
|
||||
/* ---- does the right-hand cluster still fit beside the brand? ----
|
||||
*
|
||||
* The cluster is four siblings (indicators, Search, Appearance, Log out), which flexbox wraps one
|
||||
* at a time: on a narrow bar Log out alone drops to a second row, left-aligned under the hostname.
|
||||
* Either the whole cluster shares the brand's row or it takes a row of its own, right-aligned.
|
||||
*
|
||||
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose), then give the
|
||||
* cluster a row. .fs-ind-compact may already be set by the menu's escalation above and 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(CLASS_IND_COMPACT);
|
||||
if (clusterFitsBrandRow(bar, menu))
|
||||
return;
|
||||
|
||||
bar.classList.add('fs-bar-actrow');
|
||||
}
|
||||
|
||||
/* Add the widths up rather than read positions, for the offsetTop reason above. The menu is
|
||||
* excluded either way: on a row of its own (`ul.nav { flex: 1 1 100% }`) it is not competing, and
|
||||
* where it shares the brand's row it is the child that shrinks (`flex: 1 1 auto`), so counting its
|
||||
* current width would report the cluster as not fitting whenever the menu happened to be wide. */
|
||||
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 */
|
||||
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 (docs/conventions.md). A view
|
||||
* renders its .cbi-tabmenu into #view, which fs-fit's MutationObserver already watches and re-fits
|
||||
* synchronously. #tabmenu is a sibling of #view, but nothing writes it except renderChrome(),
|
||||
* which schedules a fit itself. */
|
||||
|
||||
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
|
||||
function renderModeMenu(node, renderMainMenu) {
|
||||
/* #modemenu is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
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;
|
||||
|
||||
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
|
||||
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
|
||||
]));
|
||||
|
||||
if (isActive)
|
||||
renderMainMenu(child, child.name);
|
||||
});
|
||||
|
||||
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();
|
||||
/* #modemenu/#topmenu/#tabmenu are emitted whenever this module loads (header.ut/notices.ut,
|
||||
* !blank_page) */
|
||||
const modemenu = document.querySelector('#modemenu');
|
||||
const topmenu = document.querySelector('#topmenu');
|
||||
const tabmenu = document.querySelector('#tabmenu');
|
||||
|
||||
modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single');
|
||||
topmenu.innerHTML = '';
|
||||
tabmenu.innerHTML = ''; tabmenu.style.display = 'none';
|
||||
|
||||
renderModeMenu(root, _renderMain);
|
||||
|
||||
if (L.env.dispatchpath.length >= 3) {
|
||||
let node = root, url = '';
|
||||
/* `node.children &&`, as fs-menutree's nodeForSegs() walks it: a childless node is an
|
||||
* ordinary leaf, and reading `.children[…]` off one throws out of renderChrome(), taking
|
||||
* the mode menu, the tabs and everything init wires after it */
|
||||
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 is
|
||||
* CSS keyed off that attribute. */
|
||||
function wireRail() {
|
||||
/* #fs-rail-toggle is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
const btn = document.getElementById('fs-rail-toggle');
|
||||
|
||||
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 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 meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
|
||||
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge (issue #14). CSS
|
||||
* squares the pill there and draws this attribute instead of the label — a text node cannot be
|
||||
* reached by a selector, so the badge is lifted into an attribute here.
|
||||
*
|
||||
* The count is the only part that changes and the only part readable at that size. A pill with no
|
||||
* trailing number falls back to a neutral dot; clipping the prose instead renders as garbage
|
||||
* ("up pen"), because a centred pill gives an ellipsis no start to anchor to. The full prose
|
||||
* stays in the label for screen readers, and in `title` for the pointer. */
|
||||
const IND_DOT = '•';
|
||||
|
||||
/* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare
|
||||
* span: no role, name or tabindex, so Tab skips it and a screen reader
|
||||
* announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own
|
||||
* click handler already lives on this exact element — this only adds the second, W3C-APG way to
|
||||
* reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"),
|
||||
* never invented. widgets.wireActivate() calls el.click() because a <span>, unlike an <a>, gets
|
||||
* neither Enter nor Space for free; calling click() cannot double-fire the mouse handler, since a
|
||||
* keydown is not a click. */
|
||||
function wireIndicatorKeyboard(el) {
|
||||
if (el.dataset.fsWired) return;
|
||||
el.dataset.fsWired = '1';
|
||||
widgets.wireActivate(el, () => el.click());
|
||||
}
|
||||
|
||||
function wireIndicatorCounts() {
|
||||
/* #indicators is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
const box = document.getElementById('indicators');
|
||||
|
||||
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);
|
||||
/* Only a CLICKABLE indicator becomes a control. `ui.showIndicator` sets
|
||||
* `data-clickable` only when it was handed a handler, and a status-only pill is
|
||||
* given none (luci-base ui.js; `battstatus.js` calls it with a null handler) — a
|
||||
* `role="button"` tabstop with nothing behind it is worse than the bare span it
|
||||
* replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to
|
||||
* `[data-clickable]` for the same reason; the two must not disagree. */
|
||||
if (el.hasAttribute('data-clickable')) {
|
||||
widgets.syncAttr(el, 'role', 'button');
|
||||
widgets.syncAttr(el, 'tabindex', '0');
|
||||
widgets.syncAttr(el, 'aria-label', txt.trim() || null);
|
||||
wireIndicatorKeyboard(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ui.showIndicator replaces the label's text node on an update and appends the span on the
|
||||
* first change of the session, so childList, subtree and characterData all matter. Our own
|
||||
* attribute writes do not re-enter: attributes are not observed.
|
||||
*
|
||||
* AND THE BAR IS RE-FITTED HERE, because a pill arriving is a layout change the fit engine
|
||||
* cannot see: fs-fit watches `#view` and the dialog, and `#indicators` is in the chrome. The
|
||||
* cluster then wraps — flexbox answers first — and the compact form only follows when something
|
||||
* else happens to wake the pass. Measured on WebKit at 390px in the narrow sidebar bar, a second
|
||||
* indicator beside the poll pill pushed the whole page down 91px and held it there for 708 ms,
|
||||
* until `fs-ind-compact` landed at 771 ms and it snapped back: a lurch down and up, once per
|
||||
* appearance, which is what "the Overview twitches, as if an invisible loading bar came and
|
||||
* went" is from the reader's side. Called from the same callback as `stamp()`, so the decision
|
||||
* is taken in the microtask before paint and the wrapped frame is never drawn. It cannot
|
||||
* re-enter: `fitChrome()` writes classes on the BAR and attributes here, and neither is what
|
||||
* this observer watches.
|
||||
*
|
||||
* ON A PILL ARRIVING OR LEAVING, not on its text: `ui.showIndicator` rewrites the poll pill's
|
||||
* label on every tick, and a fit is a handful of forced layouts — running one per tick to answer
|
||||
* a label that did not change width is the cost this file spends its measurements avoiding. A
|
||||
* childList record is the cluster gaining or losing a member, which is the layout change that
|
||||
* wraps it. */
|
||||
new MutationObserver((recs) => {
|
||||
stamp();
|
||||
if (recs.some((r) => r.type === 'childList' && r.target === box)) fitChrome();
|
||||
})
|
||||
.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 fit rides the same engine as the
|
||||
* data tables' */
|
||||
fitChrome,
|
||||
|
||||
/* The width a page's content column has: the sidebar or rail eats a known amount of the
|
||||
* window and the shell adds a known padding, all memoised for fitShell(). Exported because a
|
||||
* pass answering mid-scroll (fs-select's, for a table the poll just brought in) otherwise has
|
||||
* only the window width, which in the sidebar layout is wrong by exactly the sidebar — at
|
||||
* 800px the column is 520px, so a table judged to have room overflows.
|
||||
*
|
||||
* The arithmetic is columnWidth()'s; this only adds the page's current state. */
|
||||
contentWidth() {
|
||||
/* `_shellOuter` is refreshed only by measureShell(), which only fitShell() calls, and
|
||||
* fitChrome() steps ASIDE FOR THE WHOLE SCROLL_IDLE WINDOW (400ms, fs-fit.js) whenever
|
||||
* fit.scrolling() answers yes — including for a resize that lands mid-flick, since a
|
||||
* resize is exactly what starts that window (fs-fit.js's resize observer feeds the same
|
||||
* motion sampler `scrolling()` reads). A caller landing in that window, most of all
|
||||
* fs-select's, got the width the PREVIOUS viewport had: at 568px settling to 390px, model
|
||||
* stayed 568 for up to 220ms of the 400 (measured: −178px, exactly 568−390;
|
||||
* ../tmp/task-vnstat/probe2.mjs, probe3.mjs; live-audit's `geometry|fs-content` finding on
|
||||
* owrt2410, CI run 34364446910).
|
||||
*
|
||||
* So the window's own width is compared fresh on every call, not only when `_shellOuter`
|
||||
* is still zero. `clientWidth` is the one read the old "no layout read" promise here was
|
||||
* already conditional on: the bootstrap branch (no fitter has run yet) made this exact
|
||||
* call through measureShell(). A plain `!==` compares against the cached width for free —
|
||||
* nothing invalidated layout since the last read, so this costs nothing when nothing
|
||||
* moved — and measureShell()'s own further reads (the resolved gutter) only run when the
|
||||
* comparison says the width actually did. */
|
||||
if (document.documentElement.clientWidth !== _shellOuter) measureShell();
|
||||
const root = document.documentElement;
|
||||
return columnWidth(shellGeometry(), {
|
||||
outerW: _shellOuter,
|
||||
narrow: root.hasAttribute('data-narrow'),
|
||||
top: prefs.isTopLayout(),
|
||||
rail: prefs.currentRail()
|
||||
});
|
||||
},
|
||||
|
||||
/* exported for tests/chrome-geometry.test.mjs (no tests ship in the package): driving the
|
||||
* arithmetic directly is the only way to cover every combination of layout, rail and width
|
||||
* without a browser */
|
||||
columnWidth, /* fs:probe */
|
||||
wireRail
|
||||
});
|
||||
@@ -0,0 +1,691 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
|
||||
/* fs-fit — the theme's one "does it still fit?" engine and the one place the reader's scroll
|
||||
* position is corrected. Add fit logic here, never a second observer: no CSS query can ask what the
|
||||
* content needs (media = viewport, container = container). Three rules, each a bug that was hit:
|
||||
* rule 1, measure uncollapsed, since a collapsed thing always fits; rule 2, re-fit synchronously on
|
||||
* a mutation, since deferring a poll tick to rAF paints one frame of a stacked table at full width
|
||||
* (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. The reader's
|
||||
* place is docs/anchoring.md, "The corrections"; the measurement behind each rule is
|
||||
* docs/anchoring-log.md. */
|
||||
|
||||
/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
|
||||
* without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */
|
||||
function armGate() {
|
||||
if (!fittersEnabled()) return;
|
||||
/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: tools/table-contract.mjs
|
||||
* reads this file for exactly that spelling to prove the gate rule is still armed, and an
|
||||
* indirection hides the write from it. The behaviour survives being factored out; the
|
||||
* contract does not. */
|
||||
try { document.documentElement.dataset.fsFit = '1'; }
|
||||
catch (e) { /* no document, no flag to write */ }
|
||||
}
|
||||
|
||||
const _fitters = [];
|
||||
let _rafPending = false;
|
||||
let _ro = null, _mo = null, _moFlag = null, _moTabs = null;
|
||||
|
||||
function runAll(list, what) {
|
||||
for (const fit of list) {
|
||||
try { fit(); }
|
||||
catch (e) { console.error('fs-fit: a ' + what + ' threw', e); }
|
||||
}
|
||||
}
|
||||
|
||||
/* dev switch — docs/anchoring.md, "The dev switches" */
|
||||
function fittersEnabled() {
|
||||
try { return localStorage.getItem('fsFit') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
function run(records) {
|
||||
if (!fittersEnabled()) return;
|
||||
runAll(_fitters, 'fitter');
|
||||
holdFloor(records);
|
||||
if (!_anchorPending) rememberRest();
|
||||
}
|
||||
|
||||
/* ---- the document may not get shorter while a tick is in flight ----
|
||||
* A layout taken while `dom.content()` has the container empty clamps the reader's offset into a
|
||||
* document that was never that short. Written before the tick (a pin in the same statement
|
||||
* sequence is never laid out: 1882px still clamped away) and on the containers, never the column,
|
||||
* where it suppresses the engine's own anchoring (css-scroll-anchoring-1 §2.2.2) and cost 120px of
|
||||
* growth, all 120px. The selector is `dom.content()`'s own three: a section body, a table, and a
|
||||
* table's body — the third was missing and a poll emptying it took 58px from under the reader on
|
||||
* 24.10's Overview. docs/anchoring.md, "The document may not get shorter: `holdFloor()`". */
|
||||
const SHRINKS = '.cbi-section > div, .table';
|
||||
|
||||
const FLOORED = '[data-fs-floor]';
|
||||
|
||||
/* One floor per container, cleared and re-measured in one batched pass: not while the reader
|
||||
* scrolls, and not on a table box — `min-height` is undefined there (CSS 2.1 §10.7) and a 313px
|
||||
* floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest (issue #41). */
|
||||
function holdFloor(records) {
|
||||
if (scrolling()) return;
|
||||
const host = document.getElementById('view');
|
||||
if (!host) return; /* the login page has no view */
|
||||
/* boxes stays an array — order feeds the parallel `hs` below and dirty's own .filter() — with a
|
||||
* Set alongside it purely for the O(1) "already have this one" check .indexOf() was doing in
|
||||
* O(n). */
|
||||
const boxes = [], boxSeen = new Set(), hs = [];
|
||||
host.querySelectorAll(SHRINKS).forEach((el) => {
|
||||
let box = el, cs = window.getComputedStyle(el);
|
||||
while (box && box !== host && cs.display.startsWith('table')) {
|
||||
box = box.parentElement;
|
||||
if (box) cs = window.getComputedStyle(box);
|
||||
}
|
||||
if (!box || box === host || boxSeen.has(box)) return;
|
||||
boxSeen.add(box);
|
||||
boxes.push(box);
|
||||
});
|
||||
host.querySelectorAll(FLOORED).forEach((box) => {
|
||||
if (boxSeen.has(box)) return;
|
||||
boxSeen.add(box);
|
||||
boxes.push(box);
|
||||
});
|
||||
|
||||
let dirty = boxes;
|
||||
if (records && records.length) {
|
||||
const targets = new Set();
|
||||
for (const r of records) if (r.target) targets.add(r.target);
|
||||
dirty = boxes.filter((box) => {
|
||||
for (const t of targets) if (box.contains(t) || t.contains(box)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (!dirty.length) return;
|
||||
|
||||
/* The sweep puts back the offset its own clear pass took: 8 of 38 sweeps took the document down a
|
||||
* pixel and the offset with it, losing a 60px correction. docs/anchoring-log.md, "The sweep's own clamp". */
|
||||
const sc = scroller(), page = sc || document.documentElement;
|
||||
const at = scrollTop(), tall = page.scrollHeight;
|
||||
dirty.forEach((box) => { box.style.minHeight = ''; });
|
||||
/* The box's own rect height, not `offsetHeight`'s rounding: 22 floors half a pixel too tall made
|
||||
* the document 2px taller, and the next clear handed that back as a clamp — 6 of 12 refills
|
||||
* corrected against 12 of 12. docs/anchoring-log.md, "The floor is written half a pixel too tall". */
|
||||
dirty.forEach((box) => hs.push(box.getBoundingClientRect().height));
|
||||
dirty.forEach((box, i) => {
|
||||
if (hs[i] > 0) { box.style.minHeight = hs[i] + 'px'; box.setAttribute('data-fs-floor', ''); }
|
||||
else box.removeAttribute('data-fs-floor');
|
||||
});
|
||||
/* And where the scroller stayed shorter the shrink is real: the clamp gave back only the 60px the
|
||||
* document lost at its bottom. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
const landed = scrollTop();
|
||||
if (landed >= at) return;
|
||||
if (page.scrollHeight >= tall) writeOffset(sc, at); else _clampedTo = landed;
|
||||
}
|
||||
|
||||
/* Is the page moving? asked of the position, never of the events: on iOS momentum carries the page
|
||||
* long after the finger has gone, and one offset read per frame sees it. SCROLL_IDLE is how long the
|
||||
* page must hold still before put-off work may run: 137-256px of roughness at 200ms against 59px —
|
||||
* one pixel of rounding per frame, the floor — at 250ms and above.
|
||||
* docs/anchoring.md, "Is the page moving: `scrolling()`". */
|
||||
const SCROLL_IDLE = 400;
|
||||
let _deferred = false;
|
||||
function deferMeasurement() { _deferred = true; }
|
||||
let _movingUntil = 0;
|
||||
let _lastOffset = null;
|
||||
let _sampling = false;
|
||||
/* This file's own write is a scroll event too, and counting it as motion blocked the floor, the
|
||||
* reference and the correction for 400ms — 59px uncorrected, and two of them distrusted a correct
|
||||
* engine. docs/anchoring-log.md, "A correction's own write reads as the reader moving". */
|
||||
let _ownWrite = null;
|
||||
function sawOwnWrite(y) {
|
||||
if (_ownWrite === null) return true;
|
||||
if (Math.abs(y - _ownWrite) < 1) return false;
|
||||
_ownWrite = null;
|
||||
return true;
|
||||
}
|
||||
/* Where the browser's clamp last put the offset, read by `applyAnchor()` alone — `scrolling()`
|
||||
* keeps answering for the whole theme. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
let _clampedTo = null;
|
||||
function sawClamp() {
|
||||
if (scrollTop() === _clampedTo) return true;
|
||||
_clampedTo = null;
|
||||
return false;
|
||||
}
|
||||
function writeOffset(sc, value) {
|
||||
if (sc) sc.scrollTop = value; else window.scrollTo(0, value);
|
||||
_ownWrite = sc ? sc.scrollTop : window.scrollY;
|
||||
}
|
||||
|
||||
/* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the
|
||||
* stylesheet decides it. docs/anchoring-log.md, "Which element scrolls, asked once per width". */
|
||||
let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
|
||||
function layoutKey() {
|
||||
const root = document.documentElement;
|
||||
return (root.getAttribute('data-layout') || '') + (root.hasAttribute('data-narrow') ? '|narrow' : '');
|
||||
}
|
||||
function scroller() {
|
||||
const key = layoutKey();
|
||||
if (_scrollerAt === _resizeSeq && _scrollerKey === key &&
|
||||
(_scroller === null || _scroller.isConnected))
|
||||
return _scroller;
|
||||
const sc = document.getElementById('maincontent');
|
||||
const flow = sc ? window.getComputedStyle(sc).overflowY : '';
|
||||
_scroller = (flow === 'auto' || flow === 'scroll') ? sc : null;
|
||||
_scrollerAt = _resizeSeq;
|
||||
_scrollerKey = key;
|
||||
return _scroller;
|
||||
}
|
||||
function scrollTop() {
|
||||
const sc = scroller();
|
||||
return sc ? sc.scrollTop : window.scrollY;
|
||||
}
|
||||
|
||||
function scrolling() { return Date.now() < _movingUntil; }
|
||||
function sampleMotion() {
|
||||
const y = scrollTop();
|
||||
if (_lastOffset === null || y !== _lastOffset) {
|
||||
const own = !sawOwnWrite(y);
|
||||
_lastOffset = y;
|
||||
if (!own) _movingUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
|
||||
_sampling = false;
|
||||
/* The offset's own response to the write below, not a reference element's drift: one re-established
|
||||
* on wrong ground shows none. docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
const target = _deferredFloor;
|
||||
_deferredFloor = null;
|
||||
const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
|
||||
const offsetBefore = scrollTop();
|
||||
holdFloor();
|
||||
if (target && target.isConnected) {
|
||||
const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
|
||||
if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
|
||||
}
|
||||
rememberRest();
|
||||
if (_deferred) {
|
||||
_deferred = false;
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
function noteMotion() {
|
||||
if (!sawOwnWrite(scrollTop())) return;
|
||||
_movingUntil = Date.now() + SCROLL_IDLE;
|
||||
if (_sampling) return;
|
||||
_sampling = true;
|
||||
requestAnimationFrame(sampleMotion);
|
||||
}
|
||||
|
||||
let _userUntil = 0;
|
||||
function noteIntent() {
|
||||
_userUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
function noteUser() {
|
||||
noteIntent();
|
||||
noteMotion();
|
||||
}
|
||||
|
||||
(function watchMotion() {
|
||||
const opts = { passive: true, capture: true };
|
||||
window.addEventListener('scroll', noteMotion, opts);
|
||||
for (const name of [ 'wheel', 'touchmove' ])
|
||||
window.addEventListener(name, noteUser, opts);
|
||||
for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
|
||||
window.addEventListener(name, noteIntent, opts);
|
||||
})();
|
||||
|
||||
function schedule() {
|
||||
if (_rafPending) return;
|
||||
_rafPending = true;
|
||||
requestAnimationFrame(() => { _rafPending = false; run(); });
|
||||
}
|
||||
|
||||
/* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only
|
||||
* steps rewrote 1054 class attributes. docs/anchoring-log.md, "Width only: the URL bar is a resize". */
|
||||
let _resizeSeq = 0;
|
||||
const _lastWidth = new WeakMap();
|
||||
function onResize(entries) {
|
||||
let widthMoved = false;
|
||||
for (const e of entries) {
|
||||
const w = Math.round(e.contentRect.width);
|
||||
if (_lastWidth.get(e.target) !== w) {
|
||||
_lastWidth.set(e.target, w);
|
||||
widthMoved = true;
|
||||
}
|
||||
}
|
||||
if (widthMoved) { _resizeSeq++; schedule(); }
|
||||
}
|
||||
|
||||
function watch(el) {
|
||||
if (!el) return;
|
||||
if (!_ro) _ro = new ResizeObserver(onResize);
|
||||
_ro.observe(el);
|
||||
}
|
||||
|
||||
/* ---- scroll anchoring, where the engine has none ----
|
||||
* An older WebKit absorbs none of a tick's growth and the page moved on every one, measured on the
|
||||
* reporter's own router: +133px, +134px, +123px, +108px. Computed from the reference, never the
|
||||
* offset: measuring the offset corrects an anchoring engine's own adjustment (16 movements, 1827px).
|
||||
* Whether there is one to leave anything behind is asked of the platform, never of a browser name —
|
||||
* with anchoring suppressed, a 120px growth moves the reader 120px.
|
||||
* docs/anchoring.md, "Who is responsible: `ENGINE_ANCHORS`" and "The corrections". */
|
||||
const ENGINE_ANCHORS = (() => {
|
||||
/* dev switch — docs/anchoring.md, "The dev switches" */
|
||||
try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
|
||||
catch (e) { /* no storage, no switch */ }
|
||||
try { return typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
|
||||
? CSS.supports('overflow-anchor', 'auto') : true; }
|
||||
catch (e) { return true; }
|
||||
})();
|
||||
|
||||
/* A load-time check cannot see an engine that declines on a given refill: two residuals written
|
||||
* back 419-420ms late move the observer to the 7-36ms path until trust returns. Never a browser
|
||||
* name, only a count. docs/anchoring.md, "When the platform check is not enough: `_engineTrusted`". */
|
||||
const LATE_MISS_LIMIT = 2;
|
||||
let _lateMisses = 0;
|
||||
let _engineTrusted = ENGINE_ANCHORS;
|
||||
/* The witness's own rounding on a 32-36 row lease table at 390 wide is 8-12.25px, none of them a
|
||||
* residual. docs/anchoring-log.md, "The witness is not safe to write on its own". */
|
||||
const LATE_ROUND_TOLERANCE = 16;
|
||||
|
||||
/* Trust returns after this many refills where `_rest.el` itself held: the two cheaper witnesses
|
||||
* tried recovered on an engine still 48px wrong. docs/anchoring-log.md, "Trust that comes back". */
|
||||
const TRUST_RECOVERY_LIMIT = 2;
|
||||
let _lateHits = 0;
|
||||
|
||||
let _rest = null;
|
||||
let _restAt = null, _restPage = null;
|
||||
/* The floored box `holdFloor()` refused for: wired to nothing, a growth-then-shrink refill inside
|
||||
* one motion window left `_restAt` 59px high.
|
||||
* docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
let _deferredFloor = null;
|
||||
function pageStamp() {
|
||||
return (document.body && document.body.getAttribute('data-page')) || '';
|
||||
}
|
||||
function forgetRest() {
|
||||
_rest = null;
|
||||
_restAt = null;
|
||||
_restPage = null;
|
||||
}
|
||||
/* `force`: only for the caller that has just written the offset itself, since the engine's own
|
||||
* compensation leaves `scrolling()` true for milliseconds after it — `moved 0, 0, -59` without,
|
||||
* `0, 0, 0` with it. docs/anchoring-log.md, "The reference after this file's own write must be
|
||||
* re-taken forced". */
|
||||
function rememberRest(force) {
|
||||
if (scrolling() && !force) return;
|
||||
if (ENGINE_ANCHORS && scrollTop() <= 0) {
|
||||
_rest = null;
|
||||
_restAt = 0;
|
||||
_restPage = pageStamp();
|
||||
return;
|
||||
}
|
||||
const ref = anchorRef();
|
||||
_restAt = scrollTop();
|
||||
_restPage = pageStamp();
|
||||
_rest = ref ? { el: ref.el, top: ref.top, at: _restAt, sec: ref.sec, secTop: ref.secTop } : null;
|
||||
}
|
||||
|
||||
function anchorFor() {
|
||||
const at = scrollTop();
|
||||
const clamped = (_restAt != null && at < _restAt && !scrolling() && _restPage === pageStamp());
|
||||
if (!_rest || !_rest.el.isConnected) {
|
||||
if (clamped) return { by: _restAt - at };
|
||||
if (_rest && _rest.sec && _rest.sec.isConnected && at === _restAt)
|
||||
return { el: _rest.sec, top: _rest.secTop, slack: 0 };
|
||||
return anchorRef();
|
||||
}
|
||||
if (at !== _rest.at && !clamped) return null;
|
||||
return { el: _rest.el, top: _rest.top, slack: Math.max(0, _rest.at - at) };
|
||||
}
|
||||
|
||||
function anchorRef() {
|
||||
if (scrolling()) return null;
|
||||
|
||||
const host = document.getElementById('view');
|
||||
if (!host) return null;
|
||||
const box = host.getBoundingClientRect();
|
||||
const x = Math.round(box.left + (Math.min(box.width, window.innerWidth || box.width) / 2));
|
||||
let y = 1;
|
||||
let el = document.elementFromPoint(x, y);
|
||||
const chrome = el && el.closest ? el.closest('[data-fs-chrome]') : null;
|
||||
if (chrome) y = Math.max(1, Math.round(chrome.getBoundingClientRect().bottom) + 1);
|
||||
|
||||
const floor = Math.max(1, Math.round(window.innerHeight || 800));
|
||||
const pick = (yy) => {
|
||||
if (typeof document.elementsFromPoint === 'function') {
|
||||
for (const cand of document.elementsFromPoint(x, yy))
|
||||
if (cand !== host && host.contains(cand)) return cand;
|
||||
return null;
|
||||
}
|
||||
const one = document.elementFromPoint(x, yy);
|
||||
return (one && one !== host && host.contains(one)) ? one : null;
|
||||
};
|
||||
el = null;
|
||||
for (let step = 0; step < 5 && !el; step++)
|
||||
el = pick(Math.min(floor - 1, y + (Math.round(floor * 0.12) * step)));
|
||||
if (!el) return null;
|
||||
const table = el.closest('.table.fs-dt');
|
||||
if (table) {
|
||||
const up = table.parentElement;
|
||||
el = (up && up !== host && host.contains(up)) ? up : table;
|
||||
}
|
||||
if (!el || el === host || !host.contains(el)) return null;
|
||||
if (!el.getClientRects().length) return null;
|
||||
let keep = el.parentElement;
|
||||
while (keep && keep !== host && !keep.classList.contains('cbi-section')
|
||||
&& !keep.classList.contains('cbi-map') && !keep.classList.contains('fs-ovl'))
|
||||
keep = keep.parentElement;
|
||||
if (!keep || keep === host || !host.contains(keep)) keep = null;
|
||||
return { el, top: el.getBoundingClientRect().top,
|
||||
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
|
||||
}
|
||||
|
||||
/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below,
|
||||
* the late drift correction further down) — same shape twice: the last reason recorded, and the
|
||||
* last 8 with a timestamp, for tools/scroll-anchor.mjs to read back after a probe run. One factory,
|
||||
* so the cap and the timestamp format cannot drift between the two. */
|
||||
const TRAIL_MAX = 8;
|
||||
function makeTrail() {
|
||||
let last = null;
|
||||
const entries = [];
|
||||
return {
|
||||
record(w) {
|
||||
last = w;
|
||||
entries.push(w + '@' + Math.round(performance.now()));
|
||||
if (entries.length > TRAIL_MAX) entries.shift();
|
||||
},
|
||||
last: () => last,
|
||||
entries: () => entries
|
||||
};
|
||||
}
|
||||
|
||||
let _anchorPending = null;
|
||||
const _anchor = makeTrail();
|
||||
function awhy(w) { _anchor.record(w); }
|
||||
let _anchorFrame = 0;
|
||||
function anchorEnabled() {
|
||||
try { return localStorage.getItem('fsAnchor') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
/* ---- what the engine's own anchoring leaves behind ----
|
||||
* It keeps a reference still; it does not promise that a section can vanish and come back. An
|
||||
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
|
||||
let _lateFrame = 0;
|
||||
const _late = makeTrail();
|
||||
function why(w) { _late.record(w); }
|
||||
|
||||
function lateDrift(ref, grow, floorShrink) {
|
||||
if (_lateFrame) return why('busy');
|
||||
if (!ref) return why('no-reference');
|
||||
why('armed');
|
||||
_lateFrame = requestAnimationFrame(() => {
|
||||
why('frame');
|
||||
const seen = scrollTop();
|
||||
const settle = () => {
|
||||
_lateFrame = 0;
|
||||
why('settle');
|
||||
if (!anchorEnabled()) return why('anchoring-off');
|
||||
if (Date.now() < _userUntil) return why('reader-intent');
|
||||
if (_restPage !== pageStamp()) return why('page-changed');
|
||||
/* The offset, not the event stream: the engine's own compensation starts the motion sampler, and
|
||||
* in WebKit the event arrives up to 1.2s late. `ref.at`, since run() has re-taken `_restAt` and the
|
||||
* comparison becomes a value against itself (320px through, @1440 side). */
|
||||
if (scrollTop() !== seen) return why('offset-moved');
|
||||
let el = ref.el, was = ref.top;
|
||||
if (!el || !el.isConnected) {
|
||||
if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return why('reference-gone');
|
||||
el = ref.sec; was = ref.secTop;
|
||||
}
|
||||
let drift = el.getBoundingClientRect().top - was;
|
||||
/* The witness can be blind: the fold's element need not sit below the container this tick refilled,
|
||||
* 13 of 13 refills reading 0px with the reader 120px off, and a gap under the tolerance is `grow`'s
|
||||
* own rounding. docs/anchoring-log.md, "A witness at the fold can be blind". */
|
||||
if (Math.abs(drift) < 1 && grow > 1) {
|
||||
const compensated = seen - ref.at;
|
||||
if (Math.abs(compensated) < 1) drift = grow - compensated;
|
||||
else if (Math.abs(grow - compensated) > LATE_ROUND_TOLERANCE) {
|
||||
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
|
||||
rememberRest(true);
|
||||
return why('engine-partly-' + Math.round(compensated) + '-of-' + Math.round(grow));
|
||||
}
|
||||
}
|
||||
if (Math.abs(drift) < 1) {
|
||||
if (grow > 1 || floorShrink > 1) rememberRest(true);
|
||||
return why('no-drift-grow-' + Math.round(grow)); /* the engine put it back */
|
||||
}
|
||||
if (Math.abs(drift) > (window.innerHeight || 800)) return why('drift-too-big');
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
writeOffset(sc, at + drift);
|
||||
why('wrote-' + Math.round(drift));
|
||||
/* A fresh, forced rememberRest(): `_rest` was taken mid-transition, and left standing it is what
|
||||
* the next refill measures against. docs/anchoring-log.md, "The reference after this file's
|
||||
* own write must be re-taken forced". */
|
||||
rememberRest(true);
|
||||
/* A drop in this box's own floor says nothing about the engine: every run() rewrites `min-height`,
|
||||
* an invalidation in itself. docs/anchoring-log.md, "A floor shrink is not evidence about the engine". */
|
||||
if (floorShrink > 1) return;
|
||||
/* A direct drift inside the blind branch's tolerance is that same table rounding: 1px against a
|
||||
* 132px growth counted as a miss, twice over.
|
||||
* docs/anchoring-log.md, "One pixel of direct drift is the same table rounding". */
|
||||
if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
|
||||
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
|
||||
};
|
||||
/* How long to wait is a question about the reader (docs/anchoring.md): where nothing is driving,
|
||||
* the engine is the only mover and its window is 7-36ms — 404-422ms became 8-61ms. */
|
||||
if (!scrolling() && Date.now() >= _userUntil) { why('now'); settle(); }
|
||||
else { why(scrolling() ? 'wait-idle-moving' : 'wait-idle-intent'); _lateFrame = window.setTimeout(settle, SCROLL_IDLE); }
|
||||
});
|
||||
}
|
||||
|
||||
/* A floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching: its
|
||||
* own frame slot, and the offset's own response to the write below as the check. Not gated on
|
||||
* `scrolling()` — the belated write is what the engine reacts to, and the reaction re-arms the
|
||||
* motion window, still reading true one rAF later on all three cells.
|
||||
* docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
let _floorLateFrame = 0;
|
||||
function settleDeferredFloor(offsetBefore, shrink) {
|
||||
if (_floorLateFrame) return;
|
||||
_floorLateFrame = requestAnimationFrame(() => {
|
||||
const seen = scrollTop();
|
||||
_floorLateFrame = requestAnimationFrame(() => {
|
||||
_floorLateFrame = 0;
|
||||
if (!anchorEnabled() || Date.now() < _userUntil) return;
|
||||
if (_restPage !== pageStamp()) return;
|
||||
if (scrollTop() !== seen) return; /* still settling, or the reader has moved */
|
||||
const wanted = offsetBefore - shrink;
|
||||
const gap = wanted - seen;
|
||||
if (Math.abs(gap) <= LATE_ROUND_TOLERANCE) return; /* the engine already gave it back */
|
||||
if (Math.abs(gap) > (window.innerHeight || 800)) return;
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
writeOffset(sc, at + gap);
|
||||
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
|
||||
_restAt = scrollTop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAnchor(ref) {
|
||||
if (!ref) return awhy('no-reference');
|
||||
if (!anchorEnabled()) return awhy('anchoring-off');
|
||||
if (_anchorPending) return awhy('pending-kept-first');
|
||||
_anchorPending = ref;
|
||||
if (_anchorFrame) return;
|
||||
_anchorFrame = requestAnimationFrame(() => {
|
||||
_anchorFrame = 0;
|
||||
const pending = _anchorPending;
|
||||
_anchorPending = null;
|
||||
const wrote = applyAnchor(pending);
|
||||
rememberRest(wrote);
|
||||
});
|
||||
}
|
||||
function applyAnchor(ref) {
|
||||
if (!ref) return;
|
||||
/* Not into a moving page — unless the motion IS the clamp this correction is for, which held a
|
||||
* correct -60px correction unwritten. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
if (scrolling() && !sawClamp()) return awhy('refused-moving');
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
if (ref.by != null) {
|
||||
if (ref.by < 1) return awhy('by-under-1');
|
||||
writeOffset(sc, at + ref.by);
|
||||
awhy('wrote-by-' + Math.round(ref.by));
|
||||
return true;
|
||||
}
|
||||
if (at <= 0) return awhy('at-top');
|
||||
if (!ref.el.isConnected) return awhy('reference-gone');
|
||||
const drift = ref.el.getBoundingClientRect().top - ref.top;
|
||||
if (Math.abs(drift) < 1) return awhy('no-drift'); /* nothing needed correcting here */
|
||||
_lateHits = 0;
|
||||
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return awhy('drift-too-big');
|
||||
writeOffset(sc, at + drift);
|
||||
awhy('wrote-' + Math.round(drift));
|
||||
return true;
|
||||
}
|
||||
|
||||
function observeContent() {
|
||||
if (_mo) return;
|
||||
const hosts = [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]
|
||||
.filter(Boolean);
|
||||
const viewHost = hosts[0];
|
||||
_mo = new MutationObserver((records) => {
|
||||
const settled = _rest;
|
||||
const trustEngine = _engineTrusted;
|
||||
const ref = trustEngine ? null : anchorFor();
|
||||
/* The record's own target, read before run() rewrites its floor: the growth in pixels, rather than
|
||||
* whichever element the fold happened to hit, and read on every tick so recovery can tell a tick
|
||||
* that tested the engine from one that did not. The floored box the record sits IN, since
|
||||
* `dom.content()` refills a node inside the pinned box on 1 of 12 nodes (docs/anchoring-log.md,
|
||||
* "A witness that cannot see the box it is measuring"). */
|
||||
let r = null, box = null;
|
||||
for (const m of records) {
|
||||
if (m.type !== 'childList' || !m.target.closest) continue;
|
||||
const b = m.target.closest(FLOORED);
|
||||
if (b) { r = m; box = b; break; }
|
||||
}
|
||||
const before = box && (parseFloat(box.style.minHeight) || 0);
|
||||
let grew = before ? box.offsetHeight - before : 0;
|
||||
/* only growth above the reader's reference: counting a box below it wrote the whole growth back,
|
||||
* offset +120 and reader -120 (docs/anchoring-log.md, "Growth below the reader") */
|
||||
if (grew > 1 && settled && settled.el && settled.el.isConnected
|
||||
&& box.getBoundingClientRect().top >= settled.el.getBoundingClientRect().top) grew = 0;
|
||||
if (!trustEngine && grew > 1 && _rest && _rest.el.isConnected && Date.now() >= _userUntil
|
||||
&& !scrolling() && _restPage === pageStamp()
|
||||
&& Math.abs(_rest.el.getBoundingClientRect().top - _rest.top) < 1
|
||||
&& ++_lateHits >= TRUST_RECOVERY_LIMIT) {
|
||||
_engineTrusted = true;
|
||||
_lateMisses = _lateHits = 0;
|
||||
}
|
||||
const wasScrolling = scrolling();
|
||||
if (r && before && wasScrolling) _deferredFloor = box;
|
||||
run(records);
|
||||
if (!wasScrolling) _deferredFloor = null;
|
||||
const floorShrink = (r && before) ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
|
||||
/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
|
||||
* out and written over a correct restoreScroll(). docs/anchoring.md, "The commit is not a refill". */
|
||||
let gone = 0, came = 0, took = 0, gave = 0;
|
||||
for (const m of records) {
|
||||
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
|
||||
if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
|
||||
}
|
||||
if (gone && came) {
|
||||
forgetRest();
|
||||
return;
|
||||
}
|
||||
/* Not on a batch that only took nodes away: arming on the empty half wrote -834px, and superseding
|
||||
* with the later batch cost 12 findings. docs/anchoring-log.md, "The later batch must not win". */
|
||||
if (trustEngine) {
|
||||
if (took && !gave && grew <= 0 && floorShrink <= 1) why('emptying');
|
||||
else lateDrift(settled, grew, floorShrink);
|
||||
}
|
||||
else scheduleAnchor(ref);
|
||||
});
|
||||
for (const host of hosts) {
|
||||
_mo.observe(host, { childList: true, subtree: true });
|
||||
watch(host);
|
||||
}
|
||||
_moFlag = new MutationObserver(() => run());
|
||||
_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });
|
||||
|
||||
/* A tab switch, a closing fold and a `depends()` row mutate no node, and `min-height` beats the
|
||||
* `height: 0` they collapse with: 1299px of blank left standing (docs/anchoring-log.md, "A tab
|
||||
* switch moves no node"). A same-value write is not a change, though: the fitters re-apply classes
|
||||
* every pass, and an unguarded `class` watch froze the tab, 391 callbacks in 432ms
|
||||
* (docs/anchoring-log.md, "A same-value class write is a feedback loop"). */
|
||||
_moTabs = new MutationObserver((records) =>
|
||||
records.some((r) => r.oldValue !== r.target.getAttribute(r.attributeName)
|
||||
&& (r.attributeName !== 'class' || r.target.dataset.field)) && run());
|
||||
for (const host of hosts)
|
||||
_moTabs.observe(host, { attributes: true, attributeOldValue: true,
|
||||
attributeFilter: [ 'data-tab-active', 'hidden', 'aria-expanded', 'class' ], subtree: true });
|
||||
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
add(fit) {
|
||||
if (typeof fit !== 'function') return;
|
||||
_fitters.push(fit);
|
||||
observeContent();
|
||||
try { fit(); }
|
||||
catch (e) { console.error('fs-fit: a fitter threw on registration', e); }
|
||||
},
|
||||
|
||||
scrolling,
|
||||
/* unmarked, for tools/scroll-anchor.mjs — see `makeTrail()` above */
|
||||
lateWhy: () => _late.last(),
|
||||
lateTrail: () => _late.entries().slice(),
|
||||
anchorWhy: () => _anchor.last(),
|
||||
anchorTrail: () => _anchor.entries().slice(),
|
||||
deferMeasurement,
|
||||
|
||||
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
|
||||
* of the anchor. No probe marker: packaging strips those, and the sweep reads the installed package. */
|
||||
restAt: () => _restAt,
|
||||
|
||||
/* -> whether the engine is still trusted with a refill; unmarked, like `restAt` above */
|
||||
engineTrusted: () => _engineTrusted,
|
||||
|
||||
forgetRest,
|
||||
|
||||
armGate,
|
||||
|
||||
schedule,
|
||||
|
||||
frame(fn) {
|
||||
let pending = false;
|
||||
return () => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
requestAnimationFrame(() => { pending = false; fn(); });
|
||||
};
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
roomFor(el) {
|
||||
const p = el && el.parentElement;
|
||||
if (!p) return Infinity;
|
||||
const cs = getComputedStyle(p);
|
||||
return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
},
|
||||
|
||||
/* The browser's own answer is the whole test — a canvas approximation cost ~1ms per pass and claimed
|
||||
* 144px where the engine's floor is 93 on a 114-row table — and the box's own width is the half
|
||||
* `scrollWidth` cannot see (2px at 1440). */
|
||||
overflows(el) {
|
||||
const room = this.roomFor(el);
|
||||
const grown = el.getBoundingClientRect().width;
|
||||
return Math.max(el.scrollWidth, grown) > room + 1; /* +1: sub-pixel rounding */
|
||||
},
|
||||
|
||||
/* Is somebody else already scrolling this? A 598px `div.resizeable` came out as cards on a 1280px
|
||||
* screen, where the page had 1224px of room. The walk stops at the content root. */
|
||||
inScroller(el) {
|
||||
for (let p = el.parentElement; p && p.id !== 'view' && p.id !== 'modal_overlay'; p = p.parentElement)
|
||||
if ((/(auto|scroll)/).test(window.getComputedStyle(p).overflowX)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The menu tree as a routing table: path <-> node, with LuCI's alias/firstchild resolution. Pure
|
||||
* lookup — no DOM. The chrome and the SPA router both need it, and require() raises
|
||||
* DependencyError on a loop, so the shared half is its own module rather than 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 as the server does — returning null made the wordmark full-reload instead. null is
|
||||
* reserved for a path outside LuCI's scriptname. */
|
||||
function segsFromPath(pathname) {
|
||||
const base = L.env.scriptname || '';
|
||||
if (base && !pathname.startsWith(base))
|
||||
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 rather than pages (4 `alias`, 3 `firstchild`). The server
|
||||
* does not redirect them: a full GET answers 200 at the requested URL and stamps the resolved leaf
|
||||
* into requestpath/dispatchpath/nodespec. The client must therefore resolve exactly as
|
||||
* dispatcher.uc does, or a click and an F5 on the same URL open different pages — nodeWeight() and
|
||||
* firstChildOf() are ports, not approximations. Only the ACL check is skipped, the tree from
|
||||
* /admin/menu already being ACL-filtered for this session.
|
||||
*
|
||||
* `rewrite` is deliberately not followed: the tree has none, and a wrong guess at its splice
|
||||
* semantics would 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 ----
|
||||
*
|
||||
* apply_tree_acls() (dispatcher.uc:442) marks a NODE readonly from its own `depends.acl`, but a
|
||||
* request accumulates every ancestor's acls into ctx.acls and stamps the leaf from the
|
||||
* accumulation (`resolved.node.readonly = !perm`, :1003). Reading the leaf alone therefore
|
||||
* disagrees with a full load: luci.js derives hasViewPermission() from
|
||||
* `!env.nodespec.readonly`, which views and the Save/Apply footer key their disabled state off, so
|
||||
* every page whose SECTION is read-only would gain controls the server refuses.
|
||||
*
|
||||
* The operator is AND down the path, not OR: check_acl_depends() (:312) is handed the whole
|
||||
* concatenated list (:457-470) and grants write as soon as ANY group in it does, so a page is
|
||||
* read-only only when every acl-bearing node on the path is. A leaf with a writable acl of its own
|
||||
* re-opens the whole path.
|
||||
*
|
||||
* A node with no `depends.acl` contributes nothing to ctx.acls and is skipped. That is also why
|
||||
* the test reads `depends.acl` rather than the `readonly` flag alone: an acl-bearing node the
|
||||
* session may write carries no flag and would otherwise look ungated.
|
||||
*
|
||||
* Feed it the RESOLVED segments — that is the path the dispatcher accumulates over. */
|
||||
function readonlyForSegs(segs) {
|
||||
let node = _tree;
|
||||
let gated = 0, locked = 0;
|
||||
const weigh = (n) => {
|
||||
const acl = n && n.depends && n.depends.acl;
|
||||
if (!acl || !acl.length)
|
||||
return;
|
||||
gated++;
|
||||
if (n.readonly === true)
|
||||
locked++;
|
||||
};
|
||||
weigh(node);
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
node = node && node.children && node.children[segs[i]];
|
||||
if (!node)
|
||||
break;
|
||||
weigh(node);
|
||||
}
|
||||
return gated > 0 && gated === locked;
|
||||
}
|
||||
|
||||
/* The view class a menu node instantiates, or null when the node is not SPA-able. The
|
||||
* Status -> Overview `template` node maps to view.status.index, its server template doing nothing
|
||||
* else; the globals that template also defines come from ensureOverviewHelpers() in
|
||||
* menu-footstrap-common.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,377 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require dom';
|
||||
'require network';
|
||||
'require fs-fit as fit';
|
||||
'require fs-widgets as widgets';
|
||||
'require menu-footstrap-common as common';
|
||||
|
||||
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
|
||||
* Storage sections into a grid. Content, data and styling stay luci-mod-status's — rendering a
|
||||
* custom tree every poll instead (the old 05_footstrap_dashboard.js) flickered and reset mobile
|
||||
* scroll. The stock poll fills each section in place via dom.content() and never rebuilds the
|
||||
* .cbi-section wrapper, so the wrappers stay inside our grid across polls.
|
||||
*
|
||||
* This must NOT be filed in LuCI's global include dir (view/status/include/): luci-mod-status
|
||||
* evaluates every *.js there, so the file would be fetched and run on routers using another theme,
|
||||
* with an `L.env.media` gate only silencing it after the fact. As a chrome module it is
|
||||
* unreachable except through this theme's footer partial, which is why nothing below re-checks
|
||||
* `L.env.media`. That location also supplied two timing guarantees for free, by evaluating inside
|
||||
* index.load(); both are paid for explicitly here — patchOverview() below, and
|
||||
* ensureOverviewHelpers() in menu-footstrap-common.js. */
|
||||
/* 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, not per poll tick. */
|
||||
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
|
||||
|
||||
function headerEl(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 one silently disables the grid on
|
||||
* the other. */
|
||||
return sec.querySelector('.cbi-title h3, :scope > h3');
|
||||
}
|
||||
|
||||
function sectionTitle(sec) {
|
||||
const h = headerEl(sec);
|
||||
if (!h) return '';
|
||||
/* the first non-empty TEXT node, not `firstChild`: 25.12 appends a hide/show <span> inside the
|
||||
* same <h3>, so `firstChild` depends on upstream keeping the words first */
|
||||
for (const n of h.childNodes) {
|
||||
if (n.nodeType !== 3) continue;
|
||||
const t = String(n.nodeValue || '').trim();
|
||||
if (t) return t;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ----
|
||||
*
|
||||
* Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements — the
|
||||
* topbar poll pill plus one Hide/Show toggle per card — and every one is a bare <span>: no
|
||||
* tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run
|
||||
* of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A).
|
||||
*
|
||||
* index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone
|
||||
* clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler,
|
||||
* not a competing one. */
|
||||
|
||||
/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed
|
||||
* (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the
|
||||
* same way. */
|
||||
function pillExpanded(label) {
|
||||
return label.getAttribute('data-style') !== 'active';
|
||||
}
|
||||
|
||||
/* The panel `data-style` shows or hides is `.cbi-title`'s next sibling in the 25.12 markup this was
|
||||
* measured on. 24.10 emits no `.cbi-title` wrapper (headerEl() above) and is left without
|
||||
* aria-controls rather than guessed at — role, tabindex, aria-expanded and the keyboard below still
|
||||
* apply there; aria-controls is the one piece this function cannot state with confidence. */
|
||||
function panelFor(h) {
|
||||
const holder = h.closest('.cbi-title');
|
||||
return holder ? holder.nextElementSibling : null;
|
||||
}
|
||||
|
||||
let _panelSeq = 0;
|
||||
|
||||
/* Promote one card's header to a disclosure control. Idempotent: a tick that finds the header
|
||||
* already wired touches only aria-expanded, and only when it actually changed; the click/keydown
|
||||
* listeners are added once (`dataset.fsWired`), never re-added. */
|
||||
function wireDisclosure(sec) {
|
||||
const h = headerEl(sec);
|
||||
const label = h && h.querySelector('.label[data-indicator="poll-status"]');
|
||||
if (!h || !label) return; /* not this card's shape — left alone rather than guessed at */
|
||||
|
||||
const panel = panelFor(h);
|
||||
if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);
|
||||
|
||||
widgets.syncAttr(h, 'role', 'button');
|
||||
widgets.syncAttr(h, 'tabindex', '0');
|
||||
widgets.syncAttr(h, 'aria-controls', panel ? panel.id : null);
|
||||
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
|
||||
/* the header now carries the pill's name, role and state; a screen reader user tabbing past it
|
||||
* to a second, unlabelled clickable span would hear an unexplained duplicate control — same
|
||||
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */
|
||||
widgets.syncAttr(label, 'aria-hidden', 'true');
|
||||
|
||||
if (h.dataset.fsWired) return;
|
||||
h.dataset.fsWired = '1';
|
||||
|
||||
h.addEventListener('click', (ev) => {
|
||||
/* a click landing on the pill itself already ran index.js's own handler; forwarding here
|
||||
* too would toggle the card twice */
|
||||
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
|
||||
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
|
||||
});
|
||||
/* the pill is an <a>-less <span>, so neither key is native here — contrast
|
||||
* menu-footstrap.js's wireSpaceKey, written for an <a role="button">, which gets Enter for
|
||||
* free and needs only Space added */
|
||||
widgets.wireActivate(h, () => label.click());
|
||||
}
|
||||
|
||||
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
|
||||
* "-", the poll pill its only content, 72px tall on the live Overview — seventh there (a fixture is
|
||||
* not proof of position). Real and cosmetic, so it is suppressed rather than left as a rung in the
|
||||
* tab order with nothing behind its own name. */
|
||||
function hideEmptyCard(sec) {
|
||||
if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty');
|
||||
}
|
||||
|
||||
function tidyCards(view) {
|
||||
view.querySelectorAll('.cbi-section').forEach((sec) => {
|
||||
hideEmptyCard(sec);
|
||||
wireDisclosure(sec);
|
||||
});
|
||||
}
|
||||
|
||||
/* the wrapper we built, so the poll-tick fast path costs one property read */
|
||||
let _wrapEl = null;
|
||||
|
||||
/* A port name the card had to cut stays readable on hover: styles/pages/20-overview.css ellipses
|
||||
* it at one line so every card takes the width the row can spare, and the name is the one thing on
|
||||
* a card that cannot be guessed from the rest.
|
||||
*
|
||||
* The tooltip is set unconditionally: testing `scrollWidth` against `clientWidth` per card would
|
||||
* force a synchronous layout on every poll tick, since 29_ports.js rebuilds these tiles each time.
|
||||
*
|
||||
* Runs BEFORE arrange()'s fast path, which returns as soon as the grid is intact while the tiles
|
||||
* under it are new elements. */
|
||||
function nameTooltips(view) {
|
||||
for (const icon of view.querySelectorAll('img[src*="/port_"]')) {
|
||||
const head = icon.closest('.ifacebox')?.firstElementChild;
|
||||
const name = head ? head.textContent.trim() : '';
|
||||
/* `!==`: a mutation inside the tree we observe is not cheap, even when the write is */
|
||||
if (name && head.title !== name)
|
||||
head.title = name;
|
||||
}
|
||||
}
|
||||
|
||||
function arrange() {
|
||||
/* an SPA nav can leave the observer wired while another page renders into #view: detach as soon
|
||||
* as the route stops being the overview. body[data-page] carries the DISPATCH path from both
|
||||
* the server template and the router, so /admin/status (firstchild -> overview) matches. */
|
||||
if ((document.body.getAttribute('data-page') || '') !== /* spelled out, not hoisted: tools/page-modules.mjs reads this value out of the module's
|
||||
* SOURCE to check it against the map in menu-footstrap-common.js */
|
||||
'admin-status-overview') {
|
||||
stopWatch();
|
||||
return;
|
||||
}
|
||||
const view = document.getElementById('view');
|
||||
if (!view) return;
|
||||
|
||||
nameTooltips(view);
|
||||
/* 20_memory.js, 25_storage.js and 30_network.js each call their OWN local progressbar(), not
|
||||
* the theme's window.progressbar (menu-footstrap-common.js), so the reading and colour those
|
||||
* bars need are stamped on here instead — on the same poll-tick callback nameTooltips() already
|
||||
* runs on, ahead of the fast-path return below: dom.content() rewrites a section's title every
|
||||
* tick even when the wrapper survives, so a bar's reading is stale on every tick this line is
|
||||
* skipped. */
|
||||
common.annotateMeters(view);
|
||||
/* Same reasoning, same placement: dom.content() rewrites a card's body every tick even when the
|
||||
* wrapper survives, so a card that is only now getting its title text needs wiring on this
|
||||
* pass, not just the first one. tidyCards() is its own idempotent write (wireDisclosure(),
|
||||
* hideEmptyCard() above), so a tick that changes nothing here touches no DOM either. */
|
||||
tidyCards(view);
|
||||
|
||||
/* Fast path: the poll lands here on every tick, forever, and the stock poll never rebuilds
|
||||
* the .cbi-section wrappers, so the grid survives. Deliberately not a disconnect() — if a
|
||||
* future luci-mod-status does rebuild a section, the wrapper loses its children and the slow
|
||||
* path below rebuilds the grid. */
|
||||
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 #maincontent and re-run arrange(),
|
||||
* coalesced. #maincontent (unlike #view) outlives every SPA swap, so one observer, attached once,
|
||||
* is never stale and never needs to be re-attached. */
|
||||
let _observer = null, _routeObserver = null;
|
||||
function stopWatch() {
|
||||
if (_observer) _observer.disconnect();
|
||||
_observer = null;
|
||||
_wrapEl = null; /* the grid belongs to the #view we are leaving */
|
||||
}
|
||||
function watch() {
|
||||
const view = document.getElementById('view');
|
||||
/* `#maincontent`, not `#view`: a client navigation builds a fresh `#view` before it is in the
|
||||
* document and swaps it in afterwards, and this runs on the `data-page` stamp, which comes
|
||||
* first — so the observer bound here read `isConnected: false` after one round trip and the
|
||||
* grid stopped being re-arranged on every poll tick, silently, until the next full load. The
|
||||
* shell's column outlives every swap. Same fault, same fix as fs-appearance.js. */
|
||||
const root = document.getElementById('maincontent');
|
||||
arrange();
|
||||
/* the caller (onOverview(), via wire()'s data-page observer) already confirmed the overview
|
||||
* route before reaching here; !view alone covers the mid-navigation race above */
|
||||
if (_observer || !view)
|
||||
return;
|
||||
/* 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(root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/* A chrome module is instantiated once per page load, so it has to notice SPA navigation itself.
|
||||
* `body[data-page]` is the signal — the server template and fs-router both stamp it with the
|
||||
* dispatch path — so one attribute observer covers arriving, leaving and coming back. */
|
||||
function wire() {
|
||||
if (_routeObserver || !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();
|
||||
}
|
||||
|
||||
/* Arrival at the overview, from a full page load or an SPA navigation. 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() withholds the tree until it resolves — #view stays empty for as
|
||||
* long as the slowest include takes (measured: 182 ms, of which most sections were ready at 88 ms
|
||||
* and waiting on 29_ports and 60_wifi).
|
||||
*
|
||||
* Replacing poll_status does two things:
|
||||
* 1. each section paints when its own data lands (182 -> ~90 ms). Nothing jumps: the frames are
|
||||
* already in the DOM, a section goes hidden -> filled as on any poll tick;
|
||||
* 2. drops the redundant re-fetch — stock adds the poller after the first load and Poll.add()
|
||||
* steps at once, re-fetching everything (~250 ms of ubus) right after the first paint.
|
||||
*
|
||||
* Not a re-implementation: frames, toggles, includes and their render() stay upstream's.
|
||||
* fillSection() transcribes stock's loop in the same order so it can be diffed against index.js;
|
||||
* 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;
|
||||
/* Which containers the in-flight run is filling. The guard is module-level because the duplicate
|
||||
* load it kills is, but frames are per render: joining a run blindly joins one filling somebody
|
||||
* else's frames, and the second arrival's sections then stay at `display:none` for a full poll
|
||||
* interval (5.9 s against 0.4 s). */
|
||||
let _inflightFor = null;
|
||||
|
||||
function pollProgressive(includes, containers, first_load) {
|
||||
/* a run already fetching this data for THESE frames is joined rather than duplicated; a run for
|
||||
* older frames is left to finish into the detached nodes it owns */
|
||||
if (_inflight && _inflightFor === containers)
|
||||
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 when its own data lands, 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(() => {
|
||||
/* only if still ours: a newer render may have replaced it mid-run */
|
||||
if (_inflightFor === containers) { _inflight = null; _inflightFor = null; }
|
||||
});
|
||||
_inflightFor = containers;
|
||||
/* Nobody awaits this on the first load (the caller gets a fresh Promise.resolve()), so a
|
||||
* rejection would surface as an unhandled one. `run` rejects for one ordinary reason:
|
||||
* flushCache() on an expired session, when the user is already being redirected to login.
|
||||
* Section failures cannot reach it — fillSection runs in a try/catch and inc.load() has its
|
||||
* own .catch. */
|
||||
_inflight.catch(() => {});
|
||||
|
||||
/* first load resolves now, so index.render() returns its tree and the frames reach #view while
|
||||
* the sections fill themselves; a poll tick resolves when its data is in, as 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.
|
||||
*
|
||||
* Called from the route (wire()), not at module eval: requiring 'view.status.index' at eval would
|
||||
* pull the whole stock view into memory on every page, and on a full load it would race
|
||||
* index.load(). Hence the `__fsProgressive` guard and the fact that missing the window is
|
||||
* harmless — the page then renders the stock way, one Promise.all, ~90 ms later. */
|
||||
function patchOverview() {
|
||||
/* `window.L`, never the bare `L` this factory was handed. require() passes the object it was
|
||||
* called on into the loaded module's factory, and index.js loads its own includes with that
|
||||
* same `L` — 30_network.js then calls `L.itemlist(...)`, which lives on the runtime instance
|
||||
* (`window.L = new LuCI()`), not on the prototype a chrome module receives. require() caches
|
||||
* by class name, so the first caller decides this for everybody: through the bare `L` the
|
||||
* overview dies mid-render on "L.itemlist is not a function", stuck on "Loading view…" (issue
|
||||
* #22 follow-up). docs/spa-router.md. */
|
||||
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));
|
||||
}
|
||||
|
||||
/* `progressbar`, `renderBox` and `renderBadge` are defined in menu-footstrap-common.js, not here:
|
||||
* a stock include calls them bare from its own render(), so they must exist before the view class
|
||||
* does, and this page module is required during the navigation that races it. `annotateMeters` is
|
||||
* required from that same module (see arrange()) rather than duplicated: it is not a template
|
||||
* global, so nothing forces it to live there, but the threshold split and label lookup it shares
|
||||
* with `window.progressbar` would drift into two copies otherwise. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
|
||||
* data-page observer inside */
|
||||
wire,
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require fs-fit as fit';
|
||||
|
||||
/* The Appearance axes this file owns; the controls that present them are fs-appearance.js. The
|
||||
* axis list is AXIS_KEYS, which is exactly the fields of snapshotAxes() — what Save-as-default
|
||||
* writes.
|
||||
* All client-side, instant and persisted in localStorage, with head.ut's inline script re-applying
|
||||
* them before paint so a reload never flashes the wrong one; tools/axes.mjs derives the contract
|
||||
* from this file and holds the two copies to it.
|
||||
*
|
||||
* ---- three layers, and the browser always wins ----
|
||||
* Every axis resolves as localStorage ?? router-default ?? built-in. The router default is
|
||||
* Appearance -> Save to router (written to /etc/config/footstrap, read back into window.__fsSD);
|
||||
* the built-in is a bare :root. A new browser inherits the router default; this browser's own
|
||||
* choice overrides it in either direction.
|
||||
*
|
||||
* ---- every applier stores its choice EXPLICITLY ----
|
||||
* Once a router default exists, clearing a key means "inherit the router default", not "the
|
||||
* built-in" — so an applier that lsDel'd on the default value could not express "the built-in, not
|
||||
* the router's" (a router-defaulted tint could never be turned back off). Every axis records the
|
||||
* chosen value, including the off/default one. lsDel is reserved for resetToSaved(). */
|
||||
/* A browser can refuse storage outright (blocked cookies, dom.storage.enabled=false, a partitioned
|
||||
* WebView) and then every access throws. The helpers below swallow it, because an axis that cannot
|
||||
* be remembered must still APPLY, but they record it: otherwise current*() reads null, falls back
|
||||
* to the router's look, and the Save button sits disabled reading "Saved to router" over a page
|
||||
* painted in axes the router default does not carry. */
|
||||
let _lsBroken = false;
|
||||
function storageBroken() { return _lsBroken; }
|
||||
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } }
|
||||
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { _lsBroken = true; } }
|
||||
function lsDel(k) { try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } }
|
||||
|
||||
/* A stored JSON array, or [] — the shape the two remembered lists use (the search palette's recent
|
||||
* paths, the menu's open sections). lsGet owns the try/catch around localStorage; this one covers
|
||||
* JSON.parse over a value another tab may have corrupted, and the Array guard that stops a stored
|
||||
* object being spread into a list. */
|
||||
function lsGetArr(k) {
|
||||
try {
|
||||
const a = JSON.parse(lsGet(k) || '[]');
|
||||
return Array.isArray(a) ? a : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
/* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the
|
||||
* effective default when this browser has no localStorage. No try/catch: unlike localStorage,
|
||||
* reading or writing a plain object property cannot throw, and window.__fsSD is always one (head.ut
|
||||
* stamps it as an object literal). */
|
||||
function sd(k) { return (window.__fsSD || {})[k]; }
|
||||
|
||||
/* …and the write back: an applier that persists to the router must update the blob the server
|
||||
* stamped, or current*() keeps reporting the old router default until the next full load and
|
||||
* matchesSavedDefault() lies about whether anything is left to save */
|
||||
function setSD(field, val) { (window.__fsSD = window.__fsSD || {})[field] = val; }
|
||||
|
||||
/* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ----
|
||||
* `def()` is the sd() branch of current() alone: the effective value with no localStorage. Exposed
|
||||
* because _resolvedDefault() needs exactly that branch, and a second copy of the same validation
|
||||
* drifts without a symptom — matchesSavedDefault() then lies about the one thing the Save button
|
||||
* is, its own status. */
|
||||
function modeDefault() {
|
||||
const d = sd('darkmode');
|
||||
return (d === 'dark' || d === 'light') ? d : 'auto';
|
||||
}
|
||||
function currentMode() {
|
||||
const s = lsGet('fs-darkmode');
|
||||
if (s === 'true') return 'dark';
|
||||
if (s === 'false') return 'light';
|
||||
if (s === null) return modeDefault();
|
||||
return 'auto';
|
||||
}
|
||||
/* ---- dark mode is announced in three dialects, because apps sniff for it ----
|
||||
*
|
||||
* An app with its own dark styles has to guess whether the page is dark and there is no standard:
|
||||
* apps read `data-theme="dark"` on :root (luci-app-justclash keys 21 rules off it), Bootstrap's
|
||||
* `data-bs-theme` (luci-app-ssclash), or, failing both, the luminance of the body background. All
|
||||
* three are stamped for the same fact: before that, every one of justclash's [data-theme="dark"]
|
||||
* rules was dead and a dark page rendered its light fills.
|
||||
*
|
||||
* `data-darkmode` is the name the theme's own CSS keys off. The other two are outbound
|
||||
* compatibility, like the `--*-color-*` export tier: nothing in `styles/` may read them, and
|
||||
* tools/axes.mjs fails the build if it does. */
|
||||
/* the attribute name reused below by the writer, the guard's reader and both MutationObserver
|
||||
* filters (measured: 15 B x4 -> 28 B, 32 B saved) */
|
||||
function stampDark(root, dark) {
|
||||
/* the literal stays spelled out HERE: tools/axes.mjs reads the attribute names out of
|
||||
* this function's SOURCE, so a hoisted const reads as no attribute at all and the gate
|
||||
* reports the pre-paint and the live applier as drifted. The 45 B a const would save are
|
||||
* not worth teaching a gate to resolve them. */
|
||||
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
|
||||
root.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
const _mqDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
/* the one expression for "is this page dark right now", so the applier, the OS listener and the
|
||||
* guard below cannot disagree about it */
|
||||
function intendedDark() {
|
||||
const m = currentMode();
|
||||
return m === 'dark' || (m === 'auto' && _mqDark.matches);
|
||||
}
|
||||
|
||||
function applyMode(val) {
|
||||
const root = document.documentElement;
|
||||
/* 'auto' is stored explicitly, so it overrides a router default of dark/light — otherwise a
|
||||
* router defaulted to dark could never be set back to "follow the OS" */
|
||||
if (val === 'auto') lsSet('fs-darkmode', 'auto');
|
||||
else lsSet('fs-darkmode', val === 'dark' ? 'true' : 'false');
|
||||
/* after the store, so intendedDark() reads the choice just made and no second copy of the
|
||||
* condition is needed in terms of `val` */
|
||||
stampDark(root, intendedDark());
|
||||
}
|
||||
|
||||
/* ---- the three dialects are published, so third parties write them too ----
|
||||
*
|
||||
* Announcing dark mode in a vocabulary apps understand is what makes them follow the page, and it
|
||||
* is why an app reaches for the same attribute: `luci-app-openclash` stamps `data-darkmode="true"`
|
||||
* onto :root from seven of its templates, gated on an isDarkBackground() that consults
|
||||
* `matchMedia('(prefers-color-scheme: dark)')` before it looks at the real background. So a user
|
||||
* who chose LIGHT here, on an OS set to dark, has the theme flipped by opening an OpenClash page —
|
||||
* and one of those templates removes the attribute head.ut writes as 'false'.
|
||||
*
|
||||
* No cascade trick answers a DOM write, so watch the attributes we own and restate the truth.
|
||||
* Nothing else is guarded: the other axes are private to this theme, no app has a reason to know
|
||||
* them, and a survey of ten shipping packages found none that writes one. The published trio is
|
||||
* the surface precisely because it is published.
|
||||
*
|
||||
* This corrects a wrong premise rather than fighting the app's intent: when the page really is
|
||||
* dark, the app's write agrees with ours and the guard never fires. It cannot ping-pong either —
|
||||
* our write produces a mutation, the callback re-runs, the values match, it returns. */
|
||||
function guardDarkStamp() {
|
||||
const root = document.documentElement;
|
||||
const check = () => {
|
||||
const dark = intendedDark();
|
||||
if (root.getAttribute('data-darkmode') === (dark ? 'true' : 'false') &&
|
||||
root.getAttribute('data-theme') === (dark ? 'dark' : 'light') &&
|
||||
root.getAttribute('data-bs-theme') === (dark ? 'dark' : 'light')) return;
|
||||
stampDark(root, dark);
|
||||
};
|
||||
/* an app's inline <script> runs while its template is parsed, long before this module is
|
||||
* fetched, so the attribute can already be wrong and observing alone would never see it */
|
||||
check();
|
||||
new MutationObserver(check).observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: [ 'data-darkmode', 'data-theme', 'data-bs-theme' ]
|
||||
});
|
||||
}
|
||||
/* ---- the browser's own chrome follows the page ----
|
||||
*
|
||||
* `<meta name="theme-color">` is what colours a mobile address bar, the Android task-switcher card
|
||||
* and an installed PWA's title bar. head.ut ships it at the default palette's page colour, which is
|
||||
* all a static template can know; from here it tracks the live one.
|
||||
*
|
||||
* Read from the BODY's computed background, not from `--fs-bg`: a custom property returns its token
|
||||
* stream, so a palette whose page colour is a color-mix() would put `color-mix(in srgb, …)` in the
|
||||
* attribute, and 21 of the axes reach the canvas through one mix or another.
|
||||
*
|
||||
* One observer rather than a call in each applier: the colour is a function of :root's attributes
|
||||
* and inline properties — mode, palette, tint, its strength — and every axis already writes exactly
|
||||
* those. Adding an axis therefore needs nothing here. Coalesced into a frame because the tint and
|
||||
* strength sliders write on every input event, and getComputedStyle forces style resolution. */
|
||||
function paintThemeColor() {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta || !document.body) return;
|
||||
const bg = getComputedStyle(document.body).backgroundColor;
|
||||
/* a transparent body means the sheet has not applied yet; keep the server's value */
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') meta.setAttribute('content', bg);
|
||||
}
|
||||
|
||||
function watchThemeColor() {
|
||||
paintThemeColor();
|
||||
/* fit.frame() is the same next-frame coalescer every geometry fitter schedules through: the
|
||||
* tint and strength sliders write on every input event, and getComputedStyle forces style
|
||||
* resolution, so a dozen mutations in one frame must still repaint the meta tag once. */
|
||||
new MutationObserver(fit.frame(paintThemeColor)).observe(document.documentElement,
|
||||
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
|
||||
}
|
||||
|
||||
/* "Auto" means follow the OS continuously, not only at page load. Only while the effective mode is
|
||||
* auto: an explicit browser choice, or an explicit router default with no browser override. */
|
||||
_mqDark.addEventListener('change', () => {
|
||||
if (currentMode() === 'auto') {
|
||||
const root = document.documentElement;
|
||||
stampDark(root, intendedDark());
|
||||
}
|
||||
});
|
||||
/* An axis whose values are a list, with one of them stamped as nothing.
|
||||
*
|
||||
* `values` are the names that become `attr="<name>"`; `dflt` is the one that leaves :root bare, and
|
||||
* is what a stray or missing value falls back to. `after` runs once the attribute is stamped, for
|
||||
* the axis that has to re-take a measurement.
|
||||
*
|
||||
* The list IS the validation: a name added to the stylesheet and not here is one head.ut pre-paints
|
||||
* and this rejects, so the page paints it and the first touch of any other control takes it away. */
|
||||
function listAxis(key, attr, values, dflt, after) {
|
||||
/* 'fs-pattern-ink' -> 'pattern_ink', the window.__fsSD field. The underscore is the point: the
|
||||
* localStorage key is hyphenated and the uci option is not, so a bare slice(3) names a field
|
||||
* head.ut never emits, sd() returns undefined forever, and the axis reports the built-in
|
||||
* default however the router is set — Save-as-default then writes it over the admin's value. */
|
||||
const sdKey = key.slice(3).replace(/-/g, '_');
|
||||
const ok = (v) => (values.indexOf(v) >= 0);
|
||||
const def = () => (ok(sd(sdKey)) ? sd(sdKey) : dflt);
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const s = lsGet(key);
|
||||
if (ok(s)) return s;
|
||||
if (s === null) return def();
|
||||
return dflt; /* a stray value reads as the built-in default */
|
||||
},
|
||||
apply(val) {
|
||||
const root = document.documentElement;
|
||||
const v = ok(val) ? val : dflt;
|
||||
/* stored explicitly (including the default), so it overrides a router default */
|
||||
lsSet(key, v);
|
||||
if (v === dflt) root.removeAttribute(attr);
|
||||
else root.setAttribute(attr, v);
|
||||
if (after) after();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
|
||||
* with a list of one — kept as its own name because tools/axes.mjs matches the call, and because
|
||||
* "two-valued" is what most of these axes are. */
|
||||
function enumAxis(key, attr, on, off) {
|
||||
return listAxis(key, attr, [ on ], off);
|
||||
}
|
||||
|
||||
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
|
||||
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
|
||||
const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
|
||||
densityDefault = DENSITY.def;
|
||||
|
||||
/* Content width lives entirely in fs-axes.js now (a propAxis like Rounding, issue #44): unlike
|
||||
* Density it sets no attribute, so nothing here or in fs-chrome.js needs to read one, and
|
||||
* currentContentWidth()/applyContentWidth() have no caller outside the Appearance tab — the one
|
||||
* thing that kept Density's shape in this cold-path file. */
|
||||
function currentLayout() {
|
||||
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
|
||||
}
|
||||
function isTopLayout() {
|
||||
return currentLayout() === 'top';
|
||||
}
|
||||
function applyLayout(val) {
|
||||
const layout = (val === 'top') ? 'top' : 'sidebar';
|
||||
/* always an explicit value, never a removed attribute: every layout rule matches data-layout
|
||||
* positively, and lsDel would let the server default re-assert on the next load */
|
||||
lsSet('fs-layout', layout);
|
||||
document.documentElement.setAttribute('data-layout', layout);
|
||||
/* the bar and the column leave the menu different room: re-take the fits-on-one-row
|
||||
* measurement */
|
||||
fit.schedule();
|
||||
}
|
||||
|
||||
/* Sidebar accordion: auto-collapse on = one section open at a time; off (default) they stack.
|
||||
* Only meaningful for the expanded sidebar — rail flyouts and the mobile bar are always
|
||||
* exclusive. Read by menu-footstrap.js. */
|
||||
function autoCollapseDefault() {
|
||||
return sd('autocollapse') === 'on';
|
||||
}
|
||||
function currentAutoCollapse() {
|
||||
const s = lsGet('fs-menu-autocollapse');
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === null) return autoCollapseDefault();
|
||||
return false;
|
||||
}
|
||||
function applyAutoCollapse(val) {
|
||||
const on = (val === 'on');
|
||||
lsSet('fs-menu-autocollapse', on ? 'true' : 'false');
|
||||
|
||||
/* Switching it on with several sections unfolded leaves the menu in a state the setting says is
|
||||
* impossible, so somebody must fold them — but not this module, which owns storage while the
|
||||
* menu owns every piece of the open/closed state and opens and closes only through setOpen().
|
||||
* Reaching in with a raw classList.remove satisfies the class and leaves the aria saying
|
||||
* expanded. Say what changed and let the menu apply it. */
|
||||
document.dispatchEvent(new CustomEvent('fs-autocollapse', { detail: { on } }));
|
||||
}
|
||||
|
||||
/* The sidebar rail's collapsed flag; the button that flips it is chrome (fs-chrome.js). Not part
|
||||
* of the router-wide defaults — a transient chrome collapse, not an appearance choice — so it is
|
||||
* absent from snapshotAxes() and resetToSaved(). */
|
||||
function applyRail(on) {
|
||||
const root = document.documentElement;
|
||||
if (on) { root.setAttribute('data-rail', 'true'); lsSet('fs-rail', 'true'); }
|
||||
else { root.removeAttribute('data-rail'); lsDel('fs-rail'); }
|
||||
}
|
||||
function currentRail() {
|
||||
return document.documentElement.getAttribute('data-rail') === 'true';
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
/* the storage wrappers and the router-default reader: fs-axes.js is built on these */
|
||||
lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD,
|
||||
/* the two axis shapes, so the nineteen axes in fs-axes.js can be built from them */
|
||||
listAxis, enumAxis,
|
||||
|
||||
currentMode, applyMode, modeDefault, guardDarkStamp, watchThemeColor,
|
||||
currentDensity, applyDensity, densityDefault,
|
||||
currentLayout, applyLayout, isTopLayout,
|
||||
currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
|
||||
currentRail, applyRail
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,365 @@
|
||||
'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 knowing which section owns it. A loaded router
|
||||
* carries ~200 menu nodes across 11 sections, and a tab such as Firewall -> Port Forwards appears
|
||||
* in no menu list 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 loaded
|
||||
* (fs-menutree), so the palette lists exactly the pages this session may open. The index is built
|
||||
* on the first open, not at init — a user who never searches pays nothing, and only a full load
|
||||
* 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 no copy of that decision lives
|
||||
* here. Enter synthesises the same click. */
|
||||
|
||||
/* ---- the index ---------------------------------------------------------- */
|
||||
|
||||
/* How deep below the mode the walk goes. `depth` counts recursion levels starting at 1 on a node
|
||||
* that already has two segments, so 4 admits admin/<section>/<page>/<tab>/<subtab> — one level
|
||||
* more than LuCI renders, so a deeper third-party node is still findable and the depth term in
|
||||
* search() ranks it last. */
|
||||
const MAX_DEPTH = 4;
|
||||
|
||||
let _index = null;
|
||||
|
||||
/* The node's own children, ACL- and title-filtered as ui.menu.getChildren() filters them, but not
|
||||
* through getChildren(): on an alias node it returns the alias TARGET's children, which is right
|
||||
* for drawing a menu and wrong for indexing — Network -> Firewall aliases onto the `firewall/zones`
|
||||
* leaf, so all five of its tabs vanish from the index (78 nodes instead of 238).
|
||||
*
|
||||
* Order does not matter (search ranks by score), so only the two filters are reimplemented, not
|
||||
* the sort. */
|
||||
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); indexing it would let a search
|
||||
* 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, in this order of preference (see tokenScore()). The English
|
||||
* node name is indexed on purpose: a translated UI otherwise hides the page from
|
||||
* an admin who knows OpenWrt by its English docs. */
|
||||
t: title.toLowerCase(),
|
||||
p: trail.join(' ').toLowerCase(),
|
||||
/* …minus the root segment, `admin`, which every node shares: indexed, every
|
||||
* substring of it ("ad", "min", …) hits all ~200 pages and fills the result cap */
|
||||
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;
|
||||
}
|
||||
|
||||
/* Built once per document: it projects the client menu tree, which `ui.menu.load()` caches for
|
||||
* the life of the document, so the palette is exactly as fresh as the sidebar beside it.
|
||||
* Invalidating only this half would let the two disagree, which is worse than both being stale;
|
||||
* the reload a package install prompts for refreshes them together. */
|
||||
function index() {
|
||||
if (!_index) _index = buildIndex();
|
||||
return _index;
|
||||
}
|
||||
|
||||
/* ---- matching ----------------------------------------------------------- */
|
||||
|
||||
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
|
||||
* a fuzzy subsequence match — on a two-letter query that matches nearly every entry and leaves the
|
||||
* ranking to decide everything, which reads as random. */
|
||||
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: 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 anything is typed: an admin lives in three or four pages, so the
|
||||
* empty state is its most-used view.
|
||||
*
|
||||
* Only the PATH is stored, never the title — the title is resolved through the index on every
|
||||
* render, so it follows the UI language and a page an ACL change hides drops out instead of
|
||||
* lingering as a dead row. RECENT_KEY is the one name menu-footstrap-common.js's remember() also
|
||||
* writes; the two modules stay decoupled (see there), so the string is the only thing shared. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
|
||||
* more, and a palette that only loads when it is opened cannot be what records where the admin has
|
||||
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
|
||||
* corruption guard and the Array check; only the "these are keys" filter belongs here. */
|
||||
function recentEntries() {
|
||||
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||||
const byPath = new Map(index().map((e) => [ e.path, e ]));
|
||||
return recent.map((k) => byPath.get(k)).filter(Boolean).slice(0, RECENT_MAX);
|
||||
}
|
||||
|
||||
/* ---- the palette -------------------------------------------------------- */
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
/* Built on the first open and kept — the overlay, its listeners and the index survive for the life
|
||||
* of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
|
||||
* menu-footstrap-common.js holds the shortcut and requires this on the first gesture. */
|
||||
let _built = null;
|
||||
|
||||
function build() {
|
||||
const btn = document.getElementById('fs-search-btn');
|
||||
if (!btn) return null;
|
||||
|
||||
/* the list's id, said three times below as an id, a class and aria-controls (measured: 17 B x3
|
||||
* -> 26 B, 25 B saved) */
|
||||
const ID_SEARCH_LIST = 'fs-search-list';
|
||||
/* the attribute name toggled below and stated once more as its own starting value (measured:
|
||||
* 15 B x3 -> 27 B, 18 B saved) */
|
||||
const ATTR_EXPANDED = 'aria-expanded';
|
||||
const input = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'fs-search-input',
|
||||
'role': 'combobox',
|
||||
'aria-controls': ID_SEARCH_LIST,
|
||||
[ATTR_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': ID_SEARCH_LIST, 'class': ID_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 captions the rows below it ("Recently visited") and doubles as the empty
|
||||
* state, so it belongs above the list in both readings */
|
||||
note,
|
||||
list
|
||||
]);
|
||||
/* data-fs-chrome marks a zone-1 root (docs/third-party-apps.md): this overlay is parented to
|
||||
* <body>, outside the <nav> that carries the mark in header.ut, so without it the fence does
|
||||
* not cover the palette — the shape that once left the Appearance popover unfenced */
|
||||
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 at once would pull the top result of "w", "wi", "wir"… Only arrow keys
|
||||
* and typing need this — the rows are real anchors, so a mouse over one already reaches the
|
||||
* router's pointerover listener. fs-router's warmClass() dedupes, 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> itself: an option may not contain an interactive element,
|
||||
* and the anchor must stay a real link — it carries the click to the router and 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;
|
||||
* no focus return, the user is going elsewhere */
|
||||
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(ATTR_EXPANDED, 'true');
|
||||
input.value = '';
|
||||
render('');
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function close(returnFocus = true) {
|
||||
if (ov.hidden) return;
|
||||
ov.hidden = true;
|
||||
btn.setAttribute(ATTR_EXPANDED, 'false');
|
||||
if (returnFocus) btn.focus();
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => render(input.value.trim()));
|
||||
|
||||
/* keys are handled on the overlay, not the input: a click on the scrim moves focus to the
|
||||
* overlay itself, where 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 the router reads as a keyboard
|
||||
* activation, so focus lands where a keyboard navigation puts it */
|
||||
opts[at].click();
|
||||
return;
|
||||
case 'Tab':
|
||||
/* aria-modal="true" promises Tab cannot walk out into the page behind, and the input
|
||||
* is the dialog's only tabbable element. Escape or an outside click is the way out,
|
||||
* both handing 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 above can see — every other close is a user act
|
||||
* on the document — so without this an open palette rides a popstate onto the next page,
|
||||
* aria-modal and Tab-trapped. returnFocus=false: the router places focus itself. */
|
||||
router.onNavigate(() => close(false));
|
||||
|
||||
/* Ctrl/Cmd+K and `/`, the two shortcuts users already have. `/` only when the user is not
|
||||
* typing somewhere — an <input>, a contenteditable, or a .cbi-dropdown, where fs-select.js's
|
||||
* typeahead reads it as a search character. */
|
||||
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 { open, close };
|
||||
}
|
||||
|
||||
/* the one entry point: build if this is the first gesture, then open */
|
||||
function openPalette() {
|
||||
if (!_built) _built = build();
|
||||
if (_built) _built.open();
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
open: openPalette
|
||||
});
|
||||
@@ -0,0 +1,970 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require fs-fit as fit';
|
||||
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
|
||||
'require fs-chrome as chrome';
|
||||
'require fs-widgets as widgets';
|
||||
|
||||
/* 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 = {};
|
||||
for (const o of sel.options) 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.from(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
|
||||
}
|
||||
|
||||
/* Undo enhance(): drop the widget, unhide the select and cut every listener enhance() installed.
|
||||
* resync() calls teardown()+enhance() every time a script rebuilds the option list, which CBI
|
||||
* dependencies do constantly, so a surviving listener accumulates one per rebuild, each closing
|
||||
* over a dead ui.Dropdown and its detached subtree. AbortController is the only way to drop an
|
||||
* anonymous listener. */
|
||||
function teardown(sel) {
|
||||
if (sel._fsAbort) sel._fsAbort.abort();
|
||||
sel._fsNode?.remove();
|
||||
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 shows the old value while Save reads 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) {
|
||||
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: `sel.value = x` and
|
||||
* `options[i].selected = true` — what `ui.Select.setValue()` does, and form.js calls it on every
|
||||
* dependency pass — set no content attribute and add no node, so no MutationRecord exists for
|
||||
* relevant() to wake on and the widget shows the old label while Save reads the new value.
|
||||
*
|
||||
* So this runs from the fitter, once per content mutation batch, and is deliberately the cheap half
|
||||
* of resync(): a value compare per enhanced select, no choicesKey() over every option. Re-keying
|
||||
* stays behind relevant(), which 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, .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) {
|
||||
/* marked, not merely returned from: unmarked, the same select is re-selected on every
|
||||
* mutation frame and throws again, forever. One loud failure, then left as a stock
|
||||
* <select>. */
|
||||
sel.dataset.fsSelect = 'skip';
|
||||
console.error('footstrap: a select could not be enhanced', 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 and drop the select from the
|
||||
* a11y tree */
|
||||
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
|
||||
/* a table section has no .cbi-value and no .cbi-value-title — form.js builds a bare
|
||||
* `td.cbi-value-field` — so the widget would be left nameless there. The cell's `data-title` is
|
||||
* the column heading, which LuCI fills for the card stack. */
|
||||
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 the native
|
||||
* <select>, which this theme has set `display: none` on, so the click does nothing at all.
|
||||
* Focus only, which is the parity stock gets — its `elem.click()` on a `<select>` opens no list
|
||||
* either. */
|
||||
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.after(node);
|
||||
|
||||
/* 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-evaluates on every mutation of these polled tables.
|
||||
* Not a .cbi-section-table — config forms keep their own layout.
|
||||
*
|
||||
* `.table`, not `table.table`, and a bare `<table>` counts too: a third-party app may emit a
|
||||
* `<div class="table">`, or a table with no LuCI class at all, and neither would be tagged,
|
||||
* measured or carded (docs/conventions.md: coverage is a contract). The bare-table half matches
|
||||
* nothing on the dev stand — a census over all 196 menu pages found zero — which is the point.
|
||||
*
|
||||
* No `:not(.fs-dt)`: these tables are polled, and ui.Table.update() and every hand-rolled
|
||||
* equivalent replace the rows inside the element they already have. Claiming a table once means the
|
||||
* fresh rows arrive without `.tr`/`.td` or `data-title` on a table that may by then be `.fs-stacked`
|
||||
* and clipped with no scrollbar. Both functions below are additive, skip what is already done and
|
||||
* re-decide nothing.
|
||||
*
|
||||
* `#view` is not the only place a table lands: `ui.showModal` parks its dialog on `#modal_overlay`,
|
||||
* a SIBLING of #view, so a table in the wireless-scan dialog was reached by none of this and stood
|
||||
* 56px past the dialog's edge at 390px. So the roots are listed once here and every table query is
|
||||
* built from them: the overlay is the scroll container (base/60-modal.css) and theme/70-modal.css
|
||||
* already keeps a wide child from scrolling the dialog off the screen, and a table is the widest
|
||||
* child there is.
|
||||
*
|
||||
* A CLOSED dialog is not a content root, though it is still in the DOM: `hideModal()` only drops a
|
||||
* class off <body> and the dialog keeps its markup, but the overlay then shrink-fits, so a table
|
||||
* inside it would be judged against a width the dialog will never have. `body.modal-overlay-active`
|
||||
* is the same fact the CSS uses. The flag flips AFTER the content is written, so this is asked per
|
||||
* PASS rather than fixed at load, and fs-fit.js watches that class. */
|
||||
const ROOTS = [ '#view', '#modal_overlay' ];
|
||||
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
|
||||
/* every call passes liveRoots(); folded in here rather than threaded as a parameter */
|
||||
const inRoots = (sel) => liveRoots().map((r) => `${r} ${sel}`).join(', ');
|
||||
|
||||
const foreignTables = () => inRoots('.table:not(.cbi-section-table)') + ', ' +
|
||||
inRoots('table:not(.table):not(.cbi-section-table)');
|
||||
|
||||
/* The fourth header markup, and the one only a foreign table produces: `<table><tr><th>…`, with no
|
||||
* `<thead>` for the parser to imply and none of LuCI's class names.
|
||||
*
|
||||
* "Every cell in the first row is a `<th>`" is the whole test, and it has to be EVERY: a data row
|
||||
* whose first cell is a row header would otherwise be read as the header row and every value below
|
||||
* it captioned with a value. A table with no `<th>` at all returns null and keeps the scroll
|
||||
* fallback. */
|
||||
function headerRow(t) {
|
||||
const row = t.rows && t.rows[0];
|
||||
if (!row || !row.cells.length) return null;
|
||||
return [ ...row.cells ].every((c) => c.tagName === 'TH') ? row : null;
|
||||
}
|
||||
|
||||
function tagDataTables() {
|
||||
document.querySelectorAll(foreignTables()).forEach((t) => {
|
||||
/* Four header markups, and missing one costs a page: ui.Table emits `.tr.table-titles`,
|
||||
* the apk Software page `.tr.cbi-section-table-titles`, a third-party table may use a real
|
||||
* `<thead>` (luci-mod-dashboard's are `<thead class="thead">` with `nowrap` cells, which
|
||||
* neither card nor compress, so their right-hand columns were clipped on a phone), and
|
||||
* headerRow() covers the fourth.
|
||||
*
|
||||
* `thead`, not `thead tr`: markup built by E() appends the `<th>`s straight to the
|
||||
* `<thead>`, so the parser's implied row never happens.
|
||||
*
|
||||
* Any of the four means a data table; none means a key/value include (System, Memory),
|
||||
* which must never card. */
|
||||
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
|
||||
if (!head) return;
|
||||
/* `.table` as well as `.fs-dt`, and only ever added: the theme's table vocabulary is
|
||||
* written against `.table`, so a newly recognised foreign table has to join it. A no-op on
|
||||
* everything LuCI renders. */
|
||||
t.classList.add('table', 'fs-dt');
|
||||
adoptMarkup(t, head);
|
||||
labelCells(t, head);
|
||||
});
|
||||
}
|
||||
|
||||
/* …and the rows and cells inside it, or the claim is a trap.
|
||||
*
|
||||
* `.table` alone gets the frame and the padding; everything that makes the CARD is written one
|
||||
* level down (`.table.fs-stacked .tr { display: flex }`, the `.td[data-title]::before` label, the
|
||||
* hidden header row), and a bare foreign `<table>` carries none of those names. The fitter would
|
||||
* then set `.fs-stacked` and change nothing, while `#view .table.fs-dt.fs-stacked` clips the
|
||||
* columns with no scrollbar to reach them.
|
||||
*
|
||||
* `.tr` / `.td` / `.th` are LuCI's own names for these roles (docs/third-party-apps.md), and the
|
||||
* theme is already writing `.table` onto the same element. Additive only and cheap to re-run every
|
||||
* pass: these tables are polled, so fresh rows arrive bare.
|
||||
*
|
||||
* The header also has to be recognisable, or the card shows it as a first row of column names: a
|
||||
* `<thead>` becomes `.thead`, a plain first row of `<th>` becomes `.tr.table-titles` — the two
|
||||
* names theme/30-tables.css hides when stacked. */
|
||||
function adoptMarkup(t, head) {
|
||||
/* Decided once, at claim time, then read on every pass: asking afresh each pass would answer
|
||||
* "already adopted" the moment we adopted it, and the bare rows a poll brings in would never be
|
||||
* taken. Asking it at all is what keeps the theme's hands off LuCI's own markup — adding
|
||||
* `table-titles` to a `.tr.cbi-section-table-titles` head would rewrite a class LuCI chose. */
|
||||
if (t._fsAdopt === undefined) t._fsAdopt = !t.querySelector('.tr, .thead');
|
||||
if (!t._fsAdopt) return;
|
||||
if (head.tagName === 'THEAD') head.classList.add('thead');
|
||||
else head.classList.add('tr', 'table-titles');
|
||||
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
||||
for (const c of titleRow.children) c.classList.add('th');
|
||||
/* `t.rows` covers a real <table> with or without a <tbody> — a table built with createElement
|
||||
* has its <tr> directly under the <table>, where `tbody tr` finds nothing. A `<div class="table">`
|
||||
* has no `.rows` and is LuCI's own markup, which carries the classes. */
|
||||
if (!t.rows) return;
|
||||
for (const row of t.rows) {
|
||||
if (head.contains(row) || row === head) continue;
|
||||
row.classList.add('tr');
|
||||
for (const cell of row.children) cell.classList.add(cell.tagName === 'TH' ? 'th' : 'td');
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 builders fill it in; a
|
||||
* foreign table has no reason to, and carding one without captions gives a column of values with
|
||||
* nothing saying which is which.
|
||||
*
|
||||
* The heading is copied from the header cell in the same position, never invented, and never
|
||||
* overwrites an existing data-title — the app's own answer knows more than a positional guess.
|
||||
* Re-run every pass, because these tables are polled and fresh rows arrive bare; the per-row skip
|
||||
* below is what keeps that affordable. */
|
||||
function labelCells(t, head) {
|
||||
const rows = t.querySelectorAll('.tr, tbody tr');
|
||||
/* a `<thead>` written as markup nests a real `<tr>` (the parser inserts one even where the
|
||||
* author left it out) while one built by E() holds the `<th>`s directly, so reading
|
||||
* `head.children` blind captions every cell with the header row's entire text */
|
||||
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
||||
const titles = [ ...titleRow.children ].map((c) => (c.textContent || '').trim());
|
||||
if (!titles.some(Boolean)) return;
|
||||
for (const row of rows) {
|
||||
if (row === head) continue;
|
||||
const cells = row.children;
|
||||
/* Skip a row that is already captioned, asked of the ROW and of nothing else. A claimed
|
||||
* table is revisited on every mutation pass and everything LuCI renders is captioned by
|
||||
* ui.Table as it builds it, so the common case is answered by one attribute read.
|
||||
*
|
||||
* Per row, because a poll replaces rows one at a time and a table can hold captioned and
|
||||
* bare rows at once. A table-level probe has to guess which row speaks for the rest, and
|
||||
* every choice is wrong for some shape — asking the last row stalls forever on a `<tfoot>`
|
||||
* of totals while fresh rows keep arriving above it.
|
||||
*
|
||||
* Its blind spot: the test reads the FIRST cell, so a cell replaced inside a row whose first
|
||||
* cell survives stays bare. Nothing in the tree reaches it — ui.Table.update() swaps whole
|
||||
* `<tr>`s and hand-rolled equivalents rebuild the row — and testing every cell would cost
|
||||
* the walk this skip exists to avoid on every pass of every polled table.
|
||||
*
|
||||
* A first cell that can never take a caption (spanning, or with empty header text) leaves
|
||||
* its row walked every pass. The walk is idempotent, so that is the safe direction. */
|
||||
if (cells.length && cells[0].hasAttribute('data-title')) continue;
|
||||
/* Column cursor, not the cell index: a cell spanning N columns occupies N header slots while
|
||||
* advancing the index by one, so keying titles off `i` captions every cell after it with the
|
||||
* heading to its left. The spanning cell itself takes none, having no single heading. (A
|
||||
* rowspan reaching down from an earlier row would shift this too; no LuCI table emits one,
|
||||
* and a wrong caption is worse than none.) */
|
||||
let col = 0;
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const span = (cells[i].colSpan > 1) ? cells[i].colSpan : 1;
|
||||
if (span === 1 && col < titles.length && titles[col] && !cells[i].hasAttribute('data-title'))
|
||||
cells[i].setAttribute('data-title', titles[col]);
|
||||
col += span;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- WCAG 1.3.1: a `display` override must not cost a table its own roles ----
|
||||
*
|
||||
* theme/*.css restyles `display` on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` in over
|
||||
* twenty places — `.fs-stacked`, `.fs-rowstack`, the config table's own `@container`, the phone
|
||||
* `@media`, the Overview meter row, the software page's carded description column, the 560px
|
||||
* key/value card added alongside this file — scattered across selectors this module cannot see the
|
||||
* live state of (a media query, a container query, a class some other pass toggled). A `display`
|
||||
* other than `table`/`table-row`/`table-cell`/… drops the IMPLICIT `table`/`row`/`cell` role
|
||||
* HTML-AAM derives from it: measured on Overview's key/value cards, `display: block`/`grid`/`block`
|
||||
* (table/row/cell) at both 1440 and 390px, and on the 390px card stack, `block`/`none`/`block` —
|
||||
* neither carries a role, so the column a value belongs to is never announced.
|
||||
*
|
||||
* Rather than pair each of those twenty-odd rules with a role write timed to its own trigger — a
|
||||
* race with every one of them — the role is written UNCONDITIONALLY, matching what a plain
|
||||
* `<table>` already carries implicitly. That costs nothing while the table IS a table and survives
|
||||
* the moment any of those rules land. tools/table-contract.mjs holds the pairing: a future
|
||||
* `display` rule on one of these classes with no role to match is what it is written to catch.
|
||||
*
|
||||
* The whole chain, every pass — a `role="cell"` with nothing above it saying `row`, or a `row` with
|
||||
* nothing above IT saying `table`/`rowgroup`, reads worse than no roles at all. One flat query per
|
||||
* class keeps it whole without walking each table by hand: `.tr`/`.th`/`.td` are LuCI's own names,
|
||||
* always written together with `.table` (adoptMarkup() above never adds one without the others), so
|
||||
* a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a
|
||||
* div-based table; a REAL `<thead>`/`<tbody>`/`<tfoot>` this file never classes (20_lan.js's and
|
||||
* 30_wifi.js's `<tfoot>`, theme/30-tables.css:570/634) is reached by tag name instead. */
|
||||
/* tools/table-contract.mjs reads these two names and shapes by regex, so a `display` rule on any of
|
||||
* these classes/tags is checked against a matching role — keep both names, and each entry a
|
||||
* `'.class'` or `'tag'` string literal, even though roleTables() below now reads them as one list. */
|
||||
const TABLE_ROLE_CLASSES = [
|
||||
[ '.table', 'table' ],
|
||||
[ '.thead', 'rowgroup' ],
|
||||
[ '.tbody', 'rowgroup' ],
|
||||
[ '.tfoot', 'rowgroup' ],
|
||||
[ '.tr', 'row' ],
|
||||
[ '.th', 'columnheader' ],
|
||||
[ '.td', 'cell' ],
|
||||
];
|
||||
const TABLE_ROLE_TAGS = [
|
||||
[ 'thead', 'rowgroup' ],
|
||||
[ 'tbody', 'rowgroup' ],
|
||||
[ 'tfoot', 'rowgroup' ],
|
||||
];
|
||||
|
||||
/* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/
|
||||
* `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone — the role written
|
||||
* here changes nothing about that, since a role is inert on a box the engine never generates. */
|
||||
function roleTables() {
|
||||
[ ...TABLE_ROLE_CLASSES, ...TABLE_ROLE_TAGS ].forEach(([ sel, role ]) => {
|
||||
document.querySelectorAll(inRoots(sel)).forEach((el) => widgets.syncAttr(el, 'role', role));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- card-stack a data table that no longer fits ----
|
||||
*
|
||||
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the decision.
|
||||
* Carding by @container needed three thresholds, two of them carrying their own copy of the card
|
||||
* rules, and all three were really asking "does it overflow?" — a fact the browser computes. So it
|
||||
* is measured, the card rules live once 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, 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 — the firewall zone table then
|
||||
* reported needing 1747px against a real 1190px and overflowed its section by 557px. */
|
||||
const stackables = () => inRoots('.table.fs-dt');
|
||||
|
||||
/* "Too cramped to be a table any more" — a design judgement, and the only threshold the fit
|
||||
* decision takes on trust rather than measuring. A four-column table of short values still fits at
|
||||
* 380px of room, technically, as four ribbons of two words each; nothing measures "unreadable",
|
||||
* and stock LuCI says 600px of viewport. 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 var(--fs-space-4) a side, 16px at the default density,
|
||||
* so 600 -> 568 of room. A fixed number and not a re-read of that token on
|
||||
* purpose: the threshold is the DESIGN judgement above, and Compact density
|
||||
* shrinking the gutter to 10px is not a reason to keep a table wider. */
|
||||
|
||||
/* ---- the remedy ladder: cheapest first, re-measured at every rung ----
|
||||
*
|
||||
* theme/30-tables.css gives a data table `overflow-wrap: break-word` for as long as it is a table,
|
||||
* so a column's min-content is an honest floor and the overflow the browser reports is honest with
|
||||
* it. (Letting any value break anywhere lowers min-content to one character, so a column is starved
|
||||
* instead of the table overflowing and `overflows()` goes blind; reconstructing the floor in JS
|
||||
* instead is UAX #14 by hand and wrong in both directions — `WPA2-PSK/CCMP` measured 144px against a
|
||||
* real 93px, so tables carded that had room.)
|
||||
*
|
||||
* That leaves one question — does it fit? — and a ladder of answers when it does not, each
|
||||
* re-measured:
|
||||
*
|
||||
* 1. it fits -> a table, and nothing was written
|
||||
* 2. drop the columns the VIEW marked droppable (`hide-xs`/`hide-sm`) and ask again
|
||||
* 3. let the widest breakable column shred, and ask again
|
||||
* 4. card it
|
||||
*
|
||||
* Rungs 2 and 3 need no threshold: the guard is the second measurement. Where one base64 key is the
|
||||
* entire problem, breaking that column makes the table fit; where every column is over its share,
|
||||
* breaking one changes nothing and the card is right.
|
||||
*
|
||||
* Rung 2 is upstream's own priority hint, honoured by measurement instead of by viewport: several
|
||||
* views mark their least valuable columns `hide-xs` and ui.Table copies the class onto every body
|
||||
* cell. */
|
||||
|
||||
/* Which cells carry the "may shred" mark, as a column index (-1 = none). Kept on the element so
|
||||
* the common case — the same column as last pass — writes nothing. */
|
||||
function markBreakColumn(t, rows, col) {
|
||||
if (t._fsBreakCol === col) return;
|
||||
t._fsBreakCol = col;
|
||||
for (const row of rows) {
|
||||
const cells = row.children;
|
||||
for (let i = 0; i < cells.length; i++)
|
||||
cells[i].classList.toggle('fs-td-break', i === col);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rung 3. The widest column that can be shredded: never the first, which is the row's identity
|
||||
* (issue #36 is that column being starved), and never a `nowrap`/`pre` one, where `overflow-wrap`
|
||||
* is inert so the mark would buy a layout and no width.
|
||||
*
|
||||
* With honest floors nothing is starved, so an overflowing table has every column at its own
|
||||
* min-content and the widest one holds the longest unbreakable token — one `getComputedStyle` and
|
||||
* one rect per column of one row, no text measurement. */
|
||||
function breakWidestColumn(t) {
|
||||
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
|
||||
if (!rows.length) return false;
|
||||
const cells = rows[0].children;
|
||||
let col = -1, widest = 0;
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const ws = getComputedStyle(cells[i]).whiteSpace;
|
||||
if (ws === 'nowrap' || ws === 'pre') continue;
|
||||
const w = cells[i].getBoundingClientRect().width;
|
||||
if (w > widest) { widest = w; col = i; }
|
||||
}
|
||||
if (col < 0) return false;
|
||||
markBreakColumn(t, rows, col);
|
||||
if (!fit.overflows(t)) return true;
|
||||
markBreakColumn(t, rows, -1);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---- a table that replaces a table inherits its answer ----
|
||||
*
|
||||
* The measurement below has to strip a table's marks first (fs-fit rule 1: a stacked table is a
|
||||
* pile of flex rows and always "fits"), which lays the page out with a full-width table for the
|
||||
* length of the pass — several screens taller than the card stack it is about to be. The poll
|
||||
* REPLACES these tables rather than updating them, so every tick hands the fitter a fresh element
|
||||
* with no marks and the intermediate happens again: at iPhone width it threw the reader 612px out
|
||||
* and back, twice per tick. Compensating for it afterwards was tried four ways and each was wrong
|
||||
* somewhere.
|
||||
*
|
||||
* So the answer is kept on the SLOT instead — the section frame (`slotHome()` says why not the
|
||||
* table's own parent), inside which a table is named by its id, or by its position among that
|
||||
* frame's tables.
|
||||
*
|
||||
* The inherited answer is trusted only while the inputs hold: the tier depends on how many columns
|
||||
* share the room and how wide the room is, so both are remembered with it. Room is `roomFor()`,
|
||||
* which reads the container rather than the table and is unaffected by the marks; the shape is the
|
||||
* column count (`shapeOf()` says why rows are not in it).
|
||||
*
|
||||
* A change in either re-measures, as does a table arriving in an unmeasured slot, as does every
|
||||
* full pass — which happens whenever the reader is not scrolling and is what catches a value that
|
||||
* grew long enough to change the tier. */
|
||||
const _slots = new WeakMap();
|
||||
|
||||
/* The slot is the frame, not the parent, and the difference is the whole cache: a poll tick hands
|
||||
* the container a freshly rendered subtree, so the table's immediate parent is usually new too and
|
||||
* every lookup would miss. What survives a tick is the `.cbi-section` frame. */
|
||||
function slotHome(t) {
|
||||
return (t.closest && t.closest('.cbi-section, .cbi-section-node, #view')) || t.parentElement;
|
||||
}
|
||||
|
||||
function slotKey(t, home) {
|
||||
if (t.id) return '#' + t.id;
|
||||
const kin = home ? Array.from(home.querySelectorAll('.table')) : [];
|
||||
const at = kin.indexOf(t);
|
||||
return at >= 0 ? 'at' + at : 'lone';
|
||||
}
|
||||
|
||||
/* What can change the answer: how many columns share the room, and how wide the room is — not how
|
||||
* many rows there are. A lease list gains and loses a row on almost every tick, so rows in this
|
||||
* signature mean the cache never hits and every tick measures from scratch, which is the tall
|
||||
* intermediate this exists to avoid.
|
||||
*
|
||||
* Read from the tree, never from layout: `children.length` is a DOM question and forces nothing. */
|
||||
function shapeOf(t) {
|
||||
const head = t.querySelector('.tr.table-titles, .cbi-section-table-titles') || t.firstElementChild;
|
||||
return String(head ? head.children.length : 0);
|
||||
}
|
||||
|
||||
function applyDecision(t, d) {
|
||||
/* the mark that lets the table into the layout at all — see theme/30-tables.css */
|
||||
t.classList.add('fs-fitted');
|
||||
t.classList.toggle('fs-stacked', !!d.stack);
|
||||
t.classList.toggle('fs-drop-xs', !!d.drop);
|
||||
if (d.breakCol !== undefined && d.breakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), d.breakCol);
|
||||
}
|
||||
|
||||
function fitTables() {
|
||||
/* mid-scroll, only the tables with no answer yet: one that has an answer needs nothing, one
|
||||
* that does not is held out of the layout by the stylesheet and cannot wait. Neither branch
|
||||
* reads layout while `scrolling()` is true. */
|
||||
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)') : stackables();
|
||||
document.querySelectorAll(sel).forEach((t) => {
|
||||
/* While the reader scrolls this pass only WRITES: every layout read here forces a
|
||||
* synchronous layout, and a poll tick can land mid-flick, so on a phone with ten tables that
|
||||
* is ten forced layouts in the middle of a flick — the work iOS holds the main thread back
|
||||
* to prevent. */
|
||||
const home = slotHome(t);
|
||||
const slots = home ? (_slots.get(home) || new Map()) : null;
|
||||
const key = slots ? slotKey(t, home) : null;
|
||||
const known = slots ? slots.get(key) : null;
|
||||
|
||||
/* The answer still goes on mid-scroll, and it must: letting a freshly polled table wait for
|
||||
* the scrolling to stop makes the reader watch the tables fold into cards under their
|
||||
* thumb, which is the hardest jerk of all. The answer is a write, forces nothing, and keeps
|
||||
* the fresh table the shape of the one it replaced. */
|
||||
if (fit.scrolling()) {
|
||||
/* the column's width, not the window's: they differ by the sidebar, so an 800px window
|
||||
* whose column is 520px would clear CRAMPED and leave the table unstacked and clipped.
|
||||
* `chrome.contentWidth()` answers from what the last fitter measured, reading no
|
||||
* layout. */
|
||||
applyDecision(t, known || { stack: chrome.contentWidth() < CRAMPED, drop: false, breakCol: -1 });
|
||||
fit.deferMeasurement();
|
||||
return;
|
||||
}
|
||||
|
||||
/* SOMEBODY ELSE'S SCROLLER, SOMEBODY ELSE'S DECISION. An app that puts its table in a box of
|
||||
* its own with `overflow-x: auto` has already answered the question this file asks, and
|
||||
* every answer here would overrule it: inside a 598px box a wide table overflows by
|
||||
* definition, so the ladder ends in a card stack however much room the page has.
|
||||
* luci-app-filemanager parks its listing in a `div.resizeable` of exactly that width and
|
||||
* came out as cards on a 1280px screen, with 1224px of column beside it (docs/third-party-
|
||||
* apps.md: what the theme may do to a foreign table). Asked before the slot's answer is
|
||||
* applied, so a table that was carded before this rule existed is un-carded rather than
|
||||
* re-carded. */
|
||||
if (fit.inScroller(t)) {
|
||||
t.classList.add('fs-fitted');
|
||||
t.classList.remove('fs-stacked', 'fs-drop-xs');
|
||||
return;
|
||||
}
|
||||
|
||||
/* the column count, read from the tree: asking forces no layout */
|
||||
const shape = shapeOf(t);
|
||||
|
||||
/* The answer goes on before anything is measured, and that order is the fix: `roomFor()`
|
||||
* forces layout, and a fresh table from a poll tick is unmarked when it lands, so measuring
|
||||
* first lays the page out with a full-width table — the tall intermediate the engine
|
||||
* re-anchors on. With the slot's answer applied first, the only layout this pass forces is
|
||||
* one where the table already wears the answer it had a second ago. */
|
||||
if (known) applyDecision(t, known);
|
||||
|
||||
const was = t.classList.contains('fs-stacked');
|
||||
/* rounded, because `roomFor()` subtracts parsed padding and answers in fractions, so an
|
||||
* exact compare misses the cache on a width that has not moved */
|
||||
const room = Math.round(fit.roomFor(t));
|
||||
if (!(room > 0)) {
|
||||
/* Detached, hidden, or a closed dialog: keep the previous answer rather than decide
|
||||
* against a width it does not have — but let it into the layout anyway. The stylesheet
|
||||
* keeps an unanswered table out of the flow, and a table whose section is collapsed on
|
||||
* arrival would otherwise stay hidden after the section is opened, since opening one
|
||||
* changes no width this file watches. */
|
||||
t.classList.add('fs-fitted');
|
||||
return;
|
||||
}
|
||||
|
||||
/* On its first pass the table is not measurable, because this file's own gate hides it:
|
||||
* theme/30-tables.css holds a `.fs-dt` out of the layout until something marks it
|
||||
* `.fs-fitted`, so the pass reads `scrollWidth: 0` and 0 overflows nothing. Concluding "it
|
||||
* fits" and caching that leaves the table at its natural width, clipped by
|
||||
* `.fs-main { overflow-x: clip }` with no scrollbar: measured at 768px, 777px of table inside a
|
||||
* 712px column, and a page that renders once and stands still never gets a second pass to
|
||||
* correct it.
|
||||
*
|
||||
* So a zero measurement is not an answer: lift the gate so the next frame can see the
|
||||
* table, ask for that frame, and write nothing to the slot. The schedule is asked for once,
|
||||
* so a table hidden for somebody else's reason cannot turn this into a frame loop. */
|
||||
if (t.scrollWidth === 0) {
|
||||
if (!t.classList.contains('fs-fitted')) {
|
||||
t.classList.add('fs-fitted');
|
||||
fit.schedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* The slot's answer holds for a table of the same shape in the same room — unless the table
|
||||
* has since outgrown it: a poll tick can put longer values into the same columns, and a
|
||||
* table that fitted a second ago then stands 1000px wide in an 810px column, clipped with
|
||||
* nothing to say so. That question uses the layout this pass already has.
|
||||
*
|
||||
* The other direction cannot be asked that way. `overflows()` is put to a table already
|
||||
* wearing its remedy, and every remedy makes a table fit by construction, so the answer is
|
||||
* always yes and a remedy applied once could never be lifted — one long hostname makes
|
||||
* Associated Stations drop its `hide-xs` columns, and they stay hidden after that station
|
||||
* leaves. An honest measurement needs the marks stripped, which is the visible relayout the
|
||||
* deferral above keeps out of a scroll, so the re-decision is asked for by the CONTENT
|
||||
* instead: a remedied table whose row count has gone DOWN has lost something, and that is
|
||||
* the only way it can stop needing the remedy. Rows are counted from the tree. */
|
||||
const rows = t.querySelectorAll('.tr').length;
|
||||
const remedied = !!(known && (known.stack || known.drop || known.breakCol !== -1));
|
||||
const shrank = remedied && known.rows !== undefined && rows < known.rows;
|
||||
if (known && known.room === room && known.shape === shape && !shrank && !fit.overflows(t))
|
||||
return;
|
||||
|
||||
/* 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. Every mark the ladder can
|
||||
* write comes off here. */
|
||||
t.classList.remove('fs-stacked', 'fs-drop-xs');
|
||||
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
|
||||
/* the one judgement a measurement cannot make */
|
||||
let stack = room < CRAMPED;
|
||||
if (!stack && fit.overflows(t)) {
|
||||
/* rung 2 — the view author already said which columns are expendable */
|
||||
if (t.querySelector('.hide-xs, .hide-sm')) t.classList.add('fs-drop-xs');
|
||||
/* rung 3 — one column may shred rather than the whole table becoming a stack of cards */
|
||||
if (fit.overflows(t) && !breakWidestColumn(t)) stack = true;
|
||||
}
|
||||
|
||||
t.classList.add('fs-fitted');
|
||||
/* write only on a real change: the poll re-renders these tables on every tick, and toggling
|
||||
* the class each tick invalidates style for every row for nothing */
|
||||
if (stack) {
|
||||
t.classList.add('fs-stacked');
|
||||
/* a card gives every value the whole row, so neither remedy has anything left to do */
|
||||
t.classList.remove('fs-drop-xs');
|
||||
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
}
|
||||
else if (was) t.classList.remove('fs-stacked');
|
||||
|
||||
if (slots) {
|
||||
slots.set(key, {
|
||||
stack,
|
||||
drop: t.classList.contains('fs-drop-xs'),
|
||||
breakCol: (t._fsBreakCol === undefined) ? -1 : t._fsBreakCol,
|
||||
room,
|
||||
shape,
|
||||
/* what the answer was decided over: a drop below this is the one signal that a
|
||||
* remedy may no longer be needed (see the fast path above) */
|
||||
rows,
|
||||
});
|
||||
_slots.set(home, slots);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- and the table that cannot card: it scrolls, and it says so ----
|
||||
*
|
||||
* A table with no header row has no captions to print, so a card would give a column of values with
|
||||
* nothing saying what they are — a log, a statistics matrix, a key/value include. Comparison across
|
||||
* rows is that shape's purpose, which is the case WCAG 2.2 names in the exception to SC 1.4.10
|
||||
* ("data tables and grids… it is acceptable to provide two-dimensional scrolling for such parts").
|
||||
* theme/30-tables.css already scrolls a bare `<table>` of that shape; this reaches the one it
|
||||
* cannot name — an app rendering the same thing as `<div class="table">` — by measurement rather
|
||||
* than by selector.
|
||||
*
|
||||
* A scrolling table cannot hold a popup: `overflow-x: auto` computes `overflow-y` to `auto` too
|
||||
* (css-overflow-3 §3.1), so it clips everything absolutely positioned inside it, and luci-base's
|
||||
* `openDropdown()` sizes an open list against the nearest scroll parent.
|
||||
*
|
||||
* So the ones that hold CONTROLS stack instead. Their rows are a form, not data, so there is no
|
||||
* comparison across rows to preserve; rows and cells become blocks, each control takes the width it
|
||||
* was given, and nothing becomes a scroll container. Doing nothing for them is a real defect —
|
||||
* Network -> Diagnostics is three controls in one header-less row needing 338px in 320px, and the
|
||||
* Ping button was simply unreachable past `.fs-main`'s clip. The shape is not that page's, and the
|
||||
* width that matters is the room rather than the viewport, so this measures instead of naming a
|
||||
* page. */
|
||||
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)');
|
||||
const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]';
|
||||
|
||||
/* ---- making a scroll box reachable, and handing the markup back as it was found ----
|
||||
*
|
||||
* Firefox has made scrollers focusable for years and Chrome since 132, but only when they hold no
|
||||
* focusable child, and WebKit has not shipped it at all (bug 190870). So say it in the markup: a
|
||||
* tab stop (SC 2.1.1) and a name for what receives focus (SC 4.1.2).
|
||||
*
|
||||
* A role is written only where there is none to lose. `<div class="table">` has no implicit role
|
||||
* and takes `group`; a real `<table>` keeps its own, because HTML-AAM maps `<td>` to `cell` and
|
||||
* `<th>` to `columnheader`/`rowheader` only while the table's role is `table` (or `grid`/
|
||||
* `treegrid`), so `group` would drop every cell to generic and leave a screen reader reading a flat
|
||||
* run of text.
|
||||
*
|
||||
* What is removed is what was written, remembered per element rather than inferred from the value:
|
||||
* "is it 0 / is it group" cannot tell whose attribute it is. */
|
||||
function reach(t) {
|
||||
if (!t.hasAttribute('tabindex')) { t.tabIndex = 0; t._fsTab = true; }
|
||||
if (!t.hasAttribute('role') && !(t instanceof HTMLTableElement)) {
|
||||
t.setAttribute('role', 'group');
|
||||
t._fsRole = true;
|
||||
}
|
||||
if (!t.hasAttribute('aria-label') && !t.hasAttribute('aria-labelledby')) {
|
||||
const head = t.closest('.cbi-section, fieldset, #view')?.querySelector('h2, h3, h4, legend');
|
||||
t.setAttribute('aria-label', (head && head.textContent.trim()) || _('Table'));
|
||||
t._fsNamed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function unreach(t) {
|
||||
if (t._fsTab) { t.removeAttribute('tabindex'); delete t._fsTab; }
|
||||
if (t._fsRole) { t.removeAttribute('role'); delete t._fsRole; }
|
||||
if (t._fsNamed) { t.removeAttribute('aria-label'); delete t._fsNamed; }
|
||||
}
|
||||
|
||||
function fitScrollables() {
|
||||
/* a layout read here lands mid-flick once per poll tick — see fitTables(). Put off until the
|
||||
* page is still; fs-fit's sampler runs what was deferred once the offset stops changing. */
|
||||
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
||||
document.querySelectorAll(scrollables()).forEach((t) => {
|
||||
const was = t.classList.contains('fs-xscroll');
|
||||
const wasStack = t.classList.contains('fs-rowstack');
|
||||
/* rule 1 again: a scrolling box always "fits", its overflow being inside it, and so does a
|
||||
* stacked one, whose rows are blocks */
|
||||
t.classList.remove('fs-xscroll', 'fs-rowstack');
|
||||
if (!(fit.roomFor(t) > 0)) {
|
||||
if (was) t.classList.add('fs-xscroll');
|
||||
if (wasStack) t.classList.add('fs-rowstack');
|
||||
return;
|
||||
}
|
||||
const over = fit.overflows(t);
|
||||
const controls = over && !!t.querySelector(HOLDS_CONTROLS);
|
||||
/* a table of CONTROLS stacks; a table of VALUES scrolls and keeps its shape */
|
||||
if (controls) {
|
||||
t.classList.add('fs-rowstack');
|
||||
if (was) unreach(t);
|
||||
return;
|
||||
}
|
||||
const scroll = over;
|
||||
if (scroll === was) { if (was) t.classList.add('fs-xscroll'); return; }
|
||||
if (scroll) {
|
||||
t.classList.add('fs-xscroll');
|
||||
/* a scroll box the keyboard cannot reach is content the keyboard cannot read — see
|
||||
* reach(). `group`, not `region`: a region is a landmark, and a status page with four
|
||||
* scrolling tables would put four of them in the landmark list. */
|
||||
reach(t);
|
||||
}
|
||||
else if (was) unreach(t);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- a pinned actions column is only valid for the layout it was measured in ----
|
||||
*
|
||||
* luci-base's form.js (stabilizeActionColumnWidth) measures the widest `td.cbi-section-actions > div`
|
||||
* and writes it as an inline `width`/`min-width` on the header, footer and every actions cell,
|
||||
* caching it in `data-action-col-width`. Its resize handler deletes only the CACHE, never the inline
|
||||
* widths, so the fresh measurement reads the width it pinned last time: the pin feeds itself and can
|
||||
* only grow.
|
||||
*
|
||||
* On a stock theme that is invisible, a config table being a table at every width. This theme cards
|
||||
* it under `@container fs-content (max-width: 960px)`, where the actions cell is `flex: 1 1 100%`
|
||||
* and its buttons spread across the whole card, so a measurement taken there is the CARD's width —
|
||||
* carried into table mode, firewall/zones pins 634px and scrolls sideways by 256px, permanently,
|
||||
* against 192px on a fresh load at the same width.
|
||||
*
|
||||
* So drop the pin whenever the layout it was measured in stops being the layout on screen; upstream
|
||||
* re-measures from a clean DOM and pins the right number, and if it does not, the natural width is
|
||||
* what we wanted.
|
||||
*
|
||||
* The key is the ROOM, not the mode: the card getting narrower goes stale the same way, and at 768px
|
||||
* (no sidebar, 712px column) against 800px (sidebar back, 520px column) the table is carded on both
|
||||
* sides, so a mode test sees no change while a 670px cell sits in a 520px column. A mode change
|
||||
* cannot happen without a room change, so the room catches both.
|
||||
*
|
||||
* The room is the parent's content box, 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. */
|
||||
function unpinActionColumn() {
|
||||
/* a layout read here lands mid-flick once per poll tick — see fitTables() */
|
||||
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
||||
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table'))) {
|
||||
if (!t.querySelector('.cbi-section-actions')) continue;
|
||||
/* Claim upstream's resize hook, which under SPA navigation is a leak:
|
||||
* stabilizeActionColumnWidth attaches a `resize` listener once per TABLE ELEMENT, guarded by
|
||||
* this expando, and nothing removes it. On a stock theme it dies with the document; here the
|
||||
* document lives for the session, so every visit to a config page leaves another listener
|
||||
* holding another detached table — 1 to 31 listeners over 120 navigations, ~11.8 KB per
|
||||
* navigation that never plateaus.
|
||||
*
|
||||
* Setting the flag first means the listener is never attached, and nothing is lost: the wipe
|
||||
* below re-measures from the room instead of 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; a table reached late simply keeps upstream's
|
||||
* listener. */
|
||||
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 schedules a full
|
||||
* scan, and the poll rewrites content on every tick: three document-wide querySelectorAll plus a
|
||||
* choicesKey() over every option of every enhanced select, per tick, to discover that nothing
|
||||
* 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 */
|
||||
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,
|
||||
* which an <option> is neither — so the batch is dropped, the widget keeps the old list, and
|
||||
* picking from it writes a value the new list does not contain. CBI dependency handling
|
||||
* rebuilds option lists constantly. */
|
||||
if (m.type === 'childList' && m.target.tagName === 'SELECT')
|
||||
return true;
|
||||
}
|
||||
/* `.table`, not `table.table` — the `.table` half of the selector tagDataTables() uses. Additions
|
||||
* only: a select or a table going away costs 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 does only
|
||||
* Esc/Enter/Space/arrows.
|
||||
*
|
||||
* 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; the buffer
|
||||
* resets after a pause; the same letter repeated cycles; the label matches first and the value
|
||||
* second, so "RU" and "Russia" both find it. Space is excluded — ui.Dropdown binds it to "toggle
|
||||
* the focused item" and its handler fires first.
|
||||
*
|
||||
* 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) {
|
||||
/* sb is always `.cbi-dropdown[open]` here, and stock ui.js sets that attribute and adds
|
||||
* `ul.dropdown` in the same openDropdown() call (openwrt/luci ui.js), so the two never
|
||||
* disagree — verified on openwrt-24.10. */
|
||||
const ul = sb.querySelector('ul.dropdown');
|
||||
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();
|
||||
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;
|
||||
|
||||
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 rather
|
||||
* than swallow 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) inst.setFocus(sb, hit, true);
|
||||
|
||||
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();
|
||||
|
||||
/* Arm the stylesheet's gate from here, because this is the file that clears it: the rule in
|
||||
* theme/30-tables.css holds an unanswered data table out of the layout, and `.fs-fitted` is
|
||||
* written nowhere but in this module. Armed in fs-fit.js, a document that loaded that file
|
||||
* and not this one (the footer requires them separately) hid every table forever. */
|
||||
fit.armGate();
|
||||
|
||||
/* A table must be tagged .fs-dt before it can be fitted, and re-tagged whenever the poll
|
||||
* brings a fresh one back, so tagging leads; roleTables() follows it directly so a class
|
||||
* adoptMarkup() just added carries its role in the same pass rather than waiting a tick.
|
||||
*
|
||||
* Six registrations, not one callback: fs-fit catches per registered fitter, and the first
|
||||
* of these walks third-party markup. Bundled, one throw in `tagDataTables()` leaves no table
|
||||
* tagged and — with the gate above raised — a page with no tables on it at all. */
|
||||
fit.add(tagDataTables);
|
||||
fit.add(roleTables);
|
||||
fit.add(fitTables);
|
||||
fit.add(fitScrollables);
|
||||
fit.add(unpinActionColumn);
|
||||
fit.add(resyncValues);
|
||||
|
||||
/* one scan per frame, however many mutations arrive (fit.frame is the shared coalescer) */
|
||||
const scanSoon = fit.frame(scan);
|
||||
new MutationObserver((mutations) => {
|
||||
if (relevant(mutations)) scanSoon();
|
||||
}).observe(document.body, {
|
||||
childList: true, subtree: true,
|
||||
/* `disabled` flips and attribute-driven value writes never mutate childList, so watch
|
||||
* them for resync()/enhance() */
|
||||
attributes: true, attributeFilter: [ 'disabled', 'value', 'selected' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,800 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* ---- a view's injected CSS: never DELETE it; leave a poisoned document to a real load ----
|
||||
*
|
||||
* A view's <style> dies with the document on a full load, but SPA nav never reloads, so it restyles
|
||||
* every page after. `luci-app-filemanager` injects
|
||||
* `.cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered and
|
||||
* important, outranking every cascade layer, so one visit removes Save/Reset from every config page.
|
||||
*
|
||||
* Deleting such sheets on navigation is not the answer: a stylesheet only returns if its injector
|
||||
* runs again, and a library importing CSS at module eval never does (the module is cached for the
|
||||
* life of the document). ACE's 14 KB editor sheet is imported once, and after a sweep its editor
|
||||
* came back as a black rectangle two million pixels tall. Deletion is silently one-way.
|
||||
*
|
||||
* So: a sheet matching only its own app's widgets is inert elsewhere and is left alone. One
|
||||
* reaching into the widget universe the THEME styles can repaint any page, which spends the
|
||||
* document — the router then refuses to hand it to another view and falls back to a real page load,
|
||||
* trading speed and never correctness (fs-router.js owns that refusal; this module answers the
|
||||
* question).
|
||||
*
|
||||
* `invasiveSheet()` is that test, and its universe is read back from cascade.css itself rather than
|
||||
* from a hand-written list, so it tracks the theme; 0.3 ms per nav. Exempt: anything the server
|
||||
* marked `[data-fs-shell]` (partials/head.ut) and anything inside `#view`, which dies with the
|
||||
* content swap. LuCI core injects no <style> at runtime (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: the vocabulary the invasive-sheet test
|
||||
* is written in. One copy, because widening it in the harvester alone puts names into `names` that
|
||||
* pinnedToApp() can never match, and a selector that does reach the chrome then reads as pinned and
|
||||
* is left unfenced.
|
||||
*
|
||||
* Shared safely because every use is String.match(): a /g regex is stateful under .test(), while
|
||||
* [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 would otherwise read it as syntax ----
|
||||
*
|
||||
* `[title="a,b"]` is one selector part carrying a comma, `[href*="("]` one attribute carrying an
|
||||
* unbalanced paren, `[data-x=".foo"]` names no class at all. Read literally each scanner gets a
|
||||
* different wrong answer: selectorParts() splits the part in two and the tail reads as unpinned,
|
||||
* fenceRules() rejoins it as `[title="a, b"]` and silently rewrites the app's own selector, and
|
||||
* stripPseudoArgs() drives `depth` into a hole and eats the rest of the selector.
|
||||
*
|
||||
* One masker answers it for all three. It replaces the CONTENT of every quoted string with spaces,
|
||||
* length-preserving 1:1, so a scanner can read the mask and still slice the ORIGINAL — the fence
|
||||
* must write back the app's own bytes. 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, and dedupeViewSheets keys on the
|
||||
* original — otherwise the app's next identical copy stops looking like a duplicate */
|
||||
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() masks, so it could never
|
||||
* match — see NAME_RE */
|
||||
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 must exist for it to match, so
|
||||
* it can reach neither another page nor our chrome. A part made entirely of names the theme knows
|
||||
* (`*`, `.nav`, `ul.nav > li > a`) has nothing pinning it anywhere.
|
||||
*
|
||||
* Functional pseudo-class arguments are stripped before looking for the pin, which is the whole
|
||||
* difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
|
||||
* an app class inside a NEGATION, so it excludes the app's markup rather than requiring it.
|
||||
*
|
||||
* Shared by invasiveSheet() and fenceRules(), which must agree by construction: a part able to
|
||||
* reach another page is exactly a part able to reach the chrome. */
|
||||
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
|
||||
* part carrying a comma, and both halves then reach pinnedToApp() as garbage — the tail keeps a
|
||||
* visible app name, so the file manager's motivating rule reads as pinned and is neither judged nor
|
||||
* fenced.
|
||||
*
|
||||
* Scans the mask and slices the original (see maskStrings): a comma inside `[title="a,b"]` is a
|
||||
* character in a value, and fenceRules() joins the parts 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. A regex cannot: stopping at the
|
||||
* first `)` leaves `:not(:is(.app))` as a stray paren plus a `.app` that looks like a pin.
|
||||
*
|
||||
* Works on the mask, so a paren inside `[href*="("]` cannot drive `depth` into a hole and a `.foo`
|
||||
* inside an attribute value cannot read as the app's own pin. The output is read by NAME_RE and
|
||||
* never written back to the CSSOM. */
|
||||
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`, `*`) cannot touch us if none of its DECLARATIONS
|
||||
* can: a custom property this theme never reads is inert wherever it lands, which is the difference
|
||||
* between an app costing a full page load and not.
|
||||
*
|
||||
* Still invasive: any standard property on a bare selector (`:root { color-scheme: light dark }`
|
||||
* re-points every UA widget at the OS preference), and any custom property the theme reads — the
|
||||
* private `--fs-*` tier exists so an app writing `--accent` on `:root` cannot repaint us. */
|
||||
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 must be: once 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.
|
||||
*
|
||||
* Only `true` is kept. A clean sheet can still grow hostile rules — an app building its CSS with
|
||||
* insertRule() has an empty sheet the first time we look — so a clean verdict stays provisional. */
|
||||
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, so
|
||||
* judgeSheet's "unreadable -> invasive" default fires for every linked app stylesheet the
|
||||
* instant the observer sees it; remembering that turns a benign sheet into a permanently spent
|
||||
* document and every navigation from that page into a full load.
|
||||
*
|
||||
* Re-taking is safe and does not break the rule above, because the cases do not overlap: a
|
||||
* <style> in the document always has a sheet, so its verdict is cached when taken, and a <link>
|
||||
* is never edited (the element is disabled and the href re-imported), so a later read still sees
|
||||
* the app's untouched CSS.
|
||||
*
|
||||
* The conservative half is preserved: `v` is returned as taken, so an unreadable sheet is still
|
||||
* fenced on sight. 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 pinned inside the
|
||||
* app's own markup? A part with no such pin matches the same widgets everywhere,
|
||||
* and that is what invasive means.
|
||||
*
|
||||
* Do not ask "does it name anything the theme styles?" first: a pin is a name the
|
||||
* theme does NOT know, so "names nothing of ours" reads as pinned when it often
|
||||
* means the opposite — `*:not(#zzz)` and `[class]` are both unpinned and both match
|
||||
* the whole document, and the sheet was judged clean while flattening 95 of 338
|
||||
* chrome elements. */
|
||||
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, including our own re-hosting shim. An unreadable import is 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: `luci-app-banip` and `luci-app-adblock` append a <link> to <head> at
|
||||
* module eval styling stock widgets, unlayered, on every page. A <link> inside the view tree needs
|
||||
* no handling — it dies with the swap. */
|
||||
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
|
||||
|
||||
/* Does this sheet outlive the page it arrived with? Everything this module decides hangs off that,
|
||||
* and the answer is where the element sits: a sheet inside the view tree dies with the swap, so it
|
||||
* can neither poison the next page, nor need scoping, nor be a duplicate worth removing. Named
|
||||
* because `!el.closest('#view')` states where an element is, not what follows from it. */
|
||||
function outlivesPage(el) {
|
||||
return !el.closest('#view');
|
||||
}
|
||||
|
||||
/* Is `path` — a menu.d node's `css` — already carried by this document in a form that SURVIVES a
|
||||
* swap? The router asks before committing a client navigation, since only a server render emits
|
||||
* that <link> and a page whose stylesheet is missing must arrive by full load (fs-router.js). A
|
||||
* link inside #view does not count: it is about to be deleted with the view.
|
||||
*
|
||||
* Whole path, never a suffix. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…` from the
|
||||
* same base value the runtime holds, so the server's href is reconstructable rather than guessable
|
||||
* — only the cache key comes off. A suffix match would let a third-party node declaring
|
||||
* `"css": "custom.css"` read as already-carried on the strength of `luci-app-adblock`'s own
|
||||
* custom.css, and the router would swap into a page whose stylesheet was never linked.
|
||||
*
|
||||
* Equality also fails in the direction that only costs speed: L.path() drops a path containing
|
||||
* characters it does not allow (no `+ ~ ( ) @ ! ' $ &`, nothing non-ASCII), while head.ut
|
||||
* interpolates `css` raw, so such a node's `want` collapses to the bare base, matches nothing, and
|
||||
* every entry into that page is a full load. No in-tree node sets `css`, so the shape is
|
||||
* third-party only. */
|
||||
function documentCarries(path) {
|
||||
const want = L.resource(String(path));
|
||||
for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
|
||||
if (!outlivesPage(link))
|
||||
continue;
|
||||
if ((link.getAttribute('href') || '').split('?')[0] === want)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* An invasive sheet we OWN is contained: scopeToCurrentPage() darkens it the moment the router
|
||||
* stamps the new page. One we could not attribute (an @import at the top, a sheet built with
|
||||
* insertRule(), anything unreadable) still spends the document, which is the conservative half.
|
||||
*
|
||||
* A SILENCED sheet is contained too. Re-hosting a <link> owns the @import shim and silences the
|
||||
* original for good, but the original stays in the document and a disabled sheet still answers
|
||||
* `cssRules` — counting it would re-judge it invasive on every ask, owned by nobody, and turn the
|
||||
* SPA router off for the life of the document on exactly the apps this module was written for.
|
||||
*
|
||||
* Sound because `el.sheet.disabled = true` is what decides whether CSS paints (see silence()) and
|
||||
* nothing re-enables it: scopeToCurrentPage() only touches sheets in `_owner`, and the silenced
|
||||
* original is deliberately not one. */
|
||||
function documentPoisoned() {
|
||||
const names = themeNames();
|
||||
return Array.from(document.querySelectorAll(VIEW_SHEETS)).some(
|
||||
(el) => outlivesPage(el)
|
||||
&& (!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, where 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 `* { margin: 0; padding: 0 }` owns the
|
||||
* chrome outright — on a layer-less theme the same `*` (0,0,0) loses to any class selector. The
|
||||
* layers are what hand it the win (issue #8).
|
||||
*
|
||||
* So put the sheet back on specificity footing by re-hosting it into the EXISTING `theme` layer;
|
||||
* only same-layer arbitrates by specificity. Measured 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
|
||||
*
|
||||
* Do not take the third shape: the theme would then beat the app at any specificity, including the
|
||||
* rules the app aims at its own page (openclash's `#tab-header ul.cbi-tabmenu li` against our
|
||||
* `ul.cbi-tabmenu li`). 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 names.
|
||||
*
|
||||
* 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. An unlayered `*` beats base
|
||||
* today too, and the only way out would re-break the app.
|
||||
*
|
||||
* Never delete the sheet instead. Re-hosting moves where a rule lands in the cascade and 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> up by href still finds it. */
|
||||
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
|
||||
*
|
||||
* Re-hosting settles a fight on specificity but not one against `!important`, which ranks above
|
||||
* layers. The only pure-CSS answer to a foreign flag is our own flag in an earlier layer — ~550 of
|
||||
* them, and the `color`/`background` ones would beat this theme's own forced-colors block.
|
||||
*
|
||||
* 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 except us, and `!important` has nothing left to win.
|
||||
*
|
||||
* The chrome is not one element, so the fence names no element: naming `.fs-sidebar` left the skip
|
||||
* link and the search overlay exposed while every test said the chrome was defended. With
|
||||
* `data-fs-chrome` an element declares that it is ours where it is written, and the fence and the
|
||||
* pin follow. `npm run chrome-fence` holds the three together.
|
||||
*
|
||||
* `:where()` is load-bearing: it contributes zero specificity, so 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 re-order the app's stylesheet against itself.
|
||||
*
|
||||
* Only unpinned parts are fenced: a pinned part cannot reach the chrome anyway.
|
||||
*
|
||||
* Two silent traps, 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, leaving `*, ul` as `*, ul:where(…)`;
|
||||
* - a pseudo-element must stay last — `a::after` plus a tail append serialises to
|
||||
* `a::after:where()`, the argument silently eaten, matching nothing, and the setter reports
|
||||
* success. The fence goes before it: `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 `::`, legacy `:before` included. The split
|
||||
* point is found on the mask and sliced out of the original, because `::` is legal inside a
|
||||
* quoted attribute value (an IPv6 literal): read raw, the fence lands inside the quotes and the
|
||||
* app's rule comes back matching a value it never wrote, as valid CSS the setter accepts. */
|
||||
const i = maskStrings(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 a failure leaves the rule unfenced rather than
|
||||
* half-written */
|
||||
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 separately fetched sheet, so they are not there when the shim is
|
||||
* inserted: retry until they are, then fence. A sheet that never becomes readable stays unfenced,
|
||||
* which is where we already were.
|
||||
*
|
||||
* The bound is a deadline in ms, not a frame count — 60 frames is 1 s at 60 Hz and 2 s on a 30 Hz
|
||||
* panel, so the budget would move with the display. Frames remain the retry tick, since a cache hit
|
||||
* lands on the first one. */
|
||||
function fenceImported(styleEl, names, until) {
|
||||
/* no initialiser: every path below assigns, so `= null` would be a dead store
|
||||
* (no-useless-assignment) */
|
||||
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 (Date.now() < until) { requestAnimationFrame(() => fenceImported(styleEl, names, until)); return; }
|
||||
/* Giving up is reportable: the irreversible half already happened — the app's original is
|
||||
* silenced and the shim owns the page — so a fence that never lands leaves the app's rules
|
||||
* reaching the chrome while every later pass skips the sheet as handled. rAF does not fire in a
|
||||
* background tab, so a page opened in one can pass the deadline without a single retry. */
|
||||
console.error('footstrap: could not read the re-hosted @import within the deadline — the sheet '
|
||||
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
|
||||
}
|
||||
|
||||
/* what a sheet IS, as text: the rules that are applying, not the markup that may have produced
|
||||
* them. Serialised only to compare, never re-parsed. */
|
||||
const serializeRules = (rules) => Array.from(rules, (r) => r.cssText).join('\n');
|
||||
|
||||
/* ---- a <style>'s textContent is NOT its sheet ----
|
||||
*
|
||||
* Wrapping re-sets textContent, which re-parses, and whatever the parse does not reproduce is
|
||||
* deleted — by the one fix in this file whose thesis is that deleting a view's CSS is one-way. Two
|
||||
* shapes where the text does not describe the sheet: a <style> filled by insertRule() (the text is
|
||||
* empty while the rules apply, so the wrap writes `@layer theme {}` over a live sheet), and one
|
||||
* carrying @import (invalid inside @layer, so the wrapped copy comes back without it).
|
||||
*
|
||||
* So ask the exact question — does re-parsing this text give back the sheet that is applying? —
|
||||
* rather than enumerate the shapes, which is what missed insertRule(). The probe is a constructible
|
||||
* sheet: never adopted, so nothing paints and no observer sees it. It also drops @import per spec,
|
||||
* so that case needs no test of its own.
|
||||
*
|
||||
* No probe means no answer, and the honest answer to "may I re-parse this?" is then no: the sheet
|
||||
* keeps every rule and the fence still holds the chrome without the wrap. */
|
||||
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. Remembered because a
|
||||
* silenced sheet is still an element that answers `cssRules`, so every later ask re-judges it
|
||||
* invasive; see documentPoisoned(). */
|
||||
const _silenced = new WeakSet();
|
||||
|
||||
/* Take a re-hosted <link> out of the cascade, and mean it.
|
||||
*
|
||||
* `el.disabled = true` alone does not: the IDL attribute forwards to the ELEMENT's flag, while
|
||||
* `el.sheet.disabled` is what decides whether the CSS paints — and a <link> that is still loading
|
||||
* has no `.sheet` for the assignment to reach. Every runtime injection is in that state when the
|
||||
* observer hands it here, so the sheet comes up enabled when the bytes arrive and the app's
|
||||
* original, unfenced CSS paints beside the fenced shim (95 of 338 chrome elements flattened). */
|
||||
function silence(el) {
|
||||
_silenced.add(el);
|
||||
setEnabled(el, false);
|
||||
}
|
||||
|
||||
/* ---- 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. Treating every
|
||||
* invasive sheet as spending the document is correct but is paid by ordinary pages: stock
|
||||
* `luci-app-filemanager` lands two <style>s in <head>, both invasive on their bare selectors, and
|
||||
* `luci-app-ssclash` adds four more as the Ace editor initialises. With ownership, leaving either
|
||||
* page stays an in-place SPA navigation (medians 24 ms and 27 ms) instead of a full load.
|
||||
*
|
||||
* Removing the sheet on the way out is the obvious fix and the wrong one: an append at module top
|
||||
* level happens once, because `L.require` caches the module, so a second visit renders unstyled.
|
||||
* Disabling is reversible, which is the whole difference.
|
||||
*
|
||||
* Owner = the page in `body[data-page]` when the sheet was re-hosted. fs-router's navigate() stamps
|
||||
* data-page BEFORE it require()s the view class, so when a view module evaluates and appends its
|
||||
* <style> the attribute already names its page; on a full load the server stamped it.
|
||||
*
|
||||
* Recorded on the element that PAINTS, never on the permanently silenced one: for a <link> that is
|
||||
* the @import shim, and re-enabling the original would undo silence() and put the app's unfenced
|
||||
* CSS back over the chrome.
|
||||
*
|
||||
* The owner is the APP, not the page: an app whose pages share one injected <style> would have it
|
||||
* owned by whichever page loaded first and arrive dark on the others. `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 onto other apps and onto stock pages.
|
||||
*
|
||||
* Segments, never the dash-joined `data-page`: a dispatch segment may 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; 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 ----
|
||||
*
|
||||
* `L.env.dispatchpath` is the leaf the SERVER resolved this request to, while the address bar holds
|
||||
* what was asked for: LuCI's dispatcher walks a node down to its firstchild without rewriting the
|
||||
* URL, so `/cgi-bin/luci/admin/status` and `/cgi-bin/luci/` both dispatch to
|
||||
* `admin/status/overview`. Keyed on the URL, the first SPA navigation away disables the sheets that
|
||||
* page owns — and the app's original <link> is already silenced for good, so nothing paints them
|
||||
* again for the life of the document. */
|
||||
/* Who a sheet injected right now belongs to, when that is not the page the chrome is showing.
|
||||
*
|
||||
* A require in flight cannot be stopped: click a page whose module injects CSS, click away before
|
||||
* it lands, and the <style> appears after the router has stamped data-page for the page that
|
||||
* superseded it. Credited to currentKey() it is bound to the wrong page for the life of the
|
||||
* document — disabled on its own page and enabled on one it has no business painting. So the router
|
||||
* names the owner for the duration of such a require (fs-router.js), and this is that hint.
|
||||
*
|
||||
* One slot, stamped with the navigation that set it. The router only names an owner for a require
|
||||
* that has yet to evaluate its module. Two cold requires can still overlap, and then the newer one
|
||||
* wins the slot — it is the page the user is looking at, and crediting its sheet to the superseded
|
||||
* page would leave the visible page unpainted. The generation stamp keeps the older require's
|
||||
* `.finally` from clearing a slot the newer one now holds.
|
||||
*
|
||||
* Nothing better is available: LuCI evaluates a view module inside `eval()` in its own require(),
|
||||
* so nothing observable says which module is running when a <style> appears. The remaining hole is
|
||||
* two cold requires overlapping. */
|
||||
let _ownerHint = null;
|
||||
let _ownerGen = -1;
|
||||
function attributeTo(segs, gen) {
|
||||
/* a stale require letting go of a slot somebody else now holds: leave it alone */
|
||||
if (segs == null && gen !== _ownerGen) return;
|
||||
_ownerHint = (segs == null) ? null : appKey(segs);
|
||||
_ownerGen = (segs == null) ? -1 : gen;
|
||||
}
|
||||
|
||||
function ownerKey() {
|
||||
return (_ownerHint !== null) ? _ownerHint : currentKey();
|
||||
}
|
||||
|
||||
/* L.env.dispatchpath (ctx.path) is stamped by luci-base's own header.ut before any theme script
|
||||
* runs, on every page including blank_page — verified on openwrt-24.10, no location.pathname
|
||||
* fallback needed. */
|
||||
function currentKey() {
|
||||
if (_curKey !== null) return _curKey;
|
||||
return appKey(L.env.dispatchpath);
|
||||
}
|
||||
|
||||
/* Both halves, for the reason silence() gives: el.disabled is the element's flag, el.sheet.disabled
|
||||
* decides whether the CSS paints, and a still-loading <link> has no .sheet for the assignment to
|
||||
* reach — so a switch-off re-asserts once the bytes arrive. `once`, because the element is marked
|
||||
* fsLayered by then and a sheet that never loads has nothing to silence. */
|
||||
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 and keeps the full-load path rather than being disabled on its own page.
|
||||
*
|
||||
* `keep` is the page still ON SCREEN. A client navigation has a window in which two pages are real
|
||||
* — the incoming one renders into a hidden stage while the outgoing one is still being read — and
|
||||
* scoping is needed before that staged render, since a view must not measure itself through a sheet
|
||||
* that does not own its page. Doing both halves then would strip the outgoing page's stylesheet off
|
||||
* content the user is looking at, so the router calls this twice: with `keep` before the staged
|
||||
* render, and with nothing at the swap. */
|
||||
function scopeToCurrentPage(segs, keep) {
|
||||
if (segs) _curKey = appKey(segs);
|
||||
const key = currentKey();
|
||||
const spared = (keep && keep.length) ? appKey(keep) : null;
|
||||
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
||||
if (!outlivesPage(el) || !_owner.has(el)) return;
|
||||
const owner = _owner.get(el);
|
||||
if (spared !== null && owner === spared && owner !== key) return;
|
||||
setEnabled(el, owner === key);
|
||||
});
|
||||
}
|
||||
|
||||
/* Take a sheet and scope it in the same breath. scopeToCurrentPage() runs on NAVIGATION, so it
|
||||
* only sees sheets that were already here; one arriving afterwards is scoped by nobody until the
|
||||
* next click — which is exactly the cold-require case the owner hint exists for, where the sheet
|
||||
* paints a page it does not belong to in the meantime.
|
||||
*
|
||||
* `ownerKey()` is the page the sheet belongs to and `currentKey()` the page on screen; on every
|
||||
* ordinary arrival they are the same string and this is a no-op. */
|
||||
function claimOwner(el) {
|
||||
const key = ownerKey();
|
||||
_owner.set(el, key);
|
||||
if (outlivesPage(el)) setEnabled(el, key === currentKey());
|
||||
}
|
||||
|
||||
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 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);
|
||||
claimOwner(s);
|
||||
fenceImported(s, universe.names, Date.now() + 1000); /* a cache hit lands on the first frame */
|
||||
return;
|
||||
}
|
||||
|
||||
let rules;
|
||||
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
||||
/* No rules yet: nothing to re-host, nothing to fence and nothing to MARK. An app that appends an
|
||||
* empty <style> and fills it with insertRule() arrives here first, and marking it handled 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, because pinnedToApp() strips a
|
||||
* functional pseudo-class before looking for the app's name, so an already-fenced selector reads
|
||||
* as unpinned again and a second pass appends a second fence. */
|
||||
el.dataset.fsLayered = '1';
|
||||
|
||||
/* Wrap only if the text still IS the sheet (textIsSheet). When it is not, the sheet stays
|
||||
* unlayered rather than lose rules, and 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 and would throw
|
||||
* away any selector already rewritten. A <style>'s url()s resolve against the document either
|
||||
* way, which is not true of a <link> — cssText serialises `url("img.png")` still relative, so
|
||||
* inlining a linked sheet would re-base every image and font in it. */
|
||||
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 */ }
|
||||
/* Last, because the line above may have re-parsed the sheet: assigning textContent throws the
|
||||
* old CSSStyleSheet away and the new one comes back ENABLED, so claiming before the wrap
|
||||
* switches a sheet off and back on within the same call. ownerKey() is read here either way,
|
||||
* while the router's hint still names the page whose module is evaluating. */
|
||||
claimOwner(el); /* a <style> is re-hosted IN PLACE, so it paints itself */
|
||||
}
|
||||
|
||||
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one, so if
|
||||
* cascade.css cannot be read, re-host nothing: documentPoisoned() already fails every sheet to the
|
||||
* slow path, and demoting an app we could not judge is the one move with no way back. */
|
||||
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;
|
||||
/* A sheet with no rules yet is not an innocent sheet: `insertRule()` produces no mutation
|
||||
* record, so an app that appends an empty <style> and fills it later is judged empty here
|
||||
* and never looked at again — measured, a `* { padding: 0 !important }` built that way
|
||||
* flattened the chrome and stayed unfenced for the life of the document. One deferred look
|
||||
* per such element is enough, and costs nothing where a <style> arrives with its rules
|
||||
* already in it. */
|
||||
let rules = null;
|
||||
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { /* cross-origin: judged below */ }
|
||||
if (rules && !rules.length && !el.__fsRecheck) {
|
||||
el.__fsRecheck = true;
|
||||
window.setTimeout(() => {
|
||||
let now;
|
||||
try { now = el.isConnected && el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
||||
if (now && now.length) rehostInvasiveSheets();
|
||||
}, 2000);
|
||||
}
|
||||
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` injects 4 KB
|
||||
* from render() with no guard, `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, because the rules do not go away — the surviving copy is byte-identical, so a library's
|
||||
* "have I already imported this?" check 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,
|
||||
* so a wrapped first copy and the app's next identical injection would stop matching and the copies
|
||||
* would pile up again. A <link> keys on href, which re-hosting leaves alone.
|
||||
*
|
||||
* Re-hosting must therefore run BEFORE this: a re-hosted <link>'s shim is appended to <head>, which
|
||||
* is earlier in document order than a template's <link> in .fs-content, so "keep the first" would
|
||||
* keep the raw copy and strand the shim. Re-host first and both copies are equivalent by the time
|
||||
* they are compared. */
|
||||
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 the textContent may not BE the sheet (textIsSheet):
|
||||
* every insertRule-built <style> has an empty one, so keying on the text gives them all the same
|
||||
* key and removes the second as a "duplicate" of a sheet it shares nothing with. 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 is very likely a <style> an app has
|
||||
* appended but not yet filled: removing it strands the handle it will 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 (!outlivesPage(el)) 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, and it holds
|
||||
* only while cascade.css is the FIRST sheet in the document to name a layer: the order is fixed by
|
||||
* first appearance, so an earlier sheet naming `theme` makes theme the weakest layer and inverts
|
||||
* the whole cascade — base's `* { padding: 0 }` then wins over the chrome's own rules.
|
||||
*
|
||||
* Re-hosting an app's sheet into `@layer theme` can cause exactly that, because where the app put
|
||||
* its <style> is the app's choice: Ace inserts its <style> as the FIRST CHILD of <head>, ahead of
|
||||
* cascade.css, and adds more of them on first hover.
|
||||
*
|
||||
* The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
|
||||
* while moving an existing one does not (measured both ways): re-declare the canonical order from
|
||||
* a fresh <style> placed first in <head>. Cheap and idempotent, and the one other place that can
|
||||
* see the whole document, which is why the order is repeated here rather than derived. */
|
||||
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
|
||||
* put in front of it — since only a sheet ahead of that can have named a layer before we did */
|
||||
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. Dropping the previous one keeps this at one spare <style> per document. */
|
||||
if (_layerStmt) _layerStmt.remove();
|
||||
_layerStmt = document.createElement('style');
|
||||
_layerStmt.textContent = LAYER_ORDER;
|
||||
head.insertBefore(_layerStmt, head.firstChild);
|
||||
}
|
||||
|
||||
/* Watch <head> rather than deduping on navigation: an app injecting from render() resolves after
|
||||
* the router's require() callback, so a nav-time sweep leaves the document permanently carrying one
|
||||
* stale duplicate. The observer collapses the copy in the microtask it appears in, and cannot loop
|
||||
* — a removal produces a mutation with no added nodes and the handler bails.
|
||||
*
|
||||
* The immediate pass cannot be the observer's job: a legacy Lua page's <link> is in the SERVER's
|
||||
* HTML, parsed and applying before this module is fetched, so there is no mutation to observe. It
|
||||
* is re-hosted on the first pass instead, at the cost of a brief flash of unstyled chrome.
|
||||
*
|
||||
* <head>, deliberately not the whole document: LuCI's poll rewrites content on every tick and this
|
||||
* would fire on every tick. */
|
||||
function watchViewSheets() {
|
||||
/* Dedupe on the immediate pass too, in the observer's order (re-host strictly first): a page
|
||||
* whose server HTML prints the same <link> twice produces no mutation for the observer to see,
|
||||
* so without this it carries both links and both @import shims for the life of the document —
|
||||
* 117 KB of CSS parsed twice on luci-app-openclash's Overwrite Settings page. */
|
||||
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 sheet
|
||||
* that made it necessary */
|
||||
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: a <style>
|
||||
* appended to <body> after chrome init is seen by nothing otherwise — the immediate pass has
|
||||
* run and the mutation is not observed — and the page the user is ON stays broken.
|
||||
*
|
||||
* childList without subtree, as for <head>: this fires only for direct children of <body>, while
|
||||
* LuCI's poll rewrites content inside #view, a descendant. */
|
||||
mo.observe(document.body, { childList: true });
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
attributeTo,
|
||||
documentCarries,
|
||||
documentPoisoned,
|
||||
scopeToCurrentPage,
|
||||
watchViewSheets
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The installed version, shown on the Appearance tab with no network call; which version is
|
||||
* available is the package manager's question.
|
||||
*
|
||||
* The Makefile (Build/Prepare), dev-sync.sh and tools/stage.sh sed the literal below BY FILE
|
||||
* NAME, so it cannot move to another file without changing all three. An unstamped checkout
|
||||
* stays 'dev'. */
|
||||
const FS_VERSION = '0.0.0-dev';
|
||||
|
||||
/* The parentheses are load-bearing: jsmin's regex-vs-division test is a one-character lookback
|
||||
* against a fixed allow-list, and `n` (the last letter of `return`) is not on it, so `return /re/`
|
||||
* is read as a division and swallows the rest of the file, exiting 0 (openwrt/luci#8299).
|
||||
*
|
||||
* The sentinel is matched by shape, never by `FS_VERSION !== '0.0.0-dev'`: terser runs before the
|
||||
* Makefile stamps the version, so that comparison folds to a constant and every release reports
|
||||
* '(dev)' — an SDK build has no terser step, so it broke only in releases. A regex test is not
|
||||
* folded. `-dev$` rather than `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps `git describe`
|
||||
* ('0.9.4-12-gabc1234'), which must count as a real version. */
|
||||
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
|
||||
|
||||
/* Do not re-add `VERSION` or `isReal` as exports: neither had a caller, and an export nobody
|
||||
* imports is a promise to keep the shape stable. */
|
||||
return baseclass.extend({
|
||||
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
|
||||
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The theme's UI primitives: the inline-SVG wrapper and two small idioms every module that draws a
|
||||
* control on its own repeats — an idempotent attribute write and Enter/Space activation for an
|
||||
* element with no native key handling. Nothing here knows what it is used for, so the menu, the
|
||||
* chrome and the Overview grid share it without requiring each other. */
|
||||
|
||||
/* The one inline-SVG wrapper: every theme icon is a 24x24 stroked outline differing only in path
|
||||
* data, so stroke width and linecaps are stated once and cannot drift between call sites.
|
||||
* aria-hidden, because each icon sits beside its own label — 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>';
|
||||
}
|
||||
|
||||
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
|
||||
* no mutation record. `value === null` removes the attribute instead of writing the string
|
||||
* "null". Three call sites (menu-footstrap-common.js's meter annotator, fs-chrome.js's indicator
|
||||
* pills, fs-overview.js's card disclosure) carried this same body separately; held here once. */
|
||||
function syncAttr(el, name, value) {
|
||||
if (value === null) {
|
||||
if (el.hasAttribute(name)) el.removeAttribute(name);
|
||||
} else if (el.getAttribute(name) !== value) {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enter/Space activation for an element with no native key handling of its own — a `<span>`
|
||||
* `role="button"`, unlike an `<a role="button">`, gets neither key for free. Calling `activate()`
|
||||
* rather than `el.click()` directly lets a caller forward the activation to a different element
|
||||
* (fs-overview.js's card header activates the pill it labels). */
|
||||
function wireActivate(el, activate) {
|
||||
el.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
ev.preventDefault();
|
||||
activate();
|
||||
});
|
||||
}
|
||||
|
||||
/* The Appearance page draws enums with `ui.Select` and numbers with `ui.RangeSlider`: LuCI widgets
|
||||
* are present on every supported release, already dressed by this stylesheet (`select` in
|
||||
* base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css), and cannot be got wrong here.
|
||||
* Do not re-add a segmented control or range wrapper of our own. */
|
||||
|
||||
return baseclass.extend({
|
||||
svgIcon,
|
||||
syncAttr,
|
||||
wireActivate
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
'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-prefs as prefs';
|
||||
'require fs-sheets as sheets';
|
||||
'require fs-widgets as widgets';
|
||||
|
||||
/* Page modules: `fs-overview` (Status -> Overview) adds to a stock page rather than owning a
|
||||
* route, so it is required only there. A `require` pragma would make it a hard dependency —
|
||||
* luci.js fetches and evaluates before this factory runs — costing 3.8 KB after terser on every
|
||||
* admin page that has no overview grid.
|
||||
*
|
||||
* `fs-appearance` used to be the other entry here (System -> System) until it got a route of its
|
||||
* own (`/admin/system/footstrap`, `view/footstrap/appearance.js`) and the tab it had been stapled
|
||||
* onto came out; a page reached by menu.d is loaded by the dispatcher, not by this map.
|
||||
*
|
||||
* The module keeps its own `body[data-page]` observer; `wire()` re-checks the page
|
||||
* synchronously, so a module arriving after the stamp still starts watching.
|
||||
*
|
||||
* The map duplicates a page name that also lives inside the module; `npm run page-modules`
|
||||
* derives both sides and fails on drift. */
|
||||
const PAGE_MODULES = {
|
||||
'admin-status-overview': 'fs-overview'
|
||||
};
|
||||
const _pageModules = new Map();
|
||||
function wirePageModules() {
|
||||
/* through window.L, never the factory's `L`: that one carries no require() of its own */
|
||||
const RT = window.L;
|
||||
const load = () => {
|
||||
const name = PAGE_MODULES[document.body.getAttribute('data-page') || ''];
|
||||
if (!name || _pageModules.has(name)) return;
|
||||
_pageModules.set(name, RT.require(name).then((m) => m.wire()).catch((e) => {
|
||||
/* a failed page module costs only its own page's extras: drop it and retry the next
|
||||
* time that page comes up */
|
||||
_pageModules.delete(name);
|
||||
console.error('footstrap: ' + name + ' did not load', e);
|
||||
}));
|
||||
};
|
||||
/* the server's stamp is already in the DOM; every later one is the router's */
|
||||
new MutationObserver(load).observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
|
||||
load();
|
||||
}
|
||||
|
||||
/* ---- the search palette, held at arm's length ----
|
||||
*
|
||||
* The palette is 5 KB and opens on a keystroke most sessions never press, so it is not required
|
||||
* here: this holds the shortcut and fetches the module on the first gesture. What CANNOT wait is
|
||||
* the recents list — it has to be written on every navigation, or it is empty on the first open —
|
||||
* and the warm pass that uses it, so both live here, in the file every page already loads.
|
||||
*
|
||||
* The palette reads the list back from localStorage when it opens, so the two halves share the key
|
||||
* and nothing else — the constant itself is named once, in fs-search.js (RECENT_KEY, RECENT_MAX),
|
||||
* since that module owns the "recently visited" feature; the literal below has to match it exactly. */
|
||||
const RECENT_WARM = 5;
|
||||
|
||||
/* A key is a menu path — what the router can navigate to and what warmRecent() prefetches. */
|
||||
function remember(key) {
|
||||
if (typeof key !== 'string' || !key) return;
|
||||
const recent = prefs.lsGetArr('fs-recent').filter((x) => typeof x === 'string');
|
||||
prefs.lsSet('fs-recent', JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, 8)));
|
||||
}
|
||||
|
||||
/* ---- warm the pages this admin actually uses ----
|
||||
*
|
||||
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to a
|
||||
* page still pays for its module chain. The recents list is the best predictor available and is
|
||||
* already on disk; warming the whole menu instead would pull every view module on the box
|
||||
* (docs/spa-router.md).
|
||||
*
|
||||
* The current page is skipped — remember() has just recorded it and it is loaded by definition.
|
||||
* Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
|
||||
* deliberate hover or tap. Nothing waits on this, so it runs at idle, with a long fallback delay:
|
||||
* it competes with the view's own module fetches and RPCs and must lose that race. */
|
||||
function warmRecent() {
|
||||
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
|
||||
const here = (L.env.dispatchpath || []).join('/');
|
||||
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
|
||||
const keys = prefs.lsGetArr('fs-recent').filter((p) => typeof p === 'string');
|
||||
const paths = keys.filter((p) => p !== here).slice(0, RECENT_WARM);
|
||||
if (!paths.length) return;
|
||||
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
|
||||
if (typeof window.requestIdleCallback === 'function')
|
||||
window.requestIdleCallback(go, { timeout: 4000 });
|
||||
else
|
||||
window.setTimeout(go, 2000);
|
||||
}
|
||||
|
||||
function wireSearch() {
|
||||
/* #fs-search-btn is emitted whenever this module loads (search.ut, !blank_page) */
|
||||
const btn = document.getElementById('fs-search-btn');
|
||||
const RT = window.L;
|
||||
|
||||
/* the page this full load landed on; onNavigate covers the SPA path afterwards */
|
||||
const rememberSegs = (segs) => remember((segs || []).join('/'));
|
||||
rememberSegs(L.env.dispatchpath);
|
||||
router.onNavigate(rememberSegs);
|
||||
warmRecent();
|
||||
|
||||
/* One fetch, on the first gesture. The module builds its overlay and opens itself; every later
|
||||
* gesture reaches the same instance, `require` being a singleton.
|
||||
*
|
||||
* …and then this half stands down: fs-search binds its own toggle to the same button and its
|
||||
* own copies of Ctrl+K and `/`, so while both were live the module's toggle closed the palette
|
||||
* and this one re-opened it in the microtask after, and the button looked broken. */
|
||||
let pending = false, loaded = false;
|
||||
const open = () => {
|
||||
if (pending || loaded) return;
|
||||
pending = true;
|
||||
RT.require('fs-search').then((m) => { pending = false; loaded = true; m.open(); },
|
||||
(e) => { pending = false; console.error('footstrap: fs-search did not load', e); });
|
||||
};
|
||||
|
||||
btn.addEventListener('click', () => open());
|
||||
/* the same two shortcuts the palette used to own, with the same guard: `/` must not steal a
|
||||
* keystroke from someone typing into a field, a contenteditable, or a .cbi-dropdown, where
|
||||
* fs-select.js's typeahead reads it as a search character */
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.defaultPrevented || loaded) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
|
||||
ev.preventDefault(); open(); 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();
|
||||
});
|
||||
}
|
||||
|
||||
/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement —
|
||||
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a
|
||||
* plain percentage is kept anyway so every meter on the page reads the same way. */
|
||||
const FS_METER_WARN = 80;
|
||||
const FS_METER_DANGER = 92;
|
||||
/* The same two thresholds, read off the OTHER end of the bar: forum #134 caught a memory row whose
|
||||
* fill is "how much is left", not "how much is used" — a 91% "Total Available" reading is healthy,
|
||||
* not a warning, and a 100%-full "Swap free" is the BEST possible reading, not permanent danger
|
||||
* (both measured live, docs/design-system.md "Meter polarity"). `100 - FS_METER_WARN/DANGER` rather
|
||||
* than a second pair of numbers: an inverted bar and a used-based one are the same health question
|
||||
* ("how much headroom is left") asked from opposite ends of one fill, so a future change to the
|
||||
* used-based split moves this one with it instead of drifting apart. */
|
||||
const FS_METER_INVERTED_WARN = 100 - FS_METER_WARN;
|
||||
const FS_METER_INVERTED_DANGER = 100 - FS_METER_DANGER;
|
||||
|
||||
/* Row labels this theme has SEEN misread, matched the same way fs-overview.js's `ROLES` matches a
|
||||
* card title: `_(msgid)` against the msgid luci-mod-status's own 20_memory.js used to build the
|
||||
* row, no msgctxt, so it resolves to the exact string that include rendered. Verified against
|
||||
* modules/luci-base/po/ru/base.po (the i18n-scan target for every luci-mod-status string that
|
||||
* carries no msgctxt of its own — the same catalogue "System"/"Memory"/"Storage" already resolve
|
||||
* from for `ROLES`): all four msgids below have a `ru` entry there, so they are in the domain
|
||||
* loaded on EVERY admin page, not a per-view one — unlike `_('Free')`, which has no entry in that
|
||||
* file at all and comes back as the literal English word wherever it is tried. That is what makes
|
||||
* evaluating these two Sets once, at module scope, on whichever page happens to load first, safe:
|
||||
* the string is the same from every admin page, not only from Status -> Overview.
|
||||
*
|
||||
* INVERTED: the fill is "how much is left" — the warn/danger split above has to fire on a LOW
|
||||
* reading. NEUTRAL: a cache or buffer is the kernel doing its job, not a resource running out;
|
||||
* colouring it at any reading says something false, so it gets none, ever. Everything else keeps
|
||||
* the plain fill-based rule below — seventeen bars measured live (storage, active connections) are
|
||||
* used-based with no name this theme recognises, and a third-party app's own meter is unnamed by
|
||||
* construction, so the fill-based default has to stay the FALLBACK, not a shrinking allow-list. */
|
||||
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
|
||||
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
|
||||
|
||||
/* The meter's name, if the markup already states one — never invented. A `.cbi-value` row's own
|
||||
* label (the RSSI/RSRP gallery shape) or a key/value table row's first cell (Memory, Storage, CPU
|
||||
* load on Overview) each stand for the whole row; a bare meter with neither (Software's disk-space
|
||||
* bar, the package manager) gets no `aria-label` written at all, and Chromium's accessible-name
|
||||
* computation then falls back to `title` — which is the reading, not a name ("95 / 100 (95%)"),
|
||||
* measured with `Accessibility.getPartialAXTree`. That fallback cannot be closed from here: `title`
|
||||
* is the ONLY source `::after { content: attr(title) }` has for the visible percentage
|
||||
* (styles/theme/25-progressbar.css), so removing it would blank the bar for a sighted reader too. */
|
||||
function findProgressbarLabel(pg) {
|
||||
const row = pg.closest('.cbi-value');
|
||||
let label = row ? row.querySelector('.cbi-value-title') : null;
|
||||
if (!label) {
|
||||
const tr = pg.closest('.tr');
|
||||
if (tr) {
|
||||
const own = pg.closest('.td, td');
|
||||
const cell = tr.querySelector('.td, td');
|
||||
if (cell && cell !== own) label = cell;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/* The percentage inside a meter's `title`, in any shape a caller writes it in: `window.progressbar`
|
||||
* below composes `'%s / %s (%d%%)'`, parenthesised and preceded by the byte/localised reading; a
|
||||
* bare meter markup may carry just `'97%'` with nothing around it (docs/gallery.html); and the
|
||||
* package-manager's own disk bar (`view/system/packages.js`, not this theme's) leads with it
|
||||
* instead — `'6% used (62.91 GiB used of 1006.85 GiB, 943.95 GiB free)'`, measured live, ru
|
||||
* `'6% использовано (…)'` the same shape. The first two are anchored at the END of the string, so
|
||||
* neither can match a stray "%" earlier in a localised reading; tried first, so an ordinary
|
||||
* `'X / Y (Z%)'` title is never misread by the rule below. The third is anchored at the START and
|
||||
* tried only when both fail: digits immediately followed by `%`, nothing before them — it cannot
|
||||
* match the leading "62" of "62.91 GiB" (a decimal point follows those digits, not `%`), so it
|
||||
* cannot pick a wrong number out of a title carrying several. An empty title or one with no
|
||||
* percentage in any of the three shapes yields null, on purpose — nothing to annotate. `%d` is the
|
||||
* unclamped percentage, so the result can still read past 100 or under 0 and is clamped by the
|
||||
* caller, the same way `window.progressbar` clamps its own `level`, below. Exported for
|
||||
* tests/meter.test.mjs, which is the only caller that needs the parse on its own. */
|
||||
function parseMeterPercent(title) {
|
||||
if (title == null) return null;
|
||||
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title) || (/^(-?\d+)%/).exec(title);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
|
||||
/* The attribute work `window.progressbar` and a bare `.cbi-progressbar` node both need — Status ->
|
||||
* Overview's own 20_memory.js, 25_storage.js and 30_network.js (luci-mod-status) declare and call
|
||||
* their OWN local `progressbar()`, a straight copy of the same upstream body, so this theme's
|
||||
* global never runs on the meters those stock includes actually draw. `annotateMeters()` below
|
||||
* walks the DOM after the fact instead of owning every writer; the threshold split and the name
|
||||
* logic stay in this one function either way, so the two callers cannot drift apart. */
|
||||
function annotateMeter(pg) {
|
||||
const title = pg.getAttribute('title');
|
||||
const pc = parseMeterPercent(title);
|
||||
if (pc == null) return;
|
||||
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
|
||||
widgets.syncAttr(pg, 'role', 'progressbar');
|
||||
widgets.syncAttr(pg, 'aria-valuemin', '0');
|
||||
widgets.syncAttr(pg, 'aria-valuemax', '100');
|
||||
widgets.syncAttr(pg, 'aria-valuenow', String(level));
|
||||
widgets.syncAttr(pg, 'aria-valuetext', title);
|
||||
const label = findProgressbarLabel(pg);
|
||||
const name = label ? label.textContent.trim() : '';
|
||||
widgets.syncAttr(pg, 'aria-label', label ? name : null);
|
||||
/* Polarity: an unrecognised bar (a third-party app's own meter included — see the Sets above)
|
||||
* keeps the plain fill-based rule, on purpose. A wrong red is worse than a missing colour, but a
|
||||
* MISSING colour on the common case — a used-based fill, which is what an app most often draws —
|
||||
* is worse still, and this theme has no signal at all to tell an unnamed app meter apart from
|
||||
* one of the seventeen used-based bars measured live on Overview alone. */
|
||||
let dataLevel = null;
|
||||
if (FS_METER_NEUTRAL.has(name)) {
|
||||
/* never coloured: see the comment on the Set */
|
||||
} else if (FS_METER_INVERTED.has(name)) {
|
||||
dataLevel = level <= FS_METER_INVERTED_DANGER ? 'danger'
|
||||
: (level <= FS_METER_INVERTED_WARN ? 'warn' : null);
|
||||
} else {
|
||||
dataLevel = level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null);
|
||||
}
|
||||
widgets.syncAttr(pg, 'data-fs-level', dataLevel);
|
||||
}
|
||||
|
||||
/* Every `.cbi-progressbar[title]` under `root` — the markup itself, not who last drew it. Called
|
||||
* from fs-overview.js's own poll-tick observer (see there for why this file does not run a second
|
||||
* one); `widgets.syncAttr` above makes a re-run over an unchanged bar a no-op read. */
|
||||
function annotateMeters(root) {
|
||||
(root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
|
||||
}
|
||||
|
||||
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
|
||||
*
|
||||
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
|
||||
* the stock includes (18_cpu, 30_network, 60_wifi…) call bare from their own `render()`. An SPA
|
||||
* arrival never runs that script, so the theme is their only definition — and a late definition is
|
||||
* a `ReferenceError` from a stock include on a page already committed to the document.
|
||||
*
|
||||
* They live here rather than in `fs-overview.js` because a page module is required DURING the
|
||||
* navigation that needs it, racing the router's own require of the view class with nothing
|
||||
* ordering the two. This file is required by the footer on every page and evaluates before the
|
||||
* router exists.
|
||||
*
|
||||
* Bodies are verbatim from upstream except for three deltas: L.itemlist -> window.L.itemlist (the
|
||||
* two-L trap, docs/spa-router.md); renderBox's `[title]` — dom.append parses a scalar child as
|
||||
* innerHTML (luci.js:1395) and an array member as text (:1383), and this file defines the global on
|
||||
* every admin page where upstream defines it on Status -> Overview alone; and progressbar's
|
||||
* accessibility attributes (role, aria-value*, data-fs-level) — upstream's copy writes the reading
|
||||
* into `title` alone, which nothing reads back for a screen reader off a generated `::after`. Same
|
||||
* output on the other two: nothing in 24.10, 25.12 or master calls renderBox, and progressbar's
|
||||
* `title` and fill width are untouched.
|
||||
*
|
||||
* `renderBox`/`renderBadge` keep the `typeof` guard: a full page load runs the template's inline
|
||||
* `<script>` first, in document order, before this module evaluates, so upstream has already
|
||||
* declared both — the guard just avoids overwriting an identical body. `progressbar` CANNOT reuse
|
||||
* that guard: upstream's copy of the same name wins that exact race, and it is the one WITHOUT the
|
||||
* accessibility attributes below. Guarded, the theme's version only ever won when a page other than
|
||||
* Status -> Overview loaded first and the SPA router then navigated here without a reload — which
|
||||
* is a real gap, not the common case. The assignment is unconditional so the accessible copy always
|
||||
* wins, on a full load and on an SPA arrival alike. */
|
||||
function ensureOverviewHelpers() {
|
||||
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so
|
||||
they can be diffed against upstream when it changes. Modernising the `var`s would break
|
||||
that property, which is what makes carrying the copies safe. */
|
||||
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),
|
||||
reading = '%s / %s (%d%%)'.format(fv, fm, pc);
|
||||
if (pg) {
|
||||
pg.firstElementChild.style.width = pc + '%';
|
||||
pg.setAttribute('title', reading);
|
||||
/* Accessible value, colour and name: annotateMeter() re-parses `reading` back out
|
||||
* of `title` rather than reusing `pc` here directly, so a bare `.cbi-progressbar`
|
||||
* this function never touched is annotated the identical way — the clamp, the
|
||||
* label lookup and the warn/danger split stay in that one function. */
|
||||
annotateMeter(pg);
|
||||
}
|
||||
};
|
||||
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 */
|
||||
}
|
||||
|
||||
ensureOverviewHelpers();
|
||||
|
||||
/* Chrome bootstrap: load the menu tree once, hand it to the parts that need it and wire them in
|
||||
* 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 the inline-SVG wrapper, an idempotent attribute write, Enter/Space activation
|
||||
* 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, drawn by its own routed page (menu.d, not this map)
|
||||
* fs-overview the overview grid — a theme module, not a luci-mod-status include
|
||||
* fs-version the shipped version string
|
||||
*
|
||||
* They compose by calling, never by inheriting: LuCI makes every required module a singleton, so
|
||||
* `base.extend` across modules throws (docs/conventions.md). Hence the main menu arriving as a
|
||||
* callback — menu-footstrap.js injects renderMainMenu rather than overriding a method. A
|
||||
* require() cycle raises DependencyError, so the graph is a DAG by construction and the shared
|
||||
* halves (fs-menutree, fs-prefs) are separate modules. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* the seam fs-overview.js calls, on its own already-coalesced poll-tick observer, to annotate
|
||||
* the meters a stock Status -> Overview include draws with its own local progressbar() */
|
||||
annotateMeters,
|
||||
|
||||
/* module-private otherwise: tests/meter.test.mjs drives the percent parse and the clamp/threshold
|
||||
* split directly rather than through a live `.cbi-progressbar[title]` walk */
|
||||
parseMeterPercent, /* fs:probe */
|
||||
annotateMeter, /* fs:probe */
|
||||
|
||||
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 }`). Deferring this to
|
||||
* ui.menu.load() extends the broken frame by a round trip, or forever when the .catch()
|
||||
* below swallows a menu failure. */
|
||||
sheets.watchViewSheets();
|
||||
prefs.guardDarkStamp(); /* same, for a third party stamping :root */
|
||||
prefs.watchThemeColor(); /* the mobile address bar, from the live page colour */
|
||||
|
||||
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: re-run on every #view resize and on content mutations */
|
||||
fit.add(chrome.fitChrome);
|
||||
|
||||
chrome.renderChrome();
|
||||
wireSearch();
|
||||
chrome.wireRail();
|
||||
chrome.wireIndicatorCounts();
|
||||
/* before router.wire(): the router restamps body[data-page] on every SPA navigation,
|
||||
* and that attribute is what the page modules key off */
|
||||
wirePageModules();
|
||||
router.wire();
|
||||
router.wireVisibility();
|
||||
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
|
||||
* tab together — so this fails loudly rather than silently */
|
||||
}).catch((e) => console.error('footstrap: chrome init failed', e));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
'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 that the CSS also turns into the top bar and
|
||||
* the rail flyouts — same markup, no second renderer. Disclosure primitives come from fs-widgets,
|
||||
* the auto-collapse preference from fs-prefs; the rest of the chrome is bootstrapped by
|
||||
* menu-footstrap-common, which this file composes with by injecting renderMainMenu into
|
||||
* common.init — a callback, not an override, since a required LuCI module is a singleton and
|
||||
* cannot be subclassed. Spec: docs/chrome.md */
|
||||
|
||||
/* Null prototype: `ICONS[key]` is keyed by a menu node name, which a third-party package picks in
|
||||
* its own menu.d. On a plain literal a node called `constructor` or `__proto__` resolves out of
|
||||
* Object.prototype to a truthy non-string ('[object Object]' for the latter), skips the
|
||||
* `|| ICONS._default` fallback and is concatenated into link.innerHTML. */
|
||||
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? Reads
|
||||
* the same input as the stylesheet: `data-narrow`, stamped by fitShell() in fs-chrome.js before
|
||||
* the menu renders. Never a viewport breakpoint — the CSS turns the sidebar into a bar when the
|
||||
* content column drops below --fs-content-min, so a media query leaves a band of widths where the
|
||||
* chrome is a bar and the menu still believes it is an accordion — a `max-width: 767px` media
|
||||
* query left exactly that band at 770-775px. */
|
||||
function flyoutMode() {
|
||||
return prefs.currentRail() || prefs.isTopLayout() ||
|
||||
document.documentElement.hasAttribute('data-narrow');
|
||||
}
|
||||
|
||||
/* The trigger — a bare <a>. setOpen() below keeps `.open` and aria-expanded in step. */
|
||||
const TRIGGER = ':scope > a';
|
||||
const OPEN_LI = '#topmenu > li.open';
|
||||
|
||||
/* ---- disclosure primitives ----
|
||||
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
|
||||
* and hides. The one consumer of all three is this file, so they live here rather than behind a
|
||||
* `linkSel`/`opts` parameter in fs-widgets.js — TRIGGER above is the only trigger selector either
|
||||
* ever sees. */
|
||||
|
||||
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
|
||||
* alone tells a sighted user everything and a screen-reader user nothing. */
|
||||
function setOpen(li, on) {
|
||||
li.classList.toggle('open', on);
|
||||
li.querySelector(TRIGGER)?.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 — contrast fs-widgets.js's wireActivate(), written for an element with neither. */
|
||||
function wireSpaceKey(link) {
|
||||
link.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== ' ') return;
|
||||
ev.preventDefault();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- dropdown edge-clamp (the bar, at every width) ----
|
||||
* 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. The rail flies panels out
|
||||
* sideways and needs a different placement, so it is excluded below. */
|
||||
/* how close the panel may come to the viewport edge before it is nudged back in */
|
||||
const EDGE_GAP = 8;
|
||||
/* Is this panel a bar dropdown (anchored under its item) rather than a rail flyout (anchored
|
||||
* beside it)? Same input as the stylesheet: `data-narrow` turns the sidebar into a bar and also
|
||||
* disables the rail (its rules are scoped `:not([data-narrow])`), so a narrow window is a bar even
|
||||
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped (issue #19). */
|
||||
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
|
||||
* write-then-read of layout per item crossed, none cancelled once the pointer moves 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 revealing the panel has not
|
||||
* applied yet, so it still measures 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 it and let
|
||||
* the next hover/tap recompute */
|
||||
function clearClamps() {
|
||||
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
|
||||
}
|
||||
|
||||
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() is
|
||||
* right going in — a stuck popup is worse than a folded section — but wrong coming out: the markup
|
||||
* is not rebuilt on a rail toggle, so nothing would re-apply the remembered set and "Keep open"
|
||||
* would mean 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 a section the user opened would otherwise refold on every tab switch. Only consulted
|
||||
* in the expanded sidebar with auto-collapse off. Persisted in localStorage because a module-level
|
||||
* Set does not survive a full page load, and many LuCI pages are not SPA-able. */
|
||||
const OPEN_KEY = 'fs-menu-open';
|
||||
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; a Set is used because
|
||||
* membership is the only question asked of it */
|
||||
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);
|
||||
|
||||
/* #topmenu is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
if (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), so the tree's
|
||||
* top-level admin/logout node would show 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` only paints the item; aria-current is what a screen reader reads, and it
|
||||
* belongs on the LEAF alone — 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 remembered set is 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: node names come from third-party menu.d files and may
|
||||
* differ only in punctuation, and folding them (`[^a-z0-9]+` -> '-') makes two
|
||||
* triggers' aria-controls resolve to the same panel. Escaping to the code point
|
||||
* keeps the ordinary all-alphanumeric case readable 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, which one tap on a phone would
|
||||
* otherwise wipe */
|
||||
if (flyoutMode()) {
|
||||
closeFlyouts();
|
||||
setOpen(li, !open);
|
||||
if (!open) clampDropdown(li); /* tap-opened panel must fit too */
|
||||
return;
|
||||
}
|
||||
/* the 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();
|
||||
});
|
||||
|
||||
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, since 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. WCAG 2.2
|
||||
* SC 1.4.13 also requires a hover/focus panel to be dismissible from the keyboard, with
|
||||
* focus handed back to the trigger. */
|
||||
document.addEventListener('click', (ev) => {
|
||||
/* `closest?.`: a document-level listener sees any dispatched click, including one
|
||||
* whose target is not an Element and has no closest(). The throw would kill this
|
||||
* listener for the rest of the session. */
|
||||
if (flyoutMode() && !ev.target.closest?.('#topmenu > li.has-sub'))
|
||||
closeFlyouts();
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape' || !flyoutMode()) return;
|
||||
const open = document.querySelector(OPEN_LI);
|
||||
if (!open) return;
|
||||
const trigger = open.querySelector(TRIGGER);
|
||||
closeFlyouts();
|
||||
trigger?.focus();
|
||||
});
|
||||
|
||||
/* Entering flyout mode folds everything, or a section left open as an accordion
|
||||
* reappears as a popup stuck on screen; leaving it restores the accordion.
|
||||
*
|
||||
* 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 and needs no menu re-render. */
|
||||
const modeChanged = () => {
|
||||
clearClamps();
|
||||
flyoutMode() ? closeFlyouts() : restoreAccordion();
|
||||
};
|
||||
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it): without
|
||||
* it, narrowing the window turns the sidebar into a bar with no transition handler
|
||||
* running and the accordion still unfolded inside it */
|
||||
new MutationObserver(modeChanged).observe(document.documentElement, {
|
||||
attributes: true, attributeFilter: [ 'data-rail', 'data-layout', 'data-narrow' ]
|
||||
});
|
||||
/* No media-query listener: the top bar is measured at every width (fitChrome), so no
|
||||
* breakpoint flips the chrome — the attribute observer and the resize clamp-clear
|
||||
* below cover every transition. */
|
||||
|
||||
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame
|
||||
* because resize fires dozens of times a second while a window is dragged. Width only:
|
||||
* iOS fires `resize` continuously while the URL bar slides away, and re-clamping for an
|
||||
* unchanged width is work done on a page the user is scrolling. */
|
||||
let lastWidth = window.innerWidth;
|
||||
const reclamp = fit.frame(clearClamps);
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth === lastWidth) return;
|
||||
lastWidth = window.innerWidth;
|
||||
reclamp();
|
||||
});
|
||||
|
||||
/* Appearance -> Submenus -> auto-collapse: fs-prefs.js owns the stored value and only
|
||||
* announces the change; the remembered set, `.open` and aria-expanded are all ours.
|
||||
* restoreAccordion() is already "apply the current setting" — with the set cleared and
|
||||
* auto on it opens the active section alone. Skipped in flyout mode, where force-opening
|
||||
* the active section would leave a popup stuck on screen. */
|
||||
document.addEventListener('fs-autocollapse', (ev) => {
|
||||
if (ev.detail && ev.detail.on) {
|
||||
_openSections.clear();
|
||||
saveOpenSections();
|
||||
}
|
||||
if (!flyoutMode()) restoreAccordion();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require fs-appearance as appearance';
|
||||
|
||||
/* The real Footstrap page. `menu.d/luci-theme-footstrap.json` dispatches
|
||||
* admin/system/footstrap here — an ordinary `view`, built by the menu like any other page, with
|
||||
* none of the compensating machinery a theme once needed to staple a panel onto someone else's
|
||||
* form (docs/architecture.md: a MutationObserver on #maincontent, a 5 s deadline,
|
||||
* `body[data-page]` tracking, a stale-group WeakSet, a sessionStorage return key — one lost tab on
|
||||
* a real router, after a Save & Apply, never reproduced on either stand, is what those four
|
||||
* compensating mechanisms were standing in for). The tab itself is gone too: `fs-appearance.js` no
|
||||
* longer mounts one on System -> System, and this view is the only caller of `renderStandalone()`.
|
||||
*
|
||||
* fs-appearance.js still builds the form: `renderStandalone()` wraps the same `build()` the tab
|
||||
* used to call, minus every one of those mechanisms — a dispatched page needs none of them, the
|
||||
* menu already did the mounting.
|
||||
*
|
||||
* Wrapped in a bare `.cbi-section` for the same reason the tab's content looked like a card:
|
||||
* theme/30-tables.css's `.cbi-section` (background, border, `--fs-card-pad`) is the only
|
||||
* source of that look — a view with no CBI map of its own draws no card unless something asks for
|
||||
* one, and `.cbi-value`/`.cbi-value-title`/`.cbi-value-field` (what every row inside is built
|
||||
* from) do not depend on the wrapper to lay out correctly, only to look like a page instead of
|
||||
* bare rows on the canvas. */
|
||||
return view.extend({
|
||||
render: () => appearance.renderStandalone().then((form) => E('div', { 'class': 'cbi-section' }, [ form ])),
|
||||
|
||||
/* Measured, not assumed (docs/devkit.src.html "handleSaveApply = null instead of hiding stock
|
||||
* buttons"): a view that leaves these unset still gets view.js's default Save & Apply | Apply
|
||||
* unchecked | Save | Reset footer, wired to apply_rollback — a live bar on the real page, not a
|
||||
* dead one, and every click reports "no changes to apply" because nothing here is uci form
|
||||
* state. Every axis applies immediately (fs-appearance.js's `render()` doc comment); nulling all
|
||||
* three is what actually drops the bar, and what makes that comment true. */
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -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
+205
@@ -0,0 +1,205 @@
|
||||
#!/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.6% of the sheet and mean nothing to a browser; this is the same trade terser makes for the JS.
|
||||
# Measured: 135655 -> 123935 bytes, 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, each clause 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 appearing in the theme's JS or in
|
||||
# a .ut template crosses a seam and keeps its name, so a new one cannot be forgotten.
|
||||
# * 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 a name that only appears in a
|
||||
# comment stops being reserved and the same source produces a different sheet depending on who
|
||||
# built it. Reading the source over-reserves by about a kilobyte, which 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` being a prefix of `--fs-space-2-5`.
|
||||
# * The short names are `--a`…`--z`, `--A`…`--Z`, then `--aa`…. The shortest custom property
|
||||
# otherwise present is 5 characters, so a collision is impossible; the script re-checks that
|
||||
# rather than trusting it.
|
||||
#
|
||||
# What it costs: the SHIPPED sheet is unreadable when debugging on a router. Everything that reads
|
||||
# the theme's own names 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
|
||||
# --rewrite <dir>… : the seam names are mangled TOO, and the same map is applied to the JS and
|
||||
# templates in those directories. Without it they are reserved, which is the safe default and what
|
||||
# an SDK build (no second pass to rewrite) needs.
|
||||
#
|
||||
# The seam is safe to rename only because every `--fs-` reference on the far side is a WHOLE string
|
||||
# literal — `setProperty('--fs-accent', …)`, never `'--fs-' + role`. Checked across all 89 sites;
|
||||
# if one is ever composed, this flag renames the CSS and the JS keeps asking for a name that no
|
||||
# longer exists, silently. The 36 seam names cost 8,574 B in the sheet, `--fs-accent` alone 1,452.
|
||||
REWRITE=""
|
||||
RESERVE_DIRS=""
|
||||
REWRITE_DIRS=""
|
||||
seen=""
|
||||
for a in "$@"; do
|
||||
if [ "$a" = "--rewrite" ]; then seen=1; REWRITE=1; continue; fi
|
||||
if [ -n "$seen" ]; then REWRITE_DIRS="$REWRITE_DIRS $a"; else RESERVE_DIRS="$RESERVE_DIRS $a"; fi
|
||||
done
|
||||
# shellcheck disable=SC2086 -- the dirs are ours, and a path with a space would already have broken
|
||||
# every other loop in this package's build
|
||||
set -- $RESERVE_DIRS
|
||||
|
||||
[ $# -gt 0 ] || { echo "mangle-tokens: no reserved-source dir given" >&2; exit 1; }
|
||||
|
||||
RES="$CSS.reserved.$$"
|
||||
MAP="$CSS.map.$$"
|
||||
UNSORTED="$CSS.unsorted.$$"
|
||||
SORTED="$CSS.sorted.$$"
|
||||
trap 'rm -f "$RES" "$MAP" "$MAP.ord" "$UNSORTED" "$SORTED" "$CSS.tmp.$$"' EXIT
|
||||
|
||||
if [ -n "$REWRITE" ]; then
|
||||
# nothing is reserved: every name is renamed here and in the far side together
|
||||
: > "$RES"
|
||||
else
|
||||
# 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; }
|
||||
fi
|
||||
|
||||
# ---- pass 1: count every manglable --fs- name, string-aware, write unsorted ----
|
||||
#
|
||||
# RESFILE/UNSORTFILE travel through ENVIRON, not `-v` (and not a bare `var=value` operand, which
|
||||
# awk treats the same way): both run the value through awk's OWN escape-sequence processing before
|
||||
# use, so a path holding a literal `\t` or `\n` (a `$CSS` an OpenWrt buildbot handed us, not ours to
|
||||
# assume ASCII-clean) is silently rewritten into a tab or newline and no longer names the file that
|
||||
# is actually there. ENVIRON reads the environment string verbatim.
|
||||
RESFILE="$RES" UNSORTFILE="$UNSORTED" awk '
|
||||
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 {
|
||||
RESFILE = ENVIRON["RESFILE"]; UNSORTFILE = ENVIRON["UNSORTFILE"]
|
||||
while ((getline line < RESFILE) > 0) reserved[line] = 1
|
||||
q = ""
|
||||
}
|
||||
{ css = css $0 "\n" }
|
||||
END {
|
||||
n = length(css); 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++
|
||||
}
|
||||
for (x = 1; x <= ncnt; x++) print cnt[order[x]], order[x] > UNSORTFILE
|
||||
}
|
||||
' "$CSS"
|
||||
|
||||
# sort(1) runs here, in the SHELL, never as a string awk pipes to: building that string by
|
||||
# concatenating a path in ("sort ... > \"" PATH "\"") lets a `"` in the path break out of the
|
||||
# quoting. "$SORTED" is a normal shell expansion instead, safe whatever bytes CSS holds.
|
||||
sort -k1,1nr -k2,2 "$UNSORTED" > "$SORTED" || { echo "mangle-tokens: sort failed" >&2; exit 1; }
|
||||
|
||||
# ---- pass 2: short-name alphabet from the sorted order (hottest name shortest), then rewrite ----
|
||||
# SORTFILE/MAPFILE/OUTFILE via ENVIRON too — see the note above pass 1.
|
||||
SORTFILE="$SORTED" MAPFILE="$MAP" OUTFILE="$CSS.tmp.$$" awk '
|
||||
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
|
||||
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 {
|
||||
SORTFILE = ENVIRON["SORTFILE"]; MAPFILE = ENVIRON["MAPFILE"]; OUTFILE = ENVIRON["OUTFILE"]
|
||||
x = 0
|
||||
while ((getline sline < SORTFILE) > 0) { split(sline, sf, " "); order[++x] = sf[2]; cnt[sf[2]] = sf[1] }
|
||||
ncnt = x
|
||||
|
||||
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)
|
||||
if (ncnt > nsh) { print "mangle-tokens: more names than short forms" > "/dev/stderr"; exit 1 }
|
||||
q = ""
|
||||
}
|
||||
{ css = css $0 "\n" }
|
||||
END {
|
||||
n = length(css)
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
' "$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"
|
||||
|
||||
# ---- the far side of the seam, renamed with the same map ----
|
||||
if [ -n "$REWRITE" ]; then
|
||||
[ -s "$MAP" ] || { echo "mangle-tokens: --rewrite asked for, but the map is empty" >&2; exit 1; }
|
||||
# NEVER the checkout. This rewrites files in place, so a target under the directory this script
|
||||
# itself lives in is the source tree, and renaming the seam there destroys it — measured the
|
||||
# hard way: a mistake in the argument split sent $SRC here instead of $STAGE and rewrote eight
|
||||
# shipped modules and a template before anything noticed.
|
||||
SELF_DIR=$(cd "$(dirname "$0")" && pwd -P)
|
||||
for d in $REWRITE_DIRS; do
|
||||
abs=$(cd "$d" 2>/dev/null && pwd -P) || { echo "mangle-tokens: --rewrite target $d is not a directory" >&2; exit 1; }
|
||||
case "$abs/" in
|
||||
"$SELF_DIR"/*) echo "mangle-tokens: --rewrite target $d is inside the source tree ($SELF_DIR) — refusing, this rewrites in place" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# longest first, or `--fs-accent` would rewrite the head of `--fs-accent-h`
|
||||
awk '{ print $1, $3 }' "$MAP" | awk '{ print length($1), $0 }' | sort -rn | cut -d" " -f2- > "$MAP.ord"
|
||||
touched=0
|
||||
for d in $REWRITE_DIRS; do
|
||||
for f in $(find "$d" -type f \( -name '*.js' -o -name '*.ut' \)); do
|
||||
awk -v MAPF="$MAP.ord" '
|
||||
BEGIN { while ((getline l < MAPF) > 0) { split(l, a, " "); from[++k] = a[1]; to[k] = a[2] } }
|
||||
{ for (x = 1; x <= k; x++) gsub(from[x], to[x]); print }
|
||||
' "$f" > "$f.tmp$$" && mv "$f.tmp$$" "$f"
|
||||
touched=$((touched + 1))
|
||||
done
|
||||
done
|
||||
rm -f "$MAP.ord"
|
||||
echo "mangle-tokens: seam renamed in $touched file(s)"
|
||||
fi
|
||||
@@ -0,0 +1,808 @@
|
||||
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:421
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr "Acento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
msgctxt "footstrap"
|
||||
msgid "As in file"
|
||||
msgstr "Como en el archivo"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:96
|
||||
msgid "Authorization Required"
|
||||
msgstr "Se requiere autorización"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:387
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr "Colapso automático"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:834
|
||||
msgctxt "footstrap"
|
||||
msgid "Background"
|
||||
msgstr "Fondo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:189
|
||||
msgctxt "footstrap"
|
||||
msgid "Barely visible %s"
|
||||
msgstr "Apenas visible %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr "Bordes"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Buffered"
|
||||
msgstr "En búfer"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Cached"
|
||||
msgstr "En caché"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:441
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr "Tarjetas"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:474
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr "Elegir SVG"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:467
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose image"
|
||||
msgstr "Elegir imagen"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:39
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Cleaned before upload: removed %d unsafe element(s) and %d external "
|
||||
"reference(s)."
|
||||
msgstr ""
|
||||
"Limpiado antes de subir: se eliminaron %d elemento(s) no seguro(s) y %d "
|
||||
"referencia(s) externa(s)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:188
|
||||
msgctxt "footstrap"
|
||||
msgid "Clearly visible %s"
|
||||
msgstr "Bien visible %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
#: ucode/template/themes/footstrap/header.ut:142
|
||||
msgid "Collapse menu"
|
||||
msgstr "Contraer menú"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr "Colores"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr "Compacto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:675
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr "Confirmar restablecimiento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:373
|
||||
msgctxt "footstrap"
|
||||
msgid "Content width"
|
||||
msgstr "Ancho del contenido"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:197
|
||||
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:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Controls"
|
||||
msgstr "Controles"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:33
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not process the image."
|
||||
msgstr "No se pudo procesar la imagen."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:654
|
||||
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:424
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr "Peligro"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:345
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr "Oscuro"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr "Densidad"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:482
|
||||
msgctxt "footstrap"
|
||||
msgid "Dim"
|
||||
msgstr "Oscurecimiento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:191
|
||||
msgctxt "footstrap"
|
||||
msgid "Easy to read %s"
|
||||
msgstr "Se lee bien %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
msgid "Expand menu"
|
||||
msgstr "Expandir menú"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:506
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
msgctxt "footstrap"
|
||||
msgid "File"
|
||||
msgstr "Archivo"
|
||||
|
||||
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake,
|
||||
# not a missing string.
|
||||
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
|
||||
msgid "Footstrap"
|
||||
msgstr "Footstrap"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:823
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Footstrap theme settings apply at once and are stored permanently in this "
|
||||
"browser."
|
||||
msgstr ""
|
||||
"Los ajustes del tema Footstrap se aplican al instante y se guardan de forma "
|
||||
"permanente en este navegador."
|
||||
|
||||
#: root/usr/share/rpcd/acl.d/luci-theme-footstrap.json:3
|
||||
msgid ""
|
||||
"Footstrap theme settings saved to the router, the login background and "
|
||||
"pattern uploads, and the Footstrap settings page"
|
||||
msgstr ""
|
||||
"Los ajustes del tema Footstrap guardados en el router, la carga del fondo de "
|
||||
"inicio de sesión y del patrón, y la página de ajustes de Footstrap"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:24
|
||||
msgid "Go to firmware upgrade..."
|
||||
msgstr "Ir a la actualización del firmware..."
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:15
|
||||
msgid "Go to password configuration..."
|
||||
msgstr "Ir a la configuración de contraseñas..."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Good"
|
||||
msgstr "Correcto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:193
|
||||
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:830
|
||||
msgctxt "footstrap"
|
||||
msgid "Interface"
|
||||
msgstr "Interfaz"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:78
|
||||
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:30
|
||||
msgid "JavaScript required!"
|
||||
msgstr "¡Se requiere JavaScript!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr "Mantener abierto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:364
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr "Grande"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:749
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr "Diseño"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Light"
|
||||
msgstr "Claro"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:162
|
||||
msgid "Log in"
|
||||
msgstr "Iniciar sesión"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/logout.ut:8
|
||||
#: ucode/template/themes/footstrap/partials/logout.ut:15
|
||||
msgid "Log out"
|
||||
msgstr "Cerrar sesión"
|
||||
|
||||
#: ucode/template/themes/footstrap/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr "Modo de compatibilidad Lua activo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Memory"
|
||||
msgstr "Memoria"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:139
|
||||
#: ucode/template/themes/footstrap/header.ut:150
|
||||
msgid "Menu"
|
||||
msgstr "Menú"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:22
|
||||
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:322
|
||||
msgctxt "footstrap"
|
||||
msgid "No pages found"
|
||||
msgstr "No se encontraron páginas"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:12
|
||||
msgid "No password set!"
|
||||
msgstr "¡No se ha establecido ninguna contraseña!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr "Normal"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:582
|
||||
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:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:348
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr "Paleta"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:108
|
||||
msgid "Password"
|
||||
msgstr "Contraseña"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:492
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Pattern"
|
||||
msgstr "Patrón"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:34
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an SVG file."
|
||||
msgstr "Elija un archivo SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:541
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an image file."
|
||||
msgstr "Por favor, seleccione un archivo de imagen."
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:98
|
||||
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:321
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr "Visitado recientemente"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:469
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:476
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr "Eliminar"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:618
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:685
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr "Restablecer al integrado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:713
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr ""
|
||||
"«Restablecer al integrado»: vuelve al aspecto con el que se distribuye el "
|
||||
"tema."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:603
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr "Restablecer al del router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:711
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
"«Restablecer al del router»: descarta los cambios de este navegador y sigue "
|
||||
"al router."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:377
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr "Redondeo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:601
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr "Guardar en el router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:709
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Save to router — store this look on the router. Browsers with a look of "
|
||||
"their own keep it."
|
||||
msgstr ""
|
||||
"«Guardar en el router»: guarda este aspecto en el router. Los navegadores "
|
||||
"con un aspecto propio lo conservan."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:719
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr "Aspecto guardado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:643
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr "Guardado en el router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr "Guardado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
msgctxt "footstrap"
|
||||
msgid "Scale"
|
||||
msgstr "Escala"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/search.ut:12
|
||||
#: ucode/template/themes/footstrap/partials/search.ut:14
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:227
|
||||
#: htdocs/luci-static/resources/fs-search.js:253
|
||||
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:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr "Barra lateral"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr "Barra lateral y superior"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:119
|
||||
msgid "Skip to content"
|
||||
msgstr "Saltar al contenido"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
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:24
|
||||
msgid "Storage"
|
||||
msgstr "Almacenamiento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr "Intensidad"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:384
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr "Submenús"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr "Superficies"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Swap free"
|
||||
msgstr "Intercambio libre"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "System"
|
||||
msgstr "Sistema"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:21
|
||||
msgid "System running in recovery (initramfs) mode."
|
||||
msgstr "El sistema se está ejecutando en modo de recuperación (initramfs)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-select.js:721
|
||||
msgid "Table"
|
||||
msgstr "Tabla"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:40
|
||||
msgctxt "footstrap"
|
||||
msgid "That SVG had nothing left once the unsafe parts were removed."
|
||||
msgstr "De ese SVG no quedó nada tras eliminar las partes no seguras."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:346
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr "No se ha podido leer el archivo."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:426
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not a readable image."
|
||||
msgstr "Ese archivo no es una imagen legible."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:32
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not an SVG image."
|
||||
msgstr "Ese archivo no es una imagen SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:518
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr "Ese archivo es demasiado grande."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:543
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr "Esa imagen es demasiado grande."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:380
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr "El router se negó a eliminar el archivo (estado ubus %d)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:500
|
||||
msgctxt "footstrap"
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:13
|
||||
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:637
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"This browser is not storing preferences (site data is blocked), so a change "
|
||||
"here lasts until you reload. Saving as default still works and applies to "
|
||||
"every browser."
|
||||
msgstr ""
|
||||
"Este navegador no guarda preferencias (los datos del sitio están "
|
||||
"bloqueados), así que un cambio aquí dura hasta que recargues. Guardar como "
|
||||
"predeterminado sigue funcionando y se aplica a todos los navegadores."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:751
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr "Este tema aún no está traducido en este router."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:396
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr "Tinte (identificación del enrutador)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:407
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint strength"
|
||||
msgstr "Intensidad del tinte"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:194
|
||||
msgctxt "footstrap"
|
||||
msgid "Too faint to read %s"
|
||||
msgstr "Demasiado tenue para leerse %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:339
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr "Arriba"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Total Available"
|
||||
msgstr "Total disponible"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:31
|
||||
msgctxt "footstrap"
|
||||
msgid "Upload failed."
|
||||
msgstr "Error al cargar el archivo."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:551
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr "Cargando…"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:102
|
||||
msgid "Username"
|
||||
msgstr "Nombre de usuario"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:580
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr "Fondo de pantalla"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
msgctxt "footstrap"
|
||||
msgid "Warning"
|
||||
msgstr "Advertencia"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:31
|
||||
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:443
|
||||
msgctxt "footstrap"
|
||||
msgid "in the sidebar"
|
||||
msgstr "en la barra lateral"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:270
|
||||
msgctxt "footstrap"
|
||||
msgid "on a card"
|
||||
msgstr "en una tarjeta"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr "en un control"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:399
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr "en el lienzo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"the uploaded file could not be removed either; it is still on the router."
|
||||
msgstr "tampoco se pudo eliminar el archivo subido; sigue en el router."
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid ""
|
||||
#~ "Save & Apply at the foot of the page belongs to the System form and does "
|
||||
#~ "not store them."
|
||||
#~ msgstr ""
|
||||
#~ "El botón «Guardar y aplicar» al pie de la página pertenece al formulario "
|
||||
#~ "del Sistema y no los guarda."
|
||||
|
||||
#~ 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á."
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Full"
|
||||
#~ msgstr "Completa"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Wide"
|
||||
#~ msgstr "Ancha"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Defaults"
|
||||
#~ msgstr "Predeterminados"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Reset to saved"
|
||||
#~ msgstr "Restablecer a lo guardado"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Router default"
|
||||
#~ msgstr "Configuración predeterminada del enrutador"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Save as default"
|
||||
#~ msgstr "Guardar como predeterminado"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Saved as default"
|
||||
#~ msgstr "Guardado como predeterminado"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid ""
|
||||
#~ "To keep them on the router, use Save as default under Defaults — not Save "
|
||||
#~ "& Apply at the foot of the page."
|
||||
#~ msgstr ""
|
||||
#~ "Para guardarlos en el router, usa «Guardar como predeterminado» en "
|
||||
#~ "Predeterminados, no «Guardar y aplicar» al pie de la página."
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid ""
|
||||
#~ "To make the current look the router's default for every browser, use Save "
|
||||
#~ "as default under Defaults below."
|
||||
#~ msgstr ""
|
||||
#~ "Para que el aspecto actual sea el predeterminado del router para todos "
|
||||
#~ "los navegadores, usa «Guardar como predeterminado» en Predeterminados más "
|
||||
#~ "abajo."
|
||||
|
||||
#~ msgid "Loading view…"
|
||||
#~ msgstr "Cargando vista…"
|
||||
|
||||
#~ msgid "Logout"
|
||||
#~ msgstr "Cerrar sesión"
|
||||
|
||||
#~ 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,797 @@
|
||||
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:421
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr "Акцент"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
msgctxt "footstrap"
|
||||
msgid "As in file"
|
||||
msgstr "Как в файле"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:96
|
||||
msgid "Authorization Required"
|
||||
msgstr "Требуется авторизация"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:387
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr "Сворачивать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:834
|
||||
msgctxt "footstrap"
|
||||
msgid "Background"
|
||||
msgstr "Фон"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:189
|
||||
msgctxt "footstrap"
|
||||
msgid "Barely visible %s"
|
||||
msgstr "Едва заметна %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr "Границы"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Buffered"
|
||||
msgstr "Буферизовано"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Cached"
|
||||
msgstr "Кэшировано"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:441
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr "Карточки"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:474
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr "Выбрать SVG"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:467
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose image"
|
||||
msgstr "Выбрать изображение"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:39
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Cleaned before upload: removed %d unsafe element(s) and %d external "
|
||||
"reference(s)."
|
||||
msgstr ""
|
||||
"Очищено перед загрузкой: удалено небезопасных элементов — %d, внешних ссылок "
|
||||
"— %d."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:188
|
||||
msgctxt "footstrap"
|
||||
msgid "Clearly visible %s"
|
||||
msgstr "Хорошо видна %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
#: ucode/template/themes/footstrap/header.ut:142
|
||||
msgid "Collapse menu"
|
||||
msgstr "Свернуть меню"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr "Цвета"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr "Компактно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:675
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr "Подтвердить сброс"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:373
|
||||
msgctxt "footstrap"
|
||||
msgid "Content width"
|
||||
msgstr "Ширина контента"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:197
|
||||
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:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Controls"
|
||||
msgstr "Элементы форм"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:33
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not process the image."
|
||||
msgstr "Не удалось обработать изображение."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:654
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not save the default. Reload the page and try again."
|
||||
msgstr "Не удалось сохранить умолчание. Обновите страницу и повторите."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr "Опасность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:345
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr "Тёмная"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr "Плотность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:482
|
||||
msgctxt "footstrap"
|
||||
msgid "Dim"
|
||||
msgstr "Затемнение"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:191
|
||||
msgctxt "footstrap"
|
||||
msgid "Easy to read %s"
|
||||
msgstr "Хорошо читается %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
msgid "Expand menu"
|
||||
msgstr "Развернуть меню"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:506
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
msgctxt "footstrap"
|
||||
msgid "File"
|
||||
msgstr "Файл"
|
||||
|
||||
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake,
|
||||
# not a missing string.
|
||||
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
|
||||
msgid "Footstrap"
|
||||
msgstr "Footstrap"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:823
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Footstrap theme settings apply at once and are stored permanently in this "
|
||||
"browser."
|
||||
msgstr ""
|
||||
"Настройки темы Footstrap применяются сразу и сохраняются в этом браузере "
|
||||
"навсегда."
|
||||
|
||||
#: root/usr/share/rpcd/acl.d/luci-theme-footstrap.json:3
|
||||
msgid ""
|
||||
"Footstrap theme settings saved to the router, the login background and "
|
||||
"pattern uploads, and the Footstrap settings page"
|
||||
msgstr ""
|
||||
"Настройки темы Footstrap, сохранённые на роутере, загрузка фона входа и "
|
||||
"узора, а также страница настроек Footstrap"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:24
|
||||
msgid "Go to firmware upgrade..."
|
||||
msgstr "Перейти к обновлению прошивки..."
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:15
|
||||
msgid "Go to password configuration..."
|
||||
msgstr "Перейти к настройке пароля..."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Good"
|
||||
msgstr "Норма"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:193
|
||||
msgctxt "footstrap"
|
||||
msgid "Hard to read %s — large text only"
|
||||
msgstr "Плохо читается %s — только крупный текст"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:830
|
||||
msgctxt "footstrap"
|
||||
msgid "Interface"
|
||||
msgstr "Интерфейс"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:78
|
||||
msgid "Invalid username and/or password! Please try again."
|
||||
msgstr "Неверное имя пользователя и/или пароль! Попробуйте ещё раз."
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:30
|
||||
msgid "JavaScript required!"
|
||||
msgstr "Требуется JavaScript!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr "Не сворачивать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:364
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr "Крупно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:749
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr "Раскладка"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Light"
|
||||
msgstr "Светлая"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:162
|
||||
msgid "Log in"
|
||||
msgstr "Войти"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/logout.ut:8
|
||||
#: ucode/template/themes/footstrap/partials/logout.ut:15
|
||||
msgid "Log out"
|
||||
msgstr "Выйти"
|
||||
|
||||
#: ucode/template/themes/footstrap/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr "Включён режим совместимости с Lua"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Memory"
|
||||
msgstr "Память"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:139
|
||||
#: ucode/template/themes/footstrap/header.ut:150
|
||||
msgid "Menu"
|
||||
msgstr "Меню"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:22
|
||||
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:322
|
||||
msgctxt "footstrap"
|
||||
msgid "No pages found"
|
||||
msgstr "Страницы не найдены"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:12
|
||||
msgid "No password set!"
|
||||
msgstr "Пароль не установлен!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr "Обычно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:582
|
||||
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:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:348
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr "Палитра"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:108
|
||||
msgid "Password"
|
||||
msgstr "Пароль"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:492
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Pattern"
|
||||
msgstr "Узор"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:34
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an SVG file."
|
||||
msgstr "Выберите файл SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:541
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an image file."
|
||||
msgstr "Выберите файл изображения."
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:98
|
||||
msgid "Please enter your username and password."
|
||||
msgstr "Введите имя пользователя и пароль."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:321
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr "Недавние"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:469
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:476
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr "Убрать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:618
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:685
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr "Сбросить к встроенному"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:713
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr "«Сбросить к встроенному» — вернуть вид, с которым поставляется тема."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:603
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr "Сбросить к виду роутера"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:711
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
"«Сбросить к виду роутера» — отменить правки этого браузера и следовать "
|
||||
"роутеру."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:377
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr "Скругление"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:601
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr "Сохранить на роутер"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:709
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Save to router — store this look on the router. Browsers with a look of "
|
||||
"their own keep it."
|
||||
msgstr ""
|
||||
"«Сохранить на роутер» — записать этот вид на роутер. Браузеры со своим видом "
|
||||
"его сохранят."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:719
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr "Сохранённый вид"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:643
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr "Сохранено на роутере"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr "Сохранение"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
msgctxt "footstrap"
|
||||
msgid "Scale"
|
||||
msgstr "Масштаб"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/search.ut:12
|
||||
#: ucode/template/themes/footstrap/partials/search.ut:14
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:227
|
||||
#: htdocs/luci-static/resources/fs-search.js:253
|
||||
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:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr "Сбоку"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr "Боковая панель и шапка"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:119
|
||||
msgid "Skip to content"
|
||||
msgstr "Перейти к содержимому"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "Start typing to find a page"
|
||||
msgstr "Начните вводить название страницы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Storage"
|
||||
msgstr "Накопитель"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr "Насыщенность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:384
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr "Подменю"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr "Поверхности"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Swap free"
|
||||
msgstr "Свободно в подкачке"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "System"
|
||||
msgstr "Система"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:21
|
||||
msgid "System running in recovery (initramfs) mode."
|
||||
msgstr "Система работает в режиме восстановления (initramfs)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-select.js:721
|
||||
msgid "Table"
|
||||
msgstr "Таблица"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:40
|
||||
msgctxt "footstrap"
|
||||
msgid "That SVG had nothing left once the unsafe parts were removed."
|
||||
msgstr "От этого SVG ничего не осталось после удаления опасных частей."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:346
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr "Не удалось прочитать файл."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:426
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not a readable image."
|
||||
msgstr "Не удалось прочитать файл как изображение."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:32
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not an SVG image."
|
||||
msgstr "Этот файл — не изображение SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:518
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr "Файл слишком большой."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:543
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr "Изображение слишком большое."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:380
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr "Роутер отказался удалить файл (статус ubus %d)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:500
|
||||
msgctxt "footstrap"
|
||||
msgid "Theme"
|
||||
msgstr "Тема"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:13
|
||||
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:637
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"This browser is not storing preferences (site data is blocked), so a change "
|
||||
"here lasts until you reload. Saving as default still works and applies to "
|
||||
"every browser."
|
||||
msgstr ""
|
||||
"Этот браузер не хранит настройки (данные сайта заблокированы), поэтому "
|
||||
"изменение здесь действует до перезагрузки страницы. «Сохранить как "
|
||||
"умолчание» по-прежнему работает и применяется ко всем браузерам."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:751
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr "Тема ещё не переведена на этом роутере."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:396
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr "Оттенок (опознание роутера)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:407
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint strength"
|
||||
msgstr "Насыщенность оттенка"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:194
|
||||
msgctxt "footstrap"
|
||||
msgid "Too faint to read %s"
|
||||
msgstr "Слишком блёкло, не читается %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:339
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr "Сверху"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Total Available"
|
||||
msgstr "Свободно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:31
|
||||
msgctxt "footstrap"
|
||||
msgid "Upload failed."
|
||||
msgstr "Не удалось загрузить."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:551
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr "Загрузка…"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:102
|
||||
msgid "Username"
|
||||
msgstr "Имя пользователя"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:580
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr "Обои"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
msgctxt "footstrap"
|
||||
msgid "Warning"
|
||||
msgstr "Предупреждение"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:31
|
||||
msgid ""
|
||||
"You must enable JavaScript in your browser or LuCI will not work properly."
|
||||
msgstr ""
|
||||
"Включите JavaScript в браузере, иначе LuCI не будет работать корректно."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "in the sidebar"
|
||||
msgstr "в боковой панели"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:270
|
||||
msgctxt "footstrap"
|
||||
msgid "on a card"
|
||||
msgstr "на карточке"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr "на элементе формы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:399
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr "на холсте"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"the uploaded file could not be removed either; it is still on the router."
|
||||
msgstr "загруженный файл тоже не удалось удалить — он остался на роутере."
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid ""
|
||||
#~ "Save & Apply at the foot of the page belongs to the System form and does "
|
||||
#~ "not store them."
|
||||
#~ msgstr ""
|
||||
#~ "Кнопка «Сохранить и применить» внизу страницы принадлежит форме «Система» "
|
||||
#~ "и их не сохраняет."
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid ""
|
||||
#~ "That SVG contains script or external references, which this theme will "
|
||||
#~ "not install."
|
||||
#~ msgstr ""
|
||||
#~ "В этом SVG есть скрипт или внешние ссылки — тема не станет его "
|
||||
#~ "устанавливать."
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Full"
|
||||
#~ msgstr "Во всю ширину"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Wide"
|
||||
#~ msgstr "Широкая"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Defaults"
|
||||
#~ msgstr "Умолчания"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Reset to saved"
|
||||
#~ msgstr "Сбросить к сохранённым"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Router default"
|
||||
#~ msgstr "По умолчанию для роутера"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Save as default"
|
||||
#~ msgstr "Сохранить по умолчанию"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid "Saved as default"
|
||||
#~ msgstr "Сохранено по умолчанию"
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid ""
|
||||
#~ "To keep them on the router, use Save as default under Defaults — not Save "
|
||||
#~ "& Apply at the foot of the page."
|
||||
#~ msgstr ""
|
||||
#~ "Чтобы сохранить их на роутере, нажмите «Сохранить по умолчанию» в разделе "
|
||||
#~ "«По умолчанию», а не «Сохранить и применить» внизу страницы."
|
||||
|
||||
#~ msgctxt "footstrap"
|
||||
#~ msgid ""
|
||||
#~ "To make the current look the router's default for every browser, use Save "
|
||||
#~ "as default under Defaults below."
|
||||
#~ msgstr ""
|
||||
#~ "Чтобы текущий вид стал умолчанием роутера для всех браузеров, нажмите "
|
||||
#~ "«Сохранить по умолчанию» в разделе «По умолчанию» ниже."
|
||||
|
||||
#~ msgid "Loading view…"
|
||||
#~ msgstr "Загрузка представления…"
|
||||
|
||||
#~ msgid "Logout"
|
||||
#~ 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,586 @@
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:421
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
msgctxt "footstrap"
|
||||
msgid "As in file"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:96
|
||||
msgid "Authorization Required"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:387
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:834
|
||||
msgctxt "footstrap"
|
||||
msgid "Background"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:189
|
||||
msgctxt "footstrap"
|
||||
msgid "Barely visible %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Buffered"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Cached"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:441
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:474
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:467
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose image"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:39
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Cleaned before upload: removed %d unsafe element(s) and %d external "
|
||||
"reference(s)."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:188
|
||||
msgctxt "footstrap"
|
||||
msgid "Clearly visible %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
#: ucode/template/themes/footstrap/header.ut:142
|
||||
msgid "Collapse menu"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:675
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:373
|
||||
msgctxt "footstrap"
|
||||
msgid "Content width"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:197
|
||||
msgctxt "footstrap"
|
||||
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Controls"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:33
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not process the image."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:654
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not save the default. Reload the page and try again."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:345
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:482
|
||||
msgctxt "footstrap"
|
||||
msgid "Dim"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:191
|
||||
msgctxt "footstrap"
|
||||
msgid "Easy to read %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
msgid "Expand menu"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:506
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
msgctxt "footstrap"
|
||||
msgid "File"
|
||||
msgstr ""
|
||||
|
||||
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
|
||||
msgid "Footstrap"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:823
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Footstrap theme settings apply at once and are stored permanently in this "
|
||||
"browser."
|
||||
msgstr ""
|
||||
|
||||
#: root/usr/share/rpcd/acl.d/luci-theme-footstrap.json:3
|
||||
msgid ""
|
||||
"Footstrap theme settings saved to the router, the login background and "
|
||||
"pattern uploads, and the Footstrap settings page"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:24
|
||||
msgid "Go to firmware upgrade..."
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:15
|
||||
msgid "Go to password configuration..."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Good"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:193
|
||||
msgctxt "footstrap"
|
||||
msgid "Hard to read %s — large text only"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:830
|
||||
msgctxt "footstrap"
|
||||
msgid "Interface"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:78
|
||||
msgid "Invalid username and/or password! Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:30
|
||||
msgid "JavaScript required!"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:364
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:749
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:162
|
||||
msgid "Log in"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/logout.ut:8
|
||||
#: ucode/template/themes/footstrap/partials/logout.ut:15
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Memory"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:139
|
||||
#: ucode/template/themes/footstrap/header.ut:150
|
||||
msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:22
|
||||
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:322
|
||||
msgctxt "footstrap"
|
||||
msgid "No pages found"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:12
|
||||
msgid "No password set!"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:582
|
||||
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:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:348
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:108
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:492
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Pattern"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:34
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an SVG file."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:541
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an image file."
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:98
|
||||
msgid "Please enter your username and password."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:321
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:469
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:476
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:618
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:685
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:713
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:603
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:711
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:377
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:601
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:709
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Save to router — store this look on the router. Browsers with a look of "
|
||||
"their own keep it."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:719
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:643
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
msgctxt "footstrap"
|
||||
msgid "Scale"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/search.ut:12
|
||||
#: ucode/template/themes/footstrap/partials/search.ut:14
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:227
|
||||
#: htdocs/luci-static/resources/fs-search.js:253
|
||||
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:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:119
|
||||
msgid "Skip to content"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "Start typing to find a page"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Storage"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:384
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Swap free"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:21
|
||||
msgid "System running in recovery (initramfs) mode."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-select.js:721
|
||||
msgid "Table"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:40
|
||||
msgctxt "footstrap"
|
||||
msgid "That SVG had nothing left once the unsafe parts were removed."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:346
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:426
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not a readable image."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:32
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not an SVG image."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:518
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:543
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:380
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:500
|
||||
msgctxt "footstrap"
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:13
|
||||
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:637
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"This browser is not storing preferences (site data is blocked), so a change "
|
||||
"here lasts until you reload. Saving as default still works and applies to "
|
||||
"every browser."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:751
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:396
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:407
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint strength"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:194
|
||||
msgctxt "footstrap"
|
||||
msgid "Too faint to read %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:339
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Total Available"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:31
|
||||
msgctxt "footstrap"
|
||||
msgid "Upload failed."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:551
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:102
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:580
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
msgctxt "footstrap"
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:31
|
||||
msgid ""
|
||||
"You must enable JavaScript in your browser or LuCI will not work properly."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "in the sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:270
|
||||
msgctxt "footstrap"
|
||||
msgid "on a card"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:399
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"the uploaded file could not be removed either; it is still on the router."
|
||||
msgstr ""
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
config footstrap 'settings'
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Registration, plus the two symlinks that expose what the admin uploads (a third, the pattern, is
|
||||
# deliberately NOT one — see below). Nothing here migrates a router off an older footstrap: OpenWrt
|
||||
# expects a sysupgrade rather than a package upgrade — the one exception is the stale pattern
|
||||
# symlink itself, removed below because leaving it would keep a reported security hole open.
|
||||
|
||||
changed=0
|
||||
|
||||
set_opt() {
|
||||
uci -q get "luci.$1" >/dev/null 2>&1 && return
|
||||
uci set "luci.$1=$2"
|
||||
changed=1
|
||||
}
|
||||
|
||||
set_opt themes.Footstrap /luci-static/footstrap
|
||||
|
||||
# A fresh install may pick the theme; an upgrade must never move a router off the theme it is on.
|
||||
# `changed` is what tells the two apart — on an upgrade the entry is already there — and it has to
|
||||
# carry the decision alone, because apk exports no PKG_UPGRADE.
|
||||
if [ "${PKG_UPGRADE:-}" != 1 ] && [ "$changed" = 1 ]; then
|
||||
set_opt main.mediaurlbase /luci-static/footstrap
|
||||
fi
|
||||
|
||||
[ "$changed" = 1 ] && uci commit luci
|
||||
|
||||
# uhttpd serves /www only, so the admin's uploads live in /etc — where a sysupgrade keeps them
|
||||
# (lib/upgrade/keep.d) — and are reached through symlinks, EXCEPT the pattern (below).
|
||||
#
|
||||
# The pattern is deliberately NOT symlinked into /www: an SVG is a document, and served as one
|
||||
# through a plain same-origin URL it runs as a script with the admin's session (OpenWrt forum
|
||||
# thread 251930). It is instead read by cgi-bin/luci-theme-footstrap-pattern, which answers with
|
||||
# CSP 'none' + sandbox and nosniff — headers uhttpd cannot attach to a static file. The login
|
||||
# background stays a plain symlink: fs-assets.js re-encodes every upload to a JPEG on a canvas
|
||||
# first, so there is no script grammar left in the bytes for a direct hit to run.
|
||||
mkdir -p /etc/footstrap
|
||||
if [ -d /www/luci-static/footstrap ]; then
|
||||
ln -sf /etc/footstrap/login-bg /www/luci-static/footstrap/bg
|
||||
ln -sfn /etc/footstrap/fonts /www/luci-static/footstrap/fonts
|
||||
fi
|
||||
# Clean up a pre-0.15 install's stale pattern symlink — see the header comment above for why this
|
||||
# is the one migration this script makes. `-L` only: never remove a real file some other install
|
||||
# step could have left at that path.
|
||||
[ -L /www/luci-static/footstrap/pattern.svg ] && rm -f /www/luci-static/footstrap/pattern.svg
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,11 @@
|
||||
# Preserve what the ADMIN put on this router across sysupgrade: the login/page background, the
|
||||
# wallpaper pattern and the webfonts in /etc/footstrap/fonts. All three are written at runtime, so
|
||||
# they survive a package upgrade on their own; a firmware sysupgrade keeps only paths listed here
|
||||
# (or in a conffile / /etc/sysupgrade.conf).
|
||||
# Not conffiles: they live outside root/etc/config, and tools/conffiles.mjs rejects a
|
||||
# conffiles-define entry the package does not ship.
|
||||
/etc/footstrap/login-bg
|
||||
/etc/footstrap/pattern.svg
|
||||
# The whole directory: the faces plus the fonts.css generated beside them. Keeping the faces
|
||||
# without the sheet leaves footstrap.settings.fonts pointing at nothing — a 404 on every page.
|
||||
/etc/footstrap/fonts
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"admin/system/footstrap": {
|
||||
"title": "Footstrap",
|
||||
"order": 3,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "footstrap/appearance"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-theme-footstrap" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"luci-theme-footstrap": {
|
||||
"description": "Footstrap theme settings saved to the router, the login background and pattern uploads, and the Footstrap settings page",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
# Serves the admin-uploaded wallpaper pattern with headers a browser will not execute: opened
|
||||
# directly, the /www symlink this replaces was a same-origin document with an authenticated LuCI
|
||||
# session reachable from it (OpenWrt forum thread 251930, confirmed on a stand — a marker SVG set
|
||||
# document.title, read location.origin as the router's, and reached document.cookie, pre-login
|
||||
# too, since /www needs no session). The theme's own rendering never hits this: 15-wallpaper.css
|
||||
# paints the same file through mask-image or a tiled background-image, and neither ever runs
|
||||
# script inside it — the exposure was only the direct URL, which is what CSP 'none' + sandbox and
|
||||
# nosniff below close, without touching how the mask/background paints it.
|
||||
#
|
||||
# Deliberately unauthenticated, on purpose and not an oversight: the pattern paints before login
|
||||
# too (head.ut's pre-paint, from window.__fsSD). That is safe here ONLY because this script takes
|
||||
# NO input at all — FILE below is a shell constant, never $QUERY_STRING, $PATH_INFO or anything
|
||||
# else request-supplied — so there is nothing to traverse and nothing else this handler can be
|
||||
# made to read.
|
||||
#
|
||||
# The login background (/etc/footstrap/login-bg) stays a plain /www symlink and does NOT get this
|
||||
# treatment: fs-assets.js re-encodes every upload to a JPEG on a canvas before it is sent, so the
|
||||
# stored bytes are pixels only and there is no script grammar left in the file for a direct hit to
|
||||
# run — see docs/package.md.
|
||||
|
||||
FILE=/etc/footstrap/pattern.svg
|
||||
|
||||
if [ ! -r "$FILE" ]; then
|
||||
printf 'Status: 404 Not Found\r\n'
|
||||
printf 'Content-Type: text/plain\r\n'
|
||||
printf '\r\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Status: 200 OK\r\n'
|
||||
printf 'Content-Type: image/svg+xml\r\n'
|
||||
# Cacheable forever, because the URL is not stable: fs-axes.js appends `?v=<token>` and the token
|
||||
# changes with every upload, so a new file is a new URL. Without this the /www symlink's ETag and
|
||||
# Last-Modified were gone and the tile was re-fetched on EVERY page load - a background image on a
|
||||
# router that serves it off flash with no gzip, which is the cost this handler must not add.
|
||||
printf 'Cache-Control: public, max-age=31536000, immutable\r\n'
|
||||
printf 'X-Content-Type-Options: nosniff\r\n'
|
||||
printf "Content-Security-Policy: default-src 'none'; sandbox\r\n"
|
||||
printf '\r\n'
|
||||
cat "$FILE"
|
||||
|
||||
exit 0
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/bin/sh
|
||||
# Strip the two static assets nothing else strips: the SVG favicon's comment and the manifest's
|
||||
# indentation. Over a BUILD TREE, never the checkout — the comment is a "why" git keeps, and
|
||||
# `logo.svg` is also the source `tools/build-icons.mjs` rasterises the PNGs from.
|
||||
#
|
||||
# Small, but honest bytes: the favicon is fetched by every browser on every cold visit, and uhttpd
|
||||
# serves it uncompressed like everything else. 1,360 -> ~590 B for the SVG, 366 -> ~310 B for the
|
||||
# manifest.
|
||||
#
|
||||
# Why not `strip-templates.sh`: that one is line-oriented and only removes comments from column one,
|
||||
# which is right for a template and wrong for a single-line XML document.
|
||||
#
|
||||
# Usage: strip-assets.sh <dir>
|
||||
set -eu
|
||||
|
||||
DIR="${1:-}"
|
||||
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-assets.sh <dir>" >&2; exit 2; }
|
||||
|
||||
found=0
|
||||
|
||||
# ---- SVG: drop XML comments, then the whitespace BETWEEN tags only ----
|
||||
# Never inside a tag: `viewBox="-9 -1 100 100"` and `d="M2 3 L4 5"` are attribute values whose
|
||||
# spaces are data. Only `> <` is collapsed to `><`.
|
||||
for f in $(find "$DIR" -type f -name '*.svg' | sort); do
|
||||
tmp="$f.tmp$$"
|
||||
# portable slurp: RS tricks are not POSIX — docs/development.md, "Building a package locally".
|
||||
awk '
|
||||
{ buf = buf $0 "\n" }
|
||||
END {
|
||||
$0 = buf
|
||||
# comments first: they may span lines and may contain angle brackets
|
||||
while (match($0, /<!--([^-]|-[^-]|--[^>])*-->/)) {
|
||||
$0 = substr($0, 1, RSTART - 1) substr($0, RSTART + RLENGTH)
|
||||
}
|
||||
gsub(/>[ \t\r\n]+</, "><")
|
||||
gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "")
|
||||
printf "%s", $0
|
||||
}
|
||||
' "$f" > "$tmp"
|
||||
# a truncated write must never ship: the same floor build-css.sh keeps
|
||||
if [ ! -s "$tmp" ] || [ "$(wc -c < "$tmp")" -lt 100 ]; then
|
||||
rm -f "$tmp"
|
||||
echo "strip-assets: $f came out implausibly small — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
# a silent no-op on some other awk must never ship either: the comment is the one byte
|
||||
# sequence stripping exists to remove, so its survival is the cheapest proof of a no-op.
|
||||
if grep -q '<!--' "$tmp"; then
|
||||
rm -f "$tmp"
|
||||
echo "strip-assets: $f still has an XML comment after stripping — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp" "$f"
|
||||
found=$((found + 1))
|
||||
done
|
||||
|
||||
# ---- JSON: one line, no indentation ----
|
||||
# Structure only. A value keeps every byte, so a name or a URL with a space survives.
|
||||
for f in $(find "$DIR" -type f -name '*.json' ! -path '*/rpcd/acl.d/*' | sort); do
|
||||
tmp="$f.tmp$$"
|
||||
# acl.d is excluded on purpose: rpcd skips a malformed ACL SILENTLY, so the grant would go to
|
||||
# nobody and only Save-as-default and the upload would break, on someone else's router. Those
|
||||
# files are also never fetched over the wire. Not worth the risk for ~200 B.
|
||||
awk '
|
||||
{ buf = buf $0 "\n" }
|
||||
END {
|
||||
q = 0
|
||||
out = ""
|
||||
n = length(buf)
|
||||
for (i = 1; i <= n; i++) {
|
||||
c = substr(buf, i, 1)
|
||||
if (q) {
|
||||
out = out c
|
||||
if (c == "\\") { out = out substr(buf, i + 1, 1); i++; continue }
|
||||
if (c == "\"") q = 0
|
||||
continue
|
||||
}
|
||||
if (c == "\"") { q = 1; out = out c; continue }
|
||||
if (c == " " || c == "\t" || c == "\n" || c == "\r") continue
|
||||
out = out c
|
||||
}
|
||||
printf "%s", out
|
||||
}
|
||||
' "$f" > "$tmp"
|
||||
if [ ! -s "$tmp" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "strip-assets: $f came out empty — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
# a silent no-op must never ship: a surviving newline means stripping did not happen.
|
||||
if [ "$(wc -l < "$tmp")" -gt 0 ]; then
|
||||
rm -f "$tmp"
|
||||
echo "strip-assets: $f still has a newline after stripping — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp" "$f"
|
||||
found=$((found + 1))
|
||||
done
|
||||
|
||||
[ "$found" -gt 0 ] || { echo "strip-assets: no .svg or .json in $DIR" >&2; exit 1; }
|
||||
echo "strip-assets: $found file(s)"
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
# Drop the `/* fs:probe */` exports from the JS IN A BUILD TREE.
|
||||
#
|
||||
# ./strip-probes.sh <dir>
|
||||
#
|
||||
# A handful of module-private functions are also listed in the module's `baseclass.extend({…})` for
|
||||
# one reason: a gate in this repository calls them. Nothing on a router does — the functions
|
||||
# themselves are used inside their own module, and only the export line exists for the tests.
|
||||
#
|
||||
# So the line is marked and removed on the way into a package: the checkout keeps the seam the gates
|
||||
# need and the router gets a module surface that is only what the theme calls. Same trade as
|
||||
# strip-templates.sh and strip-shell.sh.
|
||||
#
|
||||
# Only a line that ends with the marker, and the line must be a complete export entry — anything
|
||||
# else is left in place and reported, a half-removed object literal being a module that does not
|
||||
# parse and a theme that does not load.
|
||||
set -e
|
||||
|
||||
DIR="${1:-}"
|
||||
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-probes.sh <dir>" >&2; exit 1; }
|
||||
|
||||
LIST=$(mktemp)
|
||||
CUR=""
|
||||
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT INT TERM
|
||||
find "$DIR" -name '*.js' -type f | sort > "$LIST"
|
||||
|
||||
found=0
|
||||
files=0
|
||||
|
||||
while IFS= read -r f; do
|
||||
grep -q '/\* fs:probe \*/' "$f" || continue
|
||||
files=$((files + 1))
|
||||
CUR="$f.tmp$$"
|
||||
awk '
|
||||
/\/\* fs:probe \*\/$/ {
|
||||
line = $0
|
||||
sub(/[ \t]*\/\* fs:probe \*\/$/, "", line)
|
||||
# a complete entry, in either form the modules use: `name,` or `name: expression,`
|
||||
if (line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*,[ \t]*$/ ||
|
||||
line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*:.*,[ \t]*$/) { dropped++; next }
|
||||
print "strip-probes: not a whole export entry, left in place: " line | "cat 1>&2"
|
||||
}
|
||||
{ print }
|
||||
END { printf "%d", dropped > "/dev/stderr" }
|
||||
' "$f" 2> "$CUR.n" > "$CUR"
|
||||
n=$(cat "$CUR.n"); rm -f "$CUR.n"
|
||||
found=$((found + ${n:-0}))
|
||||
mv "$CUR" "$f"
|
||||
CUR=""
|
||||
done < "$LIST"
|
||||
|
||||
[ "$files" -gt 0 ] || { echo "strip-probes: no marked export in $DIR" >&2; exit 1; }
|
||||
echo "strip-probes: $found export(s) dropped from $files file(s)"
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/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 (uci-defaults is 51% comment lines, keep.d 77%).
|
||||
#
|
||||
# ONLY a line whose first non-blank character is `#`, and never the shebang. sh has no block-comment
|
||||
# syntax, so the only way a `#` can be something else 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, and refused below in the files this loop rewrites).
|
||||
#
|
||||
# Files are matched by their shebang or by living under a shell directory (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; }
|
||||
|
||||
# ONE trap, covering the file list and whatever temp the loop holds when it dies. Under `set -e` an
|
||||
# awk failure leaves `<file>.tmp<pid>` behind, and stage.sh runs this over the staged payload, so the
|
||||
# leftover is copied into the package: a `30_luci-theme-footstrap.tmp1234` under /etc/uci-defaults is
|
||||
# a file the ROUTER executes and deletes on its next boot.
|
||||
LIST=$(mktemp)
|
||||
CUR=""
|
||||
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT INT TERM
|
||||
|
||||
# The list goes through a file rather than `for f in $(find …)`: that splits on whitespace, so a
|
||||
# checkout under a path with a space fed the loop two halves of a directory name.
|
||||
find "$DIR" -type f | sort > "$LIST"
|
||||
|
||||
before=0
|
||||
after=0
|
||||
found=0
|
||||
while IFS= read -r f; do
|
||||
# shell only: a shebang, or the one comment-only manifest this package ships (keep.d). The uci
|
||||
# config and the rpcd ACL are deliberately left alone.
|
||||
case "$(head -c 2 "$f")" in
|
||||
'#!') ;;
|
||||
*) case "$f" in */keep.d/*) ;; *) continue ;; esac ;;
|
||||
esac
|
||||
# The heredoc refusal, over the files this loop actually REWRITES. Run over the whole tree it
|
||||
# aborted the build on a `<<` in the ACL or the uci config, neither of which is touched here.
|
||||
if grep -q '<<' "$f"; then
|
||||
echo "strip-shell: a heredoc in $f — refusing (a '#' line inside one is DATA, not a comment)" >&2
|
||||
exit 1
|
||||
fi
|
||||
found=$((found + 1))
|
||||
b=$(wc -c < "$f")
|
||||
CUR="$f.tmp$$"
|
||||
awk 'NR == 1 && /^#!/ { print; next }
|
||||
/^[ \t]*#/ { next }
|
||||
{ print }' "$f" > "$CUR"
|
||||
# never leave a file that lost its shebang or came out empty
|
||||
if [ ! -s "$CUR" ]; then
|
||||
echo "strip-shell: $f came out empty — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$CUR" > "$f" # cat, not mv: keep the original mode (uci-defaults must stay +x)
|
||||
rm -f "$CUR"
|
||||
CUR=""
|
||||
a=$(wc -c < "$f")
|
||||
before=$((before + b))
|
||||
after=$((after + a))
|
||||
done < "$LIST"
|
||||
|
||||
[ "$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
+120
@@ -0,0 +1,120 @@
|
||||
#!/bin/sh
|
||||
# Strip `{# … #}` template comments from the .ut files IN A BUILD TREE.
|
||||
#
|
||||
# ./strip-templates.sh <dir>
|
||||
#
|
||||
# Run from the package Makefile over $(PKG_BUILD_DIR), never over the source tree: git keeps every
|
||||
# word and the router needs none of them. Same trade this project makes for JS and CSS, and
|
||||
# templates are mostly comments — `{# … #}` is 38% of their bytes.
|
||||
#
|
||||
# sh and awk only, like build-css.sh: an OpenWrt buildbot has no node.
|
||||
#
|
||||
# What is and is not touched, which 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.
|
||||
# Verified: 0 of them appear inside a code block, and every opener has exactly one closer.
|
||||
# * `/* … */` STANDING ON ITS OWN LINES — a code comment, wherever it sits: ucode, JavaScript in
|
||||
# an inline <script>, CSS in an inline <style>.
|
||||
#
|
||||
# The rule is not "remove /* … */" but "remove a comment that OWNS its lines": `/*` is the first
|
||||
# non-blank thing on its line and `*/` the last non-blank thing on its. For that to eat live
|
||||
# code a string literal would have to span lines AND contain a line that is nothing but a
|
||||
# comment — measured across every .ut here: 18362 of 18362 comment bytes are whole-line.
|
||||
# Anything that does not fit the rule is left in place and counted.
|
||||
#
|
||||
# It deliberately does not minify: no joining of lines, no touching of anything that is not a
|
||||
# comment from column one.
|
||||
#
|
||||
# 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. head.ut's licence
|
||||
# block is the one that matters — its `-#}` swallows the newline before <!DOCTYPE html>, and
|
||||
# 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; }
|
||||
|
||||
# ONE trap over the file list and whatever temp the loop holds, and the list goes through a file
|
||||
# rather than `for f in $(find …)`: the same two fixes as strip-shell.sh, for the same reasons — a
|
||||
# checkout path with a space in it was split into words, and an awk failure under `set -e` left a
|
||||
# `<file>.tmp<pid>` inside the staged payload, which stage.sh then copies into the package.
|
||||
LIST=$(mktemp)
|
||||
CUR=""
|
||||
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT INT TERM
|
||||
find "$DIR" -name '*.ut' -type f | sort > "$LIST"
|
||||
|
||||
before=0
|
||||
after=0
|
||||
found=0
|
||||
|
||||
while IFS= read -r f; do
|
||||
found=$((found + 1))
|
||||
b=$(wc -c < "$f")
|
||||
CUR="$f.tmp$$"
|
||||
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" > "$CUR"
|
||||
mv "$CUR" "$f"
|
||||
|
||||
# …and the code comments that own their lines, line by line rather than over the slurped file:
|
||||
# a rule about what a LINE is cannot be expressed over a byte stream, and going line by line is
|
||||
# also what makes it impossible to walk into a string literal by accident (see the header).
|
||||
CUR="$f.tmp$$"
|
||||
awk -v LEFT=0 '
|
||||
function blank(t) { return t ~ /^[ \t\r]*$/ }
|
||||
{
|
||||
line = $0
|
||||
if (!incomment) {
|
||||
# a comment that owns this line: nothing but whitespace before /*
|
||||
if (line ~ /^[ \t]*\/\*/) {
|
||||
# …and if it closes on the same line, the rest of that line must be blank too
|
||||
if (line ~ /\*\//) {
|
||||
rest = line; sub(/^.*\*\//, "", rest)
|
||||
if (blank(rest)) next
|
||||
LEFT++; print line; next # code after the closer: leave it whole
|
||||
}
|
||||
incomment = 1; next
|
||||
}
|
||||
# an inline comment (code before it) is left alone and counted
|
||||
if (line ~ /\/\*/) LEFT++
|
||||
print line; next
|
||||
}
|
||||
# inside a block comment: it may only end a line, or we would drop live code
|
||||
if (line ~ /\*\//) {
|
||||
rest = line; sub(/^.*\*\//, "", rest)
|
||||
incomment = 0
|
||||
if (!blank(rest)) { LEFT++; print rest }
|
||||
}
|
||||
next
|
||||
}
|
||||
END { if (LEFT > 0) printf "strip-templates: %d comment(s) not on lines of their own, left in place\n", LEFT | "cat 1>&2" }
|
||||
' "$f" > "$CUR"
|
||||
mv "$CUR" "$f"
|
||||
CUR=""
|
||||
a=$(wc -c < "$f")
|
||||
before=$((before + b))
|
||||
after=$((after + a))
|
||||
done < "$LIST"
|
||||
|
||||
[ "$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,16 @@
|
||||
/*!
|
||||
* 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. Directory-to-layer map, concatenation order and
|
||||
* the hijack risk from a foreign sheet naming a layer first: docs/css.md "One directory per
|
||||
* cascade layer" and "Layer order is fixed by the FIRST mention of a name". */
|
||||
@layer tokens, base, theme, page;
|
||||
@@ -0,0 +1,233 @@
|
||||
@layer tokens {
|
||||
/* PRIVATE --fs-* only; the EXPORT --*-color-* tier is derived elsewhere and unread here.
|
||||
* docs/design-system.md "Two tiers, and the split is load-bearing". */
|
||||
:root {
|
||||
/* palette colours live in 03-palettes.css */
|
||||
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30); /* pop surfaces: menus, dropdown lists, the select picker */
|
||||
/* which of the three a control takes: docs/design-system.md "The derived ladder" */
|
||||
--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);
|
||||
/* opaque, not a tint (4.21:1 in dark); pinned by tools/mirror.mjs
|
||||
* (`selected-row/paint`), docs/css.md "@mirror" */
|
||||
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
|
||||
|
||||
/* every separator is this one line, stated once rather than in 69 longhand declarations */
|
||||
--fs-hairline: 1px solid var(--fs-border);
|
||||
/* ring and border-colour transition together, or the ring arrives before the edge does */
|
||||
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
|
||||
|
||||
/* the theme's one colour literal on purpose. docs/design-system.md "The derived ladder". */
|
||||
--fs-scrim: rgba(0, 0, 0, .7);
|
||||
|
||||
/* Role tint ladder, derived so it follows the palette. docs/design-system.md "The derived
|
||||
* ladder". */
|
||||
--fs-good-soft: color-mix(in srgb, var(--fs-good) 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-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-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
|
||||
|
||||
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
|
||||
* tokens, not fetched". */
|
||||
--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");
|
||||
/* css-dup cannot police a glyph inlined once, sized at two call sites — keep in step by hand */
|
||||
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
|
||||
|
||||
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
|
||||
--fs-hover-lift: brightness(.90);
|
||||
|
||||
/* four durations, no easing token. docs/design-system.md "Scales" ("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 */
|
||||
/* also read by the bar's centering calc and the notification-width calc */
|
||||
--fs-content-pad: calc(28px * var(--fs-density-space));
|
||||
/* tokens, not literals — fs-chrome.js's fitShell() reads them back. docs/design-system.md
|
||||
* "Scales" ("Shell geometry"). */
|
||||
--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 */
|
||||
/* opaque on purpose. docs/design-system.md "The derived ladder". */
|
||||
--fs-bar-bg: var(--fs-panel);
|
||||
/* base/10-reset.css's scroll-margin must equal this or an in-page jump lands under the bar */
|
||||
/* drawn, not fetched. docs/design-system.md "Two glyphs drawn as tokens, not fetched". */
|
||||
--fs-select-chevron:
|
||||
linear-gradient(-45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%),
|
||||
linear-gradient(45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%);
|
||||
--fs-bar-h: calc(46px * var(--fs-density-box));
|
||||
/* logo box, .fs-themerow controls, Log out and the poll pill share this size */
|
||||
--fs-btn-size: calc(34px * var(--fs-density-box));
|
||||
|
||||
/* one base (Appearance -> Rounding); three radii scale from it */
|
||||
--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 */
|
||||
|
||||
/* .cbi-section's own gutter; theme/45-misc.css reads it back to undo it (bleed to the card edge) */
|
||||
--fs-card-pad: calc(16px * var(--fs-density-space));
|
||||
/* vertical rhythm between cards: .cbi-section, tab groups, alerts, action bars */
|
||||
--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;
|
||||
|
||||
/* five steps, ~1.25 apart */
|
||||
/* three multipliers, one knob each for text/air/boxes */
|
||||
--fs-density-type: 1;
|
||||
--fs-density-space: 1;
|
||||
--fs-density-box: 1;
|
||||
|
||||
/* chrome-control floors, not a field's height (--fs-control-h below) */
|
||||
--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 */
|
||||
|
||||
/* rounded in @supports below (round() is younger than the floor) */
|
||||
--fs-type-2xs: calc(10px * var(--fs-density-type));
|
||||
--fs-type-xs: calc(11px * var(--fs-density-type)); /* eyebrow, badges, tooltips, dense metadata */
|
||||
--fs-type: calc(13px * var(--fs-density-type)); /* body, tables, fields, menu items — the base */
|
||||
--fs-type-lg: calc(16px * var(--fs-density-type)); /* section titles (h3/legend), alert headings */
|
||||
--fs-type-xl: calc(20px * var(--fs-density-type)); /* page titles */
|
||||
--fs-type-2xl: calc(26px * var(--fs-density-type)); /* h1 — only a third-party app emits a bare one */
|
||||
/* one spelling per weight — `700`/`bold` and `400`/`normal` were both in use. */
|
||||
--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 */
|
||||
|
||||
/* one microlabel idiom, named once */
|
||||
--fs-eyebrow-tracking: .06em;
|
||||
--fs-eyebrow-weight: 700;
|
||||
--fs-eyebrow-color: var(--fs-faint);
|
||||
|
||||
/* pick the nearer step; do not add one for a single rule */
|
||||
--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));
|
||||
/* sparse on purpose above -7 — gutters and insets, not rhythm */
|
||||
--fs-space-8: calc(32px * var(--fs-density-space));
|
||||
--fs-space-10: calc(40px * var(--fs-density-space));
|
||||
|
||||
/* every single-line field's height, derived not measured */
|
||||
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
|
||||
|
||||
/* ceiling for an elastic `.cbi-value-field` (flex: 1) — a COLUMN width, off the 4px scale */
|
||||
--fs-field-max: 440px;
|
||||
|
||||
/* every z-index comes from here */
|
||||
--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;
|
||||
|
||||
/* outbound only (audit.py --strict fails a styles/ read of one). docs/design-system.md
|
||||
* "The export tier is a RAMP" */
|
||||
--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-highest: var(--fs-text);
|
||||
--text-color-high: var(--fs-text);
|
||||
--text-color-medium: var(--fs-dim);
|
||||
--text-color-low: var(--fs-faint);
|
||||
|
||||
/* borders carry no text, so -low may fade toward the surface — a hairline is meant 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));
|
||||
|
||||
/* tools/export-tier.mjs proves the whole tier across palette x mode */
|
||||
--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;
|
||||
}
|
||||
|
||||
/* round(…) tested where a length is expected, not by a calc() probe */
|
||||
@supports (width: round(1px, 1px)) {
|
||||
:root {
|
||||
--fs-type-2xs: calc(round(10px * var(--fs-density-type), 1px));
|
||||
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px));
|
||||
--fs-type: calc(round(13px * var(--fs-density-type), 1px));
|
||||
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px));
|
||||
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px));
|
||||
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px));
|
||||
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
|
||||
}
|
||||
}
|
||||
|
||||
/* bare :root is Normal; these multiply the LADDERS, not the rules */
|
||||
:root[data-density="compact"] {
|
||||
--fs-density-type: .9;
|
||||
--fs-density-box: .85;
|
||||
--fs-density-space: .65;
|
||||
}
|
||||
:root[data-density="large"] {
|
||||
--fs-density-type: 1.15;
|
||||
--fs-density-box: 1.15;
|
||||
/* --fs-density-space stays at the bare :root default of 1 here — large density widens type
|
||||
* and controls, not the space/gutter scale. */
|
||||
}
|
||||
|
||||
/* No :root[data-content-width] block: fs-axes.js's propAxis writes --fs-content-max inline
|
||||
* instead. docs/design-system.md "The appearance axes" ("Content width"). */
|
||||
|
||||
:root[data-darkmode="true"] {
|
||||
/* palette colours are in 03-palettes.css; only mode-level tokens live here */
|
||||
--fs-disabled-opacity: .45;
|
||||
/* dark fill is light, so brightening (not darkening) moves it away from page AND ink */
|
||||
--fs-hover-lift: brightness(1.15);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* no component bridge (--*-hsl, --*-rgb): docs/conventions.md "Writing CSS" ("No colour
|
||||
* literals"). The one surviving --*-rgb is luci-mod-network's own (base/95-luci.css), not ours. */
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
@layer tokens {
|
||||
/* five colourways, one block per (palette x mode). docs/design-system.md "Palettes";
|
||||
* adding one: "Adding a palette". */
|
||||
|
||||
/* --fs-bg washed by hue tint; --fs-bg-base dodges a self-reference cycle (invalid at
|
||||
* computed-value time). docs/design-system.md "Tint and Accent". */
|
||||
:root {
|
||||
--fs-bg: var(--fs-bg-base);
|
||||
/* derived from -base so the accent-hue axis has one copy to rotate */
|
||||
--fs-accent: var(--fs-accent-base);
|
||||
/* tint OF --fs-accent, mixed FROM it so it never goes stale. LIGHT only; each dark block
|
||||
* below overrides its own. */
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
/* surfaces + status: raw per palette, derived form is read everywhere else */
|
||||
--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);
|
||||
/* deliberately under AA — the "nothing here" ink, held by `npm run placeholder`.
|
||||
* docs/design-system.md "The fourth ink is the one". */
|
||||
--fs-placeholder-mix: 55%;
|
||||
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
|
||||
/* declared here too, so the sheet stands alone — an undefined var() invalidates the rule */
|
||||
--fs-accent-h: 0;
|
||||
--fs-tint-h: 0;
|
||||
--fs-good-h: 0;
|
||||
--fs-warn-h: 0;
|
||||
--fs-danger-h: 0;
|
||||
/* multiplier on the tint chroma below (Tint strength, not Density). docs/design-system.md
|
||||
* "Tint and Accent". */
|
||||
--fs-tint-strength: 1;
|
||||
/* inert default; head.ut/fs-prefs.js stamp the real url() inline */
|
||||
--fs-login-bg-url: none;
|
||||
/* scrim over a File photo, mixed from --fs-bg-base so tint never leaks onto it */
|
||||
--fs-photo-dim: 74%;
|
||||
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
|
||||
/* tiled pattern axes; .2 is where a hairline reads as a drawing, not texture (.15 is swallowed) */
|
||||
--fs-pattern-url: none;
|
||||
--fs-pattern-size: 440px;
|
||||
--fs-pattern-strength: .2;
|
||||
/* floor + two cos() terms, not flat chroma. docs/design-system.md "Tint and Accent". */
|
||||
--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));
|
||||
/* per-mode, not per-palette — see the placeholder-mix note above */
|
||||
--fs-placeholder-mix: 64%;
|
||||
}
|
||||
:root[data-tint="hue"] { --fs-bg: oklch(from var(--fs-bg-base) l var(--fs-tint-c) var(--fs-tint-h)); }
|
||||
|
||||
/* hue mode rotates via oklch(from…); hex mode is stamped inline and re-derives the ink below */
|
||||
: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)); }
|
||||
|
||||
/* hex ink must outrank a NAMED palette's dark block ([data-palette=…][data-darkmode="true"],
|
||||
* (0,3,0)); triple attribute makes it (0,4,0) */
|
||||
:root[data-accent="hex"][data-accent][data-accent] { --fs-on-accent: oklch(from var(--fs-accent) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-good="hex"][data-good][data-good] { --fs-on-good: oklch(from var(--fs-good) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-warn="hex"][data-warn][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-danger="hex"][data-danger][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) ---- */
|
||||
/* never matches (both appliers REMOVE it for the default) — kept so :root need not duplicate
|
||||
* the derive rule above */
|
||||
:root,
|
||||
:root[data-palette="footstrap"] {
|
||||
/* LIGHT */
|
||||
--fs-bg-base: #f6f8fa;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #f6f8fa;
|
||||
--fs-border-base: #d0d7de;
|
||||
--fs-text: #1f2328;
|
||||
/* raised past AA — GitHub-light greys read as 5.2/4.9:1 on the card (page descriptions,
|
||||
* field titles), now 11.8/10.0:1 */
|
||||
--fs-dim: #33383d;
|
||||
--fs-faint: #3d4348;
|
||||
--fs-accent-base: #0969da;
|
||||
--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.28/5.19/5.36. --fs-panel-base
|
||||
* and these four inks are the same #fff in every light palette below, so THIS is their
|
||||
* only definition — hicontrast/bootstrap/2020/forum's light blocks inherit them, unset.
|
||||
* docs/design-system.md "Adding a palette". */
|
||||
--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-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: fills are light; white fails all four (2.79 worst), #10151c (a shade of
|
||||
* --fs-bg) clears them (6.55 worst) */
|
||||
--fs-on-accent: #10151c;
|
||||
--fs-on-good: #10151c;
|
||||
--fs-on-warn: #10151c;
|
||||
--fs-on-danger: #10151c;
|
||||
}
|
||||
|
||||
/* ---- hicontrast — deeper, more saturated (opt-in); docs/design-system.md "Palettes" ---- */
|
||||
:root[data-palette="hicontrast"] {
|
||||
/* LIGHT — accents DARKER than the default's on purpose: 5.22/5.35/5.93/5.80 vs its 5.08. */
|
||||
--fs-bg-base: #eef1f6;
|
||||
--fs-panel2-base: #f6f8fb;
|
||||
--fs-border-base: #e4e9f1;
|
||||
--fs-text: #101725;
|
||||
--fs-dim: #272f3d;
|
||||
--fs-faint: #2e3745;
|
||||
--fs-accent-base: #0b6fbd;
|
||||
--fs-track: #eaeef4;
|
||||
--fs-good-base: #0f7a52;
|
||||
--fs-warn-base: #8a5a00;
|
||||
--fs-danger-base: #c02b2b;
|
||||
}
|
||||
: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-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 fails (2.57 worst), #0a0d13 (=--fs-bg-base) clears
|
||||
* (7.01 worst) */
|
||||
--fs-on-accent: #0a0d13;
|
||||
--fs-on-good: #0a0d13;
|
||||
--fs-on-warn: #0a0d13;
|
||||
--fs-on-danger: #0a0d13;
|
||||
}
|
||||
|
||||
/* ---- bootstrap — luci-theme-bootstrap's own colours (opt-in). docs/design-system.md
|
||||
* "Palettes" ("bootstrap"). ---- */
|
||||
:root[data-palette="bootstrap"] {
|
||||
/* LIGHT — white ink on the fills below clears AA on all four: 5.81/5.54/5.40/5.70. */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel2-base: #f5f5f5;
|
||||
--fs-border-base: #ccc;
|
||||
--fs-text: #232323;
|
||||
--fs-dim: #383838;
|
||||
--fs-faint: #424242;
|
||||
/* --primary-color-medium; -high (#1976d2) measured 4.22:1 on --fs-panel2 */
|
||||
--fs-accent-base: #1564c0;
|
||||
--fs-track: #eee;
|
||||
--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 */
|
||||
}
|
||||
:root[data-palette="bootstrap"][data-darkmode="true"] {
|
||||
/* DARK */
|
||||
--fs-bg-base: #222;
|
||||
--fs-panel-base: #282828;
|
||||
/* raised past bootstrap's own value: docs/design-system.md "Palettes" ("bootstrap") */
|
||||
--fs-panel2-base: #303030;
|
||||
--fs-border-base: #444;
|
||||
--fs-text: #bfbfbf;
|
||||
/* --fs-dim/--fs-faint construction, not bootstrap's own — see the panel2 note above */
|
||||
--fs-dim: #a6a6a6;
|
||||
--fs-faint: #9a9a9a;
|
||||
--fs-accent-base: #53a4c2;
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
--fs-track: #333;
|
||||
--fs-good-base: #1aaf7b;
|
||||
--fs-warn-base: #aa9968;
|
||||
--fs-danger-base: #dd807e;
|
||||
/* dark ink: light fills, dark ink — matches bootstrap's own --on-*-color; #222 gives
|
||||
* 5.65 on all four */
|
||||
--fs-on-accent: #222;
|
||||
--fs-on-good: #222;
|
||||
--fs-on-warn: #222;
|
||||
--fs-on-danger: #222;
|
||||
}
|
||||
/* ---- 2020 — the OpenWrt 2020 theme's colourway (opt-in), split into a pair.
|
||||
* docs/design-system.md "Palettes" ("2020"). ---- */
|
||||
:root[data-palette="2020"] {
|
||||
/* LIGHT — white content, the CI cyan darkened until it can be read on it. */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel2-base: #eef4f8;
|
||||
--fs-border-base: #c3d2dd;
|
||||
/* navy-tinted neutrals rather than grey: the ink belongs to the same scheme as the accent */
|
||||
--fs-text: #10202b;
|
||||
--fs-dim: #2c4150;
|
||||
--fs-faint: #3d5566;
|
||||
/* #00B5E2 held at hue, taken down until it reads: 5.89:1 on white against its own 2.09:1 */
|
||||
--fs-accent-base: #036c92;
|
||||
--fs-track: #e3ebf1;
|
||||
--fs-good-base: #276e2a; /* #5CB85C darkened; 2.28:1 as text is not a rounding error */
|
||||
--fs-warn-base: #8a5a00; /* #C80, same move */
|
||||
--fs-danger-base: #c1121f; /* #C11 is 5.25:1 already — the one that stays where it was */
|
||||
}
|
||||
:root[data-palette="2020"][data-darkmode="true"] {
|
||||
/* DARK — 2020's own scheme: the navy is the canvas and the cyan is nearly untouched */
|
||||
--fs-bg-base: #002b49;
|
||||
--fs-panel-base: #0a3a58;
|
||||
--fs-panel2-base: #0e4362;
|
||||
--fs-border-base: #1d5a7d;
|
||||
--fs-text: #e6f1f7;
|
||||
--fs-dim: #b8cfdd;
|
||||
--fs-faint: #9dbccd;
|
||||
/* the CI cyan, one step up so it clears AA on the lightest of the three surfaces too */
|
||||
--fs-accent-base: #35c6ea;
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
--fs-track: #123c56;
|
||||
--fs-good-base: #74cc74; /* #5CB85C lifted for the same reason */
|
||||
--fs-warn-base: #eab346;
|
||||
--fs-danger-base: #ff9d9d;
|
||||
/* these fills are LIGHT, so the ink is the canvas navy itself */
|
||||
--fs-on-accent: #002b49;
|
||||
--fs-on-good: #002b49;
|
||||
--fs-on-warn: #002b49;
|
||||
--fs-on-danger: #002b49;
|
||||
}
|
||||
/* ---- forum — sampled from forum.openwrt.org's own getComputedStyle (opt-in).
|
||||
* docs/design-system.md "Palettes" ("forum"). ---- */
|
||||
:root[data-palette="forum"] {
|
||||
/* LIGHT — the forum's own page, exactly: white canvas, near-black ink. white on the
|
||||
* fills below: 5.19/5.14/5.17/5.17. */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel2-base: #f0f0f0;
|
||||
--fs-border-base: #ddd;
|
||||
--fs-text: #222;
|
||||
--fs-dim: #3d3d3d;
|
||||
--fs-faint: #4a4a4a;
|
||||
/* sampled #08c 3.89:1/4.49:1 (panel2 fails the 4.5:1 floor); held hue to 5.19:1/4.55:1 */
|
||||
--fs-accent-base: #0073ac;
|
||||
--fs-track: #eee;
|
||||
/* sampled #090 3.78:1; #ffff4d (highlight, fills warn) 1.07:1 as text */
|
||||
--fs-good-base: #008000;
|
||||
--fs-warn-base: #717100;
|
||||
--fs-danger-base: #c63c1b;
|
||||
}
|
||||
:root[data-palette="forum"][data-darkmode="true"] {
|
||||
/* DARK — the forum's own dark scheme: #222 canvas, #ddd ink, both exactly as sampled */
|
||||
--fs-bg-base: #222;
|
||||
--fs-panel-base: #262626;
|
||||
--fs-panel2-base: #2e2e2e;
|
||||
--fs-border-base: #3a3a3a;
|
||||
--fs-text: #ddd;
|
||||
--fs-dim: #a3a3a3;
|
||||
--fs-faint: #969696;
|
||||
/* sampled #0f82af 3.66:1/3.12:1 on bg/panel2, under 4.5:1; held the hue to 5.86:1/5.00:1 */
|
||||
--fs-accent-base: #13a8e3;
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
--fs-track: #333;
|
||||
--fs-good-base: #1fb65a;
|
||||
--fs-warn-base: #c99259;
|
||||
--fs-danger-base: #eb8066;
|
||||
/* dark ink: these fills are light, and #222 (= --fs-bg-base) gives 5.86/5.98/5.86/5.92 */
|
||||
--fs-on-accent: #222;
|
||||
--fs-on-good: #222;
|
||||
--fs-on-warn: #222;
|
||||
--fs-on-danger: #222;
|
||||
/* overrides --fs-bar-bg: forum's own masthead navy, measured live */
|
||||
--fs-bar-bg: #002b49;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@layer tokens {
|
||||
/* Static twin for each of the 33 color-mix() tokens, below Chrome 111 / Firefox 113 / Safari
|
||||
* 16.2. Last in the tokens layer, to outrank 02-tokens.css and 03-palettes.css. A fallback
|
||||
* declaration cannot do this job — a custom property fails at the point of USE, not of
|
||||
* definition. Degradation rules: docs/css.md "The browser floor". */
|
||||
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
|
||||
:root {
|
||||
--fs-accent-soft: transparent;
|
||||
--fs-good-soft: transparent;
|
||||
--fs-danger-soft: transparent;
|
||||
--fs-accent-fill: transparent;
|
||||
--fs-good-fill: transparent;
|
||||
--fs-warn-fill: transparent;
|
||||
--fs-danger-fill: transparent;
|
||||
|
||||
--fs-accent-line: var(--fs-accent);
|
||||
--fs-good-line: var(--fs-good);
|
||||
--fs-danger-line: var(--fs-danger);
|
||||
--fs-accent-line-hi: var(--fs-accent);
|
||||
--fs-good-line-hi: var(--fs-good);
|
||||
--fs-danger-line-hi: var(--fs-danger);
|
||||
|
||||
/* -solo is already color-mix-free */
|
||||
--fs-focus-ring: var(--fs-focus-ring-solo);
|
||||
--fs-focus-ring-invalid: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-danger);
|
||||
|
||||
--background-color-medium: var(--fs-panel2);
|
||||
|
||||
/* --fs-photo-dim is a percentage this cannot apply; the fixed dialog scrim beats no scrim */
|
||||
--fs-photo-scrim: var(--fs-scrim);
|
||||
|
||||
/* an unresolved token computes `color` to unset; near-black over a light field beats that */
|
||||
--fs-placeholder: var(--fs-faint);
|
||||
|
||||
/* export tier — third-party apps read these, so they must resolve to something.
|
||||
* -medium keeps the hue, -low degrades to the dim text colour it was mixed toward. */
|
||||
--border-color-high: var(--fs-border);
|
||||
--border-color-low: var(--fs-border);
|
||||
--primary-color-medium: var(--fs-accent);
|
||||
--primary-color-low: var(--fs-dim);
|
||||
--error-color-medium: var(--fs-danger);
|
||||
--error-color-low: var(--fs-dim);
|
||||
--success-color-medium: var(--fs-good);
|
||||
--success-color-low: var(--fs-dim);
|
||||
--warn-color-medium: var(--fs-warn);
|
||||
--warn-color-low: var(--fs-dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@layer base {
|
||||
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
|
||||
|
||||
/* scroll-margin-top: anchor jump must clear the sticky bar (WCAG 2.2 SC 2.4.11), token not a
|
||||
* literal — bar is 46px. */
|
||||
*, *::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;
|
||||
}
|
||||
|
||||
/* Outranks every layer's `display`, id rules included: docs/css.md "Reset: [hidden] must
|
||||
* outrank every layer, not patch one mistake". */
|
||||
[hidden]:not([hidden="until-found"]) { display: none !important; }
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* :focus-visible: UA heuristic keeps the ring off a mouse click. */
|
||||
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 (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; /* not content-box: contradicts border-box above, ~22px too wide */
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-decoration {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
vertical-align: top;
|
||||
font-family: var(--fs-font-mono);
|
||||
}
|
||||
|
||||
/* Unjoined (issue #7): bootstrap's joins square the trailing button's corner; this theme's
|
||||
* buttons keep full radius. Password reveal stays joined (theme/60-inputs.css). */
|
||||
.control-group {
|
||||
display: inline-flex;
|
||||
gap: var(--fs-space-2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
@layer base {
|
||||
/* body/link box + the type scale a view inherits. background/padding/font-family:
|
||||
* theme/20-shell.css — absorbed. */
|
||||
body {
|
||||
font-size: var(--fs-type);
|
||||
font-weight: var(--fs-weight-normal);
|
||||
line-height: var(--fs-leading); /* unitless: inherits as a FACTOR, not a clipping flat length */
|
||||
color: var(--fs-text);
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-gutter: stable; /* no-reflow guarantee without a dead scrollbar on a short page */
|
||||
}
|
||||
|
||||
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: 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. An unsupporting browser ignores it. */
|
||||
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 */
|
||||
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: var(--fs-hairline);
|
||||
}
|
||||
|
||||
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): --fs-text on a border-level surface is 4.40:1 in dark, under AA */
|
||||
background-color: var(--fs-panel2);
|
||||
color: var(--fs-text);
|
||||
padding: 1px var(--fs-space-1);
|
||||
/* docs/css.md "Typography and widgets: an opaque token needs overflow-wrap: anywhere, a
|
||||
* tooltip needs pre-wrap" — a WireGuard key measured 64px past the column at 320px. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* border-radius: the shared `code, pre` rule above already sets it — a second copy of the
|
||||
* same value here restates, not overrides. */
|
||||
pre {
|
||||
background-color: var(--fs-panel2);
|
||||
display: block;
|
||||
padding: var(--fs-space-2);
|
||||
margin: 0 0 var(--fs-space-4);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-border) 15%, transparent);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
@layer base {
|
||||
/* Forms: input types, .cbi-value row layout, focus/disabled/readonly/invalid states. */
|
||||
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);
|
||||
}
|
||||
|
||||
/* Stays in BASE: docs/css.md "Forms: control typography stays in base, on purpose". select is
|
||||
* excluded — face/size/leading live in theme/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 {
|
||||
margin-inline-start: var(--fs-space-5); /* logical: LuCI ships four RTL languages */
|
||||
flex: 1;
|
||||
min-width: 0; /* else a nested table or one long value pushes the row out of the card */
|
||||
}
|
||||
|
||||
/* size + ink: theme/60-inputs.css (.cbi-value-title) — absorbed. Left: label column geometry. */
|
||||
.cbi-value label.cbi-value-title {
|
||||
padding-top: var(--fs-space-1); /* lands the label's first baseline on the field's */
|
||||
flex: 0 0 180px; /* column WIDTH (longest option label), not on the 4px step scale */
|
||||
text-align: end; /* towards the field — not base/95-luci.css's physical .left/.right/.center */
|
||||
/* docs/css.md "Forms: the label column's min-width: 0 survives the responsive layer" —
|
||||
* stated here, not in theme/90-responsive.css. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Shared field box (210px x control-height, #localtime widening, per-widget escapes):
|
||||
* theme/60-inputs.css — absorbed whole, in-layer, or it loses to a theme rule by layer. */
|
||||
|
||||
.cbi-dynlist {
|
||||
height: auto;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* item paint: theme/60-inputs.css — absorbed. Left: drag-and-drop machinery (UIDynamicList). */
|
||||
.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;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
cursor: move;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* plate paint: theme/60-inputs.css — absorbed. Left: pins the × 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;
|
||||
}
|
||||
|
||||
.cbi-dynlist > .item.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
.cbi-dynlist > .item.dragging {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.cbi-dynlist > .item > span {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cbi-dynlist > .add-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.cbi-value-field > .cbi-checkbox,
|
||||
.cbi-value-field > div > .cbi-radio {
|
||||
height: var(--fs-control-h); /* lines a toggle up with the field above it */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cbi-radio {
|
||||
cursor: pointer;
|
||||
gap: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* .cbi-select and the checkbox/radio drawing: 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`. */
|
||||
|
||||
/* --fs-placeholder, not --fs-dim: a placeholder carries the option's DEFAULT and must not read
|
||||
* as a value the reader typed (03-palettes.css has the numbers). */
|
||||
::placeholder {
|
||||
color: var(--fs-placeholder);
|
||||
}
|
||||
|
||||
/* Transitions and the focus/hover ring: the theme's one ring token
|
||||
* (--fs-focus-ring/--fs-focus-ring-invalid). No generic glow here. */
|
||||
|
||||
input[disabled],
|
||||
button[disabled],
|
||||
select[disabled],
|
||||
textarea[disabled],
|
||||
.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);
|
||||
}
|
||||
|
||||
.cbi-section-create {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2); /* not a negative-margin pair — that trick predates flex gap, leaks */
|
||||
}
|
||||
|
||||
.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. .actions has no theme override, so
|
||||
* it stays: some luci-app-* still emits it. */
|
||||
.actions {
|
||||
background: var(--fs-panel2);
|
||||
margin-bottom: var(--fs-space-4);
|
||||
padding: var(--fs-card-pad); /* a plate, so it takes the card's own gutter on every side */
|
||||
border-top: var(--fs-hairline);
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* Physical, hand-mirrored: docs/css.md "Widgets: .actions .secondary-action still floats, and
|
||||
* needs a hand RTL mirror". */
|
||||
.actions .secondary-action {
|
||||
float: right;
|
||||
}
|
||||
:root[dir="rtl"] .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,61 @@
|
||||
@layer base {
|
||||
/* LuCI tables are div soup (.table/.tr/.th/.td), display roles 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;
|
||||
}
|
||||
|
||||
/* Separator on the ROW (theme/30-tables.css), not the cell — else doubled and squares off
|
||||
* the frame's rounded corner (issue #7). */
|
||||
.table .th, .table .td {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Containment default, not the data-table floor: docs/css.md "Tables: overflow-wrap follows
|
||||
* the tier, not the file". First column is a label too; exempt once carded (.fs-stacked .td,
|
||||
* theme/30-tables.css). */
|
||||
.table .td { overflow-wrap: anywhere; }
|
||||
.table .th { overflow-wrap: break-word; }
|
||||
.table .tr > .td:first-child { overflow-wrap: break-word; }
|
||||
|
||||
.table .tr:first-child .th {
|
||||
padding-top: var(--fs-space-2);
|
||||
font-weight: var(--fs-weight-bold);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Out-of-flow, static position (never `bottom: 0`): docs/css.md "Tables: the empty-table
|
||||
* placeholder must not pin to the table's bottom" (issue #36). */
|
||||
.tr.placeholder {
|
||||
height: calc(3em + var(--fs-space-4));
|
||||
}
|
||||
|
||||
.tr.placeholder > .td {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
text-align: center;
|
||||
line-height: 3em;
|
||||
padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */
|
||||
}
|
||||
|
||||
/* Physical: border-block-start/-end need a css-floor.mjs support decision first. */
|
||||
.tr.drag-over-above {
|
||||
border-top: 2px solid var(--fs-accent);
|
||||
}
|
||||
|
||||
.tr.drag-over-below {
|
||||
border-bottom: 2px solid var(--fs-accent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@layer base {
|
||||
/* Page chrome: the sticky <header> band, dropdown anchoring, the tab strip, breadcrumbs and the
|
||||
* footer. styles/theme/ repaints these; what stays here is the geometry a view can rely on. */
|
||||
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: an accent outline is invisible on the ACTIVE
|
||||
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full
|
||||
* (same selectors), so a copy left behind would be a dead declaration tools/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, rather than 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: 100% IS the strip's height by definition, where a literal
|
||||
* would be a hidden pair with whatever sizes the tab */
|
||||
.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 */
|
||||
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 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: var(--fs-hairline);
|
||||
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, where margin-top:auto
|
||||
* pins the footer to the bottom of the flex-column body. Do not re-add
|
||||
* `justify-content: space-between` or a span margin — they came from a two-column footer this
|
||||
* theme does not have. */
|
||||
footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
footer ul.breadcrumb {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@layer base {
|
||||
/* the modal overlay ui.js shows for config diffs, uploads and login */
|
||||
#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%;
|
||||
margin: var(--fs-space-6) auto; /* clear of the sticky bar, and centred */
|
||||
min-height: var(--fs-control-h);
|
||||
max-width: 600px;
|
||||
border-radius: var(--fs-radius-lg); /* a modal is a card — the large radius, like every panel */
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
padding: var(--fs-card-pad);
|
||||
min-width: 270px;
|
||||
}
|
||||
|
||||
/* .modal > * / pre / textarea: theme/70-modal.css — absorbed with the heading ramp and the
|
||||
* generic field box, both of which would out-layer a restatement 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: 100svh; /* not vh (crops under URL bar) or dvh (re-lays out each frame it slides) */
|
||||
}
|
||||
|
||||
body.modal-overlay-active #modal_overlay {
|
||||
inset-inline: 0;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.btn .close, .alert-message .close {
|
||||
font-family: var(--fs-font-sans);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Colour-word block absorbed, do not re-add: .btn.<word> theme/55-buttons.css,
|
||||
* .alert-message/.cbi-tooltip.<word> theme/35-alerts.css. */
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@layer base {
|
||||
/* .btn/.cbi-button are interchangeable in LuCI markup. Paint (box, fill, radius) is
|
||||
* theme/55-buttons.css's; --on-color stays here because base/95-luci.css masks the spinner
|
||||
* glyph with it, so it must track the variant regardless of layer. */
|
||||
.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. */
|
||||
.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: docs/css.md "Buttons: --on-color survives the absorption into theme". */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Not a clearfix — flex container, nothing floats: docs/css.md "Buttons: the flex clearfix,
|
||||
* and why float is dead on .cbi-page-actions". */
|
||||
.cbi-page-actions::after {
|
||||
display: table;
|
||||
content: "";
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.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/borders absorbed (theme/55-buttons.css); Save/Apply's --on-color is re-set there too
|
||||
* regardless of layer, so no base copy is needed for that pair. */
|
||||
.btn.primary,
|
||||
.cbi-button-action.important,
|
||||
.cbi-section-actions .cbi-button-edit {
|
||||
--on-color: var(--fs-on-accent);
|
||||
}
|
||||
|
||||
.cbi-button-positive.important {
|
||||
--on-color: var(--fs-on-good);
|
||||
}
|
||||
|
||||
.cbi-button-negative.important {
|
||||
--on-color: var(--fs-on-danger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
@layer base {
|
||||
/* small widgets any luci-app-* can drop anywhere: tooltip, progress bar, zone badge, close
|
||||
* cross, alert box, .label pill. Arrow: content, not paint — colour is theme/45-misc.css. */
|
||||
.cbi-title-ref::after {
|
||||
content: "➙";
|
||||
}
|
||||
|
||||
.cbi-tooltip-container {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.cbi-tooltip {
|
||||
position: absolute;
|
||||
z-index: var(--fs-z-tooltip);
|
||||
/* docs/css.md "Widgets: the tooltip parks past the unreachable edge, and the close cross
|
||||
* needs the same mirror" — a physical `left` grows a 10000px RTL scrollbar. */
|
||||
inset-inline-start: -10000px;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-panel);
|
||||
/* pre-wrap, never pre: docs/css.md "Typography and widgets: an opaque token needs
|
||||
* overflow-wrap: anywhere, a tooltip needs pre-wrap". */
|
||||
white-space: pre-wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn.disabled,
|
||||
.btn[disabled] {
|
||||
cursor: default;
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
}
|
||||
|
||||
/* .btn.large/.btn.small, close cross colour/shadow/opacities: theme/55-buttons.css and
|
||||
* theme/70-modal.css — absorbed. Left here: geometry only. */
|
||||
/* Physical, hand-mirrored: docs/css.md "Widgets: the tooltip parks past the unreachable edge,
|
||||
* and the close cross needs the same mirror" — appears in every modal and .alert-message. */
|
||||
.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: theme/35-alerts.css — absorbed. Left: geometry and the white-space reset. */
|
||||
.alert-message {
|
||||
position: relative;
|
||||
margin-bottom: var(--fs-space-2);
|
||||
white-space: unset;
|
||||
}
|
||||
|
||||
.alert-message .close {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.alert-message button {
|
||||
margin: var(--fs-space-1) 0;
|
||||
}
|
||||
|
||||
/* .label pill and the indicator pill: theme/35-alerts.css / theme/20-shell.css — absorbed. */
|
||||
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,630 @@
|
||||
@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): the empty-table row gets no hover cue (theme/30-tables.css spares it),
|
||||
* but this rule paints the CELL and a placeholder row carrying no `.cbi-section-table-row`
|
||||
* class escapes that guard — measured on UPnP (issue #15). */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* GridSection name column, discriminated by markup not release: docs/css.md "base/95-luci.css:
|
||||
* the GridSection name column, read from the markup, not the release" (issue #9). Column
|
||||
* geometry lives in theme/30-tables.css, next to the real cells it lines up with. */
|
||||
.cbi-section-table-titles.named[data-title]::before,
|
||||
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before {
|
||||
content: attr(data-title) " ";
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.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 (+ 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;
|
||||
margin: 0 var(--fs-space-1); /* WCAG 2.2 SC 2.5.8: 1px apart, targets sat inside each other */
|
||||
}
|
||||
|
||||
.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"; }
|
||||
|
||||
/* Indent and the ::before glyph's own `left` are ONE relationship (both 1.25em, stated twice
|
||||
* before) — now one 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, drawn as a stroke like --spinner-icon below and --fs-icon-refresh in 02-tokens.css
|
||||
* (fill:none, round caps, 24 grid) — not the solid filled path luci-theme-bootstrap used. */
|
||||
--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 (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 {
|
||||
font-weight: var(--fs-weight-bold); /* colour: theme/35-alerts.css — absorbed */
|
||||
}
|
||||
|
||||
.ifacebox {
|
||||
border: var(--fs-hairline);
|
||||
margin: 0 var(--fs-space-3);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--fs-leading-tight); /* a plate of single-line labels, not prose */
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.ifacebox .ifacebox-head {
|
||||
border-bottom: var(--fs-hairline);
|
||||
/* inside the box's 1px border, so radius = the box's less that border */
|
||||
border-radius: calc(var(--fs-radius-sm) - 1px) calc(var(--fs-radius-sm) - 1px) 0 0;
|
||||
padding: var(--fs-space-0-5);
|
||||
}
|
||||
|
||||
/* !important fights luci-mod-network's inline zone-colour style=: docs/css.md "base/95-luci.css:
|
||||
* the dark-mode zone tint, and why it has no grey fallback". */
|
||||
[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;
|
||||
}
|
||||
|
||||
/* generalised once: docs/css.md "base/95-luci.css: the overflow-wrap fix generalised once, not
|
||||
* restated per page". */
|
||||
.ifacebox .ifacebox-body {
|
||||
padding: var(--fs-space-1);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#cbi-network-interface .ifacebox img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.ifacebadge {
|
||||
display: inline-block;
|
||||
flex-direction: row;
|
||||
border: var(--fs-hairline);
|
||||
padding: var(--fs-space-0-5);
|
||||
color: var(--fs-text);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: default;
|
||||
line-height: var(--fs-leading-tight);
|
||||
}
|
||||
|
||||
.ifacebadge img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ifacebadge-active {
|
||||
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;
|
||||
}
|
||||
|
||||
/* same fix as .ifacebox-head above, same doc section (RU caption "Подключение IPv4
|
||||
* (внешняя сеть)" measured 41.3px past #view at 320px/Large, 0px in English). */
|
||||
.network-status-table .ifacebox-head {
|
||||
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 {
|
||||
flex: 1;
|
||||
padding: var(--fs-space-1);
|
||||
min-width: 220px;
|
||||
margin: var(--fs-space-1);
|
||||
}
|
||||
|
||||
.network-status-table .ifacebox-body .ifacebadge {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
border: 1px dashed var(--fs-border); /* width/style only — colour + ink: theme/45-misc.css */
|
||||
font-style: italic;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
div.cbi-value var,
|
||||
.td.cbi-value-field var {
|
||||
font-style: italic; /* colour: theme/60-inputs.css — absorbed */
|
||||
}
|
||||
|
||||
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 {
|
||||
/* Coverage vocabulary, not a live control: no luci-base call site passes ui.showModal a
|
||||
* `login` class, and this theme's own login is a server-rendered form. Inherited from
|
||||
* luci-theme-bootstrap so a foreign app emitting it still gets a themed result. */
|
||||
font-size: var(--fs-type-lg);
|
||||
margin-top: var(--fs-space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.uci-change-list {
|
||||
line-height: var(--fs-leading); /* prose leading for a list of changed settings, not a hand 170% */
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.uci-dialog div > del,
|
||||
.uci-dialog div > ins,
|
||||
.uci-dialog div > var {
|
||||
margin-bottom: var(--fs-space-0-5);
|
||||
border: var(--fs-hairline);
|
||||
line-height: var(--fs-leading-tight); /* one row of single-line code takes the tight step */
|
||||
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 a SMIL <animateTransform> inside the data-URI: that runs in the SVG's
|
||||
* own document, out of reach of the page's `prefers-reduced-motion`. In-flow glyph, not
|
||||
* absolutely positioned: docs/css.md "base/95-luci.css: the spinner glyph sits in the flow, on
|
||||
* purpose" (issues #15, #22). */
|
||||
.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-space-5); /* one reader, so the size lives here rather than behind a token */
|
||||
height: var(--fs-space-5);
|
||||
content: " ";
|
||||
background: var(--on-color, #000);
|
||||
mask: var(--spinner-icon) center/cover no-repeat;
|
||||
-webkit-mask: var(--spinner-icon) center/cover no-repeat;
|
||||
/* two deliberate exceptions to the motion scale (02-tokens.css): a LOOP, not a state change
|
||||
* — 1s is a spinner period not one of the four durations, and `linear` avoids the stutter
|
||||
* `ease` gives an infinite rotation at each turn's seam. */
|
||||
animation: fs-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* One user (.spinning::before above) — the rail's poll glyph is deliberately STATIC
|
||||
* (theme/20-shell.css). Unscoped: keyframe names resolve document-wide, 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);
|
||||
}
|
||||
|
||||
/* min(210px, 100%) floor: docs/css.md "base/95-luci.css: the file browser's floor, and the row
|
||||
* that is not text". */
|
||||
.cbi-filebrowser {
|
||||
min-width: min(210px, 100%);
|
||||
max-width: 100%;
|
||||
border: var(--fs-hairline); /* width/style only; radius: theme/60-inputs.css — absorbed */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* padding: theme/60-inputs.css — absorbed. Zero side padding here let a 36px themed button
|
||||
* touch this element's own clip box and lose its corners. */
|
||||
.cbi-filebrowser > * {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: .25em .25em 0 .25em;
|
||||
white-space: nowrap;
|
||||
border-bottom: var(--fs-hairline);
|
||||
}
|
||||
|
||||
.cbi-filebrowser .cbi-button-positive {
|
||||
margin-inline-end: .25em;
|
||||
}
|
||||
|
||||
/* .right is not one line of text (upload strip + Create/Cancel), so it opts out of the
|
||||
* nowrap+ellipsis assumption above: docs/css.md "base/95-luci.css: the file browser's floor,
|
||||
* and the row that is not text". */
|
||||
.cbi-filebrowser > .right {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cbi-filebrowser > div {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cbi-filebrowser > ul > li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* row hover surface + flex sizing: theme/60-inputs.css — absorbed (this file's old
|
||||
* name:10/actions:3 split fitted base's ~20px buttons, not the theme's 36px ones). */
|
||||
.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: var(--fs-hairline);
|
||||
}
|
||||
|
||||
.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` stays although nothing here rides it: an upstream animation NAME a luci-app-* may
|
||||
* ride (coverage contract, docs/conventions.md). The theme's own `.fade-in` CLASS rule is gone
|
||||
* on purpose — luci-mod-status re-marks it on every poll tick, replaying anything hung there.
|
||||
* `fs-fade` is one-shot instead, played once by the rail flyout and the search overlay. */
|
||||
@keyframes fs-fade {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* PNGs on a white ground, inverted for dark mode: docs/css.md "base/95-luci.css: Statistics
|
||||
* graphs and Realtime SVGs are art drawn for a white ground" — 180deg is exact for the three
|
||||
* primaries, not tuned further. */
|
||||
[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
/* Realtime SVGs carry inline style= for a black ground; only an author !important beats it:
|
||||
* same doc section. */
|
||||
#view div[style] > svg {
|
||||
background-color: var(--fs-panel);
|
||||
}
|
||||
|
||||
#view div[style] > svg line[style] {
|
||||
stroke: var(--fs-text) !important;
|
||||
}
|
||||
|
||||
/* child combinator, not descendant: reaches only the axis labels, not Channel Analysis's
|
||||
* per-BSSID curve labels nested inside a <g> — same doc section. */
|
||||
#view div[style] > svg > text[style] {
|
||||
fill: var(--fs-text) !important;
|
||||
text-shadow: none !important;
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
/* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label) +
|
||||
* pages/20-overview.css (chevron variant) — absorbed. */
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@layer page {
|
||||
/* LOGIN — themes/footstrap/sysauth.ut's own template, centred card, no chrome. Scoped by
|
||||
* `.fs-login`, a class the template puts on the form: replaced
|
||||
* `form:has(> .cbi-map input[name="luci_username"])`, 49 chars × 17 rules (~0.8 KB) matching
|
||||
* nothing else. Never turn this into a JS-revealed modal: the view runs pre-session, RPCs answer
|
||||
* "Access denied", render() never runs. docs/css.md "Login: a definite width, not max-width". */
|
||||
.fs-login {
|
||||
/* DEFINITE width, never max-width: a direct flex item of <body> collapses to min-content
|
||||
* (~131px) under max-width alone — docs/css.md above. */
|
||||
width: min(400px, calc(100% - 32px));
|
||||
margin: 7vh auto var(--fs-space-10);
|
||||
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* the token, not a literal 45%-black 60px halo: that ignored the palette. */
|
||||
padding: var(--fs-space-6) var(--fs-space-6) var(--fs-space-6); box-shadow: var(--fs-shadow-pop);
|
||||
}
|
||||
/* router hostname, not the eyebrow tokens: those force uppercase and a hostname is a name
|
||||
* (case is the only thing telling two similar ones apart). docs/css.md above. */
|
||||
.fs-login-host {
|
||||
margin: 0 0 var(--fs-space-6); text-align: center;
|
||||
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.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; a second frame here would nest one inside it */
|
||||
.fs-login .cbi-map > h1,
|
||||
.fs-login .cbi-map-descr {
|
||||
background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0;
|
||||
}
|
||||
/* h1, not h2: no title bar on this page to carry the document's h1, so sysauth.ut emits it here
|
||||
* and the ramp's own h1 size wraps at 400px — reset to h2's size. docs/css.md above. */
|
||||
.fs-login .cbi-map > h1 {
|
||||
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); }
|
||||
/* column, not block: an auth plugin's second action (Reset) glues to Login under margin:0 */
|
||||
.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,269 @@
|
||||
@layer page {
|
||||
/* Every !important here fights an inline style= from luci-mod-status's 29_ports.js — page is
|
||||
* the last layer, so a flag fighting only another footstrap rule is not kept. docs/css.md
|
||||
* "Overview: the port-tile reskin". */
|
||||
|
||||
/* decoration only, glyph aria-hidden — docs/css.md "Overview: the disclosure pill, the grid,
|
||||
* and the duplicate heading" */
|
||||
.cbi-title .label[data-indicator="poll-status"] {
|
||||
font-size: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--fs-faint);
|
||||
}
|
||||
/* pinned @mirror chevron/*, byte-identical to the Appearance fold's copy */
|
||||
.cbi-title .label[data-indicator="poll-status"]::after {
|
||||
/* @mirror chevron/closed */
|
||||
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);
|
||||
/* @endmirror */
|
||||
}
|
||||
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after {
|
||||
/* @mirror chevron/open */
|
||||
transform: rotate(-135deg); /* open: chevron up */
|
||||
margin-top: var(--fs-space-0-5);
|
||||
/* @endmirror */
|
||||
}
|
||||
|
||||
/* two selectors: wrapper differs by release (25.12 .cbi-title>h3, 24.10 bare h3); matches 0 of
|
||||
* 14 h3 on live 24.10.8 — not dead there, that release has no hide/show control */
|
||||
:is(.cbi-title h3, .cbi-section > h3)[role="button"] {
|
||||
cursor: pointer;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
:is(.cbi-title h3, .cbi-section > h3)[role="button"]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* every page-scoped selector below anchors on :where(#view)[data-page="…"], not bare #view —
|
||||
* docs/css.md ":where(#view), not bare #view". */
|
||||
|
||||
/* hideEmptyCard() marks a stock include with nothing to show; drop it */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section.fs-ovl-empty { display: none; }
|
||||
|
||||
/* stock <h2> duplicate of the chrome's title; via .fs-content not #view — docs/css.md above */
|
||||
.fs-content[data-page="admin-status-overview"] h2[name="content"] { display: none; }
|
||||
.fs-ovl {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--fs-space-4);
|
||||
margin-bottom: var(--fs-card-gap); /* match stock .cbi-section spacing below the grid */
|
||||
}
|
||||
/* min-width:0 so an item shrinks to its TRACK, not its content's min-width — docs/css.md above */
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* Port status reskin. Scoped to :has(img[src*="/port_"]) — do not unscope, Network/Wireless
|
||||
* reuse the same .ifacebox classes. Child order: 1 name-head, 2 speed, 3 zone bar, 4 traffic. */
|
||||
:where(#view)[data-page="admin-status-overview"] div:has(> .ifacebox img[src*="/port_"]) {
|
||||
/* 8px not 10: an 11-port switch in a 1190px column fits 11 cards only at 8px gap */
|
||||
gap: var(--fs-space-2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2), 1fr)) !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
/* flex-wrap, not a grid+@container threshold (issue #7) — docs/css.md above */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) {
|
||||
margin: 0 !important;
|
||||
min-width: 0 !important;
|
||||
/* caps a 2-port device growing into two half-page cards; fights stock's inline max-width */
|
||||
max-width: 200px !important;
|
||||
/* ImmortalWrt 25.12 sets inline margin/width instead of stock's min/max pair */
|
||||
width: auto !important;
|
||||
/* same fork centres the grid CONTAINER; *-self wins over parent *-items, no flag needed */
|
||||
justify-self: stretch;
|
||||
align-self: stretch;
|
||||
text-align: start;
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: var(--fs-space-1-5) var(--fs-space-3);
|
||||
/* no container-type: broke poll-replaced cards before containment resolved (bisected) */
|
||||
display: flex;
|
||||
/* NOT redundant: base/95-luci.css sets .ifacebox inline-flex column */
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
/* row-gap 0: every row carries its own spacing already (zone bar margins, name line box) */
|
||||
column-gap: var(--fs-space-2); row-gap: 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) img { display: none; }
|
||||
/* two stock border-bottoms stack around the colour line; drop both */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) .ifacebox-head { border-bottom: 0; border-radius: 0; }
|
||||
/* zone-colour bar (WAN red/LAN green, issue #13); order places it between name and traffic.
|
||||
* No overflow/border-radius: both clip the absolutely-positioned .cbi-tooltip */
|
||||
:where(#view)[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-0-5) 0 var(--fs-space-1);
|
||||
}
|
||||
/* no status dot: speed + zone bar report carrier, -webkit-box lays children as lines */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
/* clamped to 2 lines never 3: unclamped nowrap name sets every tile's min width */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: var(--fs-leading);
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-text);
|
||||
}
|
||||
/* no link (*_down.svg): dim to match the dim speed and faded zone bar */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-head:first-child { color: var(--fs-dim); }
|
||||
:where(#view)[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;
|
||||
/* margin-left:auto forces its own row in Chrome's line-break count; flex-grow instead */
|
||||
flex: 1 1 auto; white-space: normal; overflow-wrap: break-word;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) br { display: none; }
|
||||
:where(#view)[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.
|
||||
* Size flag fights the inline font-size:80%. */
|
||||
:where(#view)[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 to anchor text-align:end, never shrinks (nowrap figures) */
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container {
|
||||
font-family: var(--fs-font-mono);
|
||||
/* widest thing a card must fit whole, sets the card's floor; fights inline font-size:80% */
|
||||
font-size: var(--fs-type-2xs) !important;
|
||||
color: var(--fs-dim);
|
||||
line-height: var(--fs-leading-tight);
|
||||
white-space: nowrap;
|
||||
/* fights the element's own inline text-align:left */
|
||||
text-align: end !important;
|
||||
}
|
||||
|
||||
/* meter rows — a key/value-shaped .table row with a .cbi-progressbar (same shape test as
|
||||
* :218 below and 30-tables.css's key/value rule); a data table's own bar stays untouched.
|
||||
* docs/css.md above. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))):has(.cbi-progressbar) { display: block; }
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .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: the outer padding duplicated the gap. Bottom stays larger for the bar. */
|
||||
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
|
||||
border: 0;
|
||||
position: relative;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td {
|
||||
display: block;
|
||||
/* beats base's .td[width="33%"] with no flag: that sets a width ATTRIBUTE, a later layer wins */
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
/* label — undoes key/value nowrap: a mount point is one unbreakable line past the card.
|
||||
* padding-inline-end is the gutter for the out-of-flow value (::after over the bar's edge). */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
|
||||
grid-column: 1; grid-row: 1;
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
padding-inline-end: min(230px, 50%);
|
||||
font-size: var(--fs-type);
|
||||
}
|
||||
|
||||
/* value rides the label's own line at desktop (::after floats over the row's top edge) —
|
||||
* opt out of the bare-meter default reserve. <=560px below restores it (own line, no room). */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
/* narrow card: the value takes its own line, the reserve above cannot fit it — docs/css.md
|
||||
* above has the 218px/190px measurement. */
|
||||
@container fs-view (max-width: 560px) {
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:nth-child(2) {
|
||||
grid-column: 1 / -1; grid-row: 2;
|
||||
}
|
||||
|
||||
/* per-row dividers dropped, keyed on table SHAPE (no .th, no 3rd .td — same test as
|
||||
* 30-tables.css's key/value rule, docs/css.md "The key/value discriminator") not on the
|
||||
* three named cards. attendedsysupgrade's 11_upgrades.js (a stock header row) keeps its
|
||||
* dividers under this test. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
|
||||
/* own rule, not a 4th line above: .cbi-value carries no :has(), and a selector list is not
|
||||
* forgiving below the browser floor (npm run css-floor) */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
|
||||
|
||||
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
|
||||
* first column already resolved to. Data tables excluded by the same shape test above. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td:first-child {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* .active fill (theme/45-misc.css) makes three saturated bars at once here; "Up" said in
|
||||
* colour+text on --fs-panel2 instead. Network -> Interfaces keeps the fill (tells boxes apart
|
||||
* there), so this is scoped to Overview only. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox .ifacebox-head.active {
|
||||
background: var(--fs-panel2);
|
||||
color: var(--fs-good);
|
||||
font-weight: var(--fs-weight);
|
||||
}
|
||||
|
||||
/* L.itemlist wraps every caption in <strong> at 700 weight — the boldest text on the card,
|
||||
* inverted like the key/value rule above; the value keeps --fs-text with no rule of its own */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox-body strong {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's
|
||||
* bars something at full strength. Overview only — on Software the value sits beside its own
|
||||
* field label. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .cbi-progressbar::after {
|
||||
font-size: var(--fs-type);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
/* row height in DATA tables (DHCP leases, station list, attendedsysupgrade's 11_upgrades.js):
|
||||
* theme/30-tables.css pads for a page that IS a table, here it's a card read at a glance — 8
|
||||
* leases measured 740px against a 440px System card. Positive test is the complement of the
|
||||
* key/value shape above (:has(.th) or a 3rd .td), not .tr.table-titles — a stock header .th
|
||||
* emitted through initFromMarkup never carries that class. One step down the space ladder,
|
||||
* only here. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .td,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .th {
|
||||
padding-block: var(--fs-space-1-5);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@layer page {
|
||||
/* Package list and disk-space bar. :where(#view): docs/css.md ":where(#view), not bare
|
||||
* #view". Full rationale for every rule below: docs/css.md "Software: the disk-space bar and
|
||||
* the carded package row". */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:first-child { position: relative; display: block; flex: 1 1 100%; width: 100%; margin: 0 0 var(--fs-space-1-5); }
|
||||
|
||||
#packages:not(.fs-stacked) .tr > .td:first-child { white-space: nowrap; }
|
||||
|
||||
/* the id beats theme/30-tables.css's header-hiding rule in card mode — load-bearing. */
|
||||
#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);
|
||||
}
|
||||
#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;
|
||||
flex: none;
|
||||
}
|
||||
#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);
|
||||
color: var(--fs-eyebrow-color); white-space: nowrap;
|
||||
}
|
||||
/* column 4 = Description, matched by index — a `[data-title]` value is a translated string. */
|
||||
#packages.fs-stacked .td:nth-child(4) { display: block; text-align: start; }
|
||||
#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; }
|
||||
|
||||
:where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar) {
|
||||
display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim);
|
||||
}
|
||||
/* value rides the "Disk space:" label's line at both widths — one unconditional opt-out of
|
||||
* the bare-meter reserve; theme/90-responsive.css's phone rule only adds margin-bottom. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@layer page {
|
||||
/* :where(#view): docs/css.md ":where(#view), not bare #view". Rest: docs/css.md "SSH-keys: the
|
||||
* card wrapper and the wide dynlist". */
|
||||
:where(#view)[data-page="admin-system-admin-sshkeys"] > div {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: var(--fs-space-4);
|
||||
}
|
||||
|
||||
:where(#view)[data-page="admin-system-admin-sshkeys"] .cbi-dynlist {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@layer page {
|
||||
/* DHCP leases nowrap by default; `.lases` is upstream's 24.10 spelling (issue #7), do not drop.
|
||||
* docs/css.md "Leases: nowrap counted from the end". */
|
||||
#view table.fs-dt:is(.leases, .lases, .leases6):not(.fs-stacked) .td { white-space: nowrap; }
|
||||
|
||||
/* opaque columns re-permitted to wrap; counted from the END, split on column COUNT — docs/css.md
|
||||
* above has both matrices and why :where() costs nothing. */
|
||||
#view table.fs-dt:is(.leases, .lases):has(:where(.tr.table-titles > :nth-child(6))):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:is(.leases, .lases):not(:has(:where(.tr.table-titles > :nth-child(6)))):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: normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@layer page {
|
||||
/* Filter bar, scoped by SHAPE not `data-page` (a 3rd-party LogreadBox has neither). Row gap
|
||||
* only — LuCI's inline margin stays as horizontal spacing. docs/css.md "Syslog filter bar". */
|
||||
#content_syslog > div:has(> label) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
row-gap: var(--fs-space-2);
|
||||
}
|
||||
#content_syslog > div:has(> label) > label { white-space: nowrap; }
|
||||
#content_syslog > div:has(> label) > :is(select, input) {
|
||||
/* max-width clamps the theme's 210px select (3px overflow measured at 360px). */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* disjoint tiers, not base+override — neither out-specifies the other, docs/css.md above */
|
||||
@container fs-view (width > 560px) {
|
||||
#content_syslog > div:has(> label) > input:not([type]) { flex: 1 1 12rem; }
|
||||
}
|
||||
@container fs-view (width <= 560px) {
|
||||
#content_syslog > div:has(> label) > :is(select, input:not([type="checkbox"])) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* width beats base's 210px input/textarea by layer order alone (25.12), docs/css.md above */
|
||||
#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,117 @@
|
||||
@layer page {
|
||||
/* Appearance controls, appended to System -> System by fs-appearance.js. Was a viewport-capped
|
||||
* popover; now ordinary .cbi-section cards in the page layer (an axis control is not chrome).
|
||||
* docs/css.md "Appearance: from popover to page, and what stayed CSS". */
|
||||
|
||||
.fs-ap { display: flex; flex-direction: column; gap: var(--fs-space-5); }
|
||||
.fs-ap-note p:first-child { margin-top: 0; }
|
||||
.fs-ap-note p:last-child { margin-bottom: 0; }
|
||||
/* heading row: on a folded group the WHOLE row is the disclosure button, full width so the
|
||||
* target is the heading, not a chevron nobody can hit on a phone */
|
||||
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); }
|
||||
/* pinned @mirror chevron/*: same glyph as the overview card toggle (two hosts, one keyed by
|
||||
* attribute, one by class — no single selector list reaches both). docs/css.md above. */
|
||||
.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 {
|
||||
/* @mirror chevron/closed */
|
||||
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);
|
||||
/* @endmirror */
|
||||
}
|
||||
button.fs-ap-fold[aria-expanded="true"] .fs-ap-chev::after {
|
||||
/* @mirror chevron/open */
|
||||
transform: rotate(-135deg); /* open: chevron up */
|
||||
margin-top: var(--fs-space-0-5);
|
||||
/* @endmirror */
|
||||
}
|
||||
button.fs-ap-fold:hover .fs-ap-chev { color: var(--fs-accent); }
|
||||
.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: var(--fs-hairline); }
|
||||
/* rows are stock .cbi-value with no layout of their own — base/30-forms.css and
|
||||
* theme/60-inputs.css already draw every other LuCI form this way. docs/css.md above. */
|
||||
:root[data-layout="top"] .fs-ap-submenus { display: none; }
|
||||
|
||||
.fs-ap-actrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
.fs-ap-actgap { flex: 0 0 var(--fs-space-4); }
|
||||
.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; }
|
||||
/* primed Reset (2nd click confirms) reads as the destructive action it is */
|
||||
.fs-ap-armed { color: var(--fs-on-danger); background: var(--fs-danger); border-color: transparent; }
|
||||
.fs-ap-err { margin-top: var(--fs-space-2); font-size: var(--fs-type-xs); line-height: 1.35; color: var(--fs-danger); }
|
||||
|
||||
.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); }
|
||||
/* native swatch strips the browser's own inset well to the frame's edge (both engines) */
|
||||
input[type="color"].fs-color-swatch {
|
||||
flex: 0 0 auto; inline-size: 34px; block-size: 34px; padding: 0; cursor: pointer;
|
||||
background: none; border: var(--fs-hairline); 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); }
|
||||
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; }
|
||||
|
||||
/* live contrast readout, not a validation — docs/css.md above */
|
||||
.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); }
|
||||
|
||||
.fs-ap-bgprev {
|
||||
display: block; width: 100%; max-width: var(--fs-field-max); height: 84px; object-fit: cover;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
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; }
|
||||
/* File wallpaper: only Dim applies, Tint colours the canvas a photo already covers */
|
||||
:root[data-wallpaper="file"] .fs-ap-tint { display: none; }
|
||||
/* strength is a multiplier on a chroma we choose, meaningless off hue mode */
|
||||
:root:not([data-tint="hue"]) .fs-ap-tintstr { display: none; }
|
||||
|
||||
.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: var(--fs-hairline);
|
||||
}
|
||||
.fs-ap-verrow { display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-2-5); }
|
||||
.fs-ap-version { position: relative; 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; }
|
||||
/* invisible overlay grows an 84x17 link past the WCAG 2.2 SC 2.5.8 24px floor, to 88x25,
|
||||
* without moving the text (out of flow, same fix as .cbi-checkbox in theme/60-inputs.css) */
|
||||
.fs-ap-version::before { content: ""; position: absolute; inset: -4px -2px; }
|
||||
|
||||
.fs-ap-i18n {
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--fs-type-xs);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
.fs-ap-i18n code { font-size: inherit; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
@layer theme {
|
||||
/* Pins inherited-only props on [data-fs-chrome], closing what the fence cannot: inheritance
|
||||
* from html/body. ROOT-only selector and text-align: start (never left) are load-bearing —
|
||||
* third-party-apps.md, "Rule 7. The chrome is fenced off, in three places that must agree". */
|
||||
: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;
|
||||
}
|
||||
|
||||
/* Brand/logo/wordmark/logout/indicators: one look for every layout — a layout file sets only
|
||||
* PLACEMENT (flex, order, rail collapse), never 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);
|
||||
border: var(--fs-hairline); /* glyph is baked-colour (brand.ut), no currentColor use */
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.fs-logo svg { width: 20px; height: 20px; }
|
||||
.fs-logo-ring { fill: #002b49; } /* flips with mode like the favicon SVG; cyan arcs stay baked */
|
||||
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
|
||||
|
||||
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
|
||||
|
||||
/* First tab stop — never display:none/visibility:hidden, that drops it from the tab order. */
|
||||
.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);
|
||||
}
|
||||
/* clip-path, not a -68px translate: the translate still bled ~16px of drop shadow into the
|
||||
* viewport (--fs-shadow-pop is offset 12px/blur 34px); clip-path clips the shadow too. */
|
||||
.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;
|
||||
}
|
||||
/* Clipped, not display:none/visibility:hidden — both take the <h1> out of the a11y tree too. */
|
||||
.fs-sr {
|
||||
position: absolute;
|
||||
clip-path: inset(50%);
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* tabindex="-1" on <main> makes Safari/Chrome move focus here rather than just scroll to it. */
|
||||
#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); }
|
||||
|
||||
/* 34px square icon button: not declared here, only where the chrome is a bar or a rail
|
||||
* (20-shell.css, 50-toplayout.css). */
|
||||
|
||||
/* fitTabStrips (fs-chrome.js) adds .fs-dense1/2 when the horizontal menu row would wrap, and
|
||||
* strips them in the vertical sidebar — no media guard needed. */
|
||||
.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); }
|
||||
|
||||
/* One markup (`ul.nav > li > a`) for both layouts, so these three run once. */
|
||||
ul.nav { position: relative; margin: 0; list-style: none; }
|
||||
ul.nav > li { display: block; }
|
||||
ul.nav a { line-height: 19px; text-decoration: none; }
|
||||
|
||||
/* No emitter here, stays styled under the coverage contract — conventions.md, "Coverage is a
|
||||
* contract". */
|
||||
.dropdown-menu li { display: block; position: relative; }
|
||||
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; } /* denser than ul.nav a above */
|
||||
|
||||
.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 > 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);
|
||||
}
|
||||
/* Plate on the PAGE only; the open ANCESTOR keeps the ink alone — issue #37 (two identical
|
||||
* plates stacked on System -> System otherwise). chrome.md, "menu-footstrap.js". */
|
||||
.fs-sidebar ul.nav > li.active > a:not(.active) { color: var(--fs-accent); }
|
||||
|
||||
/* --fs-focus-ring-solo: none of these flips a border on focus, so the ring alone must clear
|
||||
* 3:1 — the tinted ring reads only 1.15-1.29:1 (two-ring contract, 02-tokens.css). */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* accent-color reaches a foreign app's native checkbox/radio/progress — the theme's own are
|
||||
* appearance:none and never see it. */
|
||||
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@layer theme {
|
||||
/* .fs-pattern: the masked wallpaper element. Why a dedicated element, why z-index: -1 needs a
|
||||
* transparent body — design-system.md, "Wallpaper rendering: why the canvas moves to :root". */
|
||||
.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; }
|
||||
|
||||
/* --fs-pattern-url is `none` until an upload exists (head.ut/fs-prefs.js) — the axis picked
|
||||
* with no file is a bare canvas, not 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 (default): file's own colours discarded, only alpha used through the mask —
|
||||
* design-system.md, "Pattern colours". */
|
||||
: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;
|
||||
}
|
||||
/* original: the file's own palette, plain tiled background, no 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;
|
||||
}
|
||||
|
||||
/* No wash layer behind #view: cards paint their own opaque --fs-panel, so nothing needs
|
||||
* dimming globally. */
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@layer theme {
|
||||
/* The `file` wallpaper: scrim + photo on one element — design-system.md,
|
||||
* "Wallpaper rendering: why the canvas moves to :root". */
|
||||
: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;
|
||||
background-attachment: fixed; /* one calm backdrop the page scrolls under, not a sliding strip */
|
||||
}
|
||||
/* transparent: an opaque --fs-bg here would cover the shell's one background layer. */
|
||||
:root[data-wallpaper="file"] .fs-main { background-color: transparent; }
|
||||
:root[data-wallpaper="file"] footer.fs-footer { background: transparent; }
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
@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; }
|
||||
/* Hostname handling, two rules for two failures — chrome.md, "Hostname handling". */
|
||||
.fs-wordmark {
|
||||
overflow-wrap: anywhere; min-width: 0;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
|
||||
}
|
||||
/* 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: calc(28px * var(--fs-density-box)); height: calc(28px * var(--fs-density-box));
|
||||
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()`: individual transform properties compose across rules,
|
||||
* so the RTL mirror below sets `scale` without either rule restating the other's value. */
|
||||
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
|
||||
/* The two chevrons are DIRECTION, not decoration, so with the document flipped (head.ut
|
||||
* stamps dir) they must be mirrored explicitly — logical properties do that for everything
|
||||
* else in the chrome, but a glyph needs the 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 is the default chrome and the only copy of it, unguarded — chrome.md, "The bar:
|
||||
* fit steps, scroll model, a rail that must match its guard". The vertical sidebar below is
|
||||
* the single guarded override, winning on SPECIFICITY, never on source order. */
|
||||
.fs-shell {
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 100svh; width: 100%; /* svh not dvh — chrome.md, same heading */
|
||||
}
|
||||
.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);
|
||||
border-inline-end: 0; border-bottom: var(--fs-hairline);
|
||||
/* the submenu popups anchor to this bar and must escape it */
|
||||
overflow: visible;
|
||||
}
|
||||
/* brand may shrink (min-width: 0) but is capped at 30ch, tracking the text not the viewport;
|
||||
* the extra line grows the bar's own min-height (50-toplayout.css). */
|
||||
.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);
|
||||
}
|
||||
/* anchors the dropdown to its OWN item, not the bar — issue #19, chrome.md, "A bar dropdown
|
||||
* anchors to its own <li>". A phone never shows it: `hover: none` disables the rule. */
|
||||
.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; }
|
||||
|
||||
/* popup card under its own item, sized to content and capped to the viewport; clampDropdown
|
||||
* (menu-footstrap.js) nudges one back inside near the right edge, every bar state. */
|
||||
.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-panel);
|
||||
border: var(--fs-hairline);
|
||||
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. Unguarded: every state that can open a panel on
|
||||
* hover needs it, not just one layout. */
|
||||
.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: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
background: transparent; 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);
|
||||
}
|
||||
/* .fs-ind-compact then .fs-bar-actrow, both by fitCluster (fs-chrome.js) — chrome.md, "Right
|
||||
* cluster: two fit steps". max-width releases with the flex basis, or the 30ch cap leaves
|
||||
* the brand short of the row it was just given. */
|
||||
.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: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
background: transparent;
|
||||
}
|
||||
/* 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, guarded POSITIVELY (never `:not([data-layout=
|
||||
* …])`); everything the bar declared above must be answered here. The `data-narrow`
|
||||
* measurement and the 521px JS-disabled floor: chrome.md, "`data-narrow`". */
|
||||
@media (min-width: 521px) {
|
||||
/* Static, never sticky (issue #7) — chrome.md, "A static sidebar, never position:
|
||||
* sticky". Confined here; the top layout and the phone bar keep window scrolling. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
|
||||
flex-direction: row; height: 100svh; overflow: hidden;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
|
||||
height: 100svh; overflow-y: auto;
|
||||
/* Firefox-only overflow leak past the static sidebar (issue #12) — chrome.md,
|
||||
* "contain: paint … is the fix for issue #12". */
|
||||
contain: paint;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
|
||||
width: var(--fs-sidebar-w); height: 100svh;
|
||||
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);
|
||||
border-inline-end: var(--fs-hairline); 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. */
|
||||
: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);
|
||||
/* Bold is the root level's own signal (--fs-weight-normal has no face of its own, so
|
||||
* lightening children reads as hierarchy instead); only the accordion needs it. */
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); 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;
|
||||
border: 0; border-radius: 0; box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* the bar's hover bridge does not follow: static here, its containing block would fall
|
||||
* back to the <li> and swallow clicks above it. The rail brings its own (::after). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
|
||||
/* An open section is a GROUP: an indent alone does not say where it ends, and after ten
|
||||
* children the next root category read as an eleventh (issue #37). No :not([data-rail])
|
||||
* guard needed — the rail's own rule below restates these at higher specificity. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul {
|
||||
display: flex;
|
||||
margin: var(--fs-space-1) 0 var(--fs-space-3) var(--fs-space-7);
|
||||
padding-inline-start: var(--fs-space-1);
|
||||
border-inline-start: var(--fs-hairline);
|
||||
}
|
||||
: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 one of a stack of ROWS, not a row of buttons —
|
||||
* Appearance and Log out below it run the sidebar's full width, so a pill at its own
|
||||
* content width read as a stray chip. */
|
||||
: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 (a popover on <body>). They are a PAGE now
|
||||
* (System -> Appearance): styles/pages/80-appearance.css. */
|
||||
|
||||
/* Collapsed sidebar (icon rail): <html data-rail="true">, persisted in localStorage and
|
||||
* re-applied before paint by head.ut. Labels collapse and a section's submenu becomes a
|
||||
* flyout anchored right of its icon. The guard below must equal the vertical sidebar's
|
||||
* exactly — chrome.md, "The rail's guard must be the vertical sidebar's, exactly". */
|
||||
@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/sticky: static, like the vertical sidebar (issue #7) — its 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,
|
||||
/* a rail item has no accordion — its children fly out sideways — so the chevron says
|
||||
* nothing and crowds the icon. Selector kept this long because the vertical override
|
||||
* turns the chevron back on at (0,6,3), so a plainer form here would only be (0,6,0). */
|
||||
: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: var(--fs-hairline);
|
||||
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). Hover 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: var(--fs-hairline); 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 reuses the bar's hover-bridge pseudo-element (issue #22) —
|
||||
* chrome.md, same heading as above. */
|
||||
: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%;
|
||||
}
|
||||
|
||||
/* "Refreshing" -> a green masked refresh glyph. Needs the FULL guard, not [data-rail]
|
||||
* alone: data-rail stamps pre-paint, data-narrow lands later from an async module, so a
|
||||
* narrow phone would paint a bare glyph in the phone bar until the modules land.
|
||||
* --fs-ctl-h-sm follows the Density axis, one step under the bar's pill. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
|
||||
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); 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: this is a poll running quietly forever, not something being
|
||||
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
|
||||
}
|
||||
|
||||
/* paused: the same glyph gone quiet — without it the rail kept saying GREEN while the
|
||||
* poll was stopped, the one thing the indicator exists to report. */
|
||||
: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);
|
||||
}
|
||||
|
||||
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
|
||||
* collapsed-rail indicator badges its count". One rule per box: two would need an extra
|
||||
* attribute + :not() past the css-metrics specificity ratchet. `.fs-sidebar` is
|
||||
* deliberately absent: #indicators is an id. */
|
||||
: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. */
|
||||
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
|
||||
box-sizing: border-box; padding: 0 var(--fs-space-1); overflow: hidden;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
/* .fs-ind-compact above paints the same badge (mirror-pinned) but cannot SHARE this
|
||||
* rule: it applies at every width, this one must stay inside the 521px floor. */
|
||||
: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; }
|
||||
|
||||
/* fs-router.js builds the incoming page in a second #view here, then moves its children into
|
||||
* the live one, so the visible page is never emptied while the next one loads. `visibility:
|
||||
* hidden` (not display: none) keeps the box so a self-sizing view sees no zero width;
|
||||
* `height: 0` stops the page moving; `overflow: clip` (not hidden) skips a second scrollport. */
|
||||
.fs-staging { visibility: hidden; height: 0; overflow: clip; }
|
||||
|
||||
/* Slow-navigation hairline, shown once a nav outlives 150ms (fs-router.js). Fixed, and a
|
||||
* child of <body>, not .fs-main (a column flex container shrinks its 2px to zero) or
|
||||
* .fs-shell (contain: paint would re-base and clip a fixed descendant). transform/opacity
|
||||
* only, both composited, so drawing this never costs the render it reports on. */
|
||||
#fs-nav-progress {
|
||||
position: fixed; inset-inline: 0; top: 0;
|
||||
height: 2px;
|
||||
background: var(--fs-accent);
|
||||
transform: scaleX(0); transform-origin: left center;
|
||||
opacity: 0; pointer-events: none;
|
||||
z-index: var(--fs-z-tooltip);
|
||||
}
|
||||
:root[dir="rtl"] #fs-nav-progress { transform-origin: right center; }
|
||||
#fs-nav-progress[data-state="active"] {
|
||||
opacity: 1;
|
||||
transform: scaleX(.8);
|
||||
transition: transform 2s cubic-bezier(.1, .7, .1, 1), opacity 120ms linear;
|
||||
}
|
||||
#fs-nav-progress[data-state="done"] {
|
||||
opacity: 0;
|
||||
transform: scaleX(1);
|
||||
transition: transform 120ms ease-out, opacity 300ms linear 100ms;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#fs-nav-progress[data-state="active"],
|
||||
#fs-nav-progress[data-state="done"] { transition: none; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
/* ---------- footer ----------
|
||||
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright, 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: var(--fs-hairline);
|
||||
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
|
||||
/* centre the flex LINE (base declares no justify-content) so text-align has something
|
||||
* to centre rather than a <span> already shrunk to its content */
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
/* target-size floor for the two links this sentence carries (WCAG 2.2 SC 2.5.8) —
|
||||
* chrome.md, "A footer link pair needs 26px". */
|
||||
line-height: max(26px, calc(var(--fs-type) * 2));
|
||||
}
|
||||
footer a { color: var(--fs-dim); }
|
||||
footer a:hover { color: var(--fs-accent); }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* phone tier: content gutter, alert-message width and wrap — merged, none override each
|
||||
* other (distinct selectors), must stay after the base rules above they narrow */
|
||||
@media (max-width: 767px) {
|
||||
.fs-content { padding: var(--fs-space-4); }
|
||||
/* width: auto, not the base width above: that calc subtracts the DESKTOP gutter while
|
||||
* .fs-content's own gutter is narrower here — auto plus the side margins tracks 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; }
|
||||
/* a top-level alert's action buttons were nowrap, so Dismiss was shoved past the panel
|
||||
* and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main for #view's too. */
|
||||
.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 and clipped its first label */
|
||||
.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] {
|
||||
/* wears the row's own --fs-btn-size and --fs-radius, not a pill, or it reads as a
|
||||
* different kind of object beside Search and Log out */
|
||||
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);
|
||||
/* the bar's control surface — ui.js emits a bare `<span data-indicator=…>` with NO
|
||||
* class, so a `.label`-keyed rule matches nothing */
|
||||
background: transparent;
|
||||
border: var(--fs-hairline);
|
||||
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 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; }
|
||||
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so it
|
||||
* takes the control ring the buttons beside it take (55-buttons.css). :focus-visible, not
|
||||
* :focus: a pointer press on the pill should not draw a ring. */
|
||||
.fs-indicators [data-indicator][data-clickable]:focus-visible,
|
||||
#indicators [data-indicator][data-clickable]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
@layer theme {
|
||||
/* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above
|
||||
* the right edge instead of stuffed inside a fat pill. Default reserves that value's own line
|
||||
* in flow (bootstrap's margin:1.4em model) — an abspos value with zero reserve spilled 5-6px
|
||||
* UP across the divider of the row above, off the overview page (key/value .table, no header
|
||||
* row). Contexts that put the value BESIDE the bar or on a neighbour's line opt out with
|
||||
* margin-block-start: 0. */
|
||||
.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;
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
.cbi-progressbar > div {
|
||||
height: 100%;
|
||||
background: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
transition: width var(--fs-dur-fill);
|
||||
}
|
||||
/* Colour carries the reading too: data-fs-level set from JS (80/92% thresholds) every poll
|
||||
* tick. Solid tokens, no color-mix, nothing owed to 04-nocolormix.css. */
|
||||
.cbi-progressbar[data-fs-level="warn"] > div {
|
||||
background: var(--fs-warn);
|
||||
}
|
||||
.cbi-progressbar[data-fs-level="danger"] > div {
|
||||
background: var(--fs-danger);
|
||||
}
|
||||
.cbi-progressbar::after {
|
||||
content: attr(title);
|
||||
position: absolute; inset-inline: auto 0; bottom: calc(100% + var(--fs-space-1)); 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;
|
||||
}
|
||||
|
||||
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
|
||||
* meter column, sharing one geometry via --fs-meter-value. ONE RULE PER SHAPE, not a shared
|
||||
* selector list — the table half needs :has(), and a non-parsing engine drops the WHOLE
|
||||
* rule, form row included. @mirror pins the shared declarations. */
|
||||
.cbi-value-field > .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
margin-block-start: 0;
|
||||
/* @endmirror */
|
||||
--fs-meter-value: calc(var(--fs-space-10) * 2);
|
||||
}
|
||||
.cbi-value-field > .cbi-progressbar::after {
|
||||
/* @mirror meter/beside-value */
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/* @endmirror */
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
margin-block-start: 0;
|
||||
/* @endmirror */
|
||||
--fs-meter-value: var(--fs-space-10);
|
||||
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
/* @mirror meter/beside-value */
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/* @endmirror */
|
||||
}
|
||||
|
||||
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
|
||||
* the row). Back over the bar with a one-line top margin (was 6-9px overlap).
|
||||
* `:has(.tr.table-titles)` stays for SPECIFICITY, matching the beside-rule's (0,6,0). */
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
margin-inline-end: 0;
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
inset-inline: auto 0;
|
||||
bottom: calc(100% + var(--fs-space-1));
|
||||
top: auto;
|
||||
transform: 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; }
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
@layer theme {
|
||||
/* ---------- panels / cards (cbi) ---------- */
|
||||
.cbi-section, .cbi-map > .cbi-section-node, fieldset.cbi-section, .panel {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.cbi-section { padding: var(--fs-card-pad); margin-bottom: var(--fs-card-gap); }
|
||||
/* bare <legend> only; ink from the heading ramp. No line-height — base already sets 1. */
|
||||
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;
|
||||
}
|
||||
/* bare title (no wrapper) sat flush below — same 12px; excludes .cbi-title h3, already gapped */
|
||||
.cbi-section > h3:first-child,
|
||||
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
|
||||
|
||||
/* a heading FOLLOWING a table had no air; separates more from what precedes than introduces */
|
||||
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
|
||||
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* [id]/.fs-dt = data table; no id = key/value, no frame/clip; nested tables too */
|
||||
.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%; }
|
||||
/* moved from base with the generic field box (theme/60-inputs.css) */
|
||||
.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; }
|
||||
/* filter placeholder sliced mid-glyph on a long title (issue #12); ellipsis changes the
|
||||
* failure instead. No !important — the inline 30px flag width still stands. */
|
||||
.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 below min-content; the inverse rule further down restores the
|
||||
* floor on a data table — read that block before touching either. */
|
||||
.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: var(--fs-hairline);
|
||||
line-height: 1.45; vertical-align: middle;
|
||||
text-align: start;
|
||||
}
|
||||
/* hugs the far edge, beating text-align:start above (form.TableSection's own action cells;
|
||||
* hand-built ones 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; }
|
||||
/* case is the app's: uppercase broke LuCI's own casing (issue #39); weight/size/ink alone
|
||||
* still read as a heading. */
|
||||
.cbi-section .table .th {
|
||||
color: var(--fs-eyebrow-color); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs);
|
||||
letter-spacing: var(--fs-eyebrow-tracking);
|
||||
}
|
||||
|
||||
/* --- DATA tables ([id] or JS-tagged .fs-dt) --- `:is()` at ONE specificity: split, a table
|
||||
* known only by the tag (Status -> Routing) lost to key/value's default. */
|
||||
.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); }
|
||||
|
||||
/* the min-content FLOOR belongs here: break-word (unlike base's -anywhere) does not lower
|
||||
* min-content, so an overflow stays visible to fit.overflows(). :not(.fs-stacked) is the
|
||||
* fitter's own measuring state. */
|
||||
.table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; }
|
||||
/* the ONE column the fitter allows to shred rather than card the table (fs-select.js);
|
||||
* (0,5,0) beats the floor above on specificity, not source order */
|
||||
.table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; }
|
||||
|
||||
/* processes.js's inline style= cannot be outranked by a layer — !important is the sanctioned
|
||||
* fight against it. A CARD IS NOT EXEMPT: an unlayered app rule breaks a token that would fit. */
|
||||
.table.fs-dt .td,
|
||||
.table.fs-dt .td :not(button):not(.btn):not(input):not(select):not(textarea) {
|
||||
word-break: normal !important;
|
||||
}
|
||||
|
||||
/* Whether a table cards, breaks one column or scrolls is MEASURED (fs-select.js), at any width. */
|
||||
|
||||
/* sticks each .th, not the row — a row's fill does not travel with a sticky cell here. 0 in
|
||||
* the sidebar (.fs-main scrolls itself), --fs-bar-live where the document does. */
|
||||
:where(:root[data-layout="sidebar"]:not([data-narrow]) #view, #modal_overlay)
|
||||
.table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
background: var(--fs-panel2);
|
||||
}
|
||||
:where(:root[data-layout="top"] #view, :root[data-narrow] #view)
|
||||
.table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
|
||||
position: sticky; top: var(--fs-bar-live, var(--fs-bar-h)); z-index: 1;
|
||||
background: var(--fs-panel2);
|
||||
}
|
||||
|
||||
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
|
||||
* (18 jumps/1500 frames measured) — anchoring.md, "What the engine is told". */
|
||||
.table.fs-dt { overflow-anchor: none; }
|
||||
|
||||
.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 (border-collapse:separate, or the radius is silently dropped); #view too —
|
||||
* Startup renders .table.fs-dt with no section */
|
||||
.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: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* EXPLICIT: the generic .table rule below still says overflow:hidden, clipped otherwise */
|
||||
overflow: visible;
|
||||
}
|
||||
/* frame is overflow:visible, so nothing clips the corner cells — round the four OUTER ones.
|
||||
* `:is(.tr.table-titles, .thead)` covers both header shapes; without .thead a real <thead>
|
||||
* kept square top corners. */
|
||||
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
#view .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
table.fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-top-left-radius: var(--fs-radius-lg); }
|
||||
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:last-child,
|
||||
#view .table[id] :is(.tr.table-titles, .thead) .th:last-child,
|
||||
table.fs-dt :is(.tr.table-titles, .thead) .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 .tr.placeholder; spanning it is base's job, framed flavour left here */
|
||||
.cbi-section .table[id] .tr.placeholder .td,
|
||||
#view .table[id] .tr.placeholder .td,
|
||||
table.fs-dt .tr.placeholder .td {
|
||||
padding-block: var(--fs-space-2); /* this layer's own cell padding made the plate 4px tall */
|
||||
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: "no id" is not enough — GridSection also renders no-id .cbi-section-table,
|
||||
* excluded along with .fs-dt and the legend's inline table-layout. Shape, not a class, says
|
||||
* data table: any .th or a 3rd .td rules a table out — a real key/value row is exactly 2 .td
|
||||
* and no .th. docs/css.md, "The key/value discriminator". */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):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(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:not(:first-child) {
|
||||
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
|
||||
}
|
||||
/* below ~560px the 40%/nowrap column starves the value — stacked below (560, not the config
|
||||
* table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */
|
||||
@container fs-content (max-width: 560px) {
|
||||
/* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr {
|
||||
display: block;
|
||||
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
|
||||
border: 0;
|
||||
border-bottom: var(--fs-hairline);
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child:first-child {
|
||||
display: block; width: auto; white-space: normal;
|
||||
padding: 0; border: 0;
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
|
||||
display: block; width: auto; text-align: start;
|
||||
padding: 0; border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- the realtime graph legend: the one no-id .table with inline table-layout:fixed, six
|
||||
* equal columns overflowing on a phone — folded into label/value pairs. */
|
||||
@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); }
|
||||
.zonebadge input { width: 6em; } /* moved from base with the generic field box */
|
||||
/* MAC badge kept base's flat 4px, square against the card corners (issue #5.4) */
|
||||
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
|
||||
/* head rounds WITH the box (issue #7); max() since Rounding goes to 0, an invalid subtraction */
|
||||
.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 height:0/overflow:hidden still drew empty padding/border bars
|
||||
* (issue #10). visibility not display:none — stock JS reads a graph's width once in a rAF. */
|
||||
[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%: bold --fs-danger on a tint OF --fs-danger eats its own contrast (12% measured
|
||||
* 4.4:1); at 6% every palette clears 4.5 (4.87/4.95/5.28/6.09) */
|
||||
.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 GridSection name CELL on 24.10, a ::before from data-title where form.js has no real
|
||||
* cell — named here too or the column lines up with nothing */
|
||||
.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; }
|
||||
/* zero padding-left is right only INSIDE a card; a self-framed table glued its header text
|
||||
* to its own corner without this (issue #7) */
|
||||
.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 -> rounded card; inside a CBI section the section already IS the
|
||||
* card, so the frame is stripped there (above) */
|
||||
.table {
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--fs-panel);
|
||||
}
|
||||
/* separators on the CELLS: the frame needs border-collapse:separate, a row's own border
|
||||
* paints nothing. :not(:last-child) keeps a trailing one off the rounded corner. */
|
||||
.table .tr.table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles::before { border-bottom: var(--fs-hairline); }
|
||||
.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: var(--fs-hairline); }
|
||||
/* per-CELL border, so a foreign `position: relative; top:` (dashboard) drags one cell's
|
||||
* border out of line — `top` AND `bottom` cancels either without touching `relative` itself */
|
||||
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
|
||||
/* descendant combinator so a real <table>'s inserted <tbody> is covered too */
|
||||
.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);
|
||||
background: transparent;
|
||||
}
|
||||
/* row NAME reads the same on both releases (real cell / ::before) — a LABEL, sans face */
|
||||
.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 (an untagged plain .table lit its whole block,
|
||||
* issue #5.3; a placeholder row lit up too, issue #5.2/#15) */
|
||||
.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: fs-select.js on a header-row #view .table that is NOT .cbi-section-table —
|
||||
* replacing a live :has() selector re-evaluated on every mutation. */
|
||||
|
||||
/* One card-stack copy, no @container: fs-select.js already measures OVERFLOW and sets
|
||||
* .fs-stacked. The config table keeps its own @container — its widgets bake in a width. */
|
||||
/* a poll REPLACES a data table with a fresh, unmarked copy that briefly lays out full-width,
|
||||
* throwing the reader 612px out and back — waits OUTSIDE the layout until fs-select.js
|
||||
* answers, guarded on :root[data-fs-fit]. */
|
||||
:root[data-fs-fit] #view .table.fs-dt:not(.fs-fitted),
|
||||
:root[data-fs-fit] #modal_overlay .table.fs-dt:not(.fs-fitted) { display: none; }
|
||||
|
||||
.table.fs-stacked { display: block; width: 100%; }
|
||||
/* .fs-drop-xs is the same drop one rung earlier, on an overflowed table before carding */
|
||||
.table.fs-stacked .td.hide-sm, .table.fs-stacked .th.hide-sm,
|
||||
.table.fs-stacked .td.hide-xs, .table.fs-stacked .th.hide-xs,
|
||||
.table.fs-drop-xs .td.hide-sm, .table.fs-drop-xs .th.hide-sm,
|
||||
.table.fs-drop-xs .td.hide-xs, .table.fs-drop-xs .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; }
|
||||
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
|
||||
* 1-line neighbour's (52px) */
|
||||
/* a <tfoot> with no .tr IS the row. TWO rules: a browser without :has() drops a whole list,
|
||||
* losing display:flex on every card. Under css-dup's floor — keep both in step by hand. */
|
||||
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
|
||||
.table.fs-stacked tfoot:not(:has(> .tr)),
|
||||
.table.fs-stacked .tfoot:not(:has(> .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. PINNED: the config table needs this under its
|
||||
* own @container guard, which cannot share a block with a class. */
|
||||
.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);
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */
|
||||
.table.fs-stacked .td.cbi-section-actions::before,
|
||||
.table.fs-stacked .td:not([data-title])::before,
|
||||
.table.fs-stacked .td[data-title=""]::before,
|
||||
.table.fs-stacked tfoot .td[data-title]::before,
|
||||
.table.fs-stacked .tfoot .td[data-title]::before { content: none; }
|
||||
/* row buttons are not a label/value pair — full width below the pairs. PINNED, 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 buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
|
||||
* the tail button. PINNED to the config table's copy. */
|
||||
.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 clips,
|
||||
* it does not scroll to an over-wide child */
|
||||
.table.fs-stacked .td > *,
|
||||
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* a <tfoot> is still a table-footer-group in a display:block table, drawing beside the cards
|
||||
* instead of under them; `:has(> .tr)` tells the two shapes upstream writes it in apart. */
|
||||
.table.fs-stacked tfoot:has(> .tr),
|
||||
.table.fs-stacked .tfoot:has(> .tr) { display: block; }
|
||||
.table.fs-stacked tfoot .td:empty,
|
||||
.table.fs-stacked .tfoot .td:empty { display: none; }
|
||||
|
||||
/* --- DATA table flavour (L.ui.Table, tagged .fs-dt) --- clip to the frame's corners: zebra
|
||||
* paints on the .tr while the frame is overflow:visible, poking past the radius. */
|
||||
#view .table.fs-dt.fs-stacked,
|
||||
#modal_overlay .table.fs-dt.fs-stacked { overflow: hidden; }
|
||||
/* :not(.placeholder) — the empty-state plate is out of flow; its row only reserves height,
|
||||
* and padding subtracted from that left text hanging below the frame (Wireless) */
|
||||
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
|
||||
/* half the row makes the card — low specificity (0,2,0) so col-N below can beat it (holds
|
||||
* against a LAYERED rule only). Third :not() loses on purpose to the meter below. */
|
||||
.table.fs-stacked .td:not(.cbi-section-actions):not([class*="col-"]):not(:has(.cbi-progressbar)) {
|
||||
flex: 1 1 50% !important;
|
||||
}
|
||||
/* a meter takes the whole line — its own title prints over the cell's caption otherwise */
|
||||
.table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; }
|
||||
|
||||
/* column weights (1-10): a share of a CARDED row, unguarded — `flex` only applies once a
|
||||
* `.td` becomes a flex item, so one copy serves every tier and is inert elsewhere. */
|
||||
.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); }
|
||||
|
||||
/* last resort: no header row, never carded (no data-title to print). .fs-xscroll: same,
|
||||
* MEASURED to overflow; refused to a table holding a widget (clips a popup inside). */
|
||||
#view table:not(.table):not(.cbi-section-table):not(.fs-dt),
|
||||
#modal_overlay table:not(.table):not(.cbi-section-table):not(.fs-dt),
|
||||
#view .table.fs-xscroll, #modal_overlay .table.fs-xscroll {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain; /* the gesture stops here instead of dragging the page */
|
||||
scrollbar-width: thin; /* the scrollbar IS the affordance; a shadow trick shows through zebra rows */
|
||||
}
|
||||
|
||||
/* a header-less table holding a control stacks instead of scrolling — a scroll container
|
||||
* would clip the popup inside; nothing here creates one, so a dropdown still escapes. */
|
||||
.table.fs-rowstack,
|
||||
.table.fs-rowstack .tr,
|
||||
.table.fs-rowstack .td,
|
||||
.table.fs-rowstack .th { display: block; width: auto; }
|
||||
/* the cells were table-cells a moment ago (side padding); as blocks they need the vertical
|
||||
* rhythm instead, or the controls sit flush against each other */
|
||||
.table.fs-rowstack .td { padding-inline: 0; padding-block: var(--fs-space-1); }
|
||||
|
||||
/* pins the row's NAME while scrolling — its own background (a row's fill does not travel
|
||||
* with a sticky cell) and a hairline so it reads as an edge, not text over text. */
|
||||
#view .table.fs-xscroll .tr > :first-child,
|
||||
#modal_overlay .table.fs-xscroll .tr > :first-child,
|
||||
#view .table.fs-xscroll tr > :first-child,
|
||||
#modal_overlay .table.fs-xscroll tr > :first-child {
|
||||
position: sticky; left: 0; z-index: 2;
|
||||
background: var(--fs-panel);
|
||||
border-inline-end: var(--fs-hairline);
|
||||
}
|
||||
/* a keyboard user tabs to the scroll box (fs-select.js gives it tabindex+name) — outline, not
|
||||
* box-shadow, so forced-colors keeps it */
|
||||
#view .table.fs-xscroll:focus-visible,
|
||||
#modal_overlay .table.fs-xscroll:focus-visible {
|
||||
outline: 2px solid var(--fs-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
@layer theme {
|
||||
/* alert panel: a left accent stripe on a panel card; variants recolour the frame */
|
||||
.alert-message {
|
||||
padding: var(--fs-space-3-5) var(--fs-space-4);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: var(--fs-hairline);
|
||||
/* logical: the stripe marks the READING start, swapping sides in RTL (LuCI ships ar/fa/he) */
|
||||
border-inline-start: 3px solid var(--fs-accent);
|
||||
/* --fs-panel stays the background-COLOUR; the tint is a background-IMAGE layer over it,
|
||||
* not a replacement — a `-fill` background alone is transparent to the wallpaper. */
|
||||
background-color: var(--fs-panel);
|
||||
background-image: linear-gradient(var(--fs-alert-tint, transparent), var(--fs-alert-tint, transparent));
|
||||
color: var(--fs-text);
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
/* status lives in stripe+surface, ring stays neutral. :not(.btn) is load-bearing: a confirm
|
||||
* control is a `<label class="btn alert-message danger">`, and without the guard the tint
|
||||
* out-ranked `.btn.danger`, fading its fill. */
|
||||
.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);
|
||||
}
|
||||
/* .notice groups HERE, not with .label.notice's accent: ui.js emits `notice spinning` for an
|
||||
* IN-PROGRESS banner, so it reads as informational rather than success. */
|
||||
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
|
||||
border-inline-start-color: var(--fs-accent);
|
||||
--fs-alert-tint: var(--fs-accent-fill);
|
||||
}
|
||||
|
||||
/* spinner ::before is inline-block for its usual inline hosts, but displayStatus() builds a
|
||||
* BLOCK host, opening its own line. GRID, not flex: the caller decides child count. */
|
||||
.alert-message.spinning {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center; /* centres the glyph against the FIRST row, not pinned to its top */
|
||||
/* the column gap is the glyph's own spacing, stated rather than inherited from the row `gap` */
|
||||
column-gap: var(--fs-space-2);
|
||||
}
|
||||
/* grid item now, so its inline margin would double the column gap; row 1 keeps it beside the
|
||||
* FIRST child however many follow */
|
||||
.alert-message.spinning::before { grid-row: 1; margin-inline-end: 0; }
|
||||
.alert-message.spinning > * { grid-column: 2; }
|
||||
|
||||
/* h2 is what the theme's own notices emit; h4 covers a third-party alert, h5/h6 joined so an
|
||||
* alert heading does not fall back to base's look past h4. */
|
||||
.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); }
|
||||
/* moved from base with the heading ramp: line-height:inherit beat the ramp's leading
|
||||
* in-layer, left behind it loses by layer (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 — dim measured 4.06:1 on the tint */
|
||||
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
|
||||
/* the body elements an alert may carry, absorbed from base/90-widgets.css (same values) */
|
||||
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
|
||||
/* inner table (firmware Current/Available) flattened — no nested-card look, 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: the muted ink does not survive the status tint
|
||||
* (3.35-3.36:1 against AA's 4.5). */
|
||||
.alert-message .table .th,
|
||||
/* title-row (0,4,0) outranks the plain rule above (0,3,0) regardless of source order */
|
||||
.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); letter-spacing: var(--fs-eyebrow-tracking); }
|
||||
/* column gap --fs-space-3 like every other cell; row density --fs-space-2, a dense readout */
|
||||
.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); }
|
||||
|
||||
/* validation tooltip: fill stays SOLID, unlike .label below — floats over arbitrary content,
|
||||
* so a border alone cannot carry the status. Shadow replaces base's raw 2px halo (1.08-1.86:1). */
|
||||
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
|
||||
/* base reveals it on :hover alone; no tabindex in anything LuCI ships today, but a third
|
||||
* party's own container may add one */
|
||||
.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); }
|
||||
|
||||
/* .label: core barely uses it, any luci-app-* may. .important is bootstrap's accent name. */
|
||||
.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: var(--fs-hairline);
|
||||
color: var(--fs-dim); text-shadow: none; text-transform: none;
|
||||
white-space: nowrap; margin: .125em 0 .125em .4em; /* carried over from the absorbed base rule */
|
||||
}
|
||||
/* no background: a translucent tint under same-colour text eats contrast (3.4-4.4:1);
|
||||
* inherited opaque --fs-panel2 guarantees >=4.5. */
|
||||
.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); }
|
||||
|
||||
/* section-title toggles are .label pills, clickable — SINGLE owner (pages/20-overview.css
|
||||
* used to restate this, unscoped, drifted) */
|
||||
.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: var(--fs-hairline);
|
||||
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; }
|
||||
/* toggles 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);
|
||||
}
|
||||
/* opaque surface (--fs-panel2 inherited): accent text on --fs-accent-soft measured 4.21:1
|
||||
* (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,127 @@
|
||||
@layer theme {
|
||||
/* one component, four LuCI selectors, no markup difference worth honouring: #tabmenu ul.tabs
|
||||
* (page tabs), ul.cbi-tabmenu.mode (mode switcher), .tabs (view tabs), .cbi-tabmenu (CBI form
|
||||
* tabs). Active marker: .active on ul.tabs, .cbi-tab on every .cbi-tabmenu flavour. */
|
||||
#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: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
/* zeroes the folder-tab baseline gradient (1px x 29px) instead of restating it, so a
|
||||
* third-party gradient the strip carries is sized away too */
|
||||
background-size: 0 0;
|
||||
}
|
||||
|
||||
/* a strip nested in .cbi-section -> 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;
|
||||
margin: 0; padding: 0;
|
||||
border: 0; background: none;
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* page tabs and the mode switcher never carry a sentence-length label; ul.cbi-tabmenu.mode > li
|
||||
* wins specificity over .cbi-tabmenu > li below, so mode pills stay none regardless of order */
|
||||
#tabmenu ul.tabs > li,
|
||||
ul.cbi-tabmenu.mode > li,
|
||||
.tabs > li {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* CBI form tabs only, not the mode switcher: a long label overflowed its column by 28.6px at
|
||||
* 320px/Large, EN measured 0 — docs/chrome.md "Tab strips" has the measurement */
|
||||
.cbi-tabmenu > li {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
/* one ring for both states, on --fs-focus-ring-solo: base's outline is invisible on the active
|
||||
* pill, whose background is the same accent — docs/chrome.md "Tab strips" has the contrast
|
||||
* numbers. box-shadow, not outline, because the strip clips and a shadow is not */
|
||||
#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);
|
||||
}
|
||||
|
||||
/* dense1 -> dense2 (fitTabStrips, fs-chrome.js) escalate mutually exclusively on wrap; both
|
||||
* give the pill the same padding, dense2 alone also tightens the strip's own gap */
|
||||
#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,123 @@
|
||||
@layer theme {
|
||||
/* realtime graphs size their canvas from #view, clipping 34px off the newest samples — docs/css.md "Misc: the realtime graph bleed" */
|
||||
#view .cbi-section > div[style]:has(> svg) {
|
||||
width: auto !important;
|
||||
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
|
||||
}
|
||||
|
||||
/* same box's frame, painted BLACK inline — only colour moves; wider than the bleed above, channel_analysis.js's box must not be bled but is just as black */
|
||||
#view div[style]:has(> svg) {
|
||||
border-color: var(--fs-border) !important;
|
||||
}
|
||||
|
||||
/* WCAG 1.4.1: --fs-accent on --fs-text measures 1.4:1 in dark, needs a non-colour cue in
|
||||
* prose. Nav links/buttons/pills are identified by position and shape instead. */
|
||||
p a, dd a,
|
||||
.cbi-value-description a,
|
||||
.alert-message a:not(.btn):not(.cbi-button) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* a LABEL must SET sans, not merely be excluded — excluded still inherits mono (227 elements/
|
||||
* 20 KB of bold mono for nothing); exempt in pre/code/#syslog/textarea, monospaced CONTENT */
|
||||
:is(strong, b):not(pre *, code *, #syslog *, textarea *) {
|
||||
font-family: var(--fs-font-sans);
|
||||
}
|
||||
|
||||
/* `.ifacebadge` is a BADGE (chrome, not a value) in a mono cell; wins on specificity (0,2,0)
|
||||
* over theme/60-inputs.css's (0,1,0) mono rule, not source order */
|
||||
.ifacebadge,
|
||||
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
|
||||
code, pre { font-weight: var(--fs-weight-normal); }
|
||||
|
||||
/* upstream's fade-in re-marks on EVERY poll tick, animating it pulsed a still page — docs/css.md "Misc: fade-in animates nothing" */
|
||||
.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); }
|
||||
/* lives in THIS layer, next to the head it overrides: the same rule in base was dead, a layer beats specificity */
|
||||
.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); }
|
||||
|
||||
/* @container PLUMBING only, card rules live with the tables. Named: an unnamed query binds to the nearest container-type ancestor, silently re-targeted by a new one */
|
||||
.cbi-map { container-type: inline-size; container-name: fs-content; }
|
||||
#view { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
|
||||
/* ui.js appends #modal_overlay outside #view, so the names above must repeat here or no dialog query resolves.
|
||||
* box-shadow merged in from theme/70-modal.css: base's elevation = --fs-bg's hex in footstrap
|
||||
* light, a glow not a shadow; others take --fs-shadow-pop */
|
||||
.modal { container-type: inline-size; container-name: fs-view fs-content; box-shadow: var(--fs-shadow-pop); }
|
||||
|
||||
/* pager (« prev · text · next ») — generic, not page-scoped */
|
||||
.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); }
|
||||
|
||||
/* absorbed from base; stock LuCI only renders h2/h3, the rest is third-party coverage.
|
||||
* `legend` excluded: theme/30-tables.css owns it. */
|
||||
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 get a .cbi-section card's frame, so the page reads as a
|
||||
* stack of panels rather than loose text over the background */
|
||||
.cbi-map > h2,
|
||||
#view > h2 {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
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);
|
||||
}
|
||||
/* 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: var(--fs-hairline);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
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);
|
||||
}
|
||||
|
||||
/* .right holding Add/Save directly under a field had no gap: pinned the button to the textarea */
|
||||
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
|
||||
margin-top: var(--fs-space-2-5);
|
||||
}
|
||||
|
||||
/* button row belonging to the PAGE TITLE, not a section — same 16px as .cbi-section, half above */
|
||||
.cbi-title-buttons { margin-block: var(--fs-space-2) var(--fs-card-gap); }
|
||||
|
||||
/* Delete button luci-base renders for every NAMED section sat flush against what follows — one class, not one app */
|
||||
.cbi-section-remove { margin-bottom: var(--fs-space-2); }
|
||||
|
||||
/* a view may hand the map a bare widget with no `.cbi-section`; exclusions are the children luci-base already spaces itself */
|
||||
.cbi-map > *:not(.cbi-section):not(.cbi-map-descr):not(.cbi-page-actions):not(h2):not(legend):not(.cbi-tabmenu):not(.cbi-map-tabbed):not(:empty) {
|
||||
margin-bottom: var(--fs-card-gap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
@layer theme {
|
||||
/* a DELTA on top of the bar's unguarded base (20-shell.css), every width — no @media floor,
|
||||
* fitChrome measures instead — docs/css.md "Toplayout: measured, not media-queried". */
|
||||
:root[data-layout="top"] .fs-sidebar {
|
||||
/* min-height not height: a long hostname wraps the capped brand onto a second line (20-shell.css) */
|
||||
min-height: var(--fs-bar-h); height: auto;
|
||||
gap: var(--fs-space-3);
|
||||
/* nowrap or flexbox wraps the BAR itself instead of the menu's own pills — the signal fitChrome measures */
|
||||
flex-wrap: nowrap;
|
||||
/* aligns the bar to .fs-content so the brand/menu line up with the headings below */
|
||||
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
|
||||
}
|
||||
|
||||
/* flex:1 opens the gap pushing the right cluster to the column's 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; }
|
||||
|
||||
/* dropdown anchor/bridge/clamp live unguarded in 20-shell.css, not here (issue #19) */
|
||||
|
||||
/* :not(:empty):not(.single), not a hide pair: 20-shell.css already hides the mode strip */
|
||||
: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);
|
||||
}
|
||||
|
||||
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
|
||||
|
||||
/* .fs-bar-stack: set by fitChrome (fs-chrome.js), not a media query — same reasoning as the header above */
|
||||
: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; }
|
||||
/* only the FIRST far-edge item keeps the auto margin, restated here — base guard is out-specified (0,4,0 vs 0,2,0) */
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
|
||||
|
||||
/* .fs-ind-compact/.fs-bar-actrow live with the bar in 20-shell.css too — only the top delta lives here */
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
@layer theme {
|
||||
/* buttons straight in a field (Enable/Disable, Start/Restart/Stop) only get whitespace
|
||||
* spacing inline-block, collapsing rows on wrap — wrapping flex with a real gap instead */
|
||||
.cbi-value-field:has(> .cbi-button) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* native input types listed on purpose: LuCI always uses <button class="cbi-button">, but a
|
||||
* third-party app may emit a bare <input type="submit"> and base only gives those width/
|
||||
* height: auto — coverage is a contract (docs/gallery.html) */
|
||||
.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 210x32px, a base reset would lose to it by layer */
|
||||
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: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
box-shadow: none; text-shadow: none;
|
||||
/* border-COLOR not the shorthand, which also animates width — a layout animation on every hover */
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* bare `button` rides along: harmless on a border:0 box, meaningful once an app gives it one */
|
||||
.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 only: box-shadow:none above kills any stray glow, plain mouse :focus shows
|
||||
* nothing on purpose — the pressed cue below already answers a click */
|
||||
.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/-good/-danger, an accent-tinted ring is invisible against
|
||||
* them — ring in the surface colour with a solid accent halo (--fs-focus-ring-solo) instead */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* the sole copy of the pressed inset shadow (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);
|
||||
}
|
||||
|
||||
/* three fills, not six: `.cbi-button-*` (LuCI's own views) and `.btn.<colour-word>` (third-party
|
||||
* apps) painted together in one rule, or 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);
|
||||
}
|
||||
/* must stay AHEAD of the page-action Save rule below, or its border-color:transparent erases Save's outline on hover at equal specificity */
|
||||
.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); }
|
||||
|
||||
/* .btn owns size words in full now: the plain rule above re-sets padding/radius in a later
|
||||
* layer, so a size word carrying only font-size (base's old copy) got default padding */
|
||||
.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: var(--fs-hairline); color: var(--fs-text);
|
||||
}
|
||||
.alert-message .btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Save/Apply footer: base renders a tall half-empty plate with bottom-only radius, made a compact rounded 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);
|
||||
/* WRAP the bar rather than let items shrink into it: a no-wrap row put the whole squeeze on
|
||||
* the Save & Apply split control's caption (measured up to 119px cut at 320px/Large) instead
|
||||
* of sharing it — docs/css.md "Buttons: the page-action bar wraps, not shrinks" has the
|
||||
* before/after numbers. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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 Save's same ladder step (-soft/-fill) off the DANGER role, since it discards
|
||||
* unsaved changes; label is --fs-text for the same over-tint reason as Save's below. */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* no `.spinning` padding ladder here, and it must not come back — base/95-luci.css puts the
|
||||
* glyph IN THE FLOW instead, reserving its own space (the ladder lost twice: issues #15, #22) */
|
||||
|
||||
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: var(--fs-hairline); 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): neutral outline, variants colour the ones that
|
||||
* carry meaning. Neutral :hover comes FIRST — after it, it would grey the coloured variants
|
||||
* back out on hover at equal specificity. 32px clears WCAG 2.5.8's 24px target/spacing rule;
|
||||
* 28px did not (adjacent 24px circles overlapped). */
|
||||
.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);
|
||||
/* flex-basis: auto, not a definite basis: with one, the automatic minimum resolves to 0 in
|
||||
* every row and flex-grow divides by count instead of by need — docs/css.md "Buttons:
|
||||
* `flex-basis: auto`, not the definite basis base gave every row action" has the
|
||||
* regression this fixed (task 0151) and the measured widths before/after. */
|
||||
flex-basis: auto;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.td .cbi-button, .cbi-section-actions .cbi-button, .cbi-section-table .cbi-button {
|
||||
background: transparent;
|
||||
border: var(--fs-hairline); 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;
|
||||
}
|
||||
/* hover ink is --fs-text, not the role colour: text of colour C on a tint of C eats its own
|
||||
* contrast (Save: accent on accent-soft = 4.25:1, AA fail) — role reads from fill + border */
|
||||
.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 blue text on a green fill = unreadable; green outline instead */
|
||||
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
|
||||
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
|
||||
}
|
||||
/* without this border-colour the positive toggle read less responsive than its hover neighbours (which brighten to -line-hi) */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Save & Apply is THE primary action, Save demoted so only one reads 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);
|
||||
border: 1px solid transparent;
|
||||
color: var(--fs-on-accent);
|
||||
--on-color: var(--fs-on-accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
/* a TINT at rest, not a bare outline, or Save reads disabled beside the solid Save & Apply;
|
||||
* -soft/-fill off the role ladder. Label --fs-text, not --fs-accent: accent on accent-soft
|
||||
* measures 4.25:1, an AA failure (docs/design-system.md). filter:none cancels the shared
|
||||
* hover-lift, which washes out a translucent tint 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);
|
||||
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 number --fs-ctl-h already states — a restated value is the drift this ladder exists to prevent */
|
||||
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: var(--fs-hairline);
|
||||
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: var(--fs-hairline);
|
||||
}
|
||||
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral:hover { color: var(--fs-accent); }
|
||||
/* SQUARE, ON THE LADDER: the only buttons once set in bare pixels (40x34, 34 being a height in
|
||||
* no scale) and opted out of the Density axis; 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: var(--fs-hairline);
|
||||
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,520 @@
|
||||
@layer theme {
|
||||
/* baseline, not center: a taller field (help sentence under the control) would push a
|
||||
* centered label off the control's first line */
|
||||
.cbi-value { justify-content: space-between; gap: var(--fs-space-4); padding: var(--fs-space-2) 0; margin: 0; border-bottom: var(--fs-hairline); }
|
||||
/* stacked controls need a gap, a raw <br> collapses it; password-reveal excluded, 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;
|
||||
}
|
||||
/* hairline separates a row from the NEXT one, so it drops whenever none follows, not only at the section's end */
|
||||
.cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; }
|
||||
/* .cbi-checkbox has no text baseline (synthesised at its bottom edge, floats ~5px above the label) — centred, except with a help sentence under it (see the baseline note above) */
|
||||
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
|
||||
align-items: center;
|
||||
}
|
||||
/* absorbed from base; ONLY the transition — typography stays in base (theme/45-misc.css). border-COLOR not the shorthand, which also animates width */
|
||||
input, button {
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
|
||||
.cbi-value-title {
|
||||
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
/* stated not inherited: this is TEXT, the control rule hands labels line-height: normal (measured 18px -> normal without this) */
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
/* mono is for VALUES: buttons, the help sentence and `<strong>` LABELS excluded — a label
|
||||
* fed to mono also goes bold, 227 elements/20 KB of face for nothing. theme/45-misc.css's
|
||||
* label rule must ASSIGN sans; excluding `<strong>` here alone still inherits mono */
|
||||
.cbi-value-field,
|
||||
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
|
||||
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
|
||||
|
||||
/* not base's fixed 210px: mono + padded --fs-space-3, 210px clipped a URL mid-domain (issue #5); elastic, capped to the dynlist's ceiling */
|
||||
.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); }
|
||||
/* base assumes ONE title+field pair; stack full-width on a SECOND title (not a second field — a runtime-appended field would re-stack under the user's finger, measured: a button moved 31px) */
|
||||
.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;
|
||||
}
|
||||
.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 <div class="cbi-value"> on a form, <td class="cbi-value-field"> in a table — same <var>, 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); }
|
||||
|
||||
/* base sizes a row by proportion for its own ~20px buttons; this theme's 36px button (3 per
|
||||
* row) does not fit at 23% and `overflow: hidden` clips the corners — size actions to content,
|
||||
* let the name ellipsize, wrap when that is not enough */
|
||||
.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; }
|
||||
/* `0 1 auto`: may wrap but not shrink below min-content, or it sticks out (measured 23px past the box at 320px before min-width: 0) */
|
||||
.cbi-filebrowser > ul > li > div:last-child {
|
||||
flex: 0 1 auto; min-width: 0;
|
||||
display: flex; flex-wrap: wrap; gap: var(--fs-space-2); justify-content: flex-end;
|
||||
}
|
||||
.cbi-filebrowser > ul > li > div:last-child > button { margin: 0; }
|
||||
.cbi-filebrowser .upload > div > input { width: 100%; }
|
||||
|
||||
/* Form widgets — toggles, file input. Applies to every CBI form. */
|
||||
|
||||
/* base zeroes checkbox margin; inside a .btn label only a literal space separated ✓ from text
|
||||
* (issue #5.1). toggle switch below unaffected, its own margin:0 wins on a different ancestor */
|
||||
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
|
||||
|
||||
/* checkbox -> toggle switch, drawn on the INPUT — docs/css.md "Inputs: the toggle switch,
|
||||
* drawn on the input" has the forum regression this replaced. density-scaled local tokens. */
|
||||
/* overflow-wrap: anywhere for the one caption word too long even at full width (pseudo-loc: a
|
||||
* 14-letter Russian compound grown to 20 pushed the row 24.6px past the field at 320px/Large) */
|
||||
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
|
||||
/* label[for] stays in the DOM but draws nothing: out of flow, out of the flex item count, so gap opens only on the switch input + an optional caption */
|
||||
.cbi-checkbox > label[for] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
||||
.cbi-checkbox > input[type="checkbox"] {
|
||||
--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));
|
||||
/* appearance/--bd-color/--fg-color come from the shared input[type=checkbox] rule below;
|
||||
* flex-shrink: 0 keeps the pill at its width beside a caption (task 0145 has the numbers) */
|
||||
display: inline-block; position: relative; margin: 0; flex-shrink: 0;
|
||||
width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill);
|
||||
background: var(--fs-track); border: var(--fs-hairline);
|
||||
cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle;
|
||||
}
|
||||
/* hit-target overlay to 44x30 (WCAG 2.5.8 floor 24px, pill is 22px); inset stays literal, not
|
||||
* density-scaled — a floor that shrinks with Density is not one */
|
||||
.cbi-checkbox > input[type="checkbox"]::before { inset: -4px -2px; }
|
||||
.cbi-checkbox > input[type="checkbox"]::after {
|
||||
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 re-lays out the row every frame, translateX is composited */
|
||||
transform: translateX(0);
|
||||
transition: transform var(--fs-dur-move), background var(--fs-dur);
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
|
||||
/* derived so it cannot drift from the sizes above; -2px is the pill's two 1px borders (the knob sits against the padding box, so it has 2px less room — travel is 18px not 20) */
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after {
|
||||
transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));
|
||||
background: var(--fs-on-accent);
|
||||
}
|
||||
/* the input IS the switch, ring goes here; -solo: checked switch already spends its border channel on accent, only the tinted shadow shows */
|
||||
.cbi-checkbox > input[type="checkbox"]:focus-visible {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
/* raw checkbox/radio, absorbed from base: undoes the generic input box below. 16px box on the
|
||||
* grid, mark inset FROM it. `.cbi-checkbox > input` also matches the switch's own input
|
||||
* (unscoped, it wants this rule's appearance:none) — selectors that WOULD paint the switch
|
||||
* exclude it individually below instead. */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
--bd-color: var(--fs-border);
|
||||
--fg-color: var(--fs-text);
|
||||
/* box tier not space tier: --fs-space-4 gave 16px at Normal but 10.4px at Compact (smaller than any control) and never grew at Large */
|
||||
--box: calc(16px * var(--fs-density-box));
|
||||
--mark-inset: 3px;
|
||||
/* WCAG 2.5.8's 24px spacing floor holds only while the theme owns the layout; ELEMENT is
|
||||
* 24px (drawing stays 16px, centred), max() so it never shrinks under --box at Large */
|
||||
--hit: max(24px, var(--box));
|
||||
appearance: none;
|
||||
width: var(--hit);
|
||||
height: var(--hit);
|
||||
/* negative margin pulls the extra hit area back out of flow (without it, rows measured 42px -> 51px) */
|
||||
margin: calc((var(--box) - var(--hit)) / 2);
|
||||
color: var(--fg-color);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
/* padding/border-radius not reset: land on an appearance-none box whose pseudo-elements are absolute, invisible either way */
|
||||
}
|
||||
input[type="checkbox"]::before,
|
||||
input[type="checkbox"]::after,
|
||||
input[type="radio"]::before,
|
||||
input[type="radio"]::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
|
||||
input[type="radio"]::before {
|
||||
/* inset:0; margin:auto centres a fixed box the same in RTL as LTR; a translate pair would not */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: var(--box);
|
||||
height: var(--box);
|
||||
background: linear-gradient(var(--fs-panel), var(--fs-panel2));
|
||||
border: 1px solid var(--bd-color);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
input[type="checkbox"]::before { border-radius: 2px; }
|
||||
input[type="radio"],
|
||||
input[type="radio"]::before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* keyboard/click focus ring on the DRAWN box, the theme's one ring token */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
|
||||
input[type="radio"]::after {
|
||||
/* mark inset from the DRAWING, which is centred in the target, not from the target's own edge */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: calc(var(--box) - var(--mark-inset) * 2);
|
||||
height: calc(var(--box) - var(--mark-inset) * 2);
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
|
||||
input[type="radio"]:checked::after {
|
||||
/* OURS: stroked path (this theme's icon language), replacing a fill byte-identical to luci-theme-bootstrap's; mask keys on alpha so a stroke masks like a fill 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 {
|
||||
/* radio dot filled, not ringed — a ring reads as "not selected" */
|
||||
--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);
|
||||
}
|
||||
|
||||
/* generic field box, absorbed from base; bare input keeps the historical 210px fallback for types the list below does not name */
|
||||
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: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
}
|
||||
/* base's old #localtime{width:300px} media rule not moved here: the elastic field width above already out-layers it (measured 440px, not 300) */
|
||||
|
||||
input[type="file"] {
|
||||
color: var(--fs-dim); font-size: var(--fs-type);
|
||||
padding: initial;
|
||||
border: initial;
|
||||
line-height: initial;
|
||||
box-shadow: none;
|
||||
/* no !important: width:auto out-specifies the generic input{width:210px} alone */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* native <select> chevron + theme; the data-URI lives in 03-palettes.css per palette/mode block since a URI cannot read var() */
|
||||
select, select.cbi-input-select, .cbi-value-field select {
|
||||
/* unprefixed only, floor clears it everywhere — docs/css.md "The browser floor" */
|
||||
appearance: none;
|
||||
background-color: var(--fs-panel2);
|
||||
background-image: var(--fs-select-chevron);
|
||||
/* right inset is the scale step, not a literal 12 — the .cbi-select shell below repeats the same one spelling */
|
||||
background-repeat: no-repeat; background-size: 6px 6px;
|
||||
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
|
||||
padding: var(--fs-space-2) var(--fs-space-8) var(--fs-space-2) var(--fs-space-3);
|
||||
border: var(--fs-hairline); 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: 210px;
|
||||
display: inline-block;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* height:inherit so the multiple/listbox variant does not take the closed control's one-line height */
|
||||
select[multiple] { background-color: var(--fs-panel2); color: var(--fs-text); height: inherit; }
|
||||
option { font-size: 100%; line-height: 2em; }
|
||||
select option { background: var(--fs-panel); color: var(--fs-text); }
|
||||
|
||||
/* the OS renders the native list, so `select option` above is mostly ignored; Chromium's
|
||||
* base-select renders it in the DOM instead, themeable like .cbi-dropdown; @supports-gated,
|
||||
* scoped to .cbi-value-field so a third-party app's raw <select> stays 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: var(--fs-hairline);
|
||||
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);
|
||||
/* background:transparent undoes `select option`'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); }
|
||||
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
|
||||
.cbi-value-field select option:checked {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
}
|
||||
|
||||
/* .cbi-select — legacy luci-compat shell around a native <select>, matching the themed select
|
||||
* property for property. :not(select): the class also appears on a bare <select>, which cannot draw the ::before chevron */
|
||||
.cbi-select:not(select) {
|
||||
display: inline-block;
|
||||
width: 210px;
|
||||
/* height not min-height: the child's height:100% falls back to content height against an auto-height parent (measured collapse 30px -> 15px in a 38px shell) */
|
||||
height: var(--fs-ctl-h);
|
||||
color: var(--fs-text);
|
||||
font-size: var(--fs-type);
|
||||
border: var(--fs-hairline);
|
||||
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-size: 6px 6px;
|
||||
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* base/30-forms.css only dims the old ▾ plate; with the plate gone the disabled state had no visual, so the shell carries it now */
|
||||
.cbi-select[disabled]:not(select) {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* invisible, filling the shell, clearing the plate by the same --fs-space-8 inset the themed select uses */
|
||||
.cbi-select select,
|
||||
.cbi-select select:focus {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 0 var(--fs-space-8) 0 var(--fs-space-3);
|
||||
font-size: var(--fs-type);
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
|
||||
|
||||
/* type list must be EXHAUSTIVE: an omission falls back to BASE's stock field beside a themed one, not to "unstyled" */
|
||||
.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: var(--fs-hairline);
|
||||
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: var(--fs-field-transition);
|
||||
}
|
||||
/* max-width:100% beside the fixed 210px: a textarea's intrinsic width comes from `cols` (default 20), which put two textareas 19px outside their card at 300px before this */
|
||||
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type); }
|
||||
/* a square native scrollbar is not clipped by a rounded corner; self-rounded thumb + transparent track fixes it. no scrollbar-width/-color: either switches Chromium to the square standard bar */
|
||||
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); }
|
||||
textarea::-webkit-scrollbar-corner { background: transparent; }
|
||||
/* accent arc traces the frame's own radius (two borders shrunk to 16px - radius); mirrored by
|
||||
* hand below, ::-webkit-resizer ignores logical properties */
|
||||
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); }
|
||||
|
||||
/* fallback ring for untyped inputs; excludes the toggle switch, whose :focus-visible-only ring must not double up */
|
||||
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
|
||||
.cbi-section-create-name:focus,
|
||||
input:focus:not(:where(.cbi-checkbox > input)),
|
||||
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,
|
||||
select:focus,
|
||||
.cbi-dynlist > .item:focus {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* same ring, shared by 4 selectors that cannot join the list above; :is() around :has() keeps the list forgiving — unwrapped, a :has() compound sharing a list fails the WHOLE rule below the floor */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before,
|
||||
.cbi-select.focus,
|
||||
.cbi-dynlist > .add-item:focus-within,
|
||||
.control-group:is(:has(> .cbi-input-password)):focus-within {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* .cbi-value-error input (Lua-CBI) must live HERE: base's red border-color is a longhand and
|
||||
* this rule's border shorthand wipes it by layer regardless of specificity */
|
||||
input[type="checkbox"].cbi-input-invalid:focus::before,
|
||||
input[type="radio"].cbi-input-invalid:focus::before,
|
||||
.cbi-select.cbi-input-invalid.focus,
|
||||
.cbi-input-invalid, input.cbi-input-invalid, textarea.cbi-input-invalid,
|
||||
.cbi-value-error input, .cbi-value-error textarea,
|
||||
.cbi-input-invalid:focus {
|
||||
border-color: var(--fs-danger);
|
||||
box-shadow: var(--fs-focus-ring-invalid);
|
||||
}
|
||||
/* same rejected value shown by a ui.Dropdown: base carried the widget and its closed <li>, theme only the input half */
|
||||
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
|
||||
|
||||
/* doubled [readonly] states intent at (0,2,1): at (0,1,1) it only tied the typed input rules above and silently lost */
|
||||
: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;
|
||||
}
|
||||
|
||||
/* keep base's layout (inline-flex chips + add-item row); only colours, rounding, a compact + button, narrow cap so a chip's × sits near its text */
|
||||
.cbi-dynlist {
|
||||
gap: var(--fs-space-1-5);
|
||||
background: transparent; border: 0; padding: 0;
|
||||
/* min(240px,100%) not a bare 240: a floor that exceeds the room is an overflow (measured 29px outside a 210px field at 320px) */
|
||||
min-height: 0; min-width: min(240px, 100%); max-width: var(--fs-field-max); width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
.cbi-dynlist > .item {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text);
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.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: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.item:hover::after { filter: var(--fs-hover-lift); }
|
||||
.cbi-dynlist > .item::after {
|
||||
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;
|
||||
}
|
||||
.cbi-dynlist > .add-item {
|
||||
align-items: stretch;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm); overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.cbi-dynlist > .add-item > input,
|
||||
/* width:auto keeps the flex basis in charge — this input sits inside .cbi-value-field, the elastic width above would otherwise hand it 100% 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;
|
||||
}
|
||||
|
||||
/* input + button as ONE seamless group; gap:0 is load-bearing — base gives every .control-group a gap, this is the one whose members must touch */
|
||||
.control-group:has(> .cbi-input-password) {
|
||||
display: inline-flex; align-items: stretch; gap: 0;
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* `.hidden.hidden` outranks every component rule without !important (which would invert layer
|
||||
* order later) and covers the Enter-key submit legacy Lua pages carry (issue #12).
|
||||
* :not([class*=":"]) excludes a Tailwind/UnoCSS class fs-sheets.js re-hosts into this layer —
|
||||
* docs/css.md "Inputs: `.hidden.hidden`, and the Tailwind/UnoCSS exception". */
|
||||
.hidden.hidden:not([class*=":"]) { display: none; }
|
||||
/* the WCAG 2.5.8 target (24px min) split from the 6px track — docs/css.md "Inputs: the range
|
||||
* slider's hit target and its line-box" has the two-part fix and its numbers */
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
width: 100%; height: 24px; min-height: 0; padding: 0;
|
||||
margin-block: -9px;
|
||||
background: transparent; border: 0; border-radius: 0;
|
||||
outline: none; box-shadow: none; cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
width: 100%; height: 6px;
|
||||
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
input[type="range"]::-moz-range-track {
|
||||
width: 100%; height: 6px;
|
||||
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--fs-accent); border: 2px solid var(--fs-panel);
|
||||
cursor: pointer;
|
||||
/* WebKit centres on the INPUT's box not the track: (6-16)/2 = -5px */
|
||||
margin-top: -5px;
|
||||
}
|
||||
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;
|
||||
}
|
||||
input[type="range"]:focus::-webkit-slider-runnable-track { box-shadow: var(--fs-focus-ring-solo); }
|
||||
input[type="range"]:focus::-moz-range-track { box-shadow: var(--fs-focus-ring-solo); }
|
||||
/* same ceiling as the elastic text inputs; vertical-align:middle decouples the line-box from the input's own edge, which moved 6px -> 24px above */
|
||||
.cbi-range-slider { display: inline-flex; align-items: center; vertical-align: middle; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
|
||||
.cbi-range-slider-value {
|
||||
background: var(--fs-panel2); border: var(--fs-hairline); 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 bold mono face bundled, drags in 20 KB or fakes it with synthetic emboldening */
|
||||
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,377 @@
|
||||
@layer theme {
|
||||
/* generic .dropdown-menu popup: nothing in luci-base emits it — coverage vocabulary, since
|
||||
* stock luci-theme-bootstrap styles it and a third-party app may emit it */
|
||||
.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); color: var(--fs-accent); }
|
||||
|
||||
/* the outer div IS the button, class = ui.js's SELECTED mode; unguarded this erased that
|
||||
* variant's fill while its ink survived — measured page-on-page at 1.06-1.13:1. */
|
||||
.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 (theme/55-buttons.css gives the rest theirs); painted by specificity, not source order */
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button {
|
||||
background: var(--fs-accent);
|
||||
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 ---- */
|
||||
/* machinery absorbed from base, driven by ui.js attributes; the state machine below is
|
||||
* UNFLAGGED, stating openly what upstream's !important flags resolved — docs/css.md
|
||||
* "Dropdown: the display state machine, unflagged" has the specificity table. */
|
||||
.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, unflagged (0,3,1) over the rule above's (0,1,1) */
|
||||
.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, `.more` is none until the showers flip it — one declaration per state or the audit's same-selector shadow check fires */
|
||||
.cbi-dropdown > .open,
|
||||
.cbi-dropdown > .more {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.cbi-dropdown.btn > .open,
|
||||
.cbi-dropdown.cbi-button > .open {
|
||||
margin-inline-start: .5em;
|
||||
}
|
||||
.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;
|
||||
/* ellipsis did nothing until min-width: 0 — a flex item's automatic minimum floors it at its
|
||||
* own content width — docs/css.md "Dropdown: ellipsis needs `min-width: 0` first". */
|
||||
min-width: 0;
|
||||
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: var(--fs-hairline);
|
||||
}
|
||||
.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 (docs/css.md has the per-selector table) ---- */
|
||||
.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;
|
||||
}
|
||||
/* a closed optional dropdown with an empty value renders EMPTY unflagged too: the placeholder
|
||||
* hider out-specifies the generic shower. :not([open]) keeps this out of the open list. */
|
||||
.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: a CHILD with an inline width overflows, wrapping the row cannot reach it —
|
||||
* form.js's RichListValue assumes an unbounded list (issue #15). */
|
||||
.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: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-text);
|
||||
min-height: var(--fs-ctl-h);
|
||||
/* fixed height is load-bearing next to min-height: without it a closed dropdown grows to
|
||||
* its tallest child (Save & Apply's 40px button, measured 38 -> 42px on the gallery) */
|
||||
height: var(--fs-control-h);
|
||||
font-size: var(--fs-type);
|
||||
transition: var(--fs-field-transition);
|
||||
/* absorbed from base/30-forms.css; NOT the dynlist's numbers (240/440) — one value vs a column of them */
|
||||
min-width: 210px;
|
||||
max-width: 400px;
|
||||
width: auto;
|
||||
/* base carried `!important` here to beat its OWN generic form-field rule at higher
|
||||
* specificity (0,3,0 vs 0,1,0); a later LAYER beats it for free */
|
||||
display: inline-flex;
|
||||
padding: 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 row on a BUTTON dropdown, excluded by
|
||||
* :not(.btn):not(.cbi-button) below — one menu, one row, but two selectors. */
|
||||
.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);
|
||||
/* a grid step, not calc(--fs-type * --fs-leading) (19.5px) — a half-pixel box edge is what the scale exists to stop */
|
||||
min-height: var(--fs-space-5);
|
||||
}
|
||||
/* a MULTI-select wraps items in <form><label>…</label>, which theme/60-inputs.css's `.cbi-value-field *` would hand mono — inherit keeps it the row's sans, a LABEL not a value */
|
||||
.cbi-dropdown > ul > li > form,
|
||||
.cbi-dropdown > ul > li label { font-family: inherit; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]:not([display="0"]) { border-inline-start: 0; }
|
||||
/* same ink base/30-forms.css gives `placeholder`; repeats the row rule's two selectors rather
|
||||
* than a shorter form, which LOSES to the row's own color and rendered in the value's ink. */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder],
|
||||
.cbi-dropdown > ul.dropdown > li[placeholder] { color: var(--fs-placeholder); }
|
||||
/* the multiselect's inline filter/create inputs, moved from base with the generic input box (theme/60-inputs.css) */
|
||||
.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%; }
|
||||
|
||||
/* `.open` is the only hit target that opens the list (a click elsewhere on a button dropdown
|
||||
* RUNS the button). `font-size: 0` collapsed the stock line-height to 28x0, putting the
|
||||
* chevron outside its own box — a real box fixes that. */
|
||||
.cbi-dropdown > .open {
|
||||
display: flex;
|
||||
font-size: 0; line-height: 0; border: 0;
|
||||
position: relative; padding: 0;
|
||||
align-self: stretch;
|
||||
min-width: var(--fs-btn-size); min-height: var(--fs-ctl-h-sm);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* `.more` was one base rule with `.open`, split when the theme took `.open` for the chevron */
|
||||
.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" */
|
||||
}
|
||||
/* --fs-dim reads as a smudge on the accent-filled Save & Apply; currentColor uses the variant's own ink */
|
||||
.cbi-dropdown.btn > .open::after,
|
||||
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
|
||||
|
||||
/* open list -> design popover */
|
||||
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
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: var(--fs-hairline);
|
||||
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-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 — the menu is capped at
|
||||
* min(92vw, 420px), so a verbose option would otherwise decide the width of every menu */
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
/* no border-bottom: it curls at the corners of a rounded box; the ::after inset strip below is the divider instead */
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* The row's own wrap above never reaches a `.zonebadge` (base/95-luci.css): `white-space: nowrap`
|
||||
* there, right for a one-line pill in a table cell, wrong here where a multi-homed interface
|
||||
* (RichListValue) nests two `.ifacebadge` chips in one badge with no space between them — an
|
||||
* unbreakable 254-258px at 320px (`R` past the 320px viewport, task audit-09), overflowing
|
||||
* regardless of the popup's own width. Scoped to the open list only: every other `.zonebadge`
|
||||
* keeps `nowrap`. */
|
||||
.cbi-dropdown[open] > ul.dropdown > li .zonebadge {
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
}
|
||||
.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 — docs/css.md
|
||||
* "Dropdown: the open list's rail and the selected-row's contrast". */
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected] {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
|
||||
* INSIDE it so a focused+selected row keeps both */
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected]:focus-visible,
|
||||
.cbi-dropdown[open] > ul.dropdown > li:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; pinned byte-identical to
|
||||
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: var(--fs-hairline); margin-top: var(--fs-space-1); }
|
||||
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius); color: var(--fs-text); }
|
||||
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, 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 a multi-dropdown row no longer fits, so each row cards, same shape as the
|
||||
* data-table stack (theme/30-tables.css); a @container, not the tables' own measurement, since
|
||||
* un-collapsing a row to measure it CHANGES what its widgets read. 992, not 960 — docs/css.md
|
||||
* "Dropdown: 992px, not 960" has the padding subtraction and the regression it fixed. */
|
||||
@container fs-content (max-width: 992px) {
|
||||
.table.cbi-section-table,
|
||||
.table.cbi-section-table .tbody,
|
||||
.table.cbi-section-table .td { display: block; width: auto; }
|
||||
/* hide the header in BOTH markups (form.js's .thead, the Lua CBI's bare
|
||||
* tr.cbi-section-table-titles) — a card repeats every label, the header is a duplicate */
|
||||
.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; }
|
||||
/* stretch (not flex-start) keeps a pair's cells the same height when one wraps */
|
||||
.table.cbi-section-table .tr {
|
||||
display: flex; flex-wrap: wrap; width: auto;
|
||||
border: var(--fs-hairline);
|
||||
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); }
|
||||
/* margin: 0 load-bearing: the Lua CBI gives a table CELL the .cbi-value-field class a form
|
||||
* field carries, base indents it 20px — without the reset two cells no longer fit a line */
|
||||
.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;
|
||||
/* overflow-wrap: anywhere for opaque tokens (a real IPv6 GUA has no break point) — one
|
||||
* ran 156px past a 360px viewport before this */
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* a cell that only READS a value is short, two fit a line; a real widget keeps full width (a dropdown at half a phone's width is unusable) */
|
||||
.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 (theme/30-tables.css, tools/mirror.mjs): needed under a
|
||||
* CLASS there and under this @container here, and CSS cannot OR the two guards. */
|
||||
.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);
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* the row's buttons are not a label/value pair: full width, sharing the line. PINNED — see above. */
|
||||
.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; }
|
||||
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
|
||||
* div is a single flex item. PINNED to the data table's copy, 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%; }
|
||||
}
|
||||
|
||||
/* the table card (theme/30-tables.css) clips with overflow: hidden, but a .cbi-dropdown's open
|
||||
* list is absolutely positioned — drop the clip, restore corners via separated borders instead */
|
||||
.table:has(.cbi-dropdown),
|
||||
.table:has(.cbi-dynlist) {
|
||||
overflow: visible;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@layer theme {
|
||||
/* diff tints come from base's ladder alone */
|
||||
|
||||
/* beats the heading ramp's tight leading in-layer for a dialog's h4 (45-misc.css) */
|
||||
.modal > * {
|
||||
line-height: normal;
|
||||
margin-bottom: .5em;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* wins on specificity, not base's file-order tie */
|
||||
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
|
||||
/* moved from base: width:100% loses to the 210px generic box by layer */
|
||||
.modal > pre,
|
||||
.modal > textarea {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 600px is cramped for a table form (.cbi-map cards on a big screen); vw widens toward 1100 on desktop only */
|
||||
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
|
||||
|
||||
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
|
||||
#modal_overlay .modal label.btn { white-space: normal; overflow-wrap: anywhere; min-width: 0; }
|
||||
#modal_overlay .modal li { overflow-wrap: anywhere; }
|
||||
/* a literal 4px sat outside the Rounding axis */
|
||||
#modal_overlay .uci-dialog div > ins,
|
||||
#modal_overlay .uci-dialog div > del,
|
||||
#modal_overlay .uci-dialog div > var,
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); }
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
#modal_overlay .uci-change-legend-label > var { overflow: hidden; }
|
||||
|
||||
/* fill child sits 3px inset, overflow:hidden never reaches the corner — max() floors at 0 (issue #14) */
|
||||
#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,80 @@
|
||||
@layer theme {
|
||||
/* page-search palette. fs-search.js parents it to <body>: .fs-sidebar is overflow:auto, which
|
||||
* would clip a child of the trigger, and the overlay covers the whole viewport anyway. */
|
||||
|
||||
/* --fs-scrim is the theme's one colour literal (plain black at .7) on purpose: 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);
|
||||
/* what the `hidden` attribute has to beat — base/10-reset.css covers every element now */
|
||||
display: flex; justify-content: center;
|
||||
/* not centred vertically: the box grows DOWNWARD as results come in, centred 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);
|
||||
}
|
||||
|
||||
/* a SCROLL CONTAINER, not decoration: uncapped the box grows past a short viewport (phone
|
||||
* landscape ~380px), and 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-panel);
|
||||
border: var(--fs-hairline); 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: var(--fs-hairline);
|
||||
}
|
||||
.fs-search-ico { display: inline-flex; color: var(--fs-faint); }
|
||||
.fs-search-ico .fs-ico { width: 18px; height: 18px; }
|
||||
/* the ROW is the field, the input carries no frame of its own — two classes (0,2,0) needed to
|
||||
* outrank 60-inputs.css's generic field box (0,1,1) in the same layer regardless of source
|
||||
* order. 16px, not the theme's body size: below 16px iOS Safari zooms the page on focus, and
|
||||
* this field focuses as 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; }
|
||||
/* one placeholder ink for the whole theme (03-palettes.css); not measured by placeholder-ink (no gallery fixture), must not drift from the fields that are */
|
||||
.fs-search-row .fs-search-input::placeholder { color: var(--fs-placeholder); 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 pointer AND keyboard: .active is set on both aria-activedescendant and pointermove, so mouse and arrow keys can never disagree which row Enter opens */
|
||||
.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); }
|
||||
.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; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* empty state ("No pages found") reads as a sentence, not an eyebrow label. :has(), not a
|
||||
* sibling combinator: the note sits ABOVE the list it describes, `+` 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,89 @@
|
||||
@layer theme {
|
||||
/* phone overflow safety net (<=767px): .fs-main is overflow-x: clip, cutting anything wider silently; tablets were clean */
|
||||
/* split-button labels wrap under 360px — docs/css.md "Responsive: a split button's label must wrap under 360px" */
|
||||
@container fs-view (max-width: 360px) {
|
||||
#view .cbi-button, #view .btn,
|
||||
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* foreign-<table> fallback unguarded now in theme/30-tables.css: fit is content/column, never viewport */
|
||||
|
||||
/* must stay INSIDE this @media: theme/65-dropdown.css's cap is (0,3,0) vs this (0,1,0) */
|
||||
.cbi-dropdown { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* base's 250px label overflows ~360px; !important beats package-manager.js's inline style */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls { display: block !important; }
|
||||
/* :not(.pager): blocking it stacked « / text / » onto three lines */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager {
|
||||
display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .btn { flex: 0 0 auto; }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .text { flex: 0 1 auto; min-width: 0; }
|
||||
/* disk gauge value moved BELOW the bar — docs/css.md "Responsive: the disk gauge value moves below the bar" */
|
||||
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
|
||||
width: 100%; margin-bottom: 3em;
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] 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`: only the TITLE label, a bare `.controls label` also caught nested radio labels */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div > label { display: block; min-width: 0; white-space: normal; margin-bottom: var(--fs-space-1); }
|
||||
/* radio row: stock's text nodes don't stop a mid-choice split, a real gap does */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .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);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group { display: flex; flex-wrap: wrap; min-width: 0; gap: var(--fs-space-2); }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group input { flex: 1 1 140px; min-width: 0; max-width: 100%; }
|
||||
|
||||
/* Diagnostics stack not here: fs-select.js writes .fs-rowstack at any width, theme/30-tables.css carries it */
|
||||
|
||||
/* no `table.fs-dt { display: block }`: discards the table context — docs/css.md "Responsive: no `display: block` on `table.fs-dt`" */
|
||||
|
||||
/* generic caps: a field never exceeds its column */
|
||||
.cbi-value-field input, .cbi-value-field select, .cbi-value-field .cbi-dropdown { max-width: 100%; }
|
||||
/* content-sized leaves half the row empty; checkbox/radio/file keep intrinsic size */
|
||||
.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: 0 only, base/40-tables.css already breaks a long value */
|
||||
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
|
||||
|
||||
/* side-by-side squeezes a field to ~120px; not #view-scoped, ui.js appends modals to <body> */
|
||||
.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; }
|
||||
|
||||
/* stock phone contract; `.td.hide-xs` not redundant, stacking rules (0,2,1)/(0,2,0) outrank a bare `.hide-xs` (0,1,0) */
|
||||
.hide-sm, .hide-xs,
|
||||
.table .td.hide-sm, .table .td.hide-xs,
|
||||
.table .th.hide-sm, .table .th.hide-xs { display: none; }
|
||||
/* .col-N weights UNGUARDED now, theme/30-tables.css: flex is inert on a non-flex item */
|
||||
|
||||
/* iOS Safari zooms in when focused text is under 16px and never zooms back out */
|
||||
input, textarea, select, .cbi-dropdown > ul > li { font-size: var(--fs-type-lg); }
|
||||
}
|
||||
|
||||
/* anchored to its widget: ui.js's touch branch assumes the viewport as containing block; !important beats the inline style */
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: auto !important;
|
||||
min-width: 100%;
|
||||
/* desktop only: min(92vw,420px) ignores trigger's left offset, grew past viewport under 767px (113 findings, task audit-09) */
|
||||
width: max-content;
|
||||
max-width: min(92vw, 420px);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* phone tier, after the rule above (later wins): trigger already capped to field width, `auto` keeps the popup on-screen */
|
||||
.cbi-dropdown[open] > ul.dropdown { width: auto; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@layer theme {
|
||||
/* honoured globally, every transition here is decoration. !important is the one sanctioned
|
||||
* exception, a layer cannot reach base's own transitions — DO NOT COPY THE FLAG OUT OF THIS
|
||||
* BLOCK. .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* every surface is already opaque except the wallpaper's wash */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
:root {
|
||||
/* flattens the photo wash to opaque (16-login-bg.css) */
|
||||
--fs-photo-scrim: var(--fs-bg);
|
||||
}
|
||||
}
|
||||
|
||||
/* re-states the tokens so every rule reading them follows — docs/css.md "A11y: prefers-contrast token shifts, and the numbers behind them" has why each number */
|
||||
@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: color-mix(in srgb, var(--fs-text) 82%, var(--fs-bg));
|
||||
--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-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
|
||||
/* the one token shipped under AA at rest; both modes buy the ink back here */
|
||||
--fs-placeholder-mix: 62%;
|
||||
}
|
||||
:root[data-darkmode="true"] { --fs-placeholder-mix: 80%; }
|
||||
}
|
||||
|
||||
/* forced-colors: background-color is forced to the system canvas, so state carried by
|
||||
* BACKGROUND alone vanishes (active tab, checked toggle, selected row) — re-stated with a
|
||||
* border/outline in system keywords, which forced-colors keeps — docs/css.md "A11y:
|
||||
* forced-colors state repairs". */
|
||||
@media (forced-colors: active) {
|
||||
#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 */
|
||||
}
|
||||
/* active #topmenu state was only a 10%-accent background + box-shadow rail, both dropped */
|
||||
#topmenu li.active > a {
|
||||
border-inline-start: 3px solid Highlight;
|
||||
padding-inline-start: calc(var(--fs-space-3) - 3px);
|
||||
}
|
||||
/* input[type=checkbox] (60-inputs.css draws the switch there) cannot reach ui.Checkbox's tooltip label */
|
||||
.cbi-checkbox > input[type="checkbox"] { border: 1px solid ButtonBorder; }
|
||||
.cbi-checkbox > input[type="checkbox"]:checked {
|
||||
background: Highlight;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]::after { background: ButtonBorder; }
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after { background: HighlightText; }
|
||||
/* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */
|
||||
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
|
||||
/* box-shadow is dropped entirely in forced-colors */
|
||||
:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@layer theme {
|
||||
/* without this a page prints the dark chrome and only ONE page (shell is 100svh,
|
||||
* overflow:hidden). No !important: a chrome rule is beaten on SPECIFICITY, (0,4,0) over (0,3,1). */
|
||||
@media print {
|
||||
/* theme layer outranks every tokens-layer palette block without naming data-darkmode */
|
||||
: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-pop: none;
|
||||
--fs-bar-bg: #fff;
|
||||
}
|
||||
/* nav/search/actions unusable on paper; Appearance is a PAGE now, stays and prints */
|
||||
.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; }
|
||||
/* undoes 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 */
|
||||
.fs-pattern.fs-pattern { display: none; }
|
||||
/* 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; }
|
||||
.cbi-section { break-inside: avoid; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{#
|
||||
Footer body. One layout template and one client menu renderer: sidebar and top bar are the same
|
||||
markup, morphed by :root[data-layout].
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
{% if (!blank_page): %}
|
||||
</div>{# /.fs-content #}
|
||||
|
||||
{#
|
||||
role="contentinfo" is explicit because a <footer> gets it implicitly only when
|
||||
its nearest ancestor is <body>, and this one must stay inside <main>: both
|
||||
layouts lay .fs-main out as a flex column and order the footer within it.
|
||||
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,212 @@
|
||||
{#
|
||||
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 and a lookup returns the first archive holding
|
||||
the hash — so readdir order decides who owns a string, and the Appearance layout toggle rendered
|
||||
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum" (issue #6).
|
||||
Anything left bare is a name anyone may take, and contexting cannot be selective.
|
||||
|
||||
So every Appearance label carries the `footstrap` msgctxt, while the chrome's own strings — Skip
|
||||
to content, Menu, Collapse menu, Search, Log out — are deliberately msgctxt-free, because a bare
|
||||
msgid is how a string inherits a translation in the ~40 languages this theme ships no catalogue
|
||||
for. Skip to content and Log out are luci-base msgids verbatim; the other three are ours alone and
|
||||
stay bare so a future luci-base string can adopt them. Same for the login/notice sentences and for
|
||||
System/Memory/Storage in fs-overview.js, which match the stock headings.
|
||||
-#}
|
||||
|
||||
{%
|
||||
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 (/etc/config/footstrap): head.ut stamps them before
|
||||
paint and the client's localStorage overrides every one. Absent = the built-in defaults.
|
||||
|
||||
`uci` and `config` come from the render scope dispatcher.uc builds per request: `uci` is
|
||||
already a cursor and `config.main` is already `uci.get_all('luci','main')`. Opening a second
|
||||
cursor here would allocate one per page render and re-read a package the dispatcher has
|
||||
already loaded. */
|
||||
const fsd = uci.get_all('footstrap', 'settings') ?? {};
|
||||
/* Every option saveAsDefault() writes, listed once and read back in a loop. A hand-written
|
||||
* literal with one line per axis drops an axis silently: Save to router reports success, the
|
||||
* file on disk is correct, and "Reset to router" lands on the BUILT-IN look because the value
|
||||
* the server hands the client was never there. tools/axes.mjs checks this list against
|
||||
* snapshotAxes().
|
||||
*
|
||||
* '' rather than a raw undefined when unset, so head.ut's `!= ''` guards fire: int(undefined)
|
||||
* is 0, a valid value, and an axis whose 0 is a real choice would default to it. */
|
||||
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', 'content_width',
|
||||
/* the two uploaded images' cache-bust tokens (head.ut whitelists both to hex); the files
|
||||
themselves are served. Not axes — they have no browser layer — but they reach the client
|
||||
the same way. */
|
||||
'login_bg', 'pattern',
|
||||
/* the fonts, same shape: no browser layer, no Appearance control, no entry in
|
||||
snapshotAxes(). font_sans/font_mono are font-family stacks (head.ut whitelists the
|
||||
charset); `fonts` is the cache-bust token of the @font-face sheet the admin installs
|
||||
beside the faces. All three are written 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 an admin may set to give the router a default top-nav
|
||||
theme (a shell script cannot write localStorage).
|
||||
|
||||
`config.main?.`, not `config.main.`: ucode raises a hard reference error on a property read
|
||||
through null, and this template renders every page, so a dispatcher that stopped defaulting
|
||||
that section would answer 500 everywhere. `ucode -T -c` compiles the template and never runs
|
||||
it, so the CI gate cannot see the difference. */
|
||||
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 a
|
||||
`[data-page='admin-status-overview']` rule would silently not apply. The SPA router stamps the
|
||||
resolved leaf path, so both ways of arriving agree. Stamped on <body> for the JS modules that
|
||||
read the route there, and again on .fs-content below for the page-scoped CSS that keys off it
|
||||
(styles/pages/): fs-router's seed() copies it there too, but only once the modules have loaded,
|
||||
and the Overview's stray <h2 name="content"> was painted for 65-138 ms before that.
|
||||
#}
|
||||
{% const fs_page = entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) %}
|
||||
<body class="lang_{{ dispatcher.lang }}" data-page="{{ fs_page }}">
|
||||
<script>
|
||||
/* Records every node a script parks directly on <body> (issue #56): luci-app-bandix's
|
||||
* render() appends 7 tooltip/modal nodes unconditionally on every visit, never
|
||||
* checking whether they already exist. fs-router.js's strayBodyNode()/bodyLittered()
|
||||
* read window.__fsBodyAdds to decide whether the document is still fit to navigate
|
||||
* away from — Rule 2 (docs/third-party-apps.md), extended from CSS to the DOM. First
|
||||
* byte of <body>, before any chrome element or the dispatched page's own <div id="view">.
|
||||
* Own property of this <body> instance, never Node.prototype, and its gap:
|
||||
* docs/spa-router.md, "Foreign view DOM". */
|
||||
(() => {
|
||||
const body = document.body;
|
||||
if (!body || body.__fsBodyHooked) return;
|
||||
body.__fsBodyHooked = true;
|
||||
const adds = window.__fsBodyAdds || (window.__fsBodyAdds = []);
|
||||
const names = [ 'appendChild', 'insertBefore', 'replaceChild', 'append', 'prepend',
|
||||
'insertAdjacentElement', 'insertAdjacentHTML' ];
|
||||
names.forEach((name) => {
|
||||
const orig = body[name];
|
||||
if (typeof orig !== 'function') return;
|
||||
try {
|
||||
Object.defineProperty(body, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value() {
|
||||
const before = Array.prototype.slice.call(this.children);
|
||||
const ret = orig.apply(this, arguments);
|
||||
const after = this.children;
|
||||
for (let i = 0; i < after.length; i++) {
|
||||
if (before.indexOf(after[i]) === -1) adds.push(after[i]);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* a host that refuses to redefine this one name leaves it unwrapped; the rest
|
||||
* still are */
|
||||
catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{#
|
||||
The wallpaper pattern's paint surface: 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 needs a layer of its own with nothing in it.
|
||||
|
||||
Outside the blank_page guard on purpose: the login page renders without the shell, and a
|
||||
router whose saved default is Pattern has to show it there too.
|
||||
|
||||
Inert unless the axis is on — .fs-pattern is display:none by default.
|
||||
|
||||
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: 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", leaving the shell with 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: the chrome is not one element — the skip link and the search overlay
|
||||
are outside this <nav> — so a fence naming `.fs-sidebar` protects the menu and
|
||||
leaves them exposed.
|
||||
|
||||
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 — nesting cost .fs-label its nowrap and forced
|
||||
text-align from start to left on 302 elements. `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. The
|
||||
Appearance controls are their own page, System -> Footstrap, registered by the
|
||||
theme's own menu.d entry (docs/architecture.md) — no tab, no fs-appearance.js
|
||||
mounting of any kind reaches this header. #}
|
||||
{% 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, which
|
||||
`hidden` cannot do — display:none strips the element from the accessibility tree,
|
||||
so the SPA router's .fs-title-main sync would update a node no assistive tech can
|
||||
read. .fs-sr clips instead, and since a foreign `*` outranks that class with one
|
||||
`!important` 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, which a screen reader cannot
|
||||
notice: no load event, focus dies with the clicked <a>, the new <title> is not
|
||||
announced. The router writes the incoming title here after every nav; empty on a
|
||||
full load, which announces itself. #}
|
||||
<div id="fs-nav-status" class="fs-sr" data-fs-chrome role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="fs-content" data-page="{{ fs_page }}">
|
||||
{% include('themes/footstrap/partials/notices', { boardinfo, empty_password }) %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{#
|
||||
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): the cyan arcs are baked, the ring
|
||||
flips with the theme mode through .fs-logo-ring in styles/theme/10-chrome.css. #}
|
||||
<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,464 @@
|
||||
{#
|
||||
<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 into the window.__fsSD object the pre-paint
|
||||
below reads. Enums are whitelisted and numbers clamped, so a hand-edited config can neither
|
||||
break the inline script nor inject markup (defence in depth — writing that file needs root).
|
||||
Absent or invalid becomes '' (or -1 where 0 is a real value), which the JS reads as "no router
|
||||
default"; the client's localStorage overrides every one before paint.
|
||||
|
||||
`fs_defaults` is a required include parameter: header.ut is the only caller and builds it
|
||||
before the include, so a `?? {}` here would only hide that contract from a second caller. */
|
||||
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', '2020', 'forum' ], 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 : '';
|
||||
/* Six numeric axes share this shape: '' (unset) must map to -1, never to 0, because 0 is a real
|
||||
value for every one of them (sharp corners, a transparent scrim, no tile). */
|
||||
function _sd_range(raw, min, max) {
|
||||
let n = int(raw);
|
||||
return (raw != '' && n >= min && n <= max) ? n : -1;
|
||||
}
|
||||
/* a cache-bust TOKEN (the cgi-upload md5 for an uploaded background/pattern/font sheet), not a
|
||||
path — the served path is fixed. Whitelisted to hex so a hand-edited config cannot inject
|
||||
markup into an inline script that interpolates it. */
|
||||
function _sd_token(v) {
|
||||
return (type(v) == 'string' && match(v, /^[a-f0-9]{6,64}$/)) ? v : '';
|
||||
}
|
||||
/* issue #44: how far the content column may grow past --fs-content-max's 1280px default.
|
||||
header.ut's FS_AXES carries 'content_width', so a saved router default reaches this. */
|
||||
let _sd_cwidth = _sd_range(fs_defaults.content_width, 1280, 3840);
|
||||
/* The five colour axes each hold one of three things: '' or '0' for off, '1'..'360' for a hue,
|
||||
or '#rrggbb'. Both live forms reach the client as a string, and the pre-paint re-reads which
|
||||
it is the way fs-prefs.js's normColor() does, so this passes through exactly those two shapes
|
||||
and turns everything else into ''. The hex charset is whitelisted because the value is
|
||||
interpolated into the inline data block below.
|
||||
(Do not write that block's tag name 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.) */
|
||||
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. */
|
||||
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 _sd_round = _sd_range(fs_defaults.rounding, 0, 20);
|
||||
let _sd_lbg = _sd_token(fs_defaults.login_bg);
|
||||
let _sd_tstr = _sd_range(fs_defaults.tint_strength, 0, 200);
|
||||
let _sd_pdim = _sd_range(fs_defaults.photo_dim, 0, 100);
|
||||
let _sd_pat = _sd_token(fs_defaults.pattern);
|
||||
/* the pattern's tile edge in px and the layer's opacity 0-100 */
|
||||
let _sd_psize = _sd_range(fs_defaults.pattern_size, 40, 1600);
|
||||
let _sd_pstr = _sd_range(fs_defaults.pattern_strength, 0, 100);
|
||||
/* ---- the fonts ----
|
||||
The theme carries no faces, so the two stacks in styles/02-tokens.css are what everyone gets
|
||||
unless the router names something else here. Two independent options, either usable alone:
|
||||
font_sans / font_mono a font-family stack, printed into a style element further down
|
||||
fonts the cache-bust md5 of an @font-face sheet at
|
||||
/etc/footstrap/fonts/fonts.css, for when the admin installs the
|
||||
files too
|
||||
The package is the serving half only: it reads these options, serves whatever is in
|
||||
/etc/footstrap/fonts and keeps that directory across a sysupgrade, installing no faces and
|
||||
fetching nothing at run time. `fonts/set-font.sh` is a repository tool and not part of this
|
||||
package: it names third-party font hosts, which a theme in this tree must not carry.
|
||||
Neither is an axis — no browser layer, no Appearance control, no entry in snapshotAxes().
|
||||
|
||||
The charset is most of the defence: a family stack is free text printed UNESCAPED, so the
|
||||
whitelist excludes everything that could end the declaration or the element (< > ( ) { } ;
|
||||
and the backslash), leaving no way to close a style element, start a second declaration or
|
||||
write a url(). A non-Latin family name is refused too; those faces carry a Latin name as well.
|
||||
|
||||
The two quotes are what the charset cannot refuse, and neither ends anything — they EXTEND.
|
||||
Both must be allowed (the shipped default is `"Manrope",system-ui,…`), and one left open
|
||||
starts a CSS string that runs to the end of the sheet, so a typo in the sans stack silently
|
||||
costs the mono one.
|
||||
|
||||
Counting quotes is not the test, tokenizing being stateful: inside '…' a " is literal and vice
|
||||
versa, so `"O'Reilly Sans", 'Fira Sans` holds two of each while the second `'` is still open.
|
||||
Scan in order instead: the quote that opens wins until its own kind closes it, and one still
|
||||
open at the end refuses the value. */
|
||||
function _sd_family(v) {
|
||||
if (!(type(v) == 'string' && length(v) >= 1 && length(v) <= 120 &&
|
||||
match(v, /^[A-Za-z0-9 ,._"'-]+$/)))
|
||||
return '';
|
||||
let q = '';
|
||||
for (let c in split(v, '')) {
|
||||
if (q == '')
|
||||
q = (c == '"' || c == "'") ? c : '';
|
||||
else if (c == q)
|
||||
q = '';
|
||||
}
|
||||
return (q == '') ? v : '';
|
||||
}
|
||||
let _sd_fsans = _sd_family(fs_defaults.font_sans);
|
||||
let _sd_fmono = _sd_family(fs_defaults.font_mono);
|
||||
let _sd_ftok = _sd_token(fs_defaults.fonts);
|
||||
/* ---- writing direction ----
|
||||
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops, so a router in Arabic,
|
||||
Persian or Hebrew renders right-to-left text inside a left-to-right document — paragraphs
|
||||
align to the wrong edge and every mirrored affordance points the wrong way. The theme owns
|
||||
<html>, so this is the one place that can say it.
|
||||
Matched on the base subtag, so a future `ar-EG` is covered, and the list is the standard RTL
|
||||
set rather than the three LuCI ships catalogues for. `ltr` is written explicitly for the same
|
||||
reason data-layout is: every rule then matches positively. */
|
||||
let _lang = dispatcher.lang;
|
||||
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), so every layout rule
|
||||
matches positively and a future third layout must opt in rather than inherit another's rules by
|
||||
not being it — never write a :not([data-layout=…]) guard. The chrome is right with JS off; the
|
||||
script below only overrides it.
|
||||
Absent = TOP: 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 `&` breaks entity parsing #}
|
||||
<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 controls show the effective default when this browser has no
|
||||
localStorage. See the sanitiser above for the unset encodings. #}
|
||||
<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 }}",content_width:{{ _sd_cwidth }}};</script>
|
||||
<script>
|
||||
/* Each IIFE 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, attributes, properties,
|
||||
ranges, default, order) from the JS and holds this template to it.
|
||||
|
||||
ONE element for all of them, and exactly two in this file: the data blob above carries
|
||||
interpolations and this one carries none. That is the split tools/lib/ut-scripts.mjs
|
||||
keys off — an interpolated body is not JS until the server renders it, so it is exempt
|
||||
from the lint and everything mergeable into it would go unchecked with it. Two is also
|
||||
what a Content-Security-Policy would need nonces for, should uhttpd ever send one.
|
||||
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
|
||||
Dark mode: a saved preference, else the router default, else the OS. */
|
||||
(() => {
|
||||
const root = document.documentElement,
|
||||
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 and data-bs-theme are
|
||||
outbound compat for apps that sniff them (justclash keys 21 rules off
|
||||
data-theme) and are 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' or 'auto' (explicitly follow the 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, and 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 */
|
||||
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);
|
||||
});
|
||||
})();
|
||||
|
||||
/* the remaining axes, in the precedence stated above. */
|
||||
(() => {
|
||||
/* Guarded per read, not once around the block: a browser that refuses storage throws
|
||||
on the FIRST getItem, and one try around everything aborts the whole pre-paint —
|
||||
every axis below skipped, so a router with saved defaults paints none of them.
|
||||
Storage failing must mean "this browser has no override", which is what the live
|
||||
applier's lsGet means. The outer try is the last resort for everything else. */
|
||||
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
||||
try {
|
||||
const root = document.documentElement,
|
||||
sd = window.__fsSD || {};
|
||||
let p = lsGet('fs-palette');
|
||||
/* A stray or retired palette name needs no migration here: it fails the list
|
||||
below and paints the bare :root, and listAxis() in fs-prefs.js ends the same
|
||||
way, so the live applier agrees. The three legacy names ('rvht', 'roman',
|
||||
'github') were rewritten here until this comment replaced them — the block
|
||||
was measured as changing nothing, and it ran on every page load. */
|
||||
/* the default palette is a bare :root; every other colourway is opt-in. 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' || p === '2020' || p === 'forum') 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, or the frame paints with the tile at the previous size.
|
||||
sd.pattern is whitelisted to hex above, so nothing user-controlled reaches
|
||||
the url(). */
|
||||
const pu = sd.pattern;
|
||||
/* /cgi-bin, not a /www symlink: a plain static SVG is a same-origin document
|
||||
that runs script opened directly (OpenWrt forum thread 251930); this path
|
||||
is the CGI handler that answers with CSP 'none' + sandbox instead — see
|
||||
fs-axes.js's PAT_SERVE, which this string must keep matching. */
|
||||
if (pu) root.style.setProperty('--fs-pattern-url', 'url("/cgi-bin/luci-theme-footstrap-pattern?v=' + pu + '")');
|
||||
const psRaw = lsGet('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 = lsGet('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 = lsGet('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 = lsGet('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: 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, or a browser switched back to Normal paints one frame compressed. */
|
||||
let dn = lsGet('fs-density');
|
||||
if (dn === null) dn = sd.density || null;
|
||||
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
|
||||
else root.removeAttribute('data-density');
|
||||
|
||||
/* Content width (issue #44): how far the column may grow past the 1280px
|
||||
default, as a px length rather than a step name — the slider is continuous.
|
||||
Written only when it differs from the default, so an untouched theme paints
|
||||
from the bare :root exactly as it did before this axis existed.
|
||||
sd.content_width is -1 when unset. */
|
||||
const cwRaw = lsGet('fs-content-width');
|
||||
let cw = parseInt(cwRaw, 10);
|
||||
if (cwRaw === null) cw = (sd.content_width >= 1280 ? sd.content_width : 1280);
|
||||
if (cw >= 1280 && cw <= 3840 && cw !== 1280)
|
||||
root.style.setProperty('--fs-content-max', cw + 'px');
|
||||
|
||||
/* Rounding 0-20px (02-tokens derives the control and chip radii from it). The
|
||||
default is a literal, this running before the stylesheet, so axes.mjs is the
|
||||
only thing checking the two agree. sd.rounding is -1 when unset. */
|
||||
const rRaw = lsGet('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), Accent (the UI colour) and the
|
||||
three status colours. Each is off, a hue 1-360 rotating the palette's own
|
||||
colour, or a '#rrggbb' replacing it — mirrors colorAxis()/normColor().
|
||||
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: nothing is stale here, but the live applier calls the same
|
||||
sequence 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 = lsGet(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 — cards, inset controls, the chrome bar, hairlines. 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 = lsGet(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 coming purely from
|
||||
window.__fsSD. Set the property whenever a valid token exists; whether it
|
||||
PAINTS is the wallpaper axis above, so a router defaulting to 'file' shows it
|
||||
here, pre-login included. The token is whitelisted above; the test is repeated
|
||||
because this one interpolates into a url() inside a style property, where the
|
||||
failure would be silent. */
|
||||
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 Tint's strength (default 100% = the designed chroma) */
|
||||
const tsRaw = lsGet('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, same shape as the tint strength above; the 74%
|
||||
default lives in the CSS token */
|
||||
const pdRaw = lsGet('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) {}
|
||||
})();
|
||||
|
||||
/* the icon-rail preference, so a reload does not flash the full sidebar first. Not a
|
||||
router-wide default: a transient chrome collapse, not an appearance choice. */
|
||||
(() => {
|
||||
try {
|
||||
if (localStorage.getItem('fs-rail') === 'true')
|
||||
document.documentElement.setAttribute('data-rail', 'true');
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
/* 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.documentElement.setAttribute('data-layout', saved);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="darkreader-lock">
|
||||
{# The colour a mobile browser paints its own chrome with — address bar, task switcher, the
|
||||
installed app's title bar. Without it a dark page keeps a white bar above it.
|
||||
|
||||
The literal is the default palette's page colour and the SAME value manifest.json carries,
|
||||
for the same reason: neither file can be rendered per request. It is a starting value
|
||||
only — fs-prefs.js repaints this tag from the live body background, so it follows palette,
|
||||
mode and every tint axis. tests/theme-color.test.mjs holds the two literals equal.
|
||||
|
||||
One tag, no `media` pair: a media-qualified tag would win over this one for a viewer whose
|
||||
OS is dark but who chose light HERE, and the choice is the theme's to honour. #}
|
||||
<meta name="theme-color" content="#f6f8fa">
|
||||
{# The sheet link below is emitted only when the TOKEN says the sheet exists — set the option
|
||||
after the file, never before. A preload is not in the stylesheet, so dropping the
|
||||
@font-face rules alone left the pair behind and every page went on asking the router for
|
||||
files that were gone: six 404s per page, measured, and nothing in CSS could say so. #}
|
||||
{# `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 come from the element
|
||||
below, so setting 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 declaring the
|
||||
defaults whatever order the two sheets arrive in. 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">
|
||||
{# The manifest lets an admin install this page as an app: an icon, its own window and no
|
||||
address bar, which is worth more than a tab on the phone LuCI is opened from when the
|
||||
network is broken.
|
||||
|
||||
Both files are static and cannot be otherwise, a theme having no dispatcher node to render
|
||||
them per request. That fixes two values:
|
||||
|
||||
start_url/scope — `/cgi-bin/luci/`, uhttpd's default and the path this template is
|
||||
served from on a stock install. An admin who moved LuCI gets an installed app that
|
||||
opens the standard path; the browser tab is unaffected.
|
||||
background/theme_color — the default palette's page colour. The manifest is read once
|
||||
at install time and cannot follow a live Appearance change, and it only paints the
|
||||
splash and the window chrome.
|
||||
|
||||
Chrome offers its install prompt on a secure context only, so over plain HTTP this is
|
||||
iOS's Add to Home Screen plus the icons. Nothing here is on the critical path.
|
||||
|
||||
`.json`, not the conventional `.webmanifest`: uhttpd's built-in MIME map has no entry for
|
||||
that extension, so the same bytes come back `application/octet-stream` there and with a
|
||||
JSON type only as `.json`. The map is compiled into uhttpd. #}
|
||||
<link rel="manifest" href="{{ media }}/manifest.json?v={{ pkgs_update_time }}">
|
||||
{# iOS reads this link and never the manifest's icons array, and it scales the 192 square the
|
||||
manifest also uses down to its own 180. One raster serves both — tools/build-icons.mjs. #}
|
||||
<link rel="apple-touch-icon" href="{{ media }}/app-icon-192.png">
|
||||
{% if (dispatched?.css): %}
|
||||
{# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server
|
||||
should link for its page, instead of the view injecting one at module eval.
|
||||
|
||||
`dispatched`, not `node`: the dispatcher exports the resolved node as `dispatched` and
|
||||
never binds a `node` — every ucode theme carried that typo until openwrt/luci#8920, so the
|
||||
link had never once been emitted. It also requires `css: 'string'` in the dispatcher's node
|
||||
schema, added by the same commit; without it the property is filtered out of the tree and
|
||||
this stays false, as on 24.10 and 25.12.
|
||||
|
||||
uhttpd sends no Cache-Control, so without a version key a stale app stylesheet can be
|
||||
served for days after an upgrade.
|
||||
|
||||
Deliberately not `data-fs-shell`: this sheet belongs to one page, so the SPA router must
|
||||
judge and contain it like any other app sheet. #}
|
||||
<link rel="stylesheet" href="{{ resource }}/{{ dispatched.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. #}
|
||||
<style data-fs-shell>{{ css }}</style>
|
||||
{% endif %}
|
||||
{# The catalogue is a CGI spawn on the critical path — 30 ms on the router — and at lang=en it
|
||||
spends them to deliver 13 bytes, there being no en catalogue because the msgids already
|
||||
are English. The process is the cost, not the data, so emit those bytes inline when this
|
||||
language has no catalogue and keep the tag when it has.
|
||||
The probe mirrors the server: determine_request_language() loads the catalogue with
|
||||
load_catalog(lang, '/usr/lib/lua/luci/i18n'), i.e. every `*.<lang>.lmo` in that directory,
|
||||
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 — because a missing catalogue makes every
|
||||
`_()` render English and report nothing. #}
|
||||
{%- 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, and `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. #}
|
||||
<script src="{{ resource }}/cbi.js?v={{ pkgs_update_time }}"></script>
|
||||
</head>
|
||||
@@ -0,0 +1,14 @@
|
||||
{#
|
||||
The chrome's one inline-SVG wrapper, server side: every icon the templates draw is the same 24x24
|
||||
stroked outline differing only in path data, so stroke width and linecaps are stated once and
|
||||
cannot drift between call sites. fs-widgets.js's svgIcon() is the client half of the same look.
|
||||
|
||||
`body` is the shape and is emitted raw on purpose: it is author-written markup from the caller,
|
||||
never anything a request can influence, and escaping it would print the path data as text. `cls`
|
||||
defaults to the icon class every chrome control uses.
|
||||
|
||||
aria-hidden, because each icon sits beside its own label; an unlabelled <svg> would 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,17 @@
|
||||
{#
|
||||
The logout control, guarded on the node being dispatchable. One markup for every layout, since a
|
||||
layout is a presentation choice and must not fork the markup. It is also the only logout the theme
|
||||
renders: menu-footstrap.js drops the menu tree's own admin/logout node.
|
||||
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="{{ _('Log out') }}" aria-label="{{ _('Log out') }}">
|
||||
{#
|
||||
Mirrored about x=12, so the glyph reads LEAVING rather than entering: each x becomes 24-x and
|
||||
each arc's sweep flag flips with it, leaving stroke widths, corner radii and the 24x24 box
|
||||
untouched.
|
||||
-#}
|
||||
{% include('themes/footstrap/partials/icon', { body: '<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4"/><path d="M14 12h7m0 0-3-3m3 3-3 3"/>' }) %}
|
||||
<span class="fs-label">{{ _('Log out') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{#
|
||||
One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The
|
||||
`dispatcher.lookup` guard is the part that must not drift — it 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, looked up before it is linked, so a build without that page — or a user without
|
||||
rights to it — gets the warning and no dead link.
|
||||
|
||||
title/text are emitted unescaped: 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,35 @@
|
||||
{#
|
||||
Top-of-content notices: no-root-password, initramfs recovery, the <noscript> fallback and the
|
||||
#tabmenu container. include(…, { boardinfo, empty_password }); empty_password is computed in the
|
||||
header, which already imports luci.core. The markup lives in partials/notice.ut — this file
|
||||
decides only which notices there are.
|
||||
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,15 @@
|
||||
{#
|
||||
The page-search trigger (#fs-search-btn), wired by fs-search.js. A `.fs-themerow`, the chrome's
|
||||
shared row/button class, so it renders as a square icon button in the bar and the rail and as a
|
||||
full-width labelled row in the vertical sidebar with no CSS of its own. It shares `order: 2` with
|
||||
the poll pill and Log out, 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 do
|
||||
carry the `footstrap` context, a msgid being a global name shared with every luci-app (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,225 @@
|
||||
{#
|
||||
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 because the generic template includes the header WITHOUT `blank_page: true`: with no
|
||||
theme-local sysauth the dispatcher falls back to it and the whole chrome renders around the login
|
||||
form with every control on it dead. Everything else here is a departure the notes below justify,
|
||||
and none of them may drop a field the backend posts.
|
||||
|
||||
Do not re-introduce the `<section hidden>` shape that needs a view module to reveal the form: the
|
||||
view bootstraps before a session exists, its RPCs answer "Access denied", the promise rejects and
|
||||
render() never runs — a blank page with no way to log in. The server renders the form, so it works
|
||||
with JS off and cannot be broken by a rejected promise.
|
||||
|
||||
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.
|
||||
-#}
|
||||
|
||||
{%
|
||||
/* Which router this is (openwrt/luci#8961): the login page has no chrome, so the only place the
|
||||
name appeared was the browser tab.
|
||||
|
||||
A second `ubus.call('system', 'board')`, because header.ut makes its own inside its own scope
|
||||
and an include cannot hand a local back — one extra round trip on a page that renders once
|
||||
per session, against threading `boardinfo` through a global.
|
||||
|
||||
It discloses nothing new: the same hostname already reaches an unauthenticated browser
|
||||
through <title>, as in every stock theme. */
|
||||
const boardinfo = ubus.call('system', 'board') ?? {};
|
||||
-%}
|
||||
|
||||
{% include('header', { blank_page: true }) %}
|
||||
|
||||
{# the class is what 10-login.css hooks: the markup is ours, so the form is named rather than
|
||||
found by a 49-character `:has()` selector repeated 17 times, ~0.8 KB #}
|
||||
<form method="post" class="fs-login">
|
||||
{#
|
||||
The router's name, above everything else in the card: an admin with three of these open has no
|
||||
other way to tell the tabs apart once the form is focused. `?? 'OpenWrt'` is the fallback
|
||||
head.ut and brand.ut use, and the striptags/entityencode pair is theirs too and load-bearing —
|
||||
the hostname is admin-controlled text printed to an unauthenticated page.
|
||||
|
||||
A <p> and not a heading: the card's h1 is `Authorization Required` and stays the page's one
|
||||
heading, or a screen reader walking the headings hears the router's name as a section title
|
||||
with the form nested under it.
|
||||
-#}
|
||||
<p class="fs-login-host">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</p>
|
||||
{#
|
||||
Two independent alerts, because neither variable alone says which dispatcher branch rendered
|
||||
this page.
|
||||
|
||||
`fuser` is the USERNAME the form posted, set in both branches — on the second step of a
|
||||
pluggable login (the OTP prompt, dispatcher.uc:1018) the credentials were correct and it is
|
||||
still set, so `auth_message && !fuser` reports "Invalid username and/or password!" about a
|
||||
password that was right and swallows the backend's own messages.
|
||||
|
||||
`auth_message` does not mean the password was right either: the first branch copies it out of
|
||||
get_challenges() BEFORE the password is checked (dispatcher.uc:956), and that call answers with
|
||||
a message on every pending result. So with a 2FA plugin installed both are set whenever a
|
||||
password is rejected, and gating on `fuser` alone never tells that user their password was
|
||||
wrong.
|
||||
|
||||
`auth_plugin` separates the branches: set only in the second one, i.e. exactly when
|
||||
session_setup() accepted the credentials. Gating the credentials alert on `!auth_plugin` and
|
||||
rendering the message beside it covers all four states — wrong password, wrong password with a
|
||||
challenge pending, the OTP prompt, a failed OTP.
|
||||
|
||||
entityencode where upstream prints raw: without the `!fuser` gate the message renders on the
|
||||
request that posted the form too, and this page is unauthenticated. A plugin's markup travels
|
||||
in `auth_html` (dispatcher.uc:952), so this variable is text.
|
||||
-#}
|
||||
{% if (fuser && !auth_plugin): %}
|
||||
<div class="alert-message error">
|
||||
<p>{{ _('Invalid username and/or password! Please try again.') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if (auth_message): %}
|
||||
<div class="alert-message{% if (auth_plugin): %} warning{% endif %}">
|
||||
<p>{{ entityencode(auth_message, true) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="cbi-map">
|
||||
{#
|
||||
An h1, where every other LuCI theme leaves this an h2: the login page has no chrome, so
|
||||
header.ut's `.fs-title-main` h1 never renders here and the document would go out with no
|
||||
top-level heading for a screen reader's `1` shortcut to reach.
|
||||
|
||||
`name="content"` is upstream's legacy anchor and travels with the element, not its level.
|
||||
10-login.css sets this h1 back to the h2's 20px: the level was wrong, not the size.
|
||||
-#}
|
||||
<h1 name="content">{{ _('Authorization Required') }}</h1>
|
||||
<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" autofocus />
|
||||
</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 puts its OTP input there. A theme-local sysauth that drops them never posts the
|
||||
field, so on a router with 2FA enabled nobody can log in while this theme is active.
|
||||
This template must stay a SUPERSET of the generic one's fields.
|
||||
|
||||
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 `%}`, so the indentation never reaches the output and the attributes would run
|
||||
together into one token — measured, the generic template's spelling renders
|
||||
`inputmode="numeric"pattern="[0-9]*"maxlength="6"required`.
|
||||
-#}
|
||||
{% 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: a `{{ … }}` block is not JS
|
||||
until the server renders it, so this line carries no logic at all and eslint's .ut processor
|
||||
enforces that (tools/lib/ut-scripts.mjs). The script itself is plain JS and is linted. #}
|
||||
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
|
||||
<script>
|
||||
(() => {
|
||||
/* if the router also serves HTTPS, hop over to it before the password goes over this
|
||||
* connection; the <img> probe is upstream's trick for proving the TLS port answers first
|
||||
*
|
||||
* THE HOP CAN ARRIVE WITHOUT THE SESSION. On Chromium the jump from http to https is
|
||||
* cross-site — schemeful same-site puts the two schemes in different sites — so the
|
||||
* browser withholds LuCI's `SameSite=strict` cookie (dispatcher.uc) on exactly that
|
||||
* navigation and a reader already logged in over https lands back on this form: 403 to
|
||||
* the hop against 200 to curl carrying the cookie, measured on a stand serving both
|
||||
* schemes. Firefox sends it and answers 200 straight away.
|
||||
*
|
||||
* So where this form is what the hop landed on, it is asked again once, over the same
|
||||
* scheme: that navigation is same-site everywhere and carries the cookie. The retry is
|
||||
* bounded by the REFERRER rather than by a marker in the URL — after it, the referrer is
|
||||
* this https page instead of the http one, so a reader who genuinely has no session sees
|
||||
* this form once and stays on it. A `#…` marker was tried and is not what ships: on
|
||||
* Firefox the retry never runs, so nothing takes the fragment back off the address. */
|
||||
if (document.location.protocol === 'https:') {
|
||||
/* the host is compared as a PARSED origin, not as a prefix of the string:
|
||||
* `http://192.168.1.1.example.com` starts with `http://192.168.1.1` */
|
||||
const from = document.referrer ? new URL(document.referrer) : null;
|
||||
|
||||
if (from && from.protocol === 'http:' && from.hostname === window.location.hostname)
|
||||
window.location.replace(window.location.href);
|
||||
|
||||
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
+116
@@ -0,0 +1,116 @@
|
||||
#!/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 — so --check is a gate rather than a suggestion.
|
||||
#
|
||||
# The directory is `po/`, which is what LUCI_LANGUAGES globs and what Weblate translates
|
||||
# (docs/package.md, "The catalogue lives in po/" — do not rename it).
|
||||
#
|
||||
# Nothing here runs on the buildbot — luci.mk calls po2lmo itself. This needs perl and gettext,
|
||||
# which the OpenWrt build does not.
|
||||
#
|
||||
# The scanner is LuCI's OWN build/i18n-scan.pl rather than a grep: it lexes a .ut, rewriting the
|
||||
# template into JS before xgettext, and covers the rpcd acl.d/*.json title. A grep would miss the
|
||||
# ACL string and choke on any apostrophe.
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Pinned to a commit and checksummed: a perl script fetched over the network and then EXECUTED, 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.
|
||||
. ./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 path, including LUCI_SRC's (docs/package.md,
|
||||
# "The catalogue lives in po/"). `fetched` is what it 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
|
||||
n=0
|
||||
for po in po/*/*.po; do
|
||||
[ -e "$po" ] || continue
|
||||
n=$((n + 1))
|
||||
# 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
|
||||
# A tree with no po/*/*.po at all walks zero catalogues, sets rc=0 (nothing in the loop ever ran
|
||||
# to set it otherwise) and printed "every string translated" — true of the empty set the same way
|
||||
# it is true of vacuously anything, and indistinguishable in the output from a real, fully
|
||||
# translated build. n=0 is the one case the loop cannot tell apart from success on its own.
|
||||
[ "$n" -gt 0 ] || {
|
||||
echo "update-po: no po/*/*.po found — nothing was checked, not everything is translated" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$rc" = 0 ] && echo "i18n: .pot current, every string translated ($n catalogue(s))"
|
||||
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
|
||||
Reference in new issue
Block a user