mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-02 05:33:56 +08:00
update 2026-09-24 06:37:55
This commit is contained in:
1 parent
3d5ff73d1e
commit
907a158127
144 files changed
+43946
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,283 @@
|
||||
#
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This is free software, licensed under the Apache License, Version 2.0 .
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-theme-footstrap
|
||||
# luci.mk keys the Build/Prepare hook name on LUCI_NAME, which defaults to the checkout's
|
||||
# directory name — a differently-named checkout symlinked into the feed would silently skip
|
||||
# the CSS build. Pin it.
|
||||
LUCI_NAME:=luci-theme-footstrap
|
||||
|
||||
# Version is git-derived; never hardcode PKG_VERSION. CI injects FOOTSTRAP_VERSION from the
|
||||
# tag (an SDK build has no .git, so it cannot derive one).
|
||||
FOOTSTRAP_VERSION?=
|
||||
ifneq ($(FOOTSTRAP_VERSION),)
|
||||
PKG_VERSION:=$(FOOTSTRAP_VERSION)
|
||||
PKG_RELEASE:=1
|
||||
endif
|
||||
|
||||
LUCI_TITLE:=Footstrap Theme
|
||||
# +luci-base is the WHOLE list, 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: two minify paths, chosen by who is building.
|
||||
#
|
||||
# CI (the released packages) pre-minifies with terser BEFORE the SDK build — tools/minify-js.mjs
|
||||
# mangles top-level identifiers too, which jsmin cannot: ~41 KB against jsmin's ~57 KB, −27% — and
|
||||
# signals it with FOOTSTRAP_PREMIN=1.
|
||||
# jsmin must then stay away from the already-minified output: terser legitimately emits
|
||||
# `return/^v/.test(s)`, the exact one-character-lookback trap below, so LUCI_MINIFY_JS goes to 0.
|
||||
#
|
||||
# Everyone else (an SDK user, the buildbot — no node) builds the untouched source and keeps
|
||||
# luci.mk's default jsmin: ~105 KB of this tree's JS is comments, and uhttpd serves /www with NO
|
||||
# compression, so those are wire and flash bytes either way.
|
||||
#
|
||||
# The catch on the jsmin path, and why the gates exist: jsmin decides regex-or-division from a
|
||||
# one-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its allow-list, so
|
||||
# `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299). eslint's
|
||||
# `wrap-regex` bans the shape in the source; tools/jsmin-verify.mjs proves jsmin's output is
|
||||
# token-identical to it.
|
||||
FOOTSTRAP_PREMIN?=
|
||||
ifneq ($(FOOTSTRAP_PREMIN),)
|
||||
LUCI_MINIFY_JS:=0
|
||||
endif
|
||||
|
||||
# Apache-2.0, and not a free choice: styles/base began as a fork of luci-theme-bootstrap's
|
||||
# cascade.css, the ucode templates derive from LuCI's own, and a few JS helpers are verbatim copies.
|
||||
# The notices travel with it.
|
||||
#
|
||||
# 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.
|
||||
# Calling it ourselves is belt-and-braces: OpenWrt's default_postinst also runs (then
|
||||
# deletes) every /etc/uci-defaults/* we ship, so it executes twice per install. It is
|
||||
# idempotent: the second pass finds the theme already registered and changes nothing.
|
||||
[ -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, the
|
||||
# way it does for every luci-app — which is what a package in the luci tree has to look like, and
|
||||
# what Weblate can see. It was `i18n/` while a fielded self-update script mis-picked a multi-asset
|
||||
# release with `head -1` (issue #6); that script is retired and owfeed builds one theme artifact
|
||||
# per format regardless.
|
||||
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 four containers driven by `owlab` from `../owlab.yaml` — see
|
||||
[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over
|
||||
ssh:
|
||||
|
||||
```sh
|
||||
./dev-sync.sh # push to `ssh router` (registers the theme but does NOT activate it)
|
||||
ssh router 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit luci; rm -f /tmp/luci-indexcache*'
|
||||
ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back
|
||||
```
|
||||
|
||||
Before pushing, run `npm run check` from the repository root. The rules and the traps are in
|
||||
[`../docs/conventions.md`](../docs/conventions.md); packaging is in
|
||||
[`../docs/package.md`](../docs/package.md).
|
||||
Executable
+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
+297
@@ -0,0 +1,297 @@
|
||||
#!/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 38 copies of the same six-to-eleven bytes, 554 of
|
||||
# them, plus two files that are all comment and emit nothing but `@layer page{}`.
|
||||
#
|
||||
# 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
|
||||
rm -f "$body"; 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
|
||||
rm -f "$body"; exit 1
|
||||
else
|
||||
cat "$f"
|
||||
fi
|
||||
done
|
||||
if [ -s "$body" ]; then
|
||||
printf '@layer %s {\n' "$layer"
|
||||
cat "$body"
|
||||
printf '}\n'
|
||||
fi
|
||||
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"
|
||||
|
||||
# Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines.
|
||||
#
|
||||
# String-aware: a scanner that just hunts for the next "/*" lets `content: "/*"` open a comment and
|
||||
# eat every rule up to the next "*/", with only the brace count below as a guard — and two such
|
||||
# literals balance each other, so rules can vanish in silence. Quoted data-URIs run through here on
|
||||
# every build.
|
||||
strip_comments() {
|
||||
awk '
|
||||
BEGIN { inc = 0; q = "" }
|
||||
{
|
||||
line = $0; out = ""; i = 1; n = length(line)
|
||||
while (i <= n) {
|
||||
c = substr(line, i, 1)
|
||||
if (inc) { # inside /* ... */
|
||||
if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue }
|
||||
i++; continue
|
||||
}
|
||||
if (q != "") { # inside a "..." or '"'"'...'"'"' string
|
||||
out = out c
|
||||
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
|
||||
if (c == q) q = ""
|
||||
i++; continue
|
||||
}
|
||||
if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue }
|
||||
if (c == "/" && substr(line, i + 1, 1) == "*") {
|
||||
# the banner: keep it, and everything after it on this line
|
||||
if (substr(line, i + 2, 1) == "!") { out = out substr(line, i); break }
|
||||
inc = 1; i += 2; continue
|
||||
}
|
||||
out = out c; i++
|
||||
}
|
||||
sub(/^[ \t]+/, "", out)
|
||||
sub(/[ \t]+$/, "", out)
|
||||
if (length(out)) print out
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Squeeze the whitespace CSS ignores — wire AND flash bytes, since uhttpd does not compress.
|
||||
#
|
||||
# Removed (~9.5 KB): the space after `:`, the spaces around `{ } ; ,`, the last `;` of a block, the
|
||||
# newline after every declaration. lightningcss would save ~13 KB, but its extra 3.5 KB comes from
|
||||
# rewriting colours and merging rules — transforms that can change behaviour. These cannot.
|
||||
#
|
||||
# Left alone, each for a reason:
|
||||
# - the single space between selectors: `.a .b` is a DESCENDANT combinator, `.a.b` is not;
|
||||
# - spaces inside calc(): required around `*`, `/` and the `-` of `calc(100% - 8px)`;
|
||||
# - the LINE BREAK inside a declaration, which is whitespace too — joining lines with nothing
|
||||
# between them turns a wrapped calc() into `…))- .004 …`, and a `-` with no space before it is
|
||||
# a parse error, so the declaration drops and the token it defined goes undefined, silently —
|
||||
# --fs-bg then became invalid at computed-value time and the canvas fell back to white
|
||||
# (export-tier.mjs caught it: contrast 1.5:1);
|
||||
# - `>` `+` `~` spaces: stripping them is safe but buys only ~200 bytes;
|
||||
# - anything inside a string: every data-URI here is quoted and full of `:`, `;` and spaces;
|
||||
# - one newline after `}`, so the shipped file stays greppable.
|
||||
squeeze() {
|
||||
awk '
|
||||
BEGIN { q = ""; ban = 0; lastc = ""; buf = ""; lastreal = "" }
|
||||
{
|
||||
line = $0
|
||||
# The /*! banner is an Apache-2.0 attribution, not formatting: it must survive
|
||||
# BYTE FOR BYTE. Squeezing it made "Twitter, Inc" into "Twitter,Inc" and glued its
|
||||
# lines together. Copy it out untouched.
|
||||
if (ban) { print line; lastc = ""; lastreal = ""; if (index(line, "*/")) ban = 0; next }
|
||||
if (substr(line, 1, 3) == "/*!") {
|
||||
print line; lastc = ""; lastreal = ""
|
||||
if (!index(substr(line, 4), "*/")) ban = 1
|
||||
next
|
||||
}
|
||||
# The line BREAK we are about to swallow is whitespace, and a declaration may be
|
||||
# wrapped across it. Feed it to the whitespace-run logic below as a leading space:
|
||||
# it survives only where a space would (between two tokens) and is dropped next to
|
||||
# { } ; , : — lastc == "" means output is already at the start of a line, with
|
||||
# nothing to glue to.
|
||||
if (lastc != "" && q == "") line = " " line
|
||||
out = ""; i = 1; n = length(line)
|
||||
while (i <= n) {
|
||||
c = substr(line, i, 1)
|
||||
if (q != "") { # inside a string: copy verbatim
|
||||
out = out c
|
||||
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
|
||||
if (c == q) q = ""
|
||||
lastreal = "" # a char inside a string is not structure
|
||||
i++; continue
|
||||
}
|
||||
if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue }
|
||||
if (c == " " || c == "\t") { # collapse a run of whitespace to one space
|
||||
while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++
|
||||
# the last char EMITTED, which on a continuation line lives on the
|
||||
# previous output line — hence lastc, not just `out`.
|
||||
prev = (length(out) ? substr(out, length(out), 1) : lastc)
|
||||
nxt = (i <= n ? substr(line, i, 1) : "")
|
||||
# drop it entirely next to a delimiter; otherwise it may be a combinator
|
||||
#
|
||||
# `>` is a delimiter too, and the only one that is itself a combinator: a
|
||||
# space either side of it is decoration. 516 of them in the sheet, so it is
|
||||
# 1,032 B — the file header used to guess "~200 bytes" for this whole pass.
|
||||
# Safe because a `>` outside a string can only be the child combinator: the
|
||||
# sheet has no media range syntax (`@media (width > 600px)`), and the 107
|
||||
# `>` inside string literals never reach here, the scanner having copied them
|
||||
# verbatim above. `~` and `+` are deliberately NOT joined: `[attr~=v]` and
|
||||
# `calc(100% - 10px)` make them ambiguous without tracking bracket depth,
|
||||
# and they are worth 14 B and 34 B.
|
||||
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++
|
||||
}
|
||||
buf = buf out
|
||||
if (length(out)) lastc = substr(out, length(out), 1)
|
||||
# newline only after a closing brace — one rule per line. lastreal, not lastc: a
|
||||
# line ending in a QUOTED `}` (content: "}") is not the end of a rule, and flushing
|
||||
# there would split the rule and lose the space before its next token.
|
||||
if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" }
|
||||
}
|
||||
END { if (length(buf)) print buf; else printf "\n" }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Fail loudly rather than let an unbalanced block ship.
|
||||
#
|
||||
# String-aware, for the same reason the comment stripper is: a brace inside a CSS STRING is not a
|
||||
# block, 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 copy, --dev included: counting braces in the raw
|
||||
# file made a stray "{" in prose fail the build on perfectly valid CSS.
|
||||
strip_comments "$TMP" > "$TMP.min"
|
||||
RULES_BEFORE=$(brace_count "$TMP.min") || exit 1
|
||||
|
||||
if [ "$DEV" -eq 0 ]; then
|
||||
# comments gone; now squeeze the whitespace CSS ignores
|
||||
squeeze "$TMP.min" > "$TMP"
|
||||
rm -f "$TMP.min"
|
||||
|
||||
# AND AGAIN, on what actually ships: the check above only saw the squeeze's INPUT, yet
|
||||
# the squeeze is the pass most able to corrupt the sheet — it tracks strings, joins lines
|
||||
# and deletes the `;` before a `}`. An unchanged rule count is what says it did not.
|
||||
RULES_AFTER=$(brace_count "$TMP") || exit 1
|
||||
if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then
|
||||
echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# --dev keeps comments AND formatting: this output is for reading, not shipping
|
||||
rm -f "$TMP.min"
|
||||
fi
|
||||
|
||||
# BROKEN-BUILD FLOOR (no upper size budget — removed). With only an upper bound, every way of
|
||||
# producing a SHORT file — a truncated write, a full disk, a squeeze that ate the tail — passed
|
||||
# and shipped a stylesheet with its second half missing. The floor catches that; it is a
|
||||
# correctness guard, not a size limit.
|
||||
#
|
||||
# Measured on $TMP and BEFORE the mv, with the EXIT trap still armed — like the brace checks above,
|
||||
# which had this order right. It used to run after, so the guard's own failure path LEFT the mangled
|
||||
# sheet at $OUT with nothing to clean it up: make aborts in Build/Prepare, fine, but dev-sync.sh
|
||||
# writes straight into htdocs/luci-static/footstrap/cascade.css, so a truncated sheet stayed in the
|
||||
# working tree and the CSS-only iterate loop (scp that same file) would ship it.
|
||||
SIZE=$(wc -c < "$TMP" | tr -d ' ')
|
||||
FLOOR=${FS_CSS_FLOOR:-81920} # 80 KB — well under the real sheet; only a mangled build lands here
|
||||
if [ "$DEV" -eq 0 ] && [ "$SIZE" -lt "$FLOOR" ]; then
|
||||
echo "build-css: cascade.css would be only $SIZE bytes, under the $FLOOR-byte floor." >&2
|
||||
echo "build-css: that is not a smaller stylesheet, that is a broken one. $OUT left untouched." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "$TMP" "$OUT"
|
||||
trap - EXIT
|
||||
|
||||
echo "build-css: $SIZE bytes -> $OUT"
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/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
|
||||
# There is no self-update backend to deploy any more: the theme upgrades through the package feed
|
||||
# the installer adds, which is what a package manager is for.
|
||||
#
|
||||
# No rpcd reload here either — 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,851 @@
|
||||
'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. */
|
||||
_probe = E('span', { '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). The string parse remains only as
|
||||
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
|
||||
* forms can appear. */
|
||||
let _cx = null;
|
||||
function rasterCtx() {
|
||||
if (_cx !== null) return _cx;
|
||||
try {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = 1;
|
||||
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
|
||||
} catch (e) { _cx = false; }
|
||||
return _cx;
|
||||
}
|
||||
function parseColor(s) {
|
||||
const str = String(s || '');
|
||||
const cx = rasterCtx();
|
||||
if (cx) {
|
||||
/* 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 = str;
|
||||
cx.clearRect(0, 0, 1, 1);
|
||||
cx.fillRect(0, 0, 1, 1);
|
||||
const d = cx.getImageData(0, 0, 1, 1).data;
|
||||
if (d[3] === 255) return [ d[0], d[1], d[2] ];
|
||||
/* translucent: composite over nothing is meaningless for a readout, so fall through */
|
||||
}
|
||||
const nums = str.match(/[\d.]+/g);
|
||||
if (!nums || nums.length < 3) return null;
|
||||
const unit = (/^color\(/i).test(str) ? 255 : 1;
|
||||
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
|
||||
}
|
||||
|
||||
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme 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(current, 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;
|
||||
|
||||
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
|
||||
* to default), so a private copy would go stale. `current` is only the build-time value. */
|
||||
const currentOf = o.read || (() => current);
|
||||
|
||||
/* 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);
|
||||
else reflect(currentOf());
|
||||
};
|
||||
field.addEventListener('blur', commit);
|
||||
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
|
||||
clear.addEventListener('click', () => pick(0));
|
||||
|
||||
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
|
||||
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
|
||||
].concat(ratio ? [ ratio ] : []));
|
||||
/* the caller decides when this runs: probeColor() needs the document, and this control is not
|
||||
* in it yet */
|
||||
wrap.fsRefresh = () => reflect(currentOf());
|
||||
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: `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(axis.current(), bump(axis.apply), lbl, {
|
||||
probe: probe,
|
||||
read: axis.current,
|
||||
contrast: contrast,
|
||||
cls: (opts && opts.cls) || ''
|
||||
});
|
||||
colourCtls.push(ctl);
|
||||
return ctl;
|
||||
}, opts);
|
||||
|
||||
/* Every label 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.currentPalette(), {
|
||||
footstrap: 'Footstrap',
|
||||
hicontrast: 'Hi-Contrast',
|
||||
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
|
||||
* so it is a proper noun and stays untranslated, like the two above it */
|
||||
bootstrap: 'Bootstrap',
|
||||
/* 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.applyPalette)), 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.currentContentWidth(), 1280, 3840,
|
||||
bump(axes.applyContentWidth), label, { step: 40, live: false })),
|
||||
|
||||
group(_('Rounding', 'footstrap'),
|
||||
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), 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'), {
|
||||
current: axes.currentTint, apply: axes.applyTint
|
||||
}, 'var(--fs-bg)', {
|
||||
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette
|
||||
* token this axis must not move, so the ratio is reported instead of corrected */
|
||||
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
|
||||
}, { cls: 'fs-ap-tint' }),
|
||||
|
||||
/* The strength half of the Tint, 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.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), 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.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
|
||||
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
|
||||
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
|
||||
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
|
||||
].map(([ label, current, apply, ink ]) =>
|
||||
colourGroup(label, { current, apply }, 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.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
|
||||
[ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
|
||||
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
|
||||
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
|
||||
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
|
||||
colourGroup(label, { current, apply }, 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.currentPatternSize(), 40, 1600,
|
||||
bump(axes.applyPatternSize), lbl, { step: 20 })),
|
||||
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
|
||||
bump(axes.applyPatternStrength), lbl, { step: 5 })),
|
||||
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
|
||||
theme: _('Theme', 'footstrap'),
|
||||
original: _('As in file', 'footstrap')
|
||||
}, bump(axes.applyPatternInk), lbl))
|
||||
];
|
||||
/* …and the rows the FILE photo brings. */
|
||||
const fileRows = [
|
||||
group(_('File', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
|
||||
{ extra: [ fileInput, preview, err ] }),
|
||||
group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
|
||||
bump(axes.applyPhotoDim), lbl, { step: 5 }))
|
||||
];
|
||||
|
||||
function reflect(tok) {
|
||||
if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
|
||||
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
|
||||
}
|
||||
function reflectPattern(tok) {
|
||||
if (tok) { patPreview.src = axes.patternUrl(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.currentLoginBg());
|
||||
reflectPattern(axes.currentPattern());
|
||||
togglePanel(axes.currentWallpaper());
|
||||
|
||||
const setWallpaper = (v) => { axes.applyWallpaper(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.currentWallpaper(), {
|
||||
off: _('Off', 'footstrap'),
|
||||
pattern: _('Pattern', 'footstrap'),
|
||||
file: _('File', 'footstrap')
|
||||
}, setWallpaper, label);
|
||||
return seg;
|
||||
});
|
||||
|
||||
return [ wallRow ].concat(patRows, fileRows);
|
||||
})();
|
||||
|
||||
/* ---- section 4: the router default and the version ----
|
||||
*
|
||||
* 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,555 @@
|
||||
'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. */
|
||||
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): a prologue of
|
||||
* `<!DOCTYPE svg SYSTEM "http://evil.example/x.dtd">` is a document-level construct this walk
|
||||
* never visits — `doc.doctype` is left as parsed, the same as everything else here that is not an
|
||||
* element or an attribute — and it survives `XMLSerializer` byte for byte on the same round trip
|
||||
* that runs below when anything else on the document needs cleaning. It costs nothing today
|
||||
* because no shipping engine resolves an external DTD for any document at all, `image/svg+xml`
|
||||
* included (external entity resolution has been off since the XXE era); a browser that ever did
|
||||
* would only reach that fetch by opening the file directly, which the CGI answers with
|
||||
* `Content-Security-Policy: default-src 'none'; sandbox` — every other consumer sees this bundle
|
||||
* only as a `mask-image`/`background-image`/`<img>` source, none of which parses a DOCTYPE at all.
|
||||
* Not closed: dropping `doc.doctype` would carve out one more node type in a walk that otherwise
|
||||
* judges only elements and their attributes, for a fetch nothing in this project's support matrix
|
||||
* performs. Starts mattering the day some consumer of this bundle DOES resolve an external DTD, or
|
||||
* the file is ever served as a navigable document without that header. */
|
||||
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; }
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): XML attribute-value
|
||||
* normalisation does not fold a numeric character reference the way HTML does, so
|
||||
* `href="javas	cript:alert(1)"` reaches `v` as a DOM string with a LITERAL TAB still in
|
||||
* it — `/^javascript:/i` does not match a value starting with a control character, and the
|
||||
* fixture round-trips through `XMLSerializer` byte for byte, so it survives both this
|
||||
* filter and the serializer. It still resolves to `javascript:` in a real browser, because
|
||||
* a URL parser strips TAB/LF/CR from the scheme before comparing it (WHATWG URL, "scheme
|
||||
* start state") — the same class of hole every naive `^scheme:` regex has. Not closed:
|
||||
* matching every control character a URL parser discards is the grammar-guessing this file
|
||||
* exists to avoid, for a value that never resolves as a URL here in the first place — this
|
||||
* bundle only ever leaves the router as `mask-image`/`background-image`/`<img>`, none of
|
||||
* which reads `href` as navigable, and the CGI serving it back for a direct open sends
|
||||
* `Content-Security-Policy: default-src 'none'; sandbox` regardless. Starts mattering the
|
||||
* day the tile is served without that header, or inlined into a page rather than
|
||||
* referenced as an image. */
|
||||
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; }
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): the branch below is
|
||||
* the ONLY place a `url(` is ever inspected. A PRESENTATION ATTRIBUTE that takes the same
|
||||
* function — `fill`, `stroke`, `filter`, `clip-path`, `mask`, `cursor` (SVG 1.1 §11, §15) —
|
||||
* is a plain attribute value to this walk, not a CSS declaration, so `<rect
|
||||
* fill="url(//evil.example/x.svg#g)"/>` matches nothing above and passes with its ORIGINAL
|
||||
* bytes untouched. What it can do is start a second fetch — no script, no navigation,
|
||||
* exactly the reach `_externalStyleRef` already closes for `style` — and that fetch runs
|
||||
* only where the SVG is rendered with its own resource-loading context, which this bundle
|
||||
* never is: it is only ever a `mask-image`/`background-image`/`<img>` source, and a direct
|
||||
* open is answered by the CGI's `Content-Security-Policy: default-src 'none'; sandbox`
|
||||
* regardless. Not closed: doing here what `_sanitizeStyleValue` does for `style` means the
|
||||
* same check against every presentation attribute the SVG spec lets carry a `url()`, a list
|
||||
* this file would then have to track as the spec grows it — the grammar this file exists to
|
||||
* avoid guessing at, moved from one attribute to a dozen. Starts mattering the day a
|
||||
* presentation `url()` gets a fetch path this parser-only pass does not already block by
|
||||
* other means, or the tile stops being image-only. */
|
||||
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 };
|
||||
}
|
||||
|
||||
/* read the picked file as text so it can be inspected before upload, and so what reaches the
|
||||
* router is exactly the bytes that were checked */
|
||||
function _readText(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result || ''));
|
||||
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 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.applyWallpaper(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.applyPattern(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')));
|
||||
return _readText(file).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.applyLoginBg(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,515 @@
|
||||
'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');
|
||||
const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
|
||||
|
||||
/* 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');
|
||||
const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
|
||||
|
||||
/* 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');
|
||||
const currentTint = TINT.current, applyTint = TINT.apply;
|
||||
|
||||
/* 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');
|
||||
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
|
||||
|
||||
/* 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 currentCard = CARD.current, applyCard = CARD.apply;
|
||||
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
|
||||
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
|
||||
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
|
||||
const currentBar = BAR.current, applyBar = BAR.apply;
|
||||
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
|
||||
const currentLine = LINE.current, applyLine = LINE.apply;
|
||||
|
||||
|
||||
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
|
||||
const currentGood = GOOD.current, applyGood = GOOD.apply;
|
||||
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
|
||||
const currentWarn = WARN.current, applyWarn = WARN.apply;
|
||||
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
|
||||
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
|
||||
|
||||
/* 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'));
|
||||
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
|
||||
|
||||
/* 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());
|
||||
const currentContentWidth = CWIDTH.current, applyContentWidth = CWIDTH.apply, contentWidthDefault = CWIDTH.def;
|
||||
|
||||
/* 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));
|
||||
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
|
||||
|
||||
/* 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 + '%'));
|
||||
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
|
||||
|
||||
/* 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 currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
|
||||
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));
|
||||
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
|
||||
/* 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');
|
||||
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
|
||||
/* `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: currentPalette(),
|
||||
wallpaper: currentWallpaper(),
|
||||
tint: String(currentTint()),
|
||||
accent: String(currentAccent()),
|
||||
good: String(currentGood()),
|
||||
warn: String(currentWarn()),
|
||||
danger: String(currentDanger()),
|
||||
card: String(currentCard()),
|
||||
control: String(currentControl()),
|
||||
bar: String(currentBar()),
|
||||
line: String(currentLine()),
|
||||
rounding: String(currentRadius()),
|
||||
autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
|
||||
tint_strength: String(currentTintStrength()),
|
||||
density: prefs.currentDensity(),
|
||||
photo_dim: String(currentPhotoDim()),
|
||||
pattern_size: String(currentPatternSize()),
|
||||
pattern_strength: String(currentPatternStrength()),
|
||||
pattern_ink: currentPatternInk(),
|
||||
content_width: String(currentContentWidth())
|
||||
};
|
||||
}
|
||||
/* 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(radiusDefault()),
|
||||
autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
|
||||
tint_strength: String(tintStrengthDefault()),
|
||||
density: prefs.densityDefault(),
|
||||
photo_dim: String(photoDimDefault()),
|
||||
pattern_size: String(patternSizeDefault()),
|
||||
pattern_strength: String(patternStrengthDefault()),
|
||||
pattern_ink: PINK.def(),
|
||||
content_width: String(contentWidthDefault())
|
||||
};
|
||||
}
|
||||
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');
|
||||
applyPalette('footstrap');
|
||||
applyWallpaper('off');
|
||||
applyTint(0);
|
||||
applyAccent(0);
|
||||
applyGood(0);
|
||||
applyWarn(0);
|
||||
applyDanger(0);
|
||||
applyCard(0);
|
||||
applyControl(0);
|
||||
applyBar(0);
|
||||
applyLine(0);
|
||||
applyRadius(FS_RADIUS_DEFAULT);
|
||||
prefs.applyAutoCollapse('off');
|
||||
applyTintStrength(FS_TSTR_DEFAULT);
|
||||
prefs.applyDensity('normal');
|
||||
applyPhotoDim(FS_PDIM_DEFAULT);
|
||||
applyPatternSize(FS_PSIZE_DEFAULT);
|
||||
applyPatternStrength(FS_PSTR_DEFAULT);
|
||||
applyPatternInk('theme');
|
||||
applyContentWidth(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. */
|
||||
/* NOT a /www symlink, unlike BG_SERVE 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 PAT_SERVE = '/cgi-bin/luci-theme-footstrap-pattern';
|
||||
function currentPattern() {
|
||||
const t = prefs.sd('pattern');
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
}
|
||||
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
|
||||
|
||||
/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
|
||||
* axis (data-wallpaper="pattern"). Exported because fs-assets.js applies the token it just wrote. */
|
||||
function applyPattern(tok) {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
|
||||
else root.style.removeProperty('--fs-pattern-url');
|
||||
prefs.setSD('pattern', tok || '');
|
||||
}
|
||||
|
||||
/* Still the plain uhttpd symlink the uci-default makes, unlike PAT_SERVE 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 BG_SERVE = '/luci-static/footstrap/bg';
|
||||
/* 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 token the server last saved, validated to the same hex charset head.ut's sanitiser and
|
||||
* pre-paint use, so the Appearance tab can build a cache-busted preview src. '' = none. */
|
||||
function currentLoginBg() {
|
||||
const t = prefs.sd('login_bg');
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
}
|
||||
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
|
||||
|
||||
/* applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
|
||||
function applyLoginBg(tok) {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty('--fs-login-bg-url', 'url("' + loginBgUrl(tok) + '")');
|
||||
else root.style.removeProperty('--fs-login-bg-url');
|
||||
prefs.setSD('login_bg', tok || '');
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
currentPalette, applyPalette,
|
||||
currentWallpaper, applyWallpaper,
|
||||
currentTint, applyTint,
|
||||
currentAccent, applyAccent,
|
||||
currentGood, applyGood,
|
||||
currentWarn, applyWarn,
|
||||
currentDanger, applyDanger,
|
||||
currentCard, applyCard,
|
||||
currentControl, applyControl,
|
||||
currentBar, applyBar,
|
||||
currentLine, applyLine,
|
||||
currentRadius, applyRadius,
|
||||
currentTintStrength, applyTintStrength,
|
||||
currentPhotoDim, applyPhotoDim,
|
||||
currentPatternSize, applyPatternSize,
|
||||
currentPatternStrength, applyPatternStrength,
|
||||
currentPatternInk, applyPatternInk,
|
||||
currentContentWidth, applyContentWidth, contentWidthDefault,
|
||||
|
||||
currentPattern, patternUrl, applyPattern,
|
||||
currentLoginBg, loginBgUrl, applyLoginBg,
|
||||
tokenOk,
|
||||
|
||||
snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require fs-fit as fit';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-menutree as tree';
|
||||
|
||||
/* The chrome around the content: the mode menu, the section tabs, the rail toggle and the
|
||||
* measurements 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) {
|
||||
const container = document.querySelector('#tabmenu');
|
||||
/* a template without the container must not reject: an unhandled rejection here kills the
|
||||
* whole ui.menu.load() chain, i.e. every menu */
|
||||
if (!container)
|
||||
return E([]);
|
||||
const ul = E('ul', { 'class': 'tabs' });
|
||||
const children = ui.menu.getChildren(node);
|
||||
let activeNode = null;
|
||||
|
||||
children.forEach((child) => {
|
||||
const isActive = (L.env.dispatchpath[3 + (level || 0)] === child.name);
|
||||
/* aria-current, 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 E([]);
|
||||
|
||||
container.appendChild(ul);
|
||||
container.style.display = '';
|
||||
|
||||
if (activeNode)
|
||||
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
/* ---- tab-strip auto-fit ----
|
||||
* A tab strip (#tabmenu, or a view's own .cbi-tabmenu) can carry ~11 pills (luci-app-justclash)
|
||||
* that overflow one row. Rather than wrap, shrink: two density classes (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');
|
||||
_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 px = (name, dflt) => resolveLen(name, dflt);
|
||||
const g = {
|
||||
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
|
||||
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
|
||||
railW: px('--fs-rail-w', GEOM_DFLT.railW),
|
||||
/* the token is one side's padding; the column loses it twice. 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: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
|
||||
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
|
||||
contentMax: px('--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) {
|
||||
const ul = document.querySelector('#modemenu');
|
||||
const children = ui.menu.getChildren(node);
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const isActive = L.env.requestpath.length
|
||||
? child.name === L.env.requestpath[0]
|
||||
: index === 0;
|
||||
|
||||
/* the main menu must render even where a template has no #modemenu */
|
||||
if (ul)
|
||||
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
|
||||
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
|
||||
]));
|
||||
|
||||
if (isActive)
|
||||
renderMainMenu(child, child.name);
|
||||
});
|
||||
|
||||
if (!ul)
|
||||
return;
|
||||
if (children.length <= 1)
|
||||
ul.classList.add('single');
|
||||
if (ul.children.length > 1)
|
||||
ul.style.display = '';
|
||||
}
|
||||
|
||||
/* rebuild mode menu + main menu + section tabs from the current L.env, on first load and after
|
||||
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
|
||||
function renderChrome() {
|
||||
const root = tree.tree();
|
||||
const modemenu = document.querySelector('#modemenu');
|
||||
const topmenu = document.querySelector('#topmenu');
|
||||
const tabmenu = document.querySelector('#tabmenu');
|
||||
|
||||
if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
|
||||
if (topmenu) topmenu.innerHTML = '';
|
||||
if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
|
||||
|
||||
renderModeMenu(root, _renderMain);
|
||||
|
||||
if (L.env.dispatchpath.length >= 3) {
|
||||
let node = root, url = '';
|
||||
/* `node.children &&`, 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() {
|
||||
const btn = document.getElementById('fs-rail-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
function sync() {
|
||||
const on = prefs.currentRail();
|
||||
btn.setAttribute('aria-expanded', on ? 'false' : 'true');
|
||||
const label = on ? _('Expand menu') : _('Collapse menu');
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('title', label);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
prefs.applyRail(!prefs.currentRail());
|
||||
sync();
|
||||
/* the sidebar's cut just changed by ~156px, so the 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 = '•';
|
||||
|
||||
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM — same
|
||||
* shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does
|
||||
* not export it). */
|
||||
function syncIndAttr(el, name, value) {
|
||||
if (value === null) {
|
||||
if (el.hasAttribute(name)) el.removeAttribute(name);
|
||||
} else if (el.getAttribute(name) !== value) {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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. Enter/Space call el.click() because a <span>, unlike an <a>, gets neither key
|
||||
* for free (contrast fs-widgets.js's wireSpaceKey, written for an <a role="button">); 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';
|
||||
el.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
|
||||
ev.preventDefault();
|
||||
el.click();
|
||||
});
|
||||
}
|
||||
|
||||
function wireIndicatorCounts() {
|
||||
const box = document.getElementById('indicators');
|
||||
if (!box) return;
|
||||
|
||||
function stamp() {
|
||||
box.querySelectorAll('[data-indicator]').forEach((el) => {
|
||||
const txt = el.textContent || '';
|
||||
const m = txt.match(/(\d+)\s*$/);
|
||||
el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
|
||||
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
|
||||
el.setAttribute('title', txt);
|
||||
/* 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')) {
|
||||
syncIndAttr(el, 'role', 'button');
|
||||
syncIndAttr(el, 'tabindex', '0');
|
||||
syncIndAttr(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,672 @@
|
||||
'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 */
|
||||
const boxes = [], 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 || boxes.indexOf(box) !== -1) return;
|
||||
boxes.push(box);
|
||||
});
|
||||
host.querySelectorAll(FLOORED).forEach((box) => { if (boxes.indexOf(box) === -1) boxes.push(box); });
|
||||
|
||||
let dirty = boxes;
|
||||
if (records && records.length) {
|
||||
const targets = [];
|
||||
for (const r of records) if (r.target && targets.indexOf(r.target) === -1) targets.push(r.target);
|
||||
dirty = boxes.filter((box) => targets.some((t) => box.contains(t) || t.contains(box)));
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
let _anchorPending = null;
|
||||
let _anchorWhy = null;
|
||||
const _anchorTrail = [];
|
||||
function awhy(w) {
|
||||
_anchorWhy = w;
|
||||
_anchorTrail.push(w + '@' + Math.round(performance.now()));
|
||||
if (_anchorTrail.length > 8) _anchorTrail.shift();
|
||||
}
|
||||
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;
|
||||
/* why the last late correction did or did not write — eight exits, one symptom from outside */
|
||||
let _lateWhy = null;
|
||||
const _lateTrail = [];
|
||||
function why(w) {
|
||||
_lateWhy = w;
|
||||
_lateTrail.push(w + '@' + Math.round(performance.now()));
|
||||
if (_lateTrail.length > 8) _lateTrail.shift();
|
||||
}
|
||||
|
||||
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 `_lateWhy` */
|
||||
lateWhy: () => _lateWhy,
|
||||
lateTrail: () => _lateTrail.slice(),
|
||||
anchorWhy: () => _anchorWhy,
|
||||
anchorTrail: () => _anchorTrail.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,171 @@
|
||||
'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.indexOf(base) !== 0)
|
||||
return null;
|
||||
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
|
||||
return rest.length ? rest.split('/') : [];
|
||||
}
|
||||
|
||||
/* walk the (scrubbed, ACL-filtered) menu tree to the node for a path */
|
||||
function nodeForSegs(segs) {
|
||||
let node = _tree;
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
node = node && node.children && node.children[segs[i]];
|
||||
if (!node) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/* ---- alias / firstchild resolution ----
|
||||
*
|
||||
* 7 of the 27 menu links are redirects 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,
|
||||
/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
|
||||
* node whose `depends.acl` names the group that command needs, and a node that resolves
|
||||
* elsewhere would answer for a permission the session may not hold */
|
||||
nodeForSegs,
|
||||
segsFromPath,
|
||||
currentNode,
|
||||
resolveSegs,
|
||||
readonlyForSegs,
|
||||
viewClassFor
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require dom';
|
||||
'require network';
|
||||
'require fs-fit as fit';
|
||||
'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' };
|
||||
/* the data-page value four call sites compare against; a string literal is not mangled, so a
|
||||
* repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */
|
||||
|
||||
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).
|
||||
* docs/findings.md, "A card cannot be collapsed from the keyboard".
|
||||
*
|
||||
* 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. */
|
||||
|
||||
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
|
||||
* no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than
|
||||
* imported, since that file does not export it. */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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++);
|
||||
|
||||
syncAttr(h, 'role', 'button');
|
||||
syncAttr(h, 'tabindex', '0');
|
||||
syncAttr(h, 'aria-controls', panel ? panel.id : null);
|
||||
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 */
|
||||
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();
|
||||
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
|
||||
});
|
||||
/* the pill is an <a>-less <span>, so neither key is native here — contrast
|
||||
* fs-widgets.js's wireSpaceKey, written for an <a role="button">, which gets Enter for free
|
||||
* and needs only Space added */
|
||||
h.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
|
||||
ev.preventDefault();
|
||||
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 #view and re-run arrange(),
|
||||
* coalesced and one observer per #view node. The SPA router may replace #view between visits, so
|
||||
* re-attach when the observed node is no longer the current one — a singleton bound to the first
|
||||
* #view would watch a detached tree and the grid would never apply again. */
|
||||
let _observer = null, _observedRoot = null, _routeObserver = null;
|
||||
function stopWatch() {
|
||||
if (_observer) _observer.disconnect();
|
||||
_observer = null;
|
||||
_observedRoot = 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') || view;
|
||||
if (_observer && _observedRoot !== root)
|
||||
stopWatch();
|
||||
arrange();
|
||||
/* a chrome module is alive on every page, so without the route check an observer would attach
|
||||
* to #view on, say, the firewall page and re-run arrange() for every table mutation */
|
||||
if (_observer || !view ||
|
||||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
|
||||
return;
|
||||
_observedRoot = root;
|
||||
/* 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,373 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'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 */
|
||||
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
|
||||
|
||||
/* …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) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} }
|
||||
|
||||
/* ---- 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 === 'auto') return 'auto';
|
||||
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() {
|
||||
let queued = false;
|
||||
const paint = () => {
|
||||
queued = false;
|
||||
paintThemeColor();
|
||||
};
|
||||
const schedule = () => {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
window.requestAnimationFrame(paint);
|
||||
};
|
||||
paintThemeColor();
|
||||
new MutationObserver(schedule).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());
|
||||
}
|
||||
});
|
||||
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which
|
||||
* 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head
|
||||
* to this one number, hence the named const. */
|
||||
|
||||
/* ---- the four axis shapes, each written once ----
|
||||
*
|
||||
* Sixteen axes are four shapes, so the shape lives in a factory and each instance is one line:
|
||||
* enumAxis (pattern ink), colorAxis (tint, accent, good, warn, danger), surfaceAxis (cards,
|
||||
* controls, bar, borders), propAxis (rounding, tint strength, photo dim, pattern size, pattern
|
||||
* strength, content width). Same contract throughout: `current()` is localStorage ?? def(), `def()` is the router
|
||||
* default alone, `apply()` stores the choice explicitly. None use `this` — every export is a
|
||||
* detached method reference, so a `this` here would throw on the first call.
|
||||
*
|
||||
* Each factory takes its localStorage key as the first argument, and tools/axes.mjs matches the
|
||||
* call by its literal args: an axis built by a factory has no lsGet('fs-…') call site for the gate
|
||||
* to find.
|
||||
*
|
||||
* The remaining axes stay separate, each with a quirk a shared table would need an option for:
|
||||
* `mode` stores a value it does not apply and owns an MQL listener, `layout` reads the attribute,
|
||||
* `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
|
||||
* one landed, `autoCollapse` has no :root attribute. */
|
||||
|
||||
/* 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 === dflt) return dflt;
|
||||
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);
|
||||
}
|
||||
|
||||
/* A colour axis — Tint, Accent and the three status colours are one axis pointed at five tokens:
|
||||
* same validation, same "0 is off", same ordering rule (set the custom property BEFORE the
|
||||
* attribute, or a fresh load paints one frame in the previous colour).
|
||||
*
|
||||
* A value is one of three things, and the attribute says which (03-palettes.css matches on it):
|
||||
*
|
||||
* 0 off — no attribute, the palette as it shipped
|
||||
* 1–360 a HUE: CSS rotates the palette's own colour through oklch(from … l c H), so
|
||||
* lightness, chroma and every contrast margin stay the palette's
|
||||
* '#rrggbb' a COLOUR, stamped inline on :root as the live token; the ink over it is derived
|
||||
* from its lightness in CSS
|
||||
*
|
||||
* Both live in one localStorage key rather than a colour key beside a hue key: two keys would need
|
||||
* a third to say which is in effect, and that third is the one a pre-paint script forgets.
|
||||
* `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode
|
||||
* clears the other's property, so the two can never both be half-applied. */
|
||||
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. */
|
||||
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one
|
||||
* colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status
|
||||
* colours keep the palette's values — the cue colours the paper, not the UI. On a hue it is mixed
|
||||
* in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the
|
||||
* canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */
|
||||
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';
|
||||
}
|
||||
|
||||
/* ---- Save to router: write the current effective axes to /etc/config/footstrap ----
|
||||
* The scoped rpcd ACL (config 'footstrap' only) lets the admin's session set and commit those
|
||||
* options; rpcd validates the config/section/option names, so no value reaches a shell. The server
|
||||
* reads them back on the next load and head.ut's sanitiser clamps each before it becomes
|
||||
* window.__fsSD.
|
||||
*
|
||||
* snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so
|
||||
* Save captures what the user sees. It does not touch localStorage: this browser keeps overriding,
|
||||
* and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
|
||||
/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
|
||||
*
|
||||
* Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
|
||||
* and 188 B larger after minification — three lists of short literals compress better than
|
||||
* twenty-one rows of data, because a function name is mangled and a row is not. The copies are
|
||||
* held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
|
||||
* field against header.ut's FS_AXES. */
|
||||
|
||||
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,427 @@
|
||||
'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;
|
||||
}
|
||||
|
||||
/* ---- extra sources -------------------------------------------------------
|
||||
*
|
||||
* An optional package can add rows to the same list — one indexes the SECTION titles inside each
|
||||
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
|
||||
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
|
||||
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
|
||||
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
|
||||
* is not its own (see keyOf).
|
||||
*
|
||||
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
|
||||
* first gesture and most sessions never make it; a package that had to `require` it to register
|
||||
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
|
||||
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
|
||||
*
|
||||
* `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session
|
||||
* — and the stamp below is what rebuilds the pool when it does. */
|
||||
const _sources = [];
|
||||
let _pool = null, _stamp = -1;
|
||||
|
||||
function globalSources() {
|
||||
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
|
||||
}
|
||||
|
||||
function addSource(fn) {
|
||||
_sources.push(fn);
|
||||
_pool = null;
|
||||
}
|
||||
function refresh() {
|
||||
_pool = null;
|
||||
}
|
||||
function pool() {
|
||||
const all = _sources.concat(globalSources());
|
||||
const stamp = all.length + (window.__fsSearchGen || 0);
|
||||
if (_pool && stamp === _stamp) return _pool;
|
||||
_stamp = stamp;
|
||||
_pool = all.reduce((rows, fn) => {
|
||||
try { return rows.concat(fn() || []); }
|
||||
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
|
||||
}, index().slice());
|
||||
return _pool;
|
||||
}
|
||||
|
||||
/* ---- 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 pool()) {
|
||||
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 KEY is stored, never the title — the title is resolved through the pool on every
|
||||
* render, so it follows the UI language and a row whose package went away drops out instead of
|
||||
* lingering as a dead row. A page's key is its menu path; a row from a source carries its own
|
||||
* `key`, because a section has no dispatcher node and therefore no path that is only its own —
|
||||
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
|
||||
* heading. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
|
||||
function keyOf(e) {
|
||||
return e.key || e.path;
|
||||
}
|
||||
|
||||
/* 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');
|
||||
/* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
|
||||
* section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
|
||||
* only "System" in the list — the page path is all either row carries. */
|
||||
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
|
||||
return recent.map((k) => byKey.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.
|
||||
*
|
||||
* `onTake` is how a row from an extra source finishes the job the href cannot: a
|
||||
* section row's href can only reach the PAGE, so the source that produced it opens the
|
||||
* tab and scrolls to the section itself. It fires for a click and for Enter alike —
|
||||
* Enter synthesises this very click. */
|
||||
a.addEventListener('click', () => {
|
||||
close(false);
|
||||
if (typeof e.onTake === 'function') e.onTake();
|
||||
});
|
||||
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,
|
||||
/* the seam an optional package registers through; see addSource() */
|
||||
addSource, refresh
|
||||
});
|
||||
@@ -0,0 +1,977 @@
|
||||
'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';
|
||||
|
||||
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
|
||||
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
|
||||
*
|
||||
* The native <select> stays the form field and MUST remain frameEl.firstChild:
|
||||
* ui.Select.getValue() returns `this.node.firstChild.value`. Inserting our widget BEFORE it made
|
||||
* getValue read a <div> and return `undefined`, which broke Save. So insert AFTER, and mirror the
|
||||
* value both ways. Sharing the frameEl also ties our node to the widget's lifecycle, so a CBI
|
||||
* re-render disposes of it — no orphans.
|
||||
*
|
||||
* Runs theme-wide (required from the footer); watches for selects added later by client CBI. */
|
||||
|
||||
function readChoices(sel) {
|
||||
const choices = {};
|
||||
Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; });
|
||||
return choices;
|
||||
}
|
||||
|
||||
/* cheap identity of the option list, to detect a script rebuilding it
|
||||
* (select.replaceChildren, dependency-driven re-population, …) */
|
||||
function choicesKey(sel) {
|
||||
return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
|
||||
}
|
||||
|
||||
/* Undo enhance(): drop the widget, unhide the select and 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();
|
||||
if (sel._fsNode && sel._fsNode.parentNode)
|
||||
sel._fsNode.parentNode.removeChild(sel._fsNode);
|
||||
delete sel.dataset.fsSelect;
|
||||
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
|
||||
sel.removeAttribute('aria-hidden');
|
||||
sel.style.display = '';
|
||||
}
|
||||
|
||||
/* keep an enhanced select and its widget in step when a script drives the native element directly:
|
||||
* ui.Select.setValue() rewrites value/options without dispatching `change`, so enhance()'s mirror
|
||||
* never fires and the widget 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.parentNode.insertBefore(node, sel.nextSibling);
|
||||
|
||||
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
|
||||
let syncing = false;
|
||||
|
||||
/* our widget -> native select (user picked an option) */
|
||||
node.addEventListener('cbi-dropdown-change', () => {
|
||||
const v = dd.getValue();
|
||||
if (sel.value === v) return;
|
||||
syncing = true;
|
||||
sel.value = v;
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
syncing = false;
|
||||
}, { signal: ac.signal });
|
||||
|
||||
/* native select -> our widget (a script/CBI dependency changed and dispatched change on
|
||||
* the select) — keeps the visible widget from going stale */
|
||||
sel.addEventListener('change', () => {
|
||||
if (syncing) return;
|
||||
if (dd.getValue() !== sel.value)
|
||||
dd.setValue(sel.value);
|
||||
}, { signal: ac.signal });
|
||||
}
|
||||
|
||||
/* Tag standalone data tables so the stacking rules key off a static `.fs-dt` instead of a live
|
||||
* `:has(.tr.table-titles)` the style engine re-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 inRoots = (sel, roots) => roots.map((r) => `${r} ${sel}`).join(', ');
|
||||
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
|
||||
|
||||
const foreignTables = () => inRoots('.table:not(.cbi-section-table)', liveRoots()) + ', ' +
|
||||
inRoots('table:not(.table):not(.cbi-section-table)', liveRoots());
|
||||
|
||||
/* 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. */
|
||||
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' ],
|
||||
];
|
||||
|
||||
/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries
|
||||
* the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches
|
||||
* no DOM and wakes no attribute observer. Not imported for one line — this file has none of that
|
||||
* module's other exports to justify the dependency. */
|
||||
function setRole(el, role) {
|
||||
if (el.getAttribute('role') !== role) el.setAttribute('role', role);
|
||||
}
|
||||
|
||||
/* 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.forEach(([ sel, role ]) => {
|
||||
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
|
||||
});
|
||||
TABLE_ROLE_TAGS.forEach(([ sel, role ]) => {
|
||||
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, 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', liveRoots());
|
||||
|
||||
/* "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)', liveRoots()) : 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)', liveRoots());
|
||||
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', liveRoots()))) {
|
||||
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) {
|
||||
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
|
||||
if (!ul) return [];
|
||||
return [...ul.children].filter((li) =>
|
||||
li.tagName === 'LI' &&
|
||||
/* the "custom value" row (options.create) is an input, not a choice */
|
||||
!li.querySelector('input:not([type="hidden"])') &&
|
||||
li.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
function typeaheadLabel(li) {
|
||||
return (li.textContent || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function wireTypeahead() {
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
|
||||
if (!ev.key || ev.key.length !== 1 || ev.key === ' ') return;
|
||||
/* the create-item input is a text field: let the user type into it */
|
||||
if (ev.target && ev.target.matches && ev.target.matches('input, textarea')) return;
|
||||
|
||||
const sb = ev.target.closest?.('.cbi-dropdown[open]');
|
||||
if (!sb) return;
|
||||
|
||||
const items = typeaheadItems(sb);
|
||||
if (!items.length) return;
|
||||
|
||||
/* a new dropdown starts a new search, however fast the user got here */
|
||||
if (sb !== _taLast) { _taBuf = ''; _taLast = sb; }
|
||||
|
||||
const ch = ev.key.toLowerCase();
|
||||
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 && typeof inst.setFocus === 'function')
|
||||
inst.setFocus(sb, hit, true);
|
||||
else
|
||||
hit.focus();
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
__init__() {
|
||||
wireTypeahead();
|
||||
|
||||
const scan = () => {
|
||||
document.querySelectorAll('select.cbi-input-select:not([data-fs-select])').forEach(enhance);
|
||||
document.querySelectorAll('select.cbi-input-select[data-fs-select="1"]').forEach(resync);
|
||||
};
|
||||
scan();
|
||||
|
||||
/* 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,802 @@
|
||||
'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.prototype.some.call(
|
||||
document.querySelectorAll(VIEW_SHEETS),
|
||||
(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.prototype.map.call(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();
|
||||
}
|
||||
|
||||
function currentKey() {
|
||||
if (_curKey !== null) return _curKey;
|
||||
const dp = L.env && L.env.dispatchpath;
|
||||
if (dp && dp.length) return appKey(dp);
|
||||
/* no env to read (a document that never got the bootstrap): the URL is all there is */
|
||||
const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
|
||||
return appKey(p ? p.split('/') : []);
|
||||
}
|
||||
|
||||
/* Both halves, for the reason silence() 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,74 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The theme's UI primitives: the inline-SVG wrapper, the disclosure pair the menu is built on, and
|
||||
* the colour control behind the Appearance tab's colour axes. Nothing here knows what it is used
|
||||
* for, so the menu and the Appearance tab 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>';
|
||||
}
|
||||
|
||||
/* ---- disclosure primitives, shared by the menu ----
|
||||
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
|
||||
* and hides. The trigger selector stays a parameter. */
|
||||
|
||||
/* 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. `linkSel` is the
|
||||
* layout's trigger (the menu's `:scope > a`). */
|
||||
function setOpen(li, on, linkSel) {
|
||||
li.classList.toggle('open', on);
|
||||
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
|
||||
* to answer both. */
|
||||
function wireSpaceKey(link) {
|
||||
link.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== ' ' && ev.key !== 'Spacebar') return;
|
||||
ev.preventDefault();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
/* A click outside closes; 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. `when` restricts both to flyout mode,
|
||||
* where `.open` means "popup panel" — an unfolded accordion must not close on an outside click. */
|
||||
function wireDismiss(opts) {
|
||||
const active = () => (opts.when ? opts.when() : true);
|
||||
|
||||
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 (active() && !ev.target.closest?.(opts.inside))
|
||||
opts.close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape' || !active()) return;
|
||||
const open = document.querySelector(opts.open);
|
||||
if (!open) return;
|
||||
const trigger = open.querySelector(opts.trigger);
|
||||
opts.close();
|
||||
trigger?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/* The 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,
|
||||
setOpen,
|
||||
wireSpaceKey,
|
||||
wireDismiss
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
'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';
|
||||
|
||||
/* 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. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
const RECENT_WARM = 5;
|
||||
|
||||
/* A key is a menu path, or a page path plus the heading of a section inside it
|
||||
* (`admin/system/system#Footstrap`) — a section has no dispatcher node to name it, and only the
|
||||
* source that produced the row can build that half. Exported for exactly that: the writer stays
|
||||
* one function, or the two halves would drift on the cap and the de-duplication. */
|
||||
function remember(key) {
|
||||
if (typeof key !== 'string' || !key) return;
|
||||
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||||
prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
|
||||
}
|
||||
|
||||
/* the page half of a key: what the router can navigate to and what warmRecent() prefetches */
|
||||
function pageOf(key) {
|
||||
const h = key.indexOf('#');
|
||||
return h < 0 ? key : key.slice(0, h);
|
||||
}
|
||||
|
||||
/* ---- 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('/');
|
||||
/* Keys, not paths: a section key names the page it sits on, and two sections of one page must
|
||||
* warm it once — the module chain is the page's. */
|
||||
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
|
||||
const paths = [ ...new Set(keys.map(pageOf)) ].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() {
|
||||
const btn = document.getElementById('fs-search-btn');
|
||||
if (!btn) return;
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- optional companion packages ----
|
||||
*
|
||||
* header.ut prints `window.__fsPlugins` from `footstrap.settings.plugin`, a list a package writes
|
||||
* from its own uci-defaults; each entry is a LuCI module name, already whitelisted there. The
|
||||
* chrome requires each one after everything below is wired — a plugin registers itself through the
|
||||
* seams the theme exports (`fs-router.onNavigate`, `fs-search.addSource`) and the theme names
|
||||
* nobody. A plugin that throws costs only itself.
|
||||
*
|
||||
* No plugin, no cost: an empty list is the shipped state and this loop does nothing. */
|
||||
function loadPlugins() {
|
||||
const RT = window.L;
|
||||
const names = Array.isArray(window.__fsPlugins) ? window.__fsPlugins : [];
|
||||
names.forEach((name) => {
|
||||
RT.require(name).catch((e) => console.error('footstrap: plugin ' + name + ' did not load', e));
|
||||
});
|
||||
}
|
||||
|
||||
/* 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') ]);
|
||||
|
||||
/* Write an attribute only when the value actually changes, so a poll tick that reads the same
|
||||
* numbers back touches no DOM and fires no attribute-mutation observer. `value === null` removes
|
||||
* the attribute instead of writing the string "null". */
|
||||
function fsSyncAttr(el, name, value) {
|
||||
if (value === null) {
|
||||
if (el.hasAttribute(name)) el.removeAttribute(name);
|
||||
} else if (el.getAttribute(name) !== value) {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
fsSyncAttr(pg, 'role', 'progressbar');
|
||||
fsSyncAttr(pg, 'aria-valuemin', '0');
|
||||
fsSyncAttr(pg, 'aria-valuemax', '100');
|
||||
fsSyncAttr(pg, 'aria-valuenow', String(level));
|
||||
fsSyncAttr(pg, 'aria-valuetext', title);
|
||||
const label = findProgressbarLabel(pg);
|
||||
const name = label ? label.textContent.trim() : '';
|
||||
fsSyncAttr(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);
|
||||
}
|
||||
fsSyncAttr(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); `fsSyncAttr` 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, the disclosure primitives, the colour control
|
||||
* 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 a companion package writes its own rows into the recents list through; see
|
||||
* remember() for what a key is */
|
||||
remember,
|
||||
|
||||
/* 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();
|
||||
/* last: a plugin registers against the parts above, and a broken one must not be able
|
||||
* to take the chrome with it */
|
||||
loadPlugins();
|
||||
/* 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,323 @@
|
||||
'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>. widgets.setOpen keeps `.open` and aria-expanded in step. */
|
||||
const TRIGGER = ':scope > a';
|
||||
const OPEN_LI = '#topmenu > li.open';
|
||||
|
||||
/* ---- 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 setOpen(li, on) {
|
||||
widgets.setOpen(li, on, TRIGGER);
|
||||
}
|
||||
|
||||
function closeFlyouts(except) {
|
||||
document.querySelectorAll(OPEN_LI).forEach((o) => {
|
||||
if (o !== except) setOpen(o, false);
|
||||
});
|
||||
}
|
||||
|
||||
/* Restore the accordion after leaving flyout mode (rail expanded, window grew). closeFlyouts() 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);
|
||||
|
||||
if (!ul || children.length === 0 || level > 1)
|
||||
return E([]);
|
||||
|
||||
/* dispatchpath = [mode, section, subsection, …]; sections sit at
|
||||
* index (level+1) because the first call gets the mode. */
|
||||
const idx = (level || 0) + 1;
|
||||
|
||||
children.forEach((child) => {
|
||||
/* the chrome carries its own Logout entry (partials/logout.ut), 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();
|
||||
});
|
||||
|
||||
widgets.wireSpaceKey(link);
|
||||
|
||||
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is
|
||||
* authoritative and two panels never stack. Guarded on pointerType, 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 */
|
||||
widgets.wireDismiss({
|
||||
when: flyoutMode,
|
||||
inside: '#topmenu > li.has-sub',
|
||||
open: OPEN_LI,
|
||||
trigger: TRIGGER,
|
||||
close: () => closeFlyouts()
|
||||
});
|
||||
|
||||
/* 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
+180
@@ -0,0 +1,180 @@
|
||||
#!/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.$$"
|
||||
trap 'rm -f "$RES" "$MAP" "$MAP.ord" "$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
|
||||
|
||||
awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
|
||||
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
|
||||
# read the identifier starting at i (which points at the first "-" of "--")
|
||||
function ident(s, i, j, n) {
|
||||
n = length(s); j = i
|
||||
while (j <= n && isname(substr(s, j, 1))) j++
|
||||
return substr(s, i, j - i)
|
||||
}
|
||||
BEGIN {
|
||||
while ((getline line < RESFILE) > 0) reserved[line] = 1
|
||||
q = ""
|
||||
}
|
||||
{ css = css $0 "\n" }
|
||||
END {
|
||||
n = length(css)
|
||||
|
||||
# ---- pass 1: count every manglable name, string-aware ----
|
||||
i = 1
|
||||
while (i <= n) {
|
||||
c = substr(css, i, 1)
|
||||
if (q != "") { if (c == "\\") { i += 2; continue } ; if (c == q) q = ""; i++; continue }
|
||||
if (c == "\"" || c == "'"'"'") { q = c; i++; continue }
|
||||
if (c == "-" && substr(css, i, 5) == "--fs-") {
|
||||
id = ident(css, i)
|
||||
if (!(id in reserved)) { if (!(id in cnt)) order[++ncnt] = id; cnt[id]++ }
|
||||
i += length(id); continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
# ---- short-name alphabet, hottest name gets the shortest ----
|
||||
A = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
nsh = 0
|
||||
for (a = 1; a <= 52; a++) short[++nsh] = "--" substr(A, a, 1)
|
||||
for (a = 1; a <= 52; a++) for (b = 1; b <= 52; b++) short[++nsh] = "--" substr(A, a, 1) substr(A, b, 1)
|
||||
|
||||
# selection sort by count (asort() is not portable to busybox/mawk)
|
||||
for (x = 1; x <= ncnt; x++) {
|
||||
best = x
|
||||
for (y = x + 1; y <= ncnt; y++) if (cnt[order[y]] > cnt[order[best]]) best = y
|
||||
t = order[x]; order[x] = order[best]; order[best] = t
|
||||
}
|
||||
if (ncnt > nsh) { print "mangle-tokens: more names than short forms" > "/dev/stderr"; exit 1 }
|
||||
for (x = 1; x <= ncnt; x++) {
|
||||
map[order[x]] = short[x]
|
||||
# a mangled name must not already exist in the sheet
|
||||
if (index(css, short[x] ":") || index(css, "var(" short[x] ")")) {
|
||||
print "mangle-tokens: " short[x] " already used in the sheet" > "/dev/stderr"; exit 1
|
||||
}
|
||||
print order[x] " -> " short[x] " x" cnt[order[x]] > MAPFILE
|
||||
}
|
||||
|
||||
# ---- pass 2: rewrite ----
|
||||
q = ""; i = 1; out = ""
|
||||
while (i <= n) {
|
||||
c = substr(css, i, 1)
|
||||
if (q != "") { if (c == "\\") { out = out substr(css, i, 2); i += 2; continue }
|
||||
if (c == q) q = ""; out = out c; i++; continue }
|
||||
if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue }
|
||||
if (c == "-" && substr(css, i, 5) == "--fs-") {
|
||||
id = ident(css, i)
|
||||
out = out ((id in map) ? map[id] : id)
|
||||
i += length(id); continue
|
||||
}
|
||||
out = out c; i++
|
||||
}
|
||||
printf "%s", out > OUTFILE
|
||||
print ncnt " names mangled, " (length(css) - length(out)) " bytes saved" > "/dev/stderr"
|
||||
}
|
||||
' OUTFILE="$CSS.tmp.$$" "$CSS"
|
||||
|
||||
before=$(wc -c < "$CSS")
|
||||
mv "$CSS.tmp.$$" "$CSS"
|
||||
after=$(wc -c < "$CSS")
|
||||
echo "mangle-tokens: $before -> $after bytes (-$((before - after))), $(wc -l < "$RES") name(s) reserved"
|
||||
|
||||
# ---- 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
|
||||
[ -d "$d" ] || { echo "mangle-tokens: --rewrite target $d is not a directory" >&2; exit 1; }
|
||||
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:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr "Acento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:502
|
||||
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:342
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr "Colapso automático"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
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:445
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr "Bordes"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Buffered"
|
||||
msgstr "En búfer"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Cached"
|
||||
msgstr "En caché"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr "Tarjetas"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:475
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr "Elegir SVG"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:468
|
||||
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:456
|
||||
#: ucode/template/themes/footstrap/header.ut:138
|
||||
msgid "Collapse menu"
|
||||
msgstr "Contraer menú"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:486
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr "Colores"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr "Compacto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:676
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr "Confirmar restablecimiento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:372
|
||||
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:443
|
||||
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:655
|
||||
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:425
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr "Peligro"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr "Oscuro"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:360
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr "Densidad"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
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:456
|
||||
msgid "Expand menu"
|
||||
msgstr "Expandir menú"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:507
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:585
|
||||
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:824
|
||||
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:423
|
||||
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:831
|
||||
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:385
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr "Mantener abierto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr "Grande"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:336
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:750
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr "Diseño"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
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/partials/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr "Modo de compatibilidad Lua activo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
msgid "Memory"
|
||||
msgstr "Memoria"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:135
|
||||
#: ucode/template/themes/footstrap/header.ut:146
|
||||
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:330
|
||||
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:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr "Normal"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Off"
|
||||
msgstr "Apagado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:241
|
||||
msgctxt "footstrap"
|
||||
msgid "Pages"
|
||||
msgstr "Páginas"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:347
|
||||
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:493
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
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:487
|
||||
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:329
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr "Visitado recientemente"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:470
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:477
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr "Eliminar"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:619
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:686
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr "Restablecer al integrado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:714
|
||||
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:604
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr "Restablecer al del router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:712
|
||||
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:376
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr "Redondeo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:602
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr "Guardar en el router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:710
|
||||
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:720
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr "Aspecto guardado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:644
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr "Guardado en el router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:836
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr "Guardado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
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:235
|
||||
#: htdocs/luci-static/resources/fs-search.js:261
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages"
|
||||
msgstr "Buscar páginas"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:236
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages…"
|
||||
msgstr "Buscar páginas…"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr "Barra lateral"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr "Barra lateral y superior"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:115
|
||||
msgid "Skip to content"
|
||||
msgstr "Saltar al contenido"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
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:23
|
||||
msgid "Storage"
|
||||
msgstr "Almacenamiento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr "Intensidad"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:383
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr "Submenús"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:833
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr "Superficies"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
msgid "Swap free"
|
||||
msgstr "Intercambio libre"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
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:722
|
||||
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:292
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr "No se ha podido leer el archivo."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:372
|
||||
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:464
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr "Ese archivo es demasiado grande."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:489
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr "Esa imagen es demasiado grande."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:326
|
||||
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:341
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
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:638
|
||||
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:752
|
||||
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:395
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr "Tinte (identificación del enrutador)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:408
|
||||
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:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr "Arriba"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
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:552
|
||||
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:581
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr "Fondo de pantalla"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
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:444
|
||||
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:443
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr "en un control"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:400
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr "en el lienzo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:388
|
||||
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:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr "Акцент"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:502
|
||||
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:342
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr "Сворачивать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
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:445
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr "Границы"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Buffered"
|
||||
msgstr "Буферизовано"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Cached"
|
||||
msgstr "Кэшировано"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr "Карточки"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:475
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr "Выбрать SVG"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:468
|
||||
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:456
|
||||
#: ucode/template/themes/footstrap/header.ut:138
|
||||
msgid "Collapse menu"
|
||||
msgstr "Свернуть меню"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:486
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr "Цвета"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr "Компактно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:676
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr "Подтвердить сброс"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:372
|
||||
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:443
|
||||
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:655
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not save the default. Reload the page and try again."
|
||||
msgstr "Не удалось сохранить умолчание. Обновите страницу и повторите."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:425
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr "Опасность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr "Тёмная"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:360
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr "Плотность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
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:456
|
||||
msgid "Expand menu"
|
||||
msgstr "Развернуть меню"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:507
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:585
|
||||
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:824
|
||||
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:423
|
||||
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:831
|
||||
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:385
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr "Не сворачивать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr "Крупно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:336
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:750
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr "Раскладка"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
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/partials/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr "Включён режим совместимости с Lua"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
msgid "Memory"
|
||||
msgstr "Память"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:135
|
||||
#: ucode/template/themes/footstrap/header.ut:146
|
||||
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:330
|
||||
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:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr "Обычно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Off"
|
||||
msgstr "Выкл."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:241
|
||||
msgctxt "footstrap"
|
||||
msgid "Pages"
|
||||
msgstr "Страницы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:347
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr "Палитра"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:108
|
||||
msgid "Password"
|
||||
msgstr "Пароль"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:493
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
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:487
|
||||
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:329
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr "Недавние"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:470
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:477
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr "Убрать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:619
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:686
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr "Сбросить к встроенному"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:714
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr "«Сбросить к встроенному» — вернуть вид, с которым поставляется тема."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:604
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr "Сбросить к виду роутера"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:712
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
"«Сбросить к виду роутера» — отменить правки этого браузера и следовать "
|
||||
"роутеру."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:376
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr "Скругление"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:602
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr "Сохранить на роутер"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:710
|
||||
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:720
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr "Сохранённый вид"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:644
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr "Сохранено на роутере"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:836
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr "Сохранение"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
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:235
|
||||
#: htdocs/luci-static/resources/fs-search.js:261
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages"
|
||||
msgstr "Поиск по страницам"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:236
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages…"
|
||||
msgstr "Поиск по страницам…"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr "Сбоку"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr "Боковая панель и шапка"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:115
|
||||
msgid "Skip to content"
|
||||
msgstr "Перейти к содержимому"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
msgctxt "footstrap"
|
||||
msgid "Start typing to find a page"
|
||||
msgstr "Начните вводить название страницы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
msgid "Storage"
|
||||
msgstr "Накопитель"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr "Насыщенность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:383
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr "Подменю"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:833
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr "Поверхности"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
msgid "Swap free"
|
||||
msgstr "Свободно в подкачке"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
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:722
|
||||
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:292
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr "Не удалось прочитать файл."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:372
|
||||
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:464
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr "Файл слишком большой."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:489
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr "Изображение слишком большое."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:326
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr "Роутер отказался удалить файл (статус ubus %d)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:341
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
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:638
|
||||
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:752
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr "Тема ещё не переведена на этом роутере."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:395
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr "Оттенок (опознание роутера)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:408
|
||||
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:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr "Сверху"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
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:552
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr "Загрузка…"
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:102
|
||||
msgid "Username"
|
||||
msgstr "Имя пользователя"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:581
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr "Обои"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
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:444
|
||||
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:443
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr "на элементе формы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:400
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr "на холсте"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:388
|
||||
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:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:502
|
||||
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:342
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
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:445
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Buffered"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Cached"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:475
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:468
|
||||
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:456
|
||||
#: ucode/template/themes/footstrap/header.ut:138
|
||||
msgid "Collapse menu"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:486
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:676
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:372
|
||||
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:443
|
||||
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:655
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not save the default. Reload the page and try again."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:425
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:360
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
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:456
|
||||
msgid "Expand menu"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:507
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:585
|
||||
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:824
|
||||
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:423
|
||||
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:831
|
||||
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:385
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:336
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:750
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
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/partials/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
msgid "Memory"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:135
|
||||
#: ucode/template/themes/footstrap/header.ut:146
|
||||
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:330
|
||||
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:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Off"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:241
|
||||
msgctxt "footstrap"
|
||||
msgid "Pages"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:347
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:108
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:493
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
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:487
|
||||
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:329
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:470
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:477
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:619
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:686
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:714
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:604
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:712
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:376
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:602
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:710
|
||||
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:720
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:644
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:836
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
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:235
|
||||
#: htdocs/luci-static/resources/fs-search.js:261
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:236
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages…"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:115
|
||||
msgid "Skip to content"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
msgctxt "footstrap"
|
||||
msgid "Start typing to find a page"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
msgid "Storage"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:383
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:833
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
msgid "Swap free"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
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:722
|
||||
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:292
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:372
|
||||
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:464
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:489
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:326
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:341
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
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:638
|
||||
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:752
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:395
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:408
|
||||
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:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
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:552
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/sysauth.ut:102
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:581
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
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:444
|
||||
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:443
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:400
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:388
|
||||
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
+85
@@ -0,0 +1,85 @@
|
||||
#!/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$$"
|
||||
awk '
|
||||
BEGIN { RS = "\0" }
|
||||
{
|
||||
# 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
|
||||
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 '
|
||||
BEGIN { RS = "\0"; q = 0 }
|
||||
{
|
||||
out = ""
|
||||
n = length($0)
|
||||
for (i = 1; i <= n; i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (q) {
|
||||
out = out c
|
||||
if (c == "\\") { out = out substr($0, 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
|
||||
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,25 @@
|
||||
/*!
|
||||
* luci-theme-footstrap
|
||||
* Copyright 2026 footstrap contributors
|
||||
* Licensed under the Apache License v2.0
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Portions of styles/base are derived from luci-theme-bootstrap, whose notices
|
||||
* the Apache License requires this file to carry for as long as that code ships:
|
||||
* Copyright 2012 Nut & Bolt <david@nut-bolt.nl>
|
||||
* Copyright 2011 Twitter, Inc
|
||||
*/
|
||||
|
||||
/* Layer order, declared here and nowhere else. A later layer beats an earlier one regardless of
|
||||
* specificity, so a theme rule never needs !important to outrank base; one that needs it to beat
|
||||
* another footstrap rule belongs in a later layer. Unlayered rules beat every layer, and that slot
|
||||
* is deliberately empty.
|
||||
*
|
||||
* tokens design tokens + the outbound bridge to LuCI's --*-color-* names styles/0*
|
||||
* base widget defaults every LuCI view assumes exist styles/base/*
|
||||
* theme footstrap components and layouts styles/theme/*
|
||||
* page per-page corrections styles/pages/*
|
||||
*
|
||||
* build-css.sh concatenates those four directories into cascade.css, which is generated and
|
||||
* gitignored — never edit it. */
|
||||
@layer tokens, base, theme, page;
|
||||
@@ -0,0 +1,569 @@
|
||||
@layer tokens {
|
||||
/* Design tokens, in two tiers, and the split is load-bearing:
|
||||
*
|
||||
* PRIVATE --fs-* every rule in this theme reads only these
|
||||
* EXPORT --*-color-* LuCI's conventional names, defined FROM the private tier and read
|
||||
* by nobody here — they exist so a third-party luci-app-* keeps
|
||||
* working and keeps following the palette and dark mode
|
||||
*
|
||||
* :root is a shared scope and every luci-app-* drops CSS into the same document, unlayered,
|
||||
* which outranks every cascade layer. An app declaring --accent or the conventional
|
||||
* --text-color-high, which base itself once read 158 times, repaints the theme: 312 of 336
|
||||
* gallery elements with a hostile :root before the split, 0 after. The bridge is outward-only,
|
||||
* and tools/audit.py fails on any read of an export name inside styles/.
|
||||
*
|
||||
* docs/design-system.md. Light = :root; dark = :root[data-darkmode="true"]. */
|
||||
:root {
|
||||
/* Palette colours are in 03-palettes.css, one swappable block per colourway x mode;
|
||||
* everything here is palette-independent or var()-derived. The inks (--fs-on-accent and
|
||||
* friends) live in those blocks too, beside the fills they must be legible against: a
|
||||
* dark palette has light fills and needs a dark ink, and one global `#fff` failed WCAG AA
|
||||
* on seven of the eight dark-palette fills (down to 1.69:1). */
|
||||
/* pop surfaces (menus, dropdown lists, the select picker): one value rather than a
|
||||
* literal per site */
|
||||
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30);
|
||||
/* ---- two focus rings, and which one a control takes is the contract ----
|
||||
* -soft is a TINT (10-15% of the accent), measuring 1.15-1.29:1 on the surface it lands
|
||||
* on: a glow, not an indicator, where WCAG 1.4.11 wants 3:1. It is legitimate only beside
|
||||
* a second channel — every field taking it also flips border-color to --fs-accent, which
|
||||
* measures 4.57-7.04:1 across palette x mode x surface, so the border is the indicator and
|
||||
* the tint is the halo.
|
||||
*
|
||||
* -solo is for a control where the ring is the WHOLE indicator and nothing else changes on
|
||||
* focus: the sidebar menu links and chrome icon buttons, the section tabs, both range
|
||||
* sliders. Its 2px of surface is what lets it read against a FILLED control, where an
|
||||
* accent ring would otherwise sit on an accent box; solid accent then measures 5.19-7.04
|
||||
* on panel and 4.61-7.55 on the canvas.
|
||||
*
|
||||
* The invalid twin takes -fill (18%), not -soft: a red ring must read as an alarm rather
|
||||
* than a hover tint, and it is paired with a red border. */
|
||||
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
|
||||
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
|
||||
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
|
||||
/* The chosen-row rail: a chosen row says so with an opaque surface and an inset bar, never
|
||||
* with a tint of the accent under accent text (4.21:1 in dark — theme/65-dropdown.css).
|
||||
* Named because it is written in three places that cannot be one rule: the open dropdown's
|
||||
* row, a native `select option:checked`, and the ring the focused row wears on top. The
|
||||
* first two are pinned to each other by tools/mirror.mjs. */
|
||||
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
|
||||
|
||||
/* The hairline: every separator in this theme is the same one-pixel line in the same
|
||||
* border colour, so it is stated once rather than in 69 longhand declarations that would
|
||||
* each have to change for a 2px high-contrast line or a softer colour. The shorthand is
|
||||
* the token, so `border`, `border-top` and friends all take it. */
|
||||
--fs-hairline: 1px solid var(--fs-border);
|
||||
/* a field reacting to focus: the ring arrives and the edge changes colour together, and a
|
||||
* transition list that drifts apart shows the border moving before the ring */
|
||||
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
|
||||
|
||||
/* the theme's one colour literal, deliberately: black at .7 is the absence of light behind
|
||||
* a dialog, not a tint of any token — a palette-tinted scrim over a dark page dims
|
||||
* nothing. A token, so the rules stay literal-free. */
|
||||
--fs-scrim: rgba(0, 0, 0, .7);
|
||||
|
||||
/* ---- role tint ladder (derived) ----
|
||||
* A tint OF a role, mixed FROM it, so it follows the palette with nothing hand-maintained.
|
||||
* Unnamed, these levels drift: the same outline border was 40% in a table and 45% in the
|
||||
* action bar, the same hover fill 12% in one file and 18% in another.
|
||||
*
|
||||
* -soft 12% a quiet FILL — outline-button :hover
|
||||
* -fill 18% a stronger FILL — callout/diff surface, the invalid ring
|
||||
* -line 40% a HAIRLINE — outline-button border, diff border
|
||||
* -line-hi 55% that hairline on :hover
|
||||
*
|
||||
* The role x step matrix is complete on purpose, read or not: a gap is how the drift
|
||||
* starts, since a rule wanting a level that does not exist invents a percentage.
|
||||
* --fs-accent-soft is the one member not here — its strength differs per MODE (10% light,
|
||||
* 15% dark; hicontrast dark 14%). LIGHT is identical across every palette and lives as one
|
||||
* declaration in 03-palettes.css's shared :root instead; DARK still varies by palette (one
|
||||
* differs by a digit) and stays declared per palette block. */
|
||||
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
|
||||
--fs-warn-soft: color-mix(in srgb, var(--fs-warn) 12%, transparent);
|
||||
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
|
||||
|
||||
--fs-accent-fill: color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||
--fs-good-fill: color-mix(in srgb, var(--fs-good) 18%, transparent);
|
||||
--fs-warn-fill: color-mix(in srgb, var(--fs-warn) 18%, transparent);
|
||||
--fs-danger-fill: color-mix(in srgb, var(--fs-danger) 18%, transparent);
|
||||
|
||||
--fs-accent-line: color-mix(in srgb, var(--fs-accent) 40%, transparent);
|
||||
--fs-good-line: color-mix(in srgb, var(--fs-good) 40%, transparent);
|
||||
--fs-warn-line: color-mix(in srgb, var(--fs-warn) 40%, transparent);
|
||||
--fs-danger-line: color-mix(in srgb, var(--fs-danger) 40%, transparent);
|
||||
|
||||
--fs-accent-line-hi: color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
|
||||
--fs-warn-line-hi: color-mix(in srgb, var(--fs-warn) 55%, transparent);
|
||||
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
|
||||
/* The warn row has no reader today and is kept on purpose: LuCI emits no warning BUTTON,
|
||||
* so only -fill is consumed. The row is what makes the next component reach for a token
|
||||
* instead of mixing its own percentage. A grep finding no `var(--fs-warn-line)` is
|
||||
* expected. */
|
||||
|
||||
/* The circular-arrows refresh glyph, as a mask so its colour follows the palette
|
||||
* (--fs-good active, --fs-dim paused). One copy: the poll pill collapses to it both in the
|
||||
* rail and in the bar's compact cluster. A static glyph on purpose — a spinner makes an
|
||||
* idle poll look busy and moves the click target that pauses it.
|
||||
*
|
||||
* OURS, and redrawn to keep it so. It was derived from Lucide's `refresh-cw` (ISC) —
|
||||
* `M21 3v5h-5` was byte-identical — which is a second outside licence obligation this theme
|
||||
* never declared. Redrawn as two OPEN arcs with solid triangular heads: a different
|
||||
* construction, not a nudge of the same one (Lucide caps a continuous stroke with an
|
||||
* L-shaped hook). Solid heads rather than chevrons because chevrons dissolve at the 18px
|
||||
* this renders at. */
|
||||
--fs-icon-refresh: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");
|
||||
/* …and the size it is drawn at, which css-dup cannot police for a glyph inlined once and
|
||||
* sized at two call sites. The two sites legitimately differ in technique — the rail masks
|
||||
* the element and drops the border, the bar keeps the bordered square — but not in size. */
|
||||
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
|
||||
|
||||
/* Hover cue for a solid control — the one cue that cannot be a colour, the fill it shifts
|
||||
* being whatever the role token says. Per mode, and the DIRECTION FLIPS: `filter`
|
||||
* recolours the text too, and a light-mode solid button is a saturated fill with white
|
||||
* ink, which cannot get brighter — brightening drops white on --fs-accent from 5.19:1 to
|
||||
* 4.59:1 at 1.08 and to 4.08:1 at a visible 1.15, an AA failure caused by hovering. So
|
||||
* light darkens (.90 lands at 4.94:1, still clear of the 4.5 floor) and dark, whose fill
|
||||
* is light and ink dark, brightens (1.15). */
|
||||
--fs-hover-lift: brightness(.90);
|
||||
|
||||
/* ---- motion: four durations, because the UI does four things ----
|
||||
* There is deliberately no easing token: every transition and animation omits the timing
|
||||
* function and takes the CSS default (ease), which is one curve, nothing to keep in sync
|
||||
* and fewer bytes than naming it. A rule needing another curve is making a design decision
|
||||
* and must justify it in a comment.
|
||||
*
|
||||
* The scale governs `animation` too, not only `transition`: one gesture ridden at three
|
||||
* durations is drift css-dup cannot see, the declaration bodies differing.
|
||||
* 95-a11y-media.css kills all of this under prefers-reduced-motion. */
|
||||
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
|
||||
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
|
||||
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
|
||||
--fs-dur-fill: .4s; /* a progress bar growing to its value */
|
||||
--fs-content-max: 1280px; /* content column cap */
|
||||
/* content column side padding, read by .fs-content, the top bar's centering calc and the
|
||||
* notification width calc, so the three cannot drift */
|
||||
--fs-content-pad: calc(28px * var(--fs-density-space));
|
||||
/* The shell's geometry: tokens, not literals, because the JS reads them back — the CSS
|
||||
* lays the sidebar out and fs-chrome.js's fitShell() subtracts that cut from the viewport
|
||||
* to decide whether what is left is readable. With copies in JS, narrowing the rail here
|
||||
* leaves the measurement subtracting the old width. */
|
||||
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
|
||||
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
|
||||
--fs-content-min: 500px; /* below this a content column stops being readable */
|
||||
/* the bar's surface: the top-nav bar and the sidebar's phone bar are one component in two
|
||||
* markups, so one token keeps the two identical. OPAQUE on purpose. It was 88% of the panel
|
||||
* under a 12px backdrop blur, and the pair was measured on the page most favourable to it —
|
||||
* a pattern wallpaper at full strength, the bar sitting over scrolled content: dropping the
|
||||
* blur moved 0.04% of the pixels, and what the translucency actually bought was the page
|
||||
* showing through the chrome, which reads as a glitch rather than as glass. */
|
||||
--fs-bar-bg: var(--fs-panel);
|
||||
/* height of that bar, and the scroll-margin every anchor target needs to clear it
|
||||
* (base/10-reset.css): if the two drift, an in-page jump lands under the bar */
|
||||
/* The chrome's own controls — the square buttons (search, appearance, log out) and the poll
|
||||
* pill — ride the bar rather than cutting panel-coloured holes in it: the bar is one surface,
|
||||
* and a control on it is carried by its hairline, not by a second fill. `transparent`, so the
|
||||
* control shows the bar itself rather than a second surface stacked on it.
|
||||
*
|
||||
* The backdrop a control lands on is therefore --fs-bar-bg, which is --fs-panel: the same
|
||||
* construction `.label`'s comment in theme/35-alerts.css leans on, so a coloured variant keeps
|
||||
* its >=4.5:1 by the palette's guarantee rather than by measurement. A palette that wants the
|
||||
* old panel2 hole sets this back to var(--fs-panel2). */
|
||||
--fs-bar-ctl-bg: transparent;
|
||||
/* The select's chevron, drawn rather than fetched. It used to be an inline SVG data-URI, and
|
||||
* a URI cannot read var() — the stroke colour had to be written INTO the string, so the
|
||||
* declaration was repeated once per palette and once per mode: ten copies of one glyph, ~2.4 KB
|
||||
* of the shipped sheet, and ten chances for a palette to be recoloured while its chevron was
|
||||
* not. Two gradient bands cost one declaration and take their colour from the palette's own
|
||||
* --fs-dim at paint time, so a new palette gets a correct chevron for free. The 45% / 55% stops
|
||||
* are the band's thickness; the two layers are positioned by the rules that use this, since
|
||||
* only they know the field's inset. What is given up is the round line cap the SVG had, which
|
||||
* at 12px is not visible — compared side by side before the swap. */
|
||||
--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));
|
||||
/* the square icon button in the bar and rail: the logo box, the .fs-themerow controls,
|
||||
* Log out and the collapsed poll pill are one size, so the right cluster reads as a row
|
||||
* of equal squares */
|
||||
--fs-btn-size: calc(34px * var(--fs-density-box));
|
||||
|
||||
/* Corner-radius scale: one user-tunable base (Appearance -> Rounding, 0-20px, pre-painted
|
||||
* on :root by partials/head.ut) with three semantic radii scaling from it proportionally,
|
||||
* so every surface rounds in step and pills stay round. At base 12: card 12, control 10,
|
||||
* chip 8. Every border-radius in theme/ and pages/ reads one of these; the only bare px
|
||||
* left are 0, 50% and three that are not a card's corner at all. */
|
||||
--fs-radius-base: 12px;
|
||||
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
|
||||
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
|
||||
--fs-radius-sm: calc(var(--fs-radius-base) * 2 / 3); /* chips, code, small insets, rail toggle (8 @12) */
|
||||
--fs-radius-pill: 999px; /* pills, toggles, badges — always round */
|
||||
|
||||
/* A card's inner gutter (.cbi-section). A token because it is read back to UNDO it:
|
||||
* theme/45-misc.css bleeds the realtime graphs out to the card's border edge by this
|
||||
* padding plus the 1px border, so a literal in both places would re-clip every graph when
|
||||
* the gutter narrows. */
|
||||
--fs-card-pad: calc(16px * var(--fs-density-space));
|
||||
/* the vertical rhythm between cards (.cbi-section, tab groups, alerts, action bars):
|
||||
* an unnamed level drifts, so it is stated once */
|
||||
--fs-card-gap: calc(16px * var(--fs-density-space));
|
||||
|
||||
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
/* ---- type scale ----
|
||||
*
|
||||
* 13px is the base deliberately: a router UI is dense tables of addresses and counters, and
|
||||
* the size was chosen for that. The rhythm around it is a unitless 1.5 rather than a hard
|
||||
* 18px — `line-height: 18px` is inherited as 18px into a 20px heading and clips it, while
|
||||
* 1.5 is re-resolved against each element's own size.
|
||||
*
|
||||
* Every step is a whole px; the line box 1.5 computes is not (13 -> 19.5), which is what
|
||||
* unitless leading gives on an odd size. What must stay whole is a BOX: never size an
|
||||
* element `--fs-type * --fs-leading` — take the nearer --fs-space step.
|
||||
*
|
||||
* Five steps, and the count is the point: a scale with 1px gaps builds no hierarchy, it
|
||||
* just makes two rules look unrelated in a grep. Ratio is roughly 1.25 per step, and
|
||||
* anything needing a size off this list is making a design decision and should say so. */
|
||||
/* ---- density: one axis, three multipliers ----
|
||||
*
|
||||
* Compact / Normal / Large is a client preference stamped as :root[data-density]; absent
|
||||
* means Normal, so the default costs no attribute and no override.
|
||||
*
|
||||
* Three multipliers, because one knob gets all three kinds of size wrong:
|
||||
*
|
||||
* --fs-density-type TEXT, moving least (.9 / 1.15). Compact is a request for less air,
|
||||
* not for smaller writing; .9 puts the base at 12px, which is the
|
||||
* floor.
|
||||
* --fs-density-space pure AIR — padding, margin, gap — which is what buys the room, so
|
||||
* it moves most (.65 at Compact). It does not grow at Large, where
|
||||
* inflating the air just reads as zoom.
|
||||
* --fs-density-box boxes that must HOLD text or an icon: the sidebar, the rail, the
|
||||
* bar height, the icon buttons, the control-height floors. They
|
||||
* cannot follow the air — the type gives up only a tenth, so a
|
||||
* sidebar cut by 35% no longer fits the labels it exists to show.
|
||||
*
|
||||
* This is only expressible because every size in the theme reads one of these tokens: a
|
||||
* size written outside the ladders silently stops responding to the axis.
|
||||
*
|
||||
* Type is rounded to whole pixels, a fractional font size rendering soft; space is not,
|
||||
* since a sub-pixel padding is invisible and rounding it would collide steps together. */
|
||||
--fs-density-type: 1;
|
||||
--fs-density-space: 1;
|
||||
--fs-density-box: 1;
|
||||
|
||||
/* ---- control heights: three steps, following the density ----
|
||||
* A minimum height stops a button collapsing onto its text, so it is part of how dense the
|
||||
* UI feels: left literal, Compact shrinks the writing while the buttons stay put.
|
||||
* Not --fs-control-h below, which is a FIELD's height derived from the text it must hold;
|
||||
* these are the floors a chrome control gets when its content is not text. */
|
||||
--fs-ctl-h-sm: calc(30px * var(--fs-density-box)); /* dense/small: .btn.small, table-row buttons */
|
||||
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
|
||||
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
|
||||
|
||||
/* Unrounded here, rounded in the @supports below, and the split is the difference between
|
||||
* a soft failure and no page: `round()` is the youngest thing this theme depends on
|
||||
* (Chromium 125, Firefox 118, Safari 15.4), and a custom property holds any token stream,
|
||||
* so an engine without it parses these fine and fails at SUBSTITUTION — `var(--fs-type)`
|
||||
* is then invalid at computed-value time and computes to `unset`, taking every font-size,
|
||||
* line-height and field min-height with it. Measured by serving a sheet with `round(`
|
||||
* renamed: 5881 of 5912 elements on Overview change, body type goes 13px -> 16px, and 5880
|
||||
* font-size and 5733 line-height declarations are lost. Declared this way, such an engine
|
||||
* keeps the whole ladder and pays a fractional pixel at the two non-default densities. */
|
||||
/* One step BELOW the ladder's smallest, and it exists for one measured reason: the Port
|
||||
* status card's traffic figures. They are the widest thing a card must fit whole, so their
|
||||
* size is what sets how narrow a card may be — at --fs-type-xs the floor is 106px, at 10px
|
||||
* it is 94px, which is what turns ten cards plus a lonely eleventh into one row of eleven.
|
||||
* A figure read as a pair of numbers beside a label tolerates the step that body text would
|
||||
* not. Declared here rather than as a literal in the page rule so the density axis stays in
|
||||
* one place. */
|
||||
--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 */
|
||||
/* ---- WEIGHT: there are only ever three, and they had six spellings ----------
|
||||
* The theme ships no font at all — the stacks name Manrope and JetBrains Mono first and
|
||||
* fall through to the system faces — so these are not a ramp to pick from: they are the
|
||||
* two weights a reader is likely to actually have, plus the "un-bold this"
|
||||
* value. `700` and `bold` were both in use and are the same weight; so are `400` and
|
||||
* `normal`, and 400 has no face at all (it resolves onto the 600 one — that is why body
|
||||
* text is semibold by design, and why adding a 400 face would silently restyle every page).
|
||||
* Naming them leaves one spelling each. */
|
||||
--fs-weight-normal: 400; /* "not bold" — no face of its own; resolves onto the 600 */
|
||||
--fs-weight: 600; /* the UI default: body, labels, controls */
|
||||
--fs-weight-bold: 700; /* titles, table headers, the eyebrow */
|
||||
|
||||
--fs-leading: 1.5; /* unitless ON PURPOSE — see above (13 -> 19.5px) */
|
||||
--fs-leading-tight: 1.2; /* headings and single-line plates, where 1.5 looks loose */
|
||||
|
||||
/* ---- THE EYEBROW — one microlabel idiom, named once -------------------------
|
||||
*
|
||||
* Uppercase + tracked + bold + muted is ONE thing this theme draws in a dozen places: table
|
||||
* column headers, the stacked-card `data-title` labels, the Appearance group labels, the rail
|
||||
* flyout titles, the login field titles, the search-result section note. It had drifted into
|
||||
* four spellings of that one idiom — letter-spacing .04/.05/.06em, weight 600 vs 700, colour
|
||||
* --fs-dim vs --fs-faint — the same unnamed-level drift the role x step matrix above exists to
|
||||
* stop, only in typography. An eyebrow is a LABEL, so it takes the quietest ink and the widest
|
||||
* tracking of the set; the alert table's 600 was the odd one out and read lighter than every
|
||||
* other header on the page.
|
||||
*
|
||||
* SIZE is deliberately NOT here: 10px (a card/chrome label) and 11px (a table header, i.e.
|
||||
* --fs-type-xs) are two real tiers, and collapsing them would resize half the tables.
|
||||
*
|
||||
* Two things that look like eyebrows are NOT and must stay out: `h6` is a HEADING (it takes the
|
||||
* type scale and the heading ink), and `.fs-navlabel` is the menu's section separator, whose
|
||||
* .1em is deliberately wider than any label — it separates, it does not caption. */
|
||||
--fs-eyebrow-tracking: .06em;
|
||||
--fs-eyebrow-weight: 700;
|
||||
--fs-eyebrow-color: var(--fs-faint);
|
||||
|
||||
/* ---- SPACE SCALE — a 4px grid ----------------------------------------------
|
||||
*
|
||||
* Same story: the 18px/9px pair base used was upstream's line-height and half of it, so
|
||||
* every gutter in the theme was a function of Twitter's leading. A 4px grid is independent
|
||||
* of the type scale, which is the point — changing the font size must not silently
|
||||
* re-space every widget. `--fs-card-pad` (16px) and `--fs-content-pad` (28px) are already
|
||||
* on this grid and stay named for their role; these are the generic steps.
|
||||
*
|
||||
* Do NOT add a step because one rule wants 10px. An unnamed level is how the radius tints
|
||||
* drifted into four strengths for two decisions (see the ladder above): pick the nearer
|
||||
* step, or argue in a comment for why this rule is genuinely different. */
|
||||
/* The grid is 4px, but the theme genuinely works at 2px between the low steps: a chip's
|
||||
* inset, a nav item's row padding and a card's inner gutter all landed on 6/10/14 and no
|
||||
* amount of rounding makes them 4/8/12 without visibly changing the control. So the half
|
||||
* steps are NAMED rather than pretended away — they were the single largest group of
|
||||
* magic numbers left in the tree (6px x26, 10px x32, 14px x13, 2px x13). Above 16px the
|
||||
* grid stays 4px: nothing needs finer, and every value that had drifted between the steps
|
||||
* up there (22, 26, 42) was snapped onto one. */
|
||||
--fs-space-0-5: calc(2px * var(--fs-density-space));
|
||||
--fs-space-1: calc(4px * var(--fs-density-space));
|
||||
--fs-space-1-5: calc(6px * var(--fs-density-space));
|
||||
--fs-space-2: calc(8px * var(--fs-density-space));
|
||||
--fs-space-2-5: calc(10px * var(--fs-density-space));
|
||||
--fs-space-3: calc(12px * var(--fs-density-space));
|
||||
--fs-space-3-5: calc(14px * var(--fs-density-space));
|
||||
--fs-space-4: calc(16px * var(--fs-density-space));
|
||||
--fs-space-5: calc(20px * var(--fs-density-space));
|
||||
--fs-space-6: calc(24px * var(--fs-density-space));
|
||||
--fs-space-7: calc(28px * var(--fs-density-space));
|
||||
/* The top of the ladder is sparse on purpose — these three are gutters and insets, not
|
||||
* rhythm, and each has two or three users: the sidebar accordion's indent and the search
|
||||
* palette's bottom padding (-7), the room a field leaves for its own chevron (-8), the login
|
||||
* card's bottom margin and the gauge's two right gutters (-10). They are named so that no
|
||||
* padding in this theme is an unexplained number, not because a fourth caller is expected. */
|
||||
--fs-space-8: calc(32px * var(--fs-density-space));
|
||||
--fs-space-10: calc(40px * var(--fs-density-space));
|
||||
|
||||
/* the spinner glyph's box (base/95-luci.css `.spinning::before`, width and height): one
|
||||
* number, one name, read in one place */
|
||||
--fs-spin-size: var(--fs-space-5);
|
||||
|
||||
|
||||
/* One control height for every single-line field — input, select, .cbi-select,
|
||||
* .cbi-dropdown and the checkbox/radio row beside them. Derived rather than measured by
|
||||
* hand: the text box (--fs-type x --fs-leading = 19.5px) plus the vertical inset those
|
||||
* fields carry (2 x --fs-space-1) plus both 1px borders = 29.5 -> ceil 32 at the default
|
||||
* density. A literal here is arithmetic over one particular leading, so
|
||||
* re-scaling the type silently leaves every control the wrong height for its own text. */
|
||||
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
|
||||
|
||||
/* How wide an elastic control gets in a form row. A `.cbi-value-field` is `flex: 1`, so
|
||||
* anything asking for 100% takes the whole card, and a form whose controls each end at a
|
||||
* different x reads as broken. Fixed-width controls keep their own 210px; this is the
|
||||
* ceiling for the ones that stretch. It is a COLUMN width, like the label column beside
|
||||
* it, so it is deliberately not on the 4px step scale. */
|
||||
--fs-field-max: 440px;
|
||||
|
||||
/* ---- stacking scale ----
|
||||
* Every z-index in the theme comes from here. Bare numbers across seven files record
|
||||
* nothing about what should sit above what, which is how a popover came to paint over an
|
||||
* open modal; as an ordered list that is obvious.
|
||||
*
|
||||
* Bottom to top:
|
||||
* raise in-flow child that must clear its siblings (zonebadge tooltip)
|
||||
* sticky the sticky bars: top-nav, sidebar phone bar
|
||||
* flyout nav panels: rail flyouts, top-nav dropdowns, rail tooltips
|
||||
* header base's sticky <header> (above content, below any popup)
|
||||
* popover the Appearance popover — ABOVE the chrome, BELOW a modal
|
||||
* overlay #modal_overlay: a modal is modal, it covers the chrome
|
||||
* tooltip .cbi-tooltip — must read over modal content
|
||||
* dropdown an OPEN .cbi-dropdown list — over everything, including inside a modal
|
||||
* ------------------------------------------------------------------------ */
|
||||
--fs-z-raise: 2;
|
||||
--fs-z-sticky: 50;
|
||||
--fs-z-flyout: 70;
|
||||
--fs-z-header: 800;
|
||||
--fs-z-popover: 850;
|
||||
--fs-z-overlay: 900;
|
||||
--fs-z-tooltip: 1000;
|
||||
--fs-z-dropdown: 1100;
|
||||
|
||||
/* ---- export tier: outbound only ----
|
||||
* Nothing in styles/ may read these (tools/audit.py fails the build if it does). They
|
||||
* exist so a third-party luci-app-* addressing them still gets our palette and dark mode,
|
||||
* and so an app overwriting one restyles only its own widgets.
|
||||
*
|
||||
* high/medium/low is a PROMINENCE scale, not decoration: apps do ask for the gradation —
|
||||
* one paints its "no data" latency with --primary-color-low, another reads eleven of these
|
||||
* names, and stock firewall.js and status/cpu.js read them too — so three aliases of one
|
||||
* token hand back a flat colour.
|
||||
*
|
||||
* Direction is bootstrap's, which every app was calibrated against: `high` is the most
|
||||
* pronounced value of the role, except --background-color-*, which is an ELEVATION axis
|
||||
* (high = raised). The mechanism differs per family, by measurement — see each block.
|
||||
*
|
||||
* Binding constraint: apps read a level as `color:` about as often as as `background:`, so
|
||||
* every level must clear WCAG AA as text on --fs-bg/--fs-panel/--fs-panel2 and carry a
|
||||
* legible --on-*-color as a fill. */
|
||||
--background-color-high: var(--fs-panel);
|
||||
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
|
||||
--background-color-low: var(--fs-panel2);
|
||||
|
||||
/* -low is --fs-faint, the theme's own third ink, and not a duplicate of -medium: making the
|
||||
* two the same token flattens the export (`spread(high, low)` falls to 0.082 in the
|
||||
* default palette, under the 0.10 the ramp promises) and hands an app asking for a
|
||||
* gradation one colour twice. Legibility is measured rather than assumed — every level is
|
||||
* held to AA as text on all three surfaces and as a fill under its --on-* ink, in both
|
||||
* modes, across the whole palette x tint matrix. */
|
||||
--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 supposed to
|
||||
* disappear into it */
|
||||
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
|
||||
--border-color-medium: var(--fs-border);
|
||||
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
|
||||
|
||||
/* Accent ramps: the axis is CHROMA at constant lightness — `high` is the pure palette
|
||||
* colour and each step mixes it toward --fs-dim in oklch, which interpolates lightness
|
||||
* linearly and so drains saturation while leaving luminance put, keeping every level
|
||||
* printable as text. Both alternatives were measured and rejected:
|
||||
*
|
||||
* fading `low` toward the surface spends contrast we do not have — in dark every accent
|
||||
* already sits at 4.56-4.59:1, and an 8% fade took one level to 4.18:1;
|
||||
*
|
||||
* pushing `high` toward --fs-text collapses the ramp in dark, where --fs-text is
|
||||
* near-white: the strong end comes back 0.055 from the weak end, i.e. flat.
|
||||
*
|
||||
* Bootstrap's own ramp walks lightness, which is why it reads inverted in dark mode and
|
||||
* lands at 3.6:1. Keep its direction, drop its mechanism. Every step is mixed FROM the
|
||||
* token it grades, so there is no hand-kept second copy of a colour, and
|
||||
* 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;
|
||||
}
|
||||
|
||||
/* ---- the rounded half of the type ladder, for engines that can round ----
|
||||
*
|
||||
* A fractional font size renders soft, so every size is meant to land on a whole pixel — but a
|
||||
* token using `round()` on an engine without it is not a fallback, it is a deleted token that
|
||||
* takes every rule reading it (see the ladder above). So the ladder is declared plainly there
|
||||
* and rounded here, where the at-rule can be asked first. --fs-control-h is derived from
|
||||
* --fs-type and rounds UP to the 4px control ladder for the same reason.
|
||||
*
|
||||
* `@supports (width: round(1px, 1px))` rather than a `calc()` probe: the function has to be
|
||||
* tested where a length is expected. */
|
||||
@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));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the density axis ----
|
||||
*
|
||||
* Normal is a bare :root, so the default path costs no attribute and no override.
|
||||
*
|
||||
* These multiply the LADDERS, not the rules: a size on a ladder follows the axis for free and
|
||||
* one that is not silently ignores it. */
|
||||
:root[data-density="compact"] {
|
||||
/* type moves least (.9 against .65 for the air): padding is where the room is, so leaning
|
||||
* on the text would cost legibility to save very little. At .9 the base is 12px and the
|
||||
* eyebrow 10px, which is the floor. */
|
||||
--fs-density-type: .9;
|
||||
--fs-density-box: .85;
|
||||
--fs-density-space: .65;
|
||||
}
|
||||
:root[data-density="large"] {
|
||||
/* Large is about the TEXT, so the air does not grow with it: only the type and the boxes
|
||||
* that hold it get bigger. Inflating the spacing too pushes content off the screen and
|
||||
* makes the setting read as zoom, which is the browser's job. */
|
||||
--fs-density-type: 1.15;
|
||||
--fs-density-box: 1.15;
|
||||
--fs-density-space: 1;
|
||||
}
|
||||
|
||||
/* ---- the content-width axis ----
|
||||
*
|
||||
* No :root[data-content-width] blocks here (issue #44 moved this from a three-step picker to a
|
||||
* slider): the value is a real px length written straight onto --fs-content-max as an inline
|
||||
* style by fs-axes.js's propAxis, the same shape Rounding uses, and read back exactly as before
|
||||
* by `.fs-content`, the modal width calc and the top layout's centring padding
|
||||
* (theme/20-shell.css, theme/50-toplayout.css) — deliberately including the modal, which follows
|
||||
* the column on purpose rather than keeping its own separate cap the reader would have to
|
||||
* reconcile against.
|
||||
*
|
||||
* The range is 1280-3840, and neither end is derived from this file: 1280 (--fs-content-max
|
||||
* above) is the value the theme has always drawn, so the slider's left end IS today's look and
|
||||
* the axis can never go narrower than the default — a difference from every other numeric axis,
|
||||
* none of which has a floor that also has to be the default. --fs-content-min (500px) was the
|
||||
* rule this axis had to stay clear of; with 1280 as the floor it is now a property of the range
|
||||
* instead, 780px below the nearest point the slider can reach. 3840 stands in for "uncapped": no
|
||||
* CSS viewport width in ordinary use reaches it, since even a physical 4K panel reports a scaled
|
||||
* CSS width well under it — and `columnWidth()`'s `Math.min(state.outerW - cut, g.contentMax)`
|
||||
* needs a real px length either way, `none`/`100%` failing both the `calc()` in
|
||||
* 50-toplayout.css and `px()`'s getComputedStyle probe in fs-chrome.js.
|
||||
*
|
||||
* Step 128px: 2,560px of travel at 20 stops, matching Rounding/Photo dim/Pattern strength's stop
|
||||
* count rather than Pattern scale's near-pixel drag — a stop here should move a table's column
|
||||
* count, not shave a pixel off a margin, and 128 is the largest round number that divides the
|
||||
* range into stops that few. */
|
||||
|
||||
:root[data-darkmode="true"] {
|
||||
/* palette colours are in 03-palettes.css; only mode-level, palette-independent
|
||||
* tokens live here */
|
||||
--fs-disabled-opacity: .45;
|
||||
/* the hover cue flips direction here (see --fs-hover-lift): a dark-mode solid button is a
|
||||
* light fill with dark ink, so brightening moves it away from both the page and its own
|
||||
* ink */
|
||||
--fs-hover-lift: brightness(1.15);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* No component bridge in any notation (--*-hsl triples, --*-rgb read as `rgba(var(--x), .3)`):
|
||||
* a hand-kept second copy of a colour goes stale in silence when a palette is recoloured, and
|
||||
* a missing triple makes the declaration invalid at computed-value time, so the tint vanishes
|
||||
* with no error tools/audit.py can catch — the var IS defined, only elsewhere. A tint of X is
|
||||
* mixed FROM X, and a component copy must not be reintroduced in any notation:
|
||||
* rgba(var(--error-color-high-rgb), .3) -> color-mix(in srgb, var(--fs-danger) 30%, transparent)
|
||||
* The one surviving --*-rgb is --zone-color-rgb, which is not ours: luci-mod-network writes it
|
||||
* inline on a zone badge (base/95-luci.css). */
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
@layer tokens {
|
||||
/* ---- colourways (palettes) ----
|
||||
*
|
||||
* Every colour is a variable and every palette is one self-contained block per mode, so a new
|
||||
* colourway is copy-one-pair-and-recolour. Five ship: footstrap (GitHub Primer, the default),
|
||||
* hicontrast, bootstrap (the stock LuCI theme's colours), 2020 (the OpenWrt 2020 theme's
|
||||
* colourway) and forum (the OpenWrt forum's Discourse colourway).
|
||||
*
|
||||
* TO ADD A COLOURWAY "sunset":
|
||||
* 1. Copy the two hicontrast blocks below, rename data-palette to "sunset".
|
||||
* 2. Set every colour. FIVE are declared as -base with the live token derived from them,
|
||||
* so the colour axes can recolour them with no second copy to keep in sync: the canvas
|
||||
* --fs-bg-base → --fs-bg (TINT block), the accent --fs-accent-base → --fs-accent and
|
||||
* the three status colours --fs-good-base / --fs-warn-base / --fs-danger-base (COLOUR
|
||||
* AXES block). A palette never declares --fs-good itself.
|
||||
* 3. Set the four INKS (--fs-on-accent/-good/-warn/-danger) — see below.
|
||||
* 4. Register the name in all four places, because each one fails DIFFERENTLY and quietly:
|
||||
* the PALETTE axis in fs-prefs.js (live switching), the `_sd_pal` whitelist AND the
|
||||
* pre-paint switch in partials/head.ut (a router default outside the whitelist becomes
|
||||
* '' and the page paints in the default palette), the label map in fs-appearance.js (an
|
||||
* unnamed palette is missing from the picker, not broken), and matrix() in
|
||||
* tools/lib/gallery.mjs — a palette absent there is one export-tier.mjs,
|
||||
* a11y-gallery.mjs and placeholder-ink.mjs never measure, so it ships ungated.
|
||||
* Nothing else: layout, components and the --*-color-* export bridge key off these tokens, and
|
||||
* every tint, shadow and hairline is a color-mix() over them.
|
||||
*
|
||||
* The inks are per palette and per mode. A single global `--fs-on-accent: #fff` holds up on
|
||||
* the dark fills of a LIGHT palette and nowhere else — on a dark palette's light fills it
|
||||
* measures 1.69-2.80:1, where AA wants 4.5:1 for text and 3:1 for a UI shape. Check a new
|
||||
* colourway's inks against all four of its own fills; every pair below clears 4.5:1.
|
||||
*
|
||||
* A tint of X is mixed FROM X — do not reintroduce a component copy of a colour in any
|
||||
* notation (foot of 02-tokens.css).
|
||||
*
|
||||
* Default: footstrap fills bare :root, so a no-JS load still has colours; every other
|
||||
* colourway is opt-in. :root[data-palette=…][data-darkmode=…] (0,3,0) beats the bare blocks,
|
||||
* and each block sets a COMPLETE token set so the winner fully applies. */
|
||||
|
||||
/* Native <select> closed-control chevron (theme/60-inputs.css). A data-URI cannot
|
||||
* reference var() or currentColor, so each palette block bakes its own --fs-dim into
|
||||
* the stroke — the only way the glyph can follow palette and mode. */
|
||||
|
||||
/* ---- background tint: "which router am I on?" ----
|
||||
*
|
||||
* One hue (Appearance -> Tint, 0-360°, localStorage `fs-tint`, pre-painted on :root by
|
||||
* partials/head.ut) washed into the CANVAS — --fs-bg, the surface the cards float on — so a
|
||||
* whole install reads as green, violet or amber at a glance. localStorage is keyed
|
||||
* by origin, so the cue is per router with nothing server-side. Off (no data-tint) is the
|
||||
* palette exactly as it was.
|
||||
*
|
||||
* Nothing else moves: the cards, the chrome and the semantic colours keep the palette's own
|
||||
* values, and re-hueing a status colour would make it lie about status.
|
||||
*
|
||||
* --fs-bg-base exists because a custom property cannot be defined from itself:
|
||||
* `--fs-bg: color-mix(…, var(--fs-bg), …)` is a cycle, invalid at computed-value time, and
|
||||
* drops the colour silently. A palette names the raw canvas, this block owns the derivation,
|
||||
* and every rule goes on reading --fs-bg.
|
||||
*
|
||||
* It sets chroma and hue rather than mixing a colour in, because in a POLAR space the mix's
|
||||
* hue lands on the tint's almost immediately and the percentage never controls the strength:
|
||||
* measured on the dark canvas, "2%" and "6%" at hue 165 gave the same green. Light mode failed
|
||||
* the other way, the mix leaving sRGB and clipping, so the cue's strength depended on which
|
||||
* hue was picked. So the canvas takes the user's hue at a chroma we choose and keeps its own
|
||||
* LIGHTNESS, in one relative-colour declaration:
|
||||
* one honest knob --fs-tint-c is the strength, in chroma. Light mode has less headroom and
|
||||
* does clip at the top of the wheel, which costs a few units of strength
|
||||
* but not the direction. On a canvas of pure WHITE the cue is nearly
|
||||
* hue-blind — on bootstrap's #fff, 180° and 258° land 1/255 apart —
|
||||
* which is the price of copying a stock theme's surfaces.
|
||||
* contrast is free `l` is copied through, so AA margins barely move; the mix dragged
|
||||
* lightness toward the anchor and cost one export level 4.61 -> 4.40:1.
|
||||
*
|
||||
* tools/export-tier.mjs runs the AA matrix over six hues x both modes x every palette and
|
||||
* tools/a11y-gallery.mjs runs axe over the tinted gallery at two extreme hues. Change
|
||||
* --fs-tint-c and re-run both.
|
||||
*
|
||||
* The shipped chroma is deliberately tiny — the dark floor is below the chroma the dark canvas
|
||||
* already carries. This is an identity cue, not a colourway: a large flat field is where the
|
||||
* eye is most sensitive to a cast. Loud belongs in a palette, where the cards and chrome can
|
||||
* move too. */
|
||||
:root {
|
||||
--fs-bg: var(--fs-bg-base);
|
||||
/* the UI accent, derived from the palette's own -base hex so the accent-hue axis below can
|
||||
* rotate it with no second copy to keep in sync. Absent an accent hue, --fs-accent IS the
|
||||
* palette's -base value. */
|
||||
--fs-accent: var(--fs-accent-base);
|
||||
/* a tint OF --fs-accent, mixed FROM it: a restated literal goes stale the moment the accent
|
||||
* is recoloured or the accent-hue axis rotates it. LIGHT strength only — every palette's
|
||||
* light block wanted the identical 10%, so this one declaration replaces five copies; a
|
||||
* dark block still declares its own (15%, hicontrast 14%: 02-tokens.css line ~81) and wins
|
||||
* on specificity. */
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
/* the three status colours, derived from the palette's -base hex for the reason the accent
|
||||
* is: an axis rotating a token the palettes declared directly would need a second copy of
|
||||
* every value to rotate from */
|
||||
/* The surfaces, split the same way and for the same reason as the accent. A palette
|
||||
* declares the raw value; every rule reads the derived one.
|
||||
*
|
||||
* Do not add a transparency axis over these: thinning the surface thins the TEXT's
|
||||
* background out from under it, and the contrast readouts cannot even report it — a ratio
|
||||
* against a semi-transparent backdrop is a ratio against whatever is behind that pixel.
|
||||
* The wallpaper axis is the honest way to have a picture. */
|
||||
--fs-panel: var(--fs-panel-base);
|
||||
--fs-panel2: var(--fs-panel2-base);
|
||||
--fs-border: var(--fs-border-base);
|
||||
--fs-good: var(--fs-good-base);
|
||||
--fs-warn: var(--fs-warn-base);
|
||||
--fs-danger: var(--fs-danger-base);
|
||||
/* The ink of a value that is NOT there — the `placeholder` attribute and the dropdown's
|
||||
* `li[placeholder]` row. LuCI writes an option's DEFAULT there (`form.Value.placeholder`:
|
||||
* 128 for the log buffer, 514 for the log port), so an ink that reads as body text tells
|
||||
* the reader a field is set when it is empty — reported against 0.14.8, forum topic 251930
|
||||
* posts 82-83. --fs-dim was that ink and is not far enough away: on footstrap light's
|
||||
* --fs-panel2 it measures 11.12:1 against the text's 14.84:1, which is two blacks.
|
||||
*
|
||||
* Derived, not hand-listed per palette, so it follows a palette that has not been written
|
||||
* yet. The formula is spelt once and the STRENGTH is a token of its own, because it is the
|
||||
* only part that varies — per mode here, and again under prefers-contrast:more
|
||||
* (theme/95-a11y-media.css); restating the whole mix in each of those four places cost 79 B
|
||||
* of shipped sheet and gave three more copies to keep in step. That is also why it lives
|
||||
* here rather than in 02-tokens.css's ladder — the same reason --fs-accent-soft does.
|
||||
*
|
||||
* Light lands at 3.99-4.48:1 across the four palettes, 43-44% of the way from the value's
|
||||
* ink to the field: under AA on purpose, see the dark half below for the whole argument.
|
||||
* oklch because the step is a LIGHTNESS one — srgb at the same 55% stops at 3.58:1. Held by
|
||||
* `npm run placeholder`. */
|
||||
--fs-placeholder-mix: 55%;
|
||||
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
|
||||
/* The hues the user picked, in degrees. Declared here and not only inline by
|
||||
* head.ut/fs-prefs.js, so the stylesheet stands on its own: a var() with no definition
|
||||
* anywhere is invalid at computed-value time and takes the whole declaration down. */
|
||||
--fs-accent-h: 0;
|
||||
--fs-tint-h: 0;
|
||||
--fs-good-h: 0;
|
||||
--fs-warn-h: 0;
|
||||
--fs-danger-h: 0;
|
||||
/* Tint strength (Appearance -> Tint strength — NOT the Density select, which is the
|
||||
* UI-density axis and owns that word): a multiplier on the tint chroma below, 1 being the
|
||||
* designed strength and 2 the cap. Set inline (value/100) by head.ut and fs-prefs.js.
|
||||
*
|
||||
* 0 is not "the palette back": the rule below is a relative colour that REPLACES chroma, so
|
||||
* a multiplier of 0 also strips the cast the canvas already had (every palette's canvas is
|
||||
* a blue-grey) and leaves it neutral at the same lightness — footstrap dark rgb(28,33,40)
|
||||
* paints as rgb(33,33,33). It cannot be made into the
|
||||
* untinted page here — a conditional would have to sit on :root, which a style query cannot
|
||||
* reach. The true off is clearing the Tint hue, which drops data-tint and this rule. */
|
||||
--fs-tint-strength: 1;
|
||||
/* The uploaded login/page background. --fs-login-bg-url is the url() head.ut and fs-prefs.js
|
||||
* stamp inline; `none` here is the stand-on-its-own default the hues carry above.
|
||||
* --fs-photo-scrim is the wash laid over the photo so on-canvas text stays legible on an
|
||||
* arbitrary image — derived from --fs-bg, so it follows palette, mode and tint, and its
|
||||
* opacity bounds the effective canvas contrast whatever the photo is. Both are inert until
|
||||
* data-login-bg is present (theme/16-login-bg.css). */
|
||||
--fs-login-bg-url: none;
|
||||
/* --fs-photo-dim is the scrim opacity over a File photo, a per-browser axis like the rest.
|
||||
* The scrim is mixed from --fs-bg-BASE, not --fs-bg, so it stays neutral: the tint colours
|
||||
* --fs-bg and must never leak onto a photo. */
|
||||
--fs-photo-dim: 74%;
|
||||
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
|
||||
/* The uploaded pattern, tiled behind the content by 15-wallpaper.css. --fs-pattern-url is
|
||||
* stamped inline and is `none` here for the same reason --fs-login-bg-url is. The other two
|
||||
* are ordinary per-browser axes: the tile's edge length and the layer's opacity, where .2
|
||||
* is where a neutral line reads as a drawing rather than texture (.15 is swallowed by a
|
||||
* tinted canvas). */
|
||||
--fs-pattern-url: none;
|
||||
--fs-pattern-size: 440px;
|
||||
--fs-pattern-strength: .2;
|
||||
/* How strong the cue is: a chroma, and a function of the HUE, because the canvas is not
|
||||
* neutral — every palette's canvas is a blue-grey (oklch hue 248-264) and the dark one
|
||||
* carries chroma .0153 of it. A flat chroma leaves the blue/violet end of the wheel
|
||||
* invisible, painting the canvas less blue than the palette already was. The cue is read
|
||||
* against the untinted page, so it must out-chroma the canvas's own cast where the two
|
||||
* agree: a floor plus two cos() terms, each peaking on one hue and gone 90° away.
|
||||
*
|
||||
* +boost at 258° the canvas's own hue; without it blue and violet do nothing
|
||||
* -damp at 55° red-through-yellow, which the eye picks up first, so at a shared floor
|
||||
* the warm half shouts while the cold half whispers (dark only — on a
|
||||
* near-white canvas warm is the quietest hue there is)
|
||||
*
|
||||
* One expression per mode, with no per-hue table to keep in sync. Light runs the HIGHER
|
||||
* floor: near-white has almost no chroma of its own (.0034), so the tint is the only
|
||||
* colour there. */
|
||||
--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));
|
||||
/* 64%, against the 55% above, and the difference is what a dark palette can afford: its ink
|
||||
* starts at 6.45:1 on --fs-panel2 where the light one has 14.84:1, so the same percentage
|
||||
* would buy a hint nobody can read. It lands at 3.43-6.05:1, 33-35% of the range.
|
||||
*
|
||||
* BOTH modes ship below AA, and that is the decision this token exists to record: a hint
|
||||
* mistaken for a value makes a reader configure the wrong thing, while a hint at 3.43:1
|
||||
* makes them look twice. The AA-clearing mixes (62/80%) were measured first and moved only
|
||||
* 37% and 20% of the range — the same fault, quieter. 3:1 (SC 1.4.11, and SC 1.4.3's
|
||||
* large-text floor) is the line NOT crossed, worst 3.43:1; prefers-contrast:more buys the
|
||||
* AA ink back in both modes (theme/95-a11y-media.css); and `placeholder-ink` holds all
|
||||
* three states, axe-core being excluded from this row for the same reason. */
|
||||
--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)); }
|
||||
|
||||
/* ---- the colour axes: five colours, two ways to set each ----
|
||||
*
|
||||
* Accent, Tint, Good, Warn and Danger are one axis pointed at five tokens (fs-prefs.js's
|
||||
* colorAxis). Each is OFF (no attribute = the palette exactly as it shipped) or set one of
|
||||
* two ways, which is what the attribute's VALUE says:
|
||||
*
|
||||
* data-x="hue" the slider — oklch(from <the palette's own> l c H). Lightness and chroma
|
||||
* are the palette's, so every contrast margin the palette was measured at
|
||||
* survives the rotation and the ink does NOT need recomputing.
|
||||
* data-x="hex" the colour field — the user's colour EXACTLY, stamped inline on :root by
|
||||
* head.ut/fs-prefs.js. An inline custom property outranks every layer, so
|
||||
* the declaration is not repeated here; what IS here is the consequence.
|
||||
*
|
||||
* The ink is derived, and only in hex mode. --fs-on-accent is the ink ON the accent fill — a
|
||||
* solid button's label, the toggle knob, the active tab pill — and the palettes pick it per
|
||||
* mode against fills they were measured against. A user-entered colour is measured against
|
||||
* nothing, so keeping the palette's ink puts white on #ffd400: 1.43:1, on the one control
|
||||
* whose whole job is to be pressed.
|
||||
*
|
||||
* So in hex mode the ink follows the LIGHTNESS of whatever was entered:
|
||||
* `oklch(from <fill> clamp(0, (l - .62) * -100, 1) 0 0)` is black above L .62 and white
|
||||
* below it, chroma zeroed so it is a neutral either way. The multiplier only has to be big
|
||||
* enough to saturate the clamp — there is deliberately no ramp between the two, because a
|
||||
* mid-grey ink is the one answer that fails against both.
|
||||
*
|
||||
* L .62 is where black and white cross over in sRGB (a mid grey is ~4.6:1 against each), so
|
||||
* the worse of the two is never much under AA and the better is far over it. It is derived
|
||||
* from the fill rather than picked per palette on purpose: an axis is set per BROWSER and
|
||||
* composes with either palette and either mode, so there is no block a per-palette answer
|
||||
* could live in.
|
||||
*
|
||||
* Tint has no ink row: it colours the CANVAS, whose text is --fs-text, a palette token this
|
||||
* axis must not move — which is also why the Appearance page reports the contrast a custom
|
||||
* canvas lands at instead of correcting it. */
|
||||
:root[data-accent="hue"] { --fs-accent: oklch(from var(--fs-accent-base) l c var(--fs-accent-h)); }
|
||||
:root[data-good="hue"] { --fs-good: oklch(from var(--fs-good-base) l c var(--fs-good-h)); }
|
||||
:root[data-warn="hue"] { --fs-warn: oklch(from var(--fs-warn-base) l c var(--fs-warn-h)); }
|
||||
:root[data-danger="hue"] { --fs-danger: oklch(from var(--fs-danger-base) l c var(--fs-danger-h)); }
|
||||
|
||||
/* The attribute is repeated to win on specificity, which is load-bearing: a palette's dark
|
||||
* block is `:root[data-darkmode="true"]`, also (0,2,0), and it declares the ink too — so at
|
||||
* equal specificity the later rule wins and the single-attribute form loses in dark mode only
|
||||
* (a grey accent kept the dark palette's near-black ink at 1.9:1 while light derived white).
|
||||
* Repeating the attribute makes it (0,3,0) and wins in both modes, never on source order. */
|
||||
:root[data-accent="hex"][data-accent] { --fs-on-accent: oklch(from var(--fs-accent) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-good="hex"][data-good] { --fs-on-good: oklch(from var(--fs-good) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-warn="hex"][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-danger="hex"][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
|
||||
/* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ----
|
||||
* `[data-palette="footstrap"]` never matches — both appliers REMOVE the attribute for the
|
||||
* default rather than stamp it (fs-prefs.js listAxis, partials/head.ut:216) — and it is kept
|
||||
* anyway: dropping it leaves a bare `:root` that duplicates the derive rule above, and merging
|
||||
* those two would put one palette's raw values in the block that derives every palette's. 86 B
|
||||
* to keep the two roles apart. */
|
||||
: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;
|
||||
/* The secondary tier carries LuCI's page descriptions and every field title, which at the
|
||||
* GitHub-light greys (5.2:1 and 4.9:1 on the card) is help text a reader has to lean into.
|
||||
* Now 11.8:1 and 10.0:1, still a tier below the 15.8:1 body ink so a label stays a
|
||||
* label. */
|
||||
--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-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: these fills are LIGHT. White measures 2.79/2.77/2.80/2.79 — a fail on
|
||||
* all four. #10151c (a shade of --fs-bg) gives 6.58/6.61/6.55/6.57. */
|
||||
--fs-on-accent: #10151c;
|
||||
--fs-on-good: #10151c;
|
||||
--fs-on-warn: #10151c;
|
||||
--fs-on-danger: #10151c;
|
||||
}
|
||||
|
||||
/* ---- hicontrast — deeper, more saturated (opt-in) ---- */
|
||||
:root[data-palette="hicontrast"] {
|
||||
/* LIGHT
|
||||
* The four accents below are DARKER than the default palette's, not brighter: brighter
|
||||
* made the palette named "hicontrast" the lower-contrast one — --fs-good #17b978 as a
|
||||
* label on --fs-panel measured 2.55:1 against the default's 5.08:1. They serve both as
|
||||
* text on --fs-panel (status labels, outline buttons, port speeds) and as fills under
|
||||
* white ink, and both uses want >=4.5:1 against white. At 5.22/5.35/5.93/5.80 they are
|
||||
* genuinely above the default palette, which is what the name promises. */
|
||||
--fs-bg-base: #eef1f6;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #f6f8fb;
|
||||
--fs-border-base: #e4e9f1;
|
||||
--fs-text: #101725;
|
||||
/* …and the secondary tier moves with it, the promise above being relative: 13.5:1 and
|
||||
* 12.0:1 against the default palette's 11.8 and 10.0 */
|
||||
--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;
|
||||
/* white on the fills above: 5.22/5.35/5.93/5.80 */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
:root[data-palette="hicontrast"][data-darkmode="true"] {
|
||||
/* DARK */
|
||||
--fs-bg-base: #0a0d13;
|
||||
--fs-panel-base: #111620;
|
||||
--fs-panel2-base: #161c28;
|
||||
--fs-border-base: #232b3a;
|
||||
--fs-text: #e8edf5;
|
||||
--fs-dim: #9ba6b8;
|
||||
--fs-faint: #8f9aad;
|
||||
--fs-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 measures 2.57/1.69/1.76/2.78, while #0a0d13 (= --fs-bg-base)
|
||||
* gives 7.55/11.49/11.03/7.01 */
|
||||
--fs-on-accent: #0a0d13;
|
||||
--fs-on-good: #0a0d13;
|
||||
--fs-on-warn: #0a0d13;
|
||||
--fs-on-danger: #0a0d13;
|
||||
}
|
||||
|
||||
/* ---- bootstrap — the colours of luci-theme-bootstrap, the LuCI default (opt-in) ----
|
||||
*
|
||||
* The surfaces, greys and semantic colours of the stock theme, so an admin who wants the look
|
||||
* they already know keeps it and still gets the chrome, the client navigation and the axes.
|
||||
*
|
||||
* Bootstrap computes everything from HSL axes; these are those axes evaluated:
|
||||
* --background-color high/medium/low #fff / #f9f9f9 / #f5f5f5 (dark #222 / #282828 / #2c2c2c)
|
||||
* --text-color high/medium #404040 / #808080 (dark #bfbfbf / #7f7f7f)
|
||||
* --border-color high/medium #ccc / #ddd (dark #555 / #444)
|
||||
* The canvas and the card are both --background-color-high: bootstrap is flat and lets the
|
||||
* border carry the structure. panel2 is its --background-color-low, the surface it stripes
|
||||
* tables and hovers rows with.
|
||||
*
|
||||
* Where this deviates: bootstrap's semantic colours do not clear AA on bootstrap's own
|
||||
* surfaces (on --fs-panel2, success 2.73:1, warn 1.61:1, error 3.65:1, primary 4.22:1; in dark
|
||||
* error 3.44:1 and success 4.44:1), and
|
||||
* every palette here is held to 4.5:1 on all three because the export tier is what other
|
||||
* people's apps print text in. So each is taken from the step of bootstrap's own ramp that
|
||||
* clears it where one exists, and otherwise keeps hue and saturation while the value moves
|
||||
* until it clears 4.95:1. Warn is the only large move: no yellow readable as text on white is
|
||||
* still that yellow.
|
||||
*
|
||||
* Bootstrap's dark header gradient is not here: the chrome reads --fs-panel, so a palette
|
||||
* cannot give the bar its own colour — that would need a chrome token pair, which is a change
|
||||
* to the chrome, not a colourway. */
|
||||
:root[data-palette="bootstrap"] {
|
||||
/* LIGHT */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #f5f5f5;
|
||||
--fs-border-base: #ccc;
|
||||
/* The ink is the one place this colourway leaves bootstrap's own values behind, the same
|
||||
* decision the default palette made: its #404040 body and #6a6a6a muted tier measure
|
||||
* 10.4:1 and 5.4:1 on white, and the muted one carries every field title. Matched to the
|
||||
* default palette's light ramp ratio for ratio (15.7 / 11.7 / 10.0), in neutral greys, so
|
||||
* what is copied is the hue and not the readability. */
|
||||
--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 */
|
||||
/* ink ON the fills above — white clears AA on all four: 5.81/5.54/5.40/5.70 */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
:root[data-palette="bootstrap"][data-darkmode="true"] {
|
||||
/* DARK */
|
||||
--fs-bg-base: #222;
|
||||
--fs-panel-base: #282828;
|
||||
/* bootstrap's own dark --background-color-low (#2c2c2c) sits 0.016 from --fs-panel, under
|
||||
* the 0.02 the ramp check wants, so an app asking high/-low for a gradation gets one colour
|
||||
* twice; #303030 is 0.031. Its light pair is 0.039 apart, so the same check catches this in
|
||||
* the theme this palette is named after. */
|
||||
--fs-panel2-base: #303030;
|
||||
--fs-border-base: #444;
|
||||
--fs-text: #bfbfbf;
|
||||
/* Bootstrap's dark has no third ink, so the two quiet tiers are this palette's own and AA
|
||||
* decides where the fainter one sits: the export tier's -low follows --fs-faint, and on a
|
||||
* tinted --fs-panel2 #969696 measures 4.46:1, under the floor for a name apps print text
|
||||
* in. #9a9a9a is the first step that clears it, so the floor pins --fs-faint and the step
|
||||
* between the tiers comes from above (--fs-dim moves up by the 12 channel steps the
|
||||
* hicontrast dark ramp uses). Both are already a deviation, and one step more is cheaper
|
||||
* than collapsing --text-color-medium and -low onto one colour. */
|
||||
--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: these fills are light, and bootstrap's own --on-*-color in dark is likewise its
|
||||
* --background-color-high. #222 gives 5.65/5.65/5.66/5.65. */
|
||||
--fs-on-accent: #222;
|
||||
--fs-on-good: #222;
|
||||
--fs-on-warn: #222;
|
||||
--fs-on-danger: #222;
|
||||
}
|
||||
/* ---- 2020 — the OpenWrt 2020 theme's colourway ----
|
||||
*
|
||||
* `luci-theme-openwrt-2020` is one colour scheme and no dark mode: the OpenWrt CI cyan
|
||||
* (#00B5E2) on the deep navy (#002B49), white content, and three semantic colours. This
|
||||
* palette carries that identity onto footstrap's surfaces.
|
||||
*
|
||||
* The cyan is the whole identity and is unreadable as text on white (2.09:1 against the 4.5:1
|
||||
* the export tier wants), so the LIGHT mode keeps the hue and darkens it while the DARK mode,
|
||||
* whose surfaces are the navy the cyan was designed against, carries it near its own value.
|
||||
* That split is the point of the pair.
|
||||
*
|
||||
* The three semantic colours get the same treatment: on white, 2020's green is 2.28:1 and its
|
||||
* amber 3.42:1, while its red clears AA at 5.25:1 and is the one kept close to the original.
|
||||
*
|
||||
* The navy is not the chrome here. In 2020 it is the header and the sidebar, but the chrome
|
||||
* reads --fs-panel, so in dark mode the navy is the CANVAS and the chrome sits on it. */
|
||||
:root[data-palette="2020"] {
|
||||
/* LIGHT — white content, the CI cyan darkened until it can be read on it */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel-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 */
|
||||
/* ink ON the fills above — white clears AA on all four */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
: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 — the OpenWrt forum's Discourse colourway (opt-in) ----
|
||||
*
|
||||
* Sampled from forum.openwrt.org's own `getComputedStyle(document.documentElement)`, one read
|
||||
* per colour scheme: LIGHT --primary #222 (ink) / --secondary #fff (page) / --tertiary #08c
|
||||
* (accent) / --quaternary and --danger both #e45735 / --success #090 / --highlight #ffff4d;
|
||||
* DARK --primary #ddd / --secondary #222 / --tertiary #0f82af / --danger #e45735 (unchanged
|
||||
* from light) / --success #1ca551 / --highlight #a87137. Discourse names quaternary and
|
||||
* danger as one colour in light and two in dark (#c14924); footstrap has no fifth semantic
|
||||
* slot, so the dark quaternary is not carried — --fs-danger reads the site's own --danger.
|
||||
*
|
||||
* The header bar's navy (rgb(0,43,73) = #002b49) is not here: it is the forum's masthead, not
|
||||
* its dark canvas — --secondary is #222 in BOTH the forum's own light and dark schemes (ink
|
||||
* and page swap places, the page itself never turns navy). 2020's dark canvas is navy because
|
||||
* that theme has no dark scheme of its own to sample; forum does, and it says #222. The chrome
|
||||
* reads --fs-panel regardless (2020's own note, above), so the bar could not carry it anyway.
|
||||
*
|
||||
* None of the six sampled colours clears 4.5:1 as TEXT on the surface its own scheme pairs it
|
||||
* with — Discourse checks contrast for its own use (a filled pill, an icon), not for the
|
||||
* export tier every app prints text in — so every one of them is moved the way 2020's cyan
|
||||
* was: hue and saturation held, lightness walked until the ratio clears. --fs-highlight has no
|
||||
* footstrap analogue (it marks a search hit, not a status) and the sampled #ffff4d is a
|
||||
* background wash — 1.07:1 on white — so it fills the one status slot forum's own scheme has
|
||||
* no colour for, --fs-warn, exactly as its LIGHTNESS says: the brightest of the four before the
|
||||
* move, still the one furthest from AA after it. */
|
||||
:root[data-palette="forum"] {
|
||||
/* LIGHT — the forum's own page, exactly: white canvas, near-black ink */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #f0f0f0;
|
||||
--fs-border-base: #ddd;
|
||||
--fs-text: #222;
|
||||
--fs-dim: #3d3d3d;
|
||||
--fs-faint: #4a4a4a;
|
||||
/* --tertiary #08c measures 3.89:1 on white; #0074ad held the hue at 5.12:1 there but
|
||||
* only 4.49:1 on --fs-panel2, under the 4.5:1 floor the export tier holds every surface to;
|
||||
* #0073ac holds it at 5.19:1 on white and 4.55:1 on --fs-panel2 */
|
||||
--fs-accent-base: #0073ac;
|
||||
--fs-track: #eee;
|
||||
/* --success #090 measured 3.78:1, #008000 5.14:1; --quaternary/--danger #e45735
|
||||
* measured 3.68:1, #c63c1b 5.17:1; --highlight #ffff4d measured 1.07:1 as text, #717100
|
||||
* 5.17:1 — see the block comment above for why highlight fills warn */
|
||||
--fs-good-base: #008000;
|
||||
--fs-warn-base: #717100;
|
||||
--fs-danger-base: #c63c1b;
|
||||
/* white on the fills above: 5.19/5.14/5.17/5.17 */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
: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;
|
||||
/* --tertiary #0f82af measured 3.66:1 on --fs-bg and 3.12:1 on --fs-panel2, both under the
|
||||
* 4.5:1 the export tier wants for a colour printed as text; #13a8e3 holds the hue at
|
||||
* 5.86:1 on --fs-bg and 5.00:1 on --fs-panel2 */
|
||||
--fs-accent-base: #13a8e3;
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
--fs-track: #333;
|
||||
/* --success #1ca551 measured 4.24:1 on --fs-panel2, #1fb65a 5.11:1; --danger #e45735
|
||||
* measured 3.69:1, #eb8066 5.05:1; --highlight #a87137 measured 3.29:1, #c99259 5.00:1 —
|
||||
* same reasoning as the light block for why highlight fills warn */
|
||||
--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;
|
||||
/* The masthead, and the one place the forum's navy belongs. rgb(0,43,73) is what
|
||||
forum.openwrt.org paints its header with in its DARK scheme — measured live; its light
|
||||
scheme has a white header (--header_background #fff, ink #333), which is what this
|
||||
palette's light half already gives, so the navy is declared here and only here.
|
||||
A flat fill, like --fs-bar-bg everywhere (02-tokens.css): the bar's own ink (#ddd)
|
||||
measures 10.7:1 on it and its dimmed ink (#a3a3a3) 5.8:1. */
|
||||
--fs-bar-bg: #002b49;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@layer tokens {
|
||||
/* Static twins for every token whose value is a color-mix(), for a browser that has none.
|
||||
*
|
||||
* Last in the tokens layer on purpose: it has to outrank 02-tokens.css AND the per-palette
|
||||
* definitions in 03-palettes.css, and directory order is source order (build-css.sh).
|
||||
*
|
||||
* Why a whole file rather than a fallback declaration in front of each color-mix(): a custom
|
||||
* property keeps whatever token stream it is given, so the failure is deferred to the point of USE
|
||||
* — `background: var(--fs-good-soft)` becomes invalid at computed-value time and computes to
|
||||
* `unset`, which is `initial` for a non-inherited property. IACVT does not fall back to an earlier
|
||||
* declaration in the same block, so the two-declaration trick that works for `background:
|
||||
* color-mix(…)` written literally does nothing for a token. 36 tokens are affected; without this
|
||||
* block a browser below the floor paints borders in currentcolor and loses every soft surface.
|
||||
*
|
||||
* color-mix() lands in Chrome 111, Firefox 113 and Safari 16.2 (all 2022-2023). See docs/css.md
|
||||
* for the theme's browser floor and which feature sets it.
|
||||
*
|
||||
* The rules: a tint over transparent degrades to `transparent` — an absent surface never lowers
|
||||
* contrast, while a solid version of the same colour under its own text does. A hairline degrades
|
||||
* to the solid colour it is a fraction of. A focus ring degrades to the -solo ring, which is
|
||||
* already color-mix-free, because an invisible focus ring is an accessibility regression rather
|
||||
* than a cosmetic one. */
|
||||
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
|
||||
:root {
|
||||
/* tints: absent, not solid */
|
||||
--fs-accent-soft: transparent;
|
||||
--fs-good-soft: transparent;
|
||||
--fs-warn-soft: transparent;
|
||||
--fs-danger-soft: transparent;
|
||||
--fs-accent-fill: transparent;
|
||||
--fs-good-fill: transparent;
|
||||
--fs-warn-fill: transparent;
|
||||
--fs-danger-fill: transparent;
|
||||
|
||||
/* hairlines: the solid colour they are a fraction of */
|
||||
--fs-accent-line: var(--fs-accent);
|
||||
--fs-good-line: var(--fs-good);
|
||||
--fs-warn-line: var(--fs-warn);
|
||||
--fs-danger-line: var(--fs-danger);
|
||||
--fs-accent-line-hi: var(--fs-accent);
|
||||
--fs-good-line-hi: var(--fs-good);
|
||||
--fs-warn-line-hi: var(--fs-warn);
|
||||
--fs-danger-line-hi: var(--fs-danger);
|
||||
|
||||
/* the two rings, restated: -soft and -fill are transparent above, and a ring nobody can
|
||||
* see is worse than a heavy one. -solo is already color-mix-free and measured. */
|
||||
--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);
|
||||
|
||||
/* the scrim over an uploaded photo: --fs-photo-dim is a percentage this cannot apply, so
|
||||
* take the fixed dialog scrim. Losing it entirely would leave login text over a photo. */
|
||||
--fs-photo-scrim: var(--fs-scrim);
|
||||
|
||||
/* placeholder ink: the quietest ink the palette states outright. Below the floor the
|
||||
* hint is back to reading like a value, which is the pre-0.14.9 look and still the
|
||||
* safer degradation — an unresolvable token takes `color` down to `unset`, and an
|
||||
* inherited near-black placeholder over a light field beats an unstyled one. */
|
||||
--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,115 @@
|
||||
@layer base {
|
||||
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
|
||||
|
||||
/* scroll-margin-top: an anchor jump must clear the sticky bar or it lands underneath it
|
||||
* (WCAG 2.2 SC 2.4.11). The token, never a literal — the 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;
|
||||
}
|
||||
|
||||
/* `hidden` is how a VIEW says an element is not there, and the only way available to code that
|
||||
* ships no stylesheet. The UA gives it `display: none` at the weakest possible strength, so any
|
||||
* `display` a theme sets on a class beats it and the element paints anyway: measured here on
|
||||
* `.tr` (table-row), `.td` (table-cell), `ul.nav > li` (block), `.cbi-page-actions` (flex) and
|
||||
* `.ifacebox` (inline-flex).
|
||||
*
|
||||
* The flag is the point, not a workaround for a layer mistake: this must outrank every display
|
||||
* in tokens/base/theme/page at once, including the id-specific ones in the page layer, and it
|
||||
* has to keep doing so for rules not written yet. `until-found` is excluded
|
||||
* because there `display: none` would break the find-in-page reveal the value exists for. */
|
||||
[hidden]:not([hidden="until-found"]) { display: none !important; }
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* :focus-visible, not :focus — the ring is for the keyboard, and the UA heuristic keeps it off a
|
||||
* mouse click. A 1px dotted outline is near-invisible on a dark panel, and it was the only focus
|
||||
* indicator generic links had. */
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--fs-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -.5em;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -.25em;
|
||||
}
|
||||
|
||||
/* font-size: theme/60-inputs.css (the control-typography rule + `option`) — absorbed */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
option,
|
||||
textarea {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="reset"],
|
||||
input[type="submit"] {
|
||||
cursor: pointer;
|
||||
appearance: button;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* disabled-opacity fade: 30-forms.css, same elements + pointer-events/cursor */
|
||||
|
||||
input[type="search"] {
|
||||
appearance: textfield;
|
||||
/* no box-sizing: content-box — it contradicts the universal border-box above, and the
|
||||
* theme sizes fields by padding, so a search box comes out ~22px too wide */
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-decoration {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
vertical-align: top;
|
||||
font-family: var(--fs-font-mono);
|
||||
}
|
||||
|
||||
/* LuCI's .control-group is bootstrap's JOINED input-group: the trailing button pulled back over
|
||||
* the input so their borders coincided, with its left corners squared to match. This theme does
|
||||
* not join controls — every button takes the full token radius from a later layer, so the
|
||||
* squared corners never apply while the pull-back still does and the button's rounded corner
|
||||
* sits on the input's (issue #7). Separate controls with a real gap instead.
|
||||
*
|
||||
* The password reveal is the one group that IS joined, and theme/60-inputs.css builds that seam
|
||||
* itself (one border around the group, `gap: 0`). */
|
||||
.control-group {
|
||||
display: inline-flex;
|
||||
gap: var(--fs-space-2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
@layer base {
|
||||
/* The body box, links, and the type scale a view inherits from plain markup.
|
||||
* body background/padding/font-family: theme/20-shell.css — absorbed.
|
||||
* Every size and gutter here reads the scale in 02-tokens.css rather than stating a literal. */
|
||||
body {
|
||||
font-size: var(--fs-type);
|
||||
font-weight: var(--fs-weight-normal);
|
||||
/* unitless: it inherits as a FACTOR, so a 20px heading gets 30px of leading instead of the
|
||||
* flat length that would clip it */
|
||||
line-height: var(--fs-leading);
|
||||
color: var(--fs-text);
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* gutter, not `overflow-y: scroll`: the same no-reflow guarantee without a dead scrollbar
|
||||
* down every short page */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--fs-accent);
|
||||
text-decoration: none;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--fs-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nowrap:not(span) {
|
||||
/* :not(span) — a badge must be free to wrap on a narrow screen, and nowrap
|
||||
defeats every other width constraint on it */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
p,
|
||||
.cbi-map-descr,
|
||||
.cbi-section-descr,
|
||||
.table .tr.cbi-section-table-descr .th {
|
||||
font-size: var(--fs-type);
|
||||
font-weight: var(--fs-weight-normal);
|
||||
margin-bottom: var(--fs-space-2);
|
||||
/* prose, so no orphans: 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), not a border-level surface: --fs-text on the latter is 4.40:1 in dark,
|
||||
* under AA */
|
||||
background-color: var(--fs-panel2);
|
||||
color: var(--fs-text);
|
||||
padding: 1px var(--fs-space-1);
|
||||
/* task 0162: a WireGuard public key (`admin/status/wireguard`, one 44-char base64 run, no
|
||||
* space or hyphen anywhere in it) sits inside an inline `<code>` with no width of its own —
|
||||
* `white-space` stays `normal` (inherited), so the run never finds a space to wrap at and
|
||||
* measured 64px past the content column at 320px. `overflow-wrap: anywhere`, the same
|
||||
* escape hatch every other opaque, user-supplied token in this sheet already carries
|
||||
* (a MAC in pages/60-assoclist.css, a hostname in pages/10-login.css): it only ever fires
|
||||
* once a line would otherwise overflow, so a short inline snippet that already fits wraps
|
||||
* nowhere differently. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
pre {
|
||||
--border-color: color-mix(in srgb, var(--fs-border) 15%, transparent);
|
||||
|
||||
background-color: var(--fs-panel2);
|
||||
display: block;
|
||||
padding: var(--fs-space-2);
|
||||
margin: 0 0 var(--fs-space-4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
@layer base {
|
||||
/* Forms: input types, the .cbi-value row layout, and the focus/disabled/
|
||||
* readonly/invalid states shared by every CBI widget. */
|
||||
fieldset {
|
||||
margin-bottom: var(--fs-space-2);
|
||||
padding-top: var(--fs-space-2);
|
||||
}
|
||||
|
||||
/* size: theme/30-tables.css (`legend`) — absorbed */
|
||||
fieldset legend {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
color: var(--fs-text);
|
||||
padding-top: var(--fs-space-5);
|
||||
}
|
||||
|
||||
/* Control typography stays in BASE: moved to the theme layer, a bare-element rule out-ranks
|
||||
* every classed base refinement regardless of specificity, and the firewall's zone badges grew
|
||||
* from 10.8 to 13px. In base, classed rules beat it on specificity, which is the contract they
|
||||
* were written against. `select` is not a member — its face, size and leading live in
|
||||
* 60-inputs.css. */
|
||||
label,
|
||||
input,
|
||||
button {
|
||||
font-family: var(--fs-font-sans);
|
||||
font-size: var(--fs-type);
|
||||
font-weight: var(--fs-weight-normal);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.cbi-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--fs-space-4);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.cbi-value-field {
|
||||
/* logical, not margin-left: this is the gap AFTER the label column, and LuCI ships four RTL
|
||||
* languages (ar/fa/he/ur). Identical to margin-left while nothing in openwrt/luci stamps
|
||||
* dir=rtl, so it costs nothing and is already correct if one ever does. */
|
||||
margin-inline-start: var(--fs-space-5);
|
||||
flex: 1;
|
||||
/* a flex item will not shrink below min-content by default, so a nested table or one long
|
||||
* unbreakable value pushes the whole row out of the card */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* size + ink: theme/60-inputs.css (`.cbi-value-title`) — absorbed. Left: the label
|
||||
* column's geometry, which theme does not touch. */
|
||||
.cbi-value label.cbi-value-title {
|
||||
/* the nudge that lands the label's first baseline on the field's, tuned for the unitless
|
||||
* leading in 20-typography.css */
|
||||
padding-top: var(--fs-space-1);
|
||||
/* 180px is a column WIDTH, not a rhythm — what fits LuCI's longest option labels beside a
|
||||
* field — so it is deliberately not on the 4px step scale */
|
||||
flex: 0 0 180px;
|
||||
/* `end`, not `right`: the label is aligned TOWARDS its field, which is a logical intent.
|
||||
* Not the same as base/95-luci.css's `.left/.right/.center`, which are LuCI's forcing
|
||||
* utilities — a class named `right` means right in any direction. Never sweep those onto
|
||||
* start/end. */
|
||||
text-align: end;
|
||||
/* theme/90-responsive.css restates `flex: 1 1 100%` under 767px (label above field), and a
|
||||
* PERCENTAGE flex-basis is still a "specified size suggestion" only once it is smaller than
|
||||
* the automatic (content) minimum — with no `min-width` stated there or here, a label whose
|
||||
* text has too few break points renders at its own min-content instead of the row's width, a
|
||||
* flex item's automatic minimum size doing exactly what it always does. Sweep-measured on
|
||||
* ssclash's settings form at 320px: a Russian label rendered 320px wide inside a 254-276px
|
||||
* row (Compact/Normal/Large), the section clipped 33-50px with no scroller to reach the rest,
|
||||
* 0 with the English string in the same box. Reproduced locally the same shape with a
|
||||
* synthetic unbroken run in the gallery's own fixture (docs/gallery.html) — real RU option
|
||||
* text with normal word breaks does not need to hit this floor at all once the item can
|
||||
* shrink to the row's own width instead of its content's.
|
||||
*
|
||||
* `min-width: 0` here, not in theme/90-responsive.css: that file restates `flex`, never
|
||||
* `min-width`, so a value stated on the desktop rule survives the later layer's flex-basis
|
||||
* change untouched — the cascade only overrides the PROPERTIES a later rule actually sets. */
|
||||
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;
|
||||
}
|
||||
|
||||
/* The shared field box (the 210px x control-height default, #localtime's widening, and the
|
||||
* per-widget escapes: file, button types, checkbox/radio, .cbi-select, readonly, the
|
||||
* cell-filling widths) lives in theme/60-inputs.css — absorbed whole. The escapes had to move
|
||||
* with the box: each beat it here in-layer, and left behind they would lose to a theme rule by
|
||||
* layer whatever their specificity. */
|
||||
|
||||
.cbi-dynlist {
|
||||
height: auto;
|
||||
/* min-height: theme/60-inputs.css — absorbed */
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* item paint: theme/60-inputs.css — absorbed. Left: the drag-and-drop machinery
|
||||
* UIDynamicList needs. */
|
||||
.cbi-dynlist > .item {
|
||||
margin-bottom: var(--fs-space-1);
|
||||
padding: var(--fs-space-1) 2em var(--fs-space-1) var(--fs-space-2);
|
||||
position: relative;
|
||||
pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
cursor: move; /* drag-and-drop */
|
||||
user-select: text; /* text selection in drag-and-drop */
|
||||
}
|
||||
|
||||
/* display/padding/border + radius + × ink: theme/60-inputs.css (the bare `.item::after`
|
||||
* plate + its dynlist refinement) — absorbed. Left: what pins the plate to the chip. */
|
||||
.cbi-dynlist > .item::after {
|
||||
content: "×";
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
top: -1px;
|
||||
inset-inline-end: -1px;
|
||||
bottom: -1px;
|
||||
font-weight: var(--fs-weight-bold);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* indication line for drag-and-drop in UIDynamicList*/
|
||||
.cbi-dynlist > .item.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
/* Make item being dragged in UIDynamicList partially transparent*/
|
||||
.cbi-dynlist > .item.dragging {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
/* prevent pointer changing when over the span element in UIDynamicList */
|
||||
.cbi-dynlist > .item > span {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cbi-dynlist > .add-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* indication line for drag-and-drop in UIDynamicList*/
|
||||
.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
/* indication line for drag-and-drop in UIDynamicList*/
|
||||
.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
.cbi-dynlist > .add-item > input,
|
||||
.cbi-dynlist > .add-item > button {
|
||||
/* flex: theme/60-inputs.css — absorbed */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the same one-line control height, so a toggle lines up with the field above it */
|
||||
.cbi-value-field > .cbi-checkbox,
|
||||
.cbi-value-field > div > .cbi-radio {
|
||||
height: var(--fs-control-h);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cbi-radio {
|
||||
cursor: pointer;
|
||||
gap: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* .cbi-select lives in theme/60-inputs.css — absorbed whole. Do not re-add an option rule here
|
||||
* either: layer beats specificity, so theme's bare `option` outranks even
|
||||
* `.cbi-select select option:checked`. */
|
||||
|
||||
/* Checkbox/radio drawing: theme/60-inputs.css — absorbed whole (it undoes the generic input
|
||||
* box, which lives there now). */
|
||||
|
||||
/* --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 are the theme's one ring token (--fs-focus-ring /
|
||||
* --fs-focus-ring-invalid). A generic glow here rings every clicked button and every hovered
|
||||
* widget in a second, unnamed focus style. */
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* `gap`, not a negative-margin pair: that trick predates flex gap and leaks, the outer -3px
|
||||
* pulling the row into whatever sits beside it */
|
||||
.cbi-section-create {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
}
|
||||
|
||||
.cbi-section-create > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* the input's cell-filling width: theme/60-inputs.css — absorbed with the generic box */
|
||||
|
||||
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. The legacy `.actions` plate has no
|
||||
* theme override, so it stays: some luci-app-* still emits it. */
|
||||
.actions {
|
||||
background: var(--fs-panel2);
|
||||
margin-bottom: var(--fs-space-4);
|
||||
/* a plate, so it takes the card's own gutter on every side */
|
||||
padding: var(--fs-card-pad);
|
||||
border-top: var(--fs-hairline);
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* Physical, mirrored by hand — see `.close` in base/90-widgets.css for why `float` takes no
|
||||
* logical keyword. Unmirrored it contradicts its own container: `.actions` above sets
|
||||
* `text-align: end`, which follows the writing direction, so in RTL the plate aligns its
|
||||
* content one way and the secondary action the other. Measured in a live document at 1400px:
|
||||
* 582px from the reading edge in LTR, 10px in RTL. */
|
||||
.actions .secondary-action,
|
||||
.cbi-page-actions .secondary-action{
|
||||
float: right;
|
||||
}
|
||||
:root[dir="rtl"] .actions .secondary-action,
|
||||
:root[dir="rtl"] .cbi-page-actions .secondary-action { float: left; }
|
||||
|
||||
.actions .secondary-action a,
|
||||
.cbi-page-actions .secondary-action a {
|
||||
line-height: var(--fs-control-h);
|
||||
}
|
||||
|
||||
.actions .secondary-action a:hover,
|
||||
.cbi-page-actions .secondary-action a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cbi-page-actions > form {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
@layer base {
|
||||
/* Tables. LuCI builds them out of divs carrying .table/.tr/.th/.td rather than
|
||||
* real table markup, so the display roles have to be assigned by hand. */
|
||||
.tr { display: table-row; }
|
||||
.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; }
|
||||
.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; }
|
||||
|
||||
.table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin-bottom: var(--fs-space-4);
|
||||
padding: 0;
|
||||
font-size: var(--fs-type);
|
||||
border-collapse: collapse;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* No per-cell border-top, and that is a fix rather than an omission: the theme draws a table's
|
||||
* separators on the ROW (theme/30-tables.css), so a cell border here is a second copy of every
|
||||
* separator — and because the cell line is straight and runs to the table's very edge while the
|
||||
* frame is rounded, a square corner cuts across the radius (issue #7). */
|
||||
.table .th, .table .td {
|
||||
display: table-cell;
|
||||
/* middle is deliberate: a cell's content is a mix of text, badges and buttons, and
|
||||
* top-aligning mixed-height rows reads as a layout fault */
|
||||
vertical-align: middle;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* An unbreakable value must not leave the card, in ANY .table. Asking what KIND of table it is
|
||||
* first leaves a third-party key/value `<div class="table">` matching nothing, so one unbroken
|
||||
* token — an ICCID, a base64 key, a DUID — holds the column open and pushes the table outside
|
||||
* its card (measured on 3ginfo-lite).
|
||||
*
|
||||
* Not scoped to the phone tier: that leaves a hole where the column is narrow but the WINDOW is
|
||||
* not (with a 130-character token the table escaped its card by 147px at 900px and 279px at
|
||||
* 768px), which is the argument the fit engine is built on — whether something fits is a
|
||||
* property of the content and its column, not of the viewport. The cost is nil: across 8 pages
|
||||
* x 4 widths x 849 text cells nothing else changes.
|
||||
*
|
||||
* `anywhere`, not `break-word`: `break-word` permits the break without reducing the cell's
|
||||
* min-content contribution, so a `display: table` box still cannot shrink below its widest
|
||||
* token and the column stays open — tried, and the table stayed 355px wide in a 346px column.
|
||||
*
|
||||
* That is a CONTAINMENT default, right for every tier with nowhere to put an overflow — a
|
||||
* key/value include, the config table, the Overview meter rows. It is wrong for the one tier
|
||||
* that IS measured: a data table can card, break one column or scroll, so it wants the truth
|
||||
* instead, and theme/30-tables.css gives it `break-word` for as long as it is a table. Do not
|
||||
* unify the two by moving either.
|
||||
*
|
||||
* Set per ROLE: a value may break anywhere, a label breaks at words. `anywhere` costs a
|
||||
* min-content of ONE CHARACTER, which is the point on a value — the column may collapse rather
|
||||
* than push the table out — and the bug it looks like on a header, since an auto-layout table
|
||||
* hands the surplus to whichever column asks loudest and the header row pays: measured on
|
||||
* Processes, `PID` came out as "PI/D" and the header row stood 104px tall, against 54px with
|
||||
* the floor at its longest word (issue #32).
|
||||
*
|
||||
* `break-word` for the label, not `normal`: a header that IS one long token must still break
|
||||
* rather than overflow, it just may not shrink the column below that word. */
|
||||
.table .td { overflow-wrap: anywhere; }
|
||||
.table .th { overflow-wrap: break-word; }
|
||||
|
||||
/* …and the first column is a label too, whichever element it is made of.
|
||||
*
|
||||
* `anywhere` costs a min-content of one character, and auto table layout hands each column its
|
||||
* share by MAX-content, so a two-column key/value table whose value is one enormous token
|
||||
* starves the key column down to that character — measured at 8000 characters of value, the key
|
||||
* column fell from 95px to 55px, and on the reporter's host the header read one glyph per line
|
||||
* (issue #36, on Firefox and Blink alike).
|
||||
*
|
||||
* `break-word` puts the floor back at the longest WORD, the same trade `.th` makes above: the
|
||||
* first column is the row's identity, and it is what you read the table by.
|
||||
*
|
||||
* A carded row is exempt and must be: `.table.fs-stacked .td` (theme/30-tables.css) restates
|
||||
* `anywhere` from a later layer, and once every cell is on its own line there is no
|
||||
* neighbouring column left to starve. */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* The empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps the
|
||||
* table's own height when there is nothing to show.
|
||||
*
|
||||
* Out of flow on purpose: a single-cell row in a `display: table` spans only the FIRST column,
|
||||
* so in flow the plate is a narrow box under column one and its sentence becomes that column's
|
||||
* max-content, widening it (275px -> 502px on an empty section). Absolute takes it
|
||||
* out of the column sizing entirely and `inset-inline: 0` spans it across the table.
|
||||
*
|
||||
* WHAT IT MUST NOT DO IS PIN ITSELF TO THE BOTTOM. It used to say `bottom: 0`, which inside the
|
||||
* `position: relative` .table means the bottom of the whole TABLE — the same place as its own row
|
||||
* only while the placeholder is the last row there is. `form.TableSection` renders its `footer`
|
||||
* into a `<tfoot>` AFTER the `<tbody>`, so on every empty section that declares one the plate
|
||||
* printed straight across the totals: measured on luci-app-dockerman's Containers, Images and
|
||||
* Volumes tables (issue #36) as a 1190x35 overlap, the full width of the table, identically on
|
||||
* Blink and on Gecko 153. With no `bottom` at all an absolutely positioned box keeps its STATIC
|
||||
* position — where the row actually is — which is what was meant all along, and the reserved row
|
||||
* height below is what holds that space open. */
|
||||
.tr.placeholder {
|
||||
height: calc(3em + var(--fs-space-4));
|
||||
}
|
||||
|
||||
.tr.placeholder > .td {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
text-align: center;
|
||||
line-height: 3em;
|
||||
/* …and the plate must be exactly as tall as the row reserved for it, or it hangs out of the
|
||||
* table at whichever end it is anchored to. `--fs-space-4` is two `--fs-space-2` at every
|
||||
* density, so this and the row height above are the same number twice — which is what lets
|
||||
* the cell keep its own size while the table flavours pad their ordinary cells
|
||||
* differently. Measured on Wireless, whose table pads 10px a side: the plate came out 67px
|
||||
* inside a 63px row and printed 4px below the frame's bottom border. */
|
||||
padding-block: var(--fs-space-2);
|
||||
}
|
||||
|
||||
.tr.drag-over-above,
|
||||
.tr.drag-over-below {
|
||||
border: 2px solid var(--fs-accent);
|
||||
border-width: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.tr.drag-over-below {
|
||||
border-width: 0 0 2px 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,81 @@
|
||||
@layer base {
|
||||
/* the modal overlay ui.js shows for config diffs, uploads and login, plus the colour variants
|
||||
* shared by .btn / .alert-message / .cbi-tooltip */
|
||||
#modal_overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: var(--fs-scrim);
|
||||
z-index: var(--fs-z-overlay);
|
||||
overflow: auto;
|
||||
transition: opacity var(--fs-dur);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 90%;
|
||||
/* clear of the sticky bar, and centred */
|
||||
margin: var(--fs-space-6) auto;
|
||||
min-height: var(--fs-control-h);
|
||||
max-width: 600px;
|
||||
/* a modal is a CARD — the large radius, like every other panel */
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
/* box-shadow: theme/70-modal.css — absorbed; the old one was a shadow in the colour of the
|
||||
* page, and the theme gives every .modal --fs-shadow-pop instead */
|
||||
/* the card's own gutter on every side; the button row's spacing is its own business */
|
||||
padding: var(--fs-card-pad);
|
||||
min-width: 270px;
|
||||
}
|
||||
|
||||
/* .modal > * (normal leading, the .5em rhythm, max-width): theme/70-modal.css — absorbed
|
||||
* with the heading ramp, which would out-layer the leading here */
|
||||
|
||||
/* .modal > pre/textarea (pre-wrap, fill the dialog): theme/70-modal.css — absorbed with
|
||||
* the generic field box (its 210px would out-layer the width here) */
|
||||
|
||||
.modal > .button-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal > .button-row > button:not(:last-of-type) {
|
||||
margin-inline-end: var(--fs-space-2);
|
||||
}
|
||||
|
||||
.modal > .button-row > button:first-of-type {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
body.modal-overlay-active {
|
||||
overflow: hidden;
|
||||
/* svh: `100vh` counts the mobile URL bar as if it were not there and crops the modal, while
|
||||
* `dvh` re-lays the dialog out on every frame of the bar's slide. The small viewport height
|
||||
* is the one that is both correct and still. */
|
||||
height: 100svh;
|
||||
}
|
||||
|
||||
body.modal-overlay-active #modal_overlay {
|
||||
inset-inline: 0;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* the cross inside a button or alert: `line-height: 1` centres the glyph in its own box, where a
|
||||
* fixed leading stops matching the moment the type scale moves */
|
||||
.btn .close, .alert-message .close {
|
||||
font-family: var(--fs-font-sans);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The whole colour-word block is absorbed — do not re-add any of it. `.btn.<word>` lives in
|
||||
* theme/55-buttons.css, `.alert-message.<word>` and `.cbi-tooltip.<word>` in
|
||||
* theme/35-alerts.css. The alert variants were already dead: theme paints plain
|
||||
* `.alert-message`, and a later layer beats this one whatever the specificity. The tooltip
|
||||
* variants were not — they were alive and un-themed, which is why the note that used to sit
|
||||
* here said the theme did not claim them; the gallery renders all four now, and that is what
|
||||
* turned the claim into a measurement. */
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@layer base {
|
||||
/* `.btn` and `.cbi-button` are interchangeable in LuCI markup; a row action carries both plus a
|
||||
* `-<variant>` class. The paint — box, fill, radius — is theme/55-buttons.css's, along with the
|
||||
* `.item::after` member, whose plate now lives with the dynlist in theme/60-inputs.css. What is
|
||||
* left here is --on-color, which is not paint: base/95-luci.css masks the spinner glyph with
|
||||
* it, so it must track the variant. */
|
||||
.btn,
|
||||
.cbi-button {
|
||||
--on-color: var(--fs-text);
|
||||
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-type);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* border-color: theme/60-inputs.css — absorbed. The ink stays: theme paints no colour here. */
|
||||
.cbi-input-invalid,
|
||||
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li,
|
||||
.cbi-value-error input {
|
||||
color: var(--fs-danger);
|
||||
}
|
||||
|
||||
/* `--on-color` only. A `border-color` here would be dead: theme/55-buttons.css sets `border` on
|
||||
* `.cbi-button, .btn`, and a later layer beats this one whatever the specificity, so the
|
||||
* variant wears the theme's colour either way — proven, not reasoned: stripping the ones that
|
||||
* used to sit here changes no computed style over docs/gallery.html, which renders every one of
|
||||
* these variants. `--on-color` stays because it is not paint — base/95-luci.css masks the
|
||||
* spinner glyph with it. */
|
||||
.cbi-button-positive,
|
||||
.cbi-button-fieldadd,
|
||||
.cbi-button-add,
|
||||
.cbi-button-save {
|
||||
--on-color: var(--fs-good);
|
||||
}
|
||||
|
||||
.btn.primary,
|
||||
.cbi-button-action,
|
||||
.cbi-button-apply,
|
||||
.cbi-button-reload,
|
||||
.cbi-button-edit {
|
||||
--on-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.cbi-button-negative,
|
||||
.cbi-section-remove .cbi-button,
|
||||
.cbi-button-reset,
|
||||
.cbi-button-remove {
|
||||
--on-color: var(--fs-danger);
|
||||
}
|
||||
|
||||
.cbi-page-actions::after {
|
||||
display: table;
|
||||
content: "";
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.cbi-page-actions > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* No `float` here: theme/55-buttons.css makes `.cbi-page-actions` a flex container, and float
|
||||
* does not apply to a flex item, so one would compute to `none` and move nothing while reading
|
||||
* like a physical side the logical-property sweep had missed. No context revives it — print
|
||||
* hides the whole container (theme/97-print.css). Verified in a live document in both writing
|
||||
* directions: the button sits 569px from the reading edge with the float and 569px without. */
|
||||
.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) {
|
||||
margin-inline-end: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* fills and borders: theme/55-buttons.css — absorbed. --on-color stays (the spinner mask). */
|
||||
.btn.primary,
|
||||
.cbi-button-action.important,
|
||||
.cbi-page-actions .cbi-button-apply,
|
||||
.cbi-section-actions .cbi-button-edit {
|
||||
--on-color: var(--fs-on-accent);
|
||||
}
|
||||
|
||||
.cbi-button-positive.important,
|
||||
.cbi-page-actions .cbi-button-save {
|
||||
--on-color: var(--fs-on-good);
|
||||
}
|
||||
|
||||
.cbi-button-negative.important {
|
||||
--on-color: var(--fs-on-danger);
|
||||
/* fill + border: theme/55-buttons.css — absorbed */
|
||||
}
|
||||
|
||||
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
|
||||
--on-color: var(--fs-good);
|
||||
/* fill + border: theme/55-buttons.css — absorbed */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
@layer base {
|
||||
/* small widgets a view or a third-party luci-app-* can drop anywhere: tooltip, progress bar,
|
||||
* zone badge, button states, close cross, alert box, .label pill */
|
||||
/* .cbi-title-ref colour: theme/45-misc.css — absorbed. The arrow stays: content,
|
||||
* not paint. */
|
||||
.cbi-title-ref::after {
|
||||
content: "➙";
|
||||
}
|
||||
|
||||
.cbi-tooltip-container {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.cbi-tooltip {
|
||||
position: absolute;
|
||||
z-index: var(--fs-z-tooltip);
|
||||
/* logical: `left: -10000px` in an RTL document parks the tooltip past the INLINE-END edge,
|
||||
* which is scrollable overflow — every page grew a 10000px horizontal scrollbar in ar/fa/he
|
||||
* (measured). Past the inline-start edge is unreachable in both directions. */
|
||||
inset-inline-start: -10000px;
|
||||
/* box-shadow: theme/35-alerts.css — absorbed */
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-panel);
|
||||
/* `pre`, not `pre-wrap`, NEVER wraps — task 0162: live-audit measured a firewall rule-jump
|
||||
* tooltip (`nftables/iptables`, "Chain POSTROUTING, Rule #1") 65-72px past the column at
|
||||
* 320px, spaces and all, because `pre` disables wrapping outright regardless of where a space
|
||||
* sits. `pre-wrap` keeps every literal run of whitespace this rule was carried for — the same
|
||||
* reason `35-alerts.css`'s validation tooltip and a multi-line firewall hint still read
|
||||
* correctly — while letting the browser wrap at one of them once the line is too long. */
|
||||
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;
|
||||
/* box-shadow: theme/35-alerts.css states one elevation for every .cbi-tooltip */
|
||||
}
|
||||
|
||||
.zonebadge .cbi-tooltip > * {
|
||||
margin: 1px;
|
||||
z-index: var(--fs-z-raise);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zone-forwards {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.zone-forwards > * {
|
||||
flex: 1 1 40%;
|
||||
padding: 1px;
|
||||
text-wrap: auto;
|
||||
}
|
||||
|
||||
.zone-forwards > span {
|
||||
flex-basis: 10%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zone-forwards .zone-src,
|
||||
.zone-forwards .zone-dest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* pressed-state shadow (.btn.active/:active): theme/55-buttons.css — absorbed */
|
||||
.btn.disabled,
|
||||
.btn[disabled] {
|
||||
cursor: default;
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
}
|
||||
|
||||
/* `.btn.large` / `.btn.small`: theme/55-buttons.css, in full — absorbed. */
|
||||
|
||||
/* close cross — colour, shadow, opacities: theme/70-modal.css — absorbed. Left:
|
||||
* geometry. */
|
||||
/* `line-height: 1` centres the glyph in its own box; it was 13.5px under a 20px face, and a
|
||||
* hand-nudged length only lands for one font size */
|
||||
/* Physical, and mirrored by hand below — the same shape as `::-webkit-resizer`
|
||||
* (theme/60-inputs.css) and the two chevrons (theme/20-shell.css). `float` takes no logical
|
||||
* keyword this theme can rely on: `inline-end` is newer than
|
||||
* the `oklch(from …)` and `:has()` this sheet already requires only in Chromium, and a browser
|
||||
* that does not know the keyword drops the declaration and leaves the cross floating
|
||||
* nowhere. Measured in a live document at 1400px: the cross sat 549px from the reading edge in
|
||||
* LTR and 41px in RTL, i.e. it jumped from the end of the heading to its start — in front of
|
||||
* the title, where a dismissal control is not. It appears in every modal and in every
|
||||
* `.alert-message`, so this is the RTL bug with the widest reach. */
|
||||
.close {
|
||||
float: right;
|
||||
font-size: var(--fs-type-xl);
|
||||
font-weight: var(--fs-weight-bold);
|
||||
line-height: 1;
|
||||
}
|
||||
:root[dir="rtl"] .close { float: left; }
|
||||
|
||||
.close:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* alert paint: 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;
|
||||
}
|
||||
|
||||
/* colour, margin and the content reset: theme/35-alerts.css — absorbed with the heading ramp */
|
||||
|
||||
.alert-message button {
|
||||
margin: var(--fs-space-1) 0;
|
||||
}
|
||||
|
||||
/* `.label` pill and variants: theme/35-alerts.css — absorbed, as is the indicator pill
|
||||
* (showIndicator() only ever emits into #indicators, which theme/20-shell.css paints). */
|
||||
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,749 @@
|
||||
@layer base {
|
||||
/* Widgets with no generic equivalent: CBI section tables, interface/zone badges,
|
||||
* the uci change dialog, spinners, the file browser, and a few rules luci-mod-*
|
||||
* depends on. */
|
||||
/* .hidden: theme/60-inputs.css (with the .cbi-* variants LuCI emits) */
|
||||
form.inline { display: inline; margin-bottom: 0; }
|
||||
|
||||
#modemenu li:last-child span.divider { display: none }
|
||||
|
||||
/* #syslog: absorbed into pages/70-syslog.css — in base it LOST to the theme layer's generic
|
||||
* form-field box (a layer beats specificity), so the log rendered as a 210px column. */
|
||||
|
||||
/* `:not(.placeholder)`: a hover cue promises a row you can act on, and the empty-table row is
|
||||
* not one. The theme's row hover already spares it (theme/30-tables.css, issue #5.2), 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).
|
||||
* Fixed here rather than repainted later, so there is one hover contract. */
|
||||
.cbi-section-table .tr:not(.placeholder):hover .td,
|
||||
.cbi-section-table .tr:not(.placeholder):hover .th,
|
||||
.cbi-section-table .tr:not(.placeholder):hover::before {
|
||||
background-color: var(--fs-panel2);
|
||||
}
|
||||
|
||||
.cbi-section-table .tr.cbi-section-table-descr .th {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
}
|
||||
|
||||
.cbi-section-table-titles {
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
/* The GridSection name column, where the two releases' markup genuinely differs and one
|
||||
* stylesheet serves both: 25.12's form.js renders the name as a real `th` plus
|
||||
* `td.cbi-section-table-titles` and keeps `data-title` on the row only for the drag handle,
|
||||
* while 24.10 renders no cell at all and expects `::before` to generate the column from
|
||||
* `data-title`. Upstream ships a different cascade.css per branch (25.12's says
|
||||
* `content: none`, 24.10's `content: attr(data-title) " "`), so taking 25.12's copy verbatim
|
||||
* deletes the Name column on every 24.10 router (issue #9).
|
||||
*
|
||||
* Each row type therefore asks the MARKUP, never the release:
|
||||
* header — carries `data-title` on 24.10 only; 25.12 appends a real `th` instead.
|
||||
* row — BOTH set `data-title`, so the discriminator is the name cell 25.12 adds.
|
||||
* descr — has neither, so it follows the header that already proved the branch. It needs the
|
||||
* empty spacer (`data-title` is absent, so the content is just " ") or its column
|
||||
* labels sit one cell left of the values they describe. A descr row with no header
|
||||
* row above it (every column carrying a description but no title) therefore keeps no
|
||||
* spacer — no shipped page renders that, so it is untested rather than handled.
|
||||
* Never key on the VALUE: the header's data-title is _('Name'), i.e. translated.
|
||||
*
|
||||
* This rule states only that the column EXISTS — its padding, type and separator are the cell
|
||||
* rules' job in theme/30-tables.css, next to the real cells it has to line up with. (Upstream
|
||||
* declares the border-top here; ours would draw the stray line above the header that issue #7
|
||||
* removed from every other cell.) */
|
||||
/* SPLIT OFF THE :has() HALF, and the split is the point: an engine that cannot parse `:has()`
|
||||
* throws away the WHOLE rule, siblings included — so a Firefox older than 121 lost the two
|
||||
* plain selectors above with it, and stacked tables came up with no card labels at all. Same
|
||||
* declarations, two rules, and the half that needs nothing new survives on its own. */
|
||||
.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 (+ the numeric-cell refinement): theme/30-tables.css —
|
||||
* absorbed with the generic field box, which would out-layer them here */
|
||||
|
||||
.table.cbi-section-table .td.cbi-section-table-cell {
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.td.cbi-section-actions {
|
||||
text-align: end;
|
||||
vertical-align: middle;
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.td.cbi-section-actions > * {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.td.cbi-section-actions > :not(.cbi-dropdown) > *,
|
||||
.td.cbi-section-actions > * > form > * {
|
||||
flex: 1 1 4em;
|
||||
/* at 1px apart, adjacent row actions sat inside each other's 24px target circle
|
||||
* (WCAG 2.2 SC 2.5.8) — see the min-height in theme/55-buttons.css */
|
||||
margin: 0 var(--fs-space-1);
|
||||
}
|
||||
|
||||
.td.cbi-section-actions > * > form {
|
||||
display: inline-flex;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table.valign-middle .td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cbi-rowstyle-2,
|
||||
.tr.table-titles,
|
||||
.tr.cbi-section-table-titles {
|
||||
background: var(--fs-panel2);
|
||||
}
|
||||
|
||||
th[data-sortable-row] { cursor: pointer; }
|
||||
th[data-sort-direction="asc"]::after { content: "\a0\25b2"; }
|
||||
th[data-sort-direction="desc"]::after { content: "\a0\25bc"; }
|
||||
|
||||
/* The help sentence, indented to clear the ? glyph its ::before hangs in the margin. The
|
||||
* indent and the glyph's own `left` are ONE relationship — both said 1.25em, stated twice — so
|
||||
* they now derive from a single local. */
|
||||
.cbi-value-description {
|
||||
--help-indent: 1.25em;
|
||||
|
||||
margin: var(--fs-space-1) 0 0 var(--help-indent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cbi-value-description:not(:empty)::before {
|
||||
/* OURS, and drawn as a STROKE like every other glyph this theme owns (--spinner-icon below,
|
||||
* --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid). It replaces a solid
|
||||
* filled path copied verbatim from luci-theme-bootstrap: a drawn path is authored expression
|
||||
* in a way `padding: 8px` is not, which made it the sharpest item in that inheritance. */
|
||||
--help-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='9.4'/><path d='M8.9 9.4a3.2 3.2 0 1 1 3.9 3.6v1.4'/><path d='M12.8 17.6h-.02'/></svg>");
|
||||
|
||||
position: absolute;
|
||||
inset-inline-start: calc(var(--help-indent) * -1);
|
||||
content: "\a0";
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-inline-end: var(--fs-space-1);
|
||||
background: var(--fs-accent);
|
||||
mask-image: var(--help-icon);
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(--help-icon);
|
||||
-webkit-mask-size: cover;
|
||||
}
|
||||
|
||||
/* tint (and the 6%-not-12% reasoning): theme/30-tables.css — absorbed */
|
||||
.cbi-section-error {
|
||||
border: 1px solid var(--fs-danger);
|
||||
border-radius: var(--fs-radius);
|
||||
padding: var(--fs-space-2);
|
||||
margin-bottom: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.cbi-section-error ul { margin: 0 0 0 var(--fs-space-5); }
|
||||
|
||||
.cbi-section-error ul li {
|
||||
/* colour: theme/35-alerts.css — absorbed */
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
.ifacebox {
|
||||
border: var(--fs-hairline);
|
||||
margin: 0 var(--fs-space-3);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
/* surface: theme/45-misc.css (flat) — absorbed */
|
||||
border-radius: var(--fs-radius-sm);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
/* a plate of single-line labels, not prose */
|
||||
line-height: var(--fs-leading-tight);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.ifacebox .ifacebox-head {
|
||||
border-bottom: var(--fs-hairline);
|
||||
/* the head sits INSIDE the box's 1px border, so its own radius is the box's less that
|
||||
* border — restating a smaller literal is how the two drifted apart before. */
|
||||
border-radius: calc(var(--fs-radius-sm) - 1px) calc(var(--fs-radius-sm) - 1px) 0 0;
|
||||
padding: var(--fs-space-0-5);
|
||||
/* surface + ink: theme/45-misc.css — absorbed */
|
||||
}
|
||||
|
||||
/* The !important fights luci-mod-network's inline zone-colour `style=`: a raw zone colour is too
|
||||
* bright on a dark panel, so it is re-mixed at .4/.3.
|
||||
*
|
||||
* No fallback on --zone-color-rgb, and that is load-bearing: LuCI sets that var inline only
|
||||
* where there IS a zone colour, so elsewhere the declaration is invalid at computed-value time
|
||||
* and, being !important, resolves to `unset` and wipes the plain inline background — which is
|
||||
* the desired outcome. A fallback grey makes the rule apply there and tints five interface
|
||||
* boxes that correctly show the panel behind them (measured, computed-style diff). */
|
||||
[data-darkmode="true"] .zonebadge[style],
|
||||
[data-darkmode="true"] .ifacebox-head[style] {
|
||||
background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important;
|
||||
}
|
||||
|
||||
/* `.ifacebox` above is `white-space: nowrap` — right for a plate of one-word head labels, wrong
|
||||
* for the body's own stat/address lines once they are long enough to need a break: measured
|
||||
* three times under three different scopes before this rule (Network overview's
|
||||
* `#cbi-network-interface`, the upstream plates' `.network-status-table`, and — pseudo-loc,
|
||||
* gallery's plain Port-tile fixture with no `port_*.svg` icon — a 320px/Large stat line "1000
|
||||
* Mbit/s / 1.2 GiB / 8.3 GiB" already 33px past a 118px box in PLAIN ENGLISH, no translation
|
||||
* involved) — the base rule owns it once instead of a fourth scoped copy the next real page
|
||||
* would need again. `overflow-wrap: anywhere`, not `break-word`: only `anywhere` lowers the
|
||||
* box's own min-content, letting the flex item actually shrink to the tile (65-dropdown.css's
|
||||
* own reasoning for the same two properties). */
|
||||
.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);
|
||||
/* surface: theme/45-misc.css (flat) — absorbed */
|
||||
color: var(--fs-text);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: default;
|
||||
/* a badge is one line of label — the tight step, like .ifacebox above */
|
||||
line-height: var(--fs-leading-tight);
|
||||
}
|
||||
|
||||
.ifacebadge img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ifacebadge-active {
|
||||
/* border-color: theme/45-misc.css (accent) — absorbed */
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
.network-status-table {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.network-status-table .ifacebox {
|
||||
margin: var(--fs-space-2);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.network-status-table .ifacebox-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
text-align: start;
|
||||
/* the wrap fix (an uncompressed IPv6 is ~380px of unbreakable text, measured 45px over the
|
||||
* card at 390px before it shipped) now lives on `.ifacebox .ifacebox-body` above, generalised
|
||||
* to every plate rather than restated per scope — this rule keeps only what is unique to the
|
||||
* upstream layout: full-height flex column, address-block start alignment. */
|
||||
}
|
||||
|
||||
/* The head got the port tiles' `nowrap` (short one-word labels: "wan", "lan2") but sits in the
|
||||
* SAME shrinking flex item as the body above, and RU's "IPv4 Upstream" caption is a sentence,
|
||||
* not a word: "Подключение IPv4 (внешняя сеть)" measured 301px in a 288px column at 320px/
|
||||
* Density Large, 41.3px past #view, with the .cbi-section clipped by 42px and no scroller — EN
|
||||
* at the same width and density: 0. Same two declarations as the body, same reasoning. */
|
||||
.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 {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: var(--fs-space-1);
|
||||
min-width: 220px;
|
||||
margin: var(--fs-space-1);
|
||||
}
|
||||
|
||||
.ifacebadge.large {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.network-status-table .ifacebox-body .ifacebadge > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ifacebadge > *,
|
||||
.ifacebadge.large > * {
|
||||
margin: 0 var(--fs-space-1);
|
||||
}
|
||||
|
||||
.zonebadge {
|
||||
padding: var(--fs-space-0-5);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
.zonebadge > em,
|
||||
.zonebadge > strong {
|
||||
margin: 0 var(--fs-space-1);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* .zonebadge input width: theme/30-tables.css — absorbed with the generic box */
|
||||
|
||||
.zonebadge > .ifacebadge {
|
||||
margin-inline-start: var(--fs-space-0-5);
|
||||
}
|
||||
|
||||
.zonebadge-empty {
|
||||
/* width/style only — colour + ink: theme/45-misc.css */
|
||||
border: 1px dashed var(--fs-border);
|
||||
font-style: italic;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
/* color: theme/60-inputs.css — absorbed. */
|
||||
div.cbi-value var,
|
||||
.td.cbi-value-field var {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div.cbi-value var[data-tooltip],
|
||||
.td.cbi-value-field var[data-tooltip],
|
||||
div.cbi-value var.cbi-tooltip-container,
|
||||
.td.cbi-value-field var.cbi-tooltip-container {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted var(--fs-accent);
|
||||
}
|
||||
|
||||
div.cbi-value var.cbi-tooltip-container,
|
||||
.td.cbi-value-field var.cbi-tooltip-container .cbi-tooltip {
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
#modal_overlay > .modal.uci-dialog,
|
||||
#modal_overlay > .modal.cbi-modal {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
#modal_overlay > .modal.login > button.important {
|
||||
/* no !important needed: the ID makes this (1,3,1), which no button rule can reach */
|
||||
/* Coverage vocabulary, not a live control: nothing in luci-base gives a modal the `login`
|
||||
* class (ui.showModal takes classes and no call site passes one), and this theme's own
|
||||
* login is a server-rendered form, not a modal. It is inherited from luci-theme-bootstrap,
|
||||
* which carries the same selector, so an app that emits it gets a themed result — a step up
|
||||
* the scale, where it was a bare 120% of whatever the button happened to be. */
|
||||
font-size: var(--fs-type-lg);
|
||||
margin-top: var(--fs-space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* a list of changed settings, one per line: prose leading, not a hand 170%. */
|
||||
.uci-change-list {
|
||||
line-height: var(--fs-leading);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* one changed-setting row. It carried `line-height: 15px` — a hand number under a 13px face;
|
||||
* a row of single-line code takes the tight step like every other plate here. */
|
||||
.uci-dialog div > del,
|
||||
.uci-dialog div > ins,
|
||||
.uci-dialog div > var {
|
||||
margin-bottom: var(--fs-space-0-5);
|
||||
border: var(--fs-hairline);
|
||||
line-height: var(--fs-leading-tight);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--fs-space-0-5);
|
||||
position: relative;
|
||||
background-color: var(--fs-panel2);
|
||||
}
|
||||
|
||||
.uci-dialog div > ins {
|
||||
background-color: var(--fs-good-fill);
|
||||
border-color: var(--fs-good-line);
|
||||
}
|
||||
|
||||
.uci-dialog div > del {
|
||||
background-color: var(--fs-danger-fill);
|
||||
border-color: var(--fs-danger-line);
|
||||
}
|
||||
|
||||
.uci-dialog var > ins {
|
||||
background-color: var(--fs-good-fill);
|
||||
}
|
||||
|
||||
.uci-dialog var > del {
|
||||
background-color: var(--fs-danger-fill);
|
||||
}
|
||||
|
||||
.uci-change-list > var > * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.uci-dialog del,
|
||||
.uci-dialog ins,
|
||||
.uci-dialog var {
|
||||
text-decoration: none;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-style: normal;
|
||||
color: var(--fs-text);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.uci-change-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.uci-change-legend-label {
|
||||
flex-basis: 150px;
|
||||
margin: var(--fs-space-0-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.uci-change-legend-label > ins,
|
||||
.uci-change-legend-label > del,
|
||||
.uci-change-legend-label > var {
|
||||
margin-inline-end: var(--fs-space-1);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.uci-change-legend-label > * > * {
|
||||
flex-basis: 100%;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
#applyreboot-section {
|
||||
line-height: 300%;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: .5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.flash {
|
||||
animation: flash var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
#view > div.spinning:first-child {
|
||||
display: table;
|
||||
margin: 15vh auto;
|
||||
}
|
||||
|
||||
/* Rotate in CSS, NEVER with a SMIL <animateTransform> inside the data-URI: that runs in
|
||||
* the SVG's own document, which the page's `prefers-reduced-motion` cannot reach — the
|
||||
* one spinner a motion-sensitive user could not turn off. theme/95-a11y-media.css now
|
||||
* stops it like everything else. (The circle under the arc is rotationally symmetric,
|
||||
* so spinning the whole box looks identical.) */
|
||||
/* ---- the glyph sits in the flow, which is the fix for a whole bug class ----
|
||||
*
|
||||
* Absolutely positioned, the host has to be told to leave room for it — a `padding-left` that
|
||||
* must out-rank whatever else pads that element. That race cannot be won: every padding rule
|
||||
* aimed at a button is a candidate and the loser is silent, the glyph simply drawing on top of
|
||||
* the label. Twice reported from the field, each time by a more specific rule than the last:
|
||||
* issue #15 (`.td .cbi-button`, 0,2,0) and issue #22, whose
|
||||
* `.cbi-section .table[id] .td .cbi-button` is 0,5,0 — there the ladder had padding-left at
|
||||
* 10px against the 32px the glyph needs.
|
||||
*
|
||||
* In the flow the glyph reserves its own space, so no host needs a padding and there is nothing
|
||||
* to out-rank. `flex: 0 0 auto` because the buttons are `inline-flex` and a flex item with a
|
||||
* mask is squeezable; `vertical-align: middle` for the non-flex hosts. The centring is the
|
||||
* host's own align-items or baseline alignment. */
|
||||
.spinning::before {
|
||||
--spinner-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");
|
||||
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
vertical-align: middle;
|
||||
margin-inline-end: var(--fs-space-1);
|
||||
width: var(--fs-spin-size);
|
||||
height: var(--fs-spin-size);
|
||||
content: " ";
|
||||
background: var(--on-color, #000);
|
||||
mask: var(--spinner-icon) center/cover no-repeat;
|
||||
-webkit-mask: var(--spinner-icon) center/cover no-repeat;
|
||||
/* The two deliberate exceptions to the motion scale (02-tokens.css), both because this
|
||||
* is a LOOP, not a state change: 1s is a spinner period, not one of the four durations;
|
||||
* and `linear` is the one justified curve — `ease` would slow at each turn's seam, so an
|
||||
* infinite rotation visibly stutters once a second instead of spinning. */
|
||||
animation: fs-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* One user in this theme (`.spinning::before` above) — the rail's poll glyph is
|
||||
* deliberately STATIC (theme/20-shell.css; the spin was what broke that click).
|
||||
* Unscoped anyway: keyframe names resolve document-wide, so a luci-app-* may ride it. */
|
||||
@keyframes fs-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
[data-darkmode="true"] .spinning::before {
|
||||
background: var(--on-color, #fff);
|
||||
}
|
||||
|
||||
[data-tab-title] {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* the revealed state of both collapsibles — tab pane and file browser — in one rule */
|
||||
[data-tab-active="true"],
|
||||
.cbi-filebrowser.open {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
transition: opacity var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
.cbi-filebrowser {
|
||||
/* `min(210px, 100%)`, not a bare 210: the floor keeps the file list readable, and a floor
|
||||
* wider than the field it sits in is not a floor but an overflow — measured at a 300px
|
||||
* viewport, the panel held 210px inside a 203px field and its whole upload row (the input,
|
||||
* Upload file, Cancel) stood 7px outside the card. Same shape as the dynlist's floor one file
|
||||
* over, and the same fix. Nothing moves at any width where the field is wider than 210. */
|
||||
min-width: min(210px, 100%);
|
||||
max-width: 100%;
|
||||
/* width/style only; radius: theme/60-inputs.css — absorbed */
|
||||
border: var(--fs-hairline);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* padding: theme/60-inputs.css — absorbed. The zero side padding here was what let a 36px
|
||||
* themed button touch this element's own clip box and lose its corners to it. */
|
||||
.cbi-filebrowser > * {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: .25em .25em 0 .25em;
|
||||
white-space: nowrap;
|
||||
border-bottom: var(--fs-hairline);
|
||||
}
|
||||
|
||||
.cbi-filebrowser .cbi-button-positive {
|
||||
margin-inline-end: .25em;
|
||||
}
|
||||
|
||||
/* `.cbi-filebrowser > *` above gives every row `nowrap` + ellipsis on the assumption that a row
|
||||
* is ONE LINE OF TEXT — true for the path and each listing row, false for `.right`: its content
|
||||
* is the upload strip (its own flex row) plus the Create/Cancel buttons, none of it text that an
|
||||
* ellipsis can stand in for. `nowrap`, inherited by `.upload` and its Browse button, was blocking
|
||||
* the wrap that button already had (`flex-wrap: wrap`, theme/60-inputs.css) from ever firing, and
|
||||
* `overflow: hidden` had no ellipsis to show for a clipped BUTTON — pseudo-loc measured Cancel's
|
||||
* label rendered 76px past this box with no "…" drawn, the severe case the gate's own docs call
|
||||
* "text the reader cannot reach", not the accepted dropdown/tab shape. `normal` restores the
|
||||
* ordinary wrap a button row gets everywhere else in the theme; `visible` undoes a clip a
|
||||
* text-only rule has no business applying to a row of controls. */
|
||||
.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, and the row's whole flex sizing: theme/60-inputs.css — absorbed. The
|
||||
* proportional split that lived here (name `flex: 10` / actions `flex: 3`) fitted base's ~20px
|
||||
* buttons and not the theme's 36px ones, which is how a file row came to clip its own Delete. */
|
||||
.cbi-filebrowser > ul > li > div:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cbi-filebrowser > ul > li > div:last-child {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.cbi-filebrowser > ul > li > div:last-child > button {
|
||||
padding: .125em .25em;
|
||||
}
|
||||
|
||||
.cbi-filebrowser .upload {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -.125em .25em -.125em;
|
||||
padding: 0 0 .125em 0;
|
||||
border-bottom: 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; }
|
||||
}
|
||||
|
||||
/* The `fade-in` keyframes above stay although nothing here rides them: it is an upstream
|
||||
* animation NAME a luci-app-* may ride (coverage contract, docs/conventions.md). The theme's own
|
||||
* rule on the `.fade-in` CLASS is gone on purpose — luci-mod-status re-marks with it on every
|
||||
* poll tick, so anything hung there replays on every tick across the whole column.
|
||||
*
|
||||
* `fs-fade` is the theme's own and is one-shot: the rail flyout and the search overlay each
|
||||
* play it once, when the surface opens, which is why its 4px rise is right there and wrong on
|
||||
* a class the poll rewrites. Both keyframes live here because every keyframes in this theme
|
||||
* does, and an unresolvable animation-name is simply dropped, silently. */
|
||||
@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;
|
||||
}
|
||||
|
||||
/* Statistics renders its graphs as PNGs on a white ground, so dark mode inverts them. 180deg is
|
||||
* arithmetic rather than taste: invert() maps every hue to h+180, so rotating 180 back restores
|
||||
* the original hue while keeping the inverted lightness. Read back off collectd's own cpu
|
||||
* series, at 150deg System (h=0) came back at h=301 and User (h=240) at h=180 — 59 and 60
|
||||
* degrees out, i.e. the blue plot was drawn cyan. At 180deg all three primaries are exact.
|
||||
*
|
||||
* It is not exact for every colour and cannot be: CSS hue-rotate is a linear matrix
|
||||
* approximation, so a non-primary survives only approximately (amber lands 13deg out). Do not
|
||||
* tune the angle — moving off 180 trades an exact red/green/blue for a marginally better
|
||||
* amber. */
|
||||
[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
/* Realtime graphs (Status → Load/Traffic) inject resources/svg/*.svg into a div with
|
||||
* an inline style. The <svg> root carries none itself, so the background needs no flag.
|
||||
* Its grid <line>s do — `style="stroke:black"` in the .svg — and a black hairline is
|
||||
* invisible on a dark panel; only an author !important beats an inline declaration. */
|
||||
#view div[style] > svg {
|
||||
background-color: var(--fs-panel);
|
||||
}
|
||||
|
||||
#view div[style] > svg line[style] {
|
||||
stroke: var(--fs-text) !important;
|
||||
}
|
||||
|
||||
/* …and the axis labels beside them: every <text> in the .svg carries an inline fill of the
|
||||
* file's own light grey plus a black halo, drawn for the black background the file assumes
|
||||
* rather than for the panel this theme paints. On a light palette that grey lands on white at
|
||||
* 1.16:1, legible only as the shadow around it, against 17.9:1 for --fs-text, and 14.7:1 on the
|
||||
* dark panel, where it is the halo that has to go instead. Both come from an inline
|
||||
* declaration, so both need the flag.
|
||||
*
|
||||
* The weight is not decoration: the SVG hardcodes a 9pt label, the smallest type this theme
|
||||
* renders, and at the regular weight also the thinnest.
|
||||
*
|
||||
* A CHILD combinator, and the difference is a page: Channel Analysis derives a colour per BSSID
|
||||
* and paints both the curve and its SSID label with it, and those labels are built inside a
|
||||
* <g>, while the grey axis labels are direct children of the <svg>. A descendant selector
|
||||
* recolours both and leaves the curves as they were; `> text[style]` reaches only the axis. */
|
||||
#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,104 @@
|
||||
@layer page {
|
||||
/* LOGIN — the server-rendered form of themes/footstrap/sysauth.ut, as a centred card.
|
||||
*
|
||||
* Scoped by `.fs-login`, a class that template puts on the form: the form is ours, so naming it
|
||||
* is free, where the `form:has(> .cbi-map input[name="luci_username"])` it replaced cost 49
|
||||
* characters × 17 rules, ~0.8 KB of the stylesheet. Nothing else ever matched that selector —
|
||||
* ui.js builds no login form of its own and has no `luci_username` anywhere.
|
||||
*
|
||||
* The theme ships its own sysauth.ut so the page carries NO chrome: without one, LuCI falls back
|
||||
* to the generic template, which includes the header without blank_page and draws the whole
|
||||
* sidebar around a page whose only control is a password field.
|
||||
*
|
||||
* Do not turn this into a modal (form in a `<section hidden>`, revealed by a view module): the
|
||||
* view runs before a session exists, its RPCs answer "Access denied", render() never runs, and
|
||||
* the page is blank with no way to log in. Server-rendered, the form works with JS off. */
|
||||
.fs-login {
|
||||
/* A DEFINITE width, never `max-width`: the card is a direct flex item of <body>
|
||||
* (base/20-typography.css makes body a flex column so the footer can margin-top:auto),
|
||||
* and on the cross axis an `auto` side margin overrides the stretch and sizes the item
|
||||
* to FIT-CONTENT. `max-width: 400px` capped nothing — the card collapsed to its
|
||||
* min-content, ~131px, and the description wrapped one word per line. */
|
||||
width: min(400px, calc(100% - 32px));
|
||||
margin: 7vh auto var(--fs-space-10);
|
||||
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* the token, not a literal 45%-black 60px halo: that ignored the palette and drew
|
||||
* a heavy grey cloud under a white card in light mode. */
|
||||
padding: var(--fs-space-6) var(--fs-space-6) var(--fs-space-6); box-shadow: var(--fs-shadow-pop);
|
||||
}
|
||||
/* The router's own name, first line of the card (sysauth.ut): a centred identity block, set apart
|
||||
* from the form, above a left-aligned heading and left-aligned fields — the shape an appliance
|
||||
* console uses. Centred and separated is what makes the mixed alignment read as deliberate
|
||||
* rather than as a stray line, the 24px gap below being the card's own padding.
|
||||
*
|
||||
* Still smaller than the heading it stands over (16px against its 20px): it answers "which box
|
||||
* is this", which matters only until you have read it, while `Authorization Required` answers
|
||||
* "what now", the page's actual subject. Set larger, the two swap roles and the heading reads
|
||||
* as a subtitle.
|
||||
*
|
||||
* Not a heading element, on purpose: the outline is about the page's subject, and a heading
|
||||
* above it would announce the login form as two sections.
|
||||
*
|
||||
* The eyebrow tokens are NOT reused, though this is an eyebrow's position: they carry
|
||||
* `text-transform: uppercase`, and a hostname is a name — `Rb5009-Usb` is not what the admin
|
||||
* typed, and case is the only thing telling two similar names apart on a router farm.
|
||||
*
|
||||
* `overflow-wrap: anywhere` because the card is a DEFINITE 400px (above) and a hostname is one
|
||||
* unbreakable token of up to 63 characters — nothing in it is a break opportunity, so without
|
||||
* this a long one runs out of the card instead of wrapping inside it. */
|
||||
.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 — the generic "heading gets its own card" rule would
|
||||
* draw a second frame inside it. `h1` because this page has no title bar to carry the
|
||||
* document's top-level heading (sysauth.ut says why); the card rule in theme/45-misc.css is
|
||||
* written for `.cbi-map > h2` and so does not reach it, but the reset stays listed — the
|
||||
* heading's level is not what should decide whether a second frame appears inside this card. */
|
||||
.fs-login .cbi-map > h1,
|
||||
.fs-login .cbi-map-descr {
|
||||
background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0;
|
||||
}
|
||||
/* the h2's size, on an h1: the ramp puts an h1 at 26px (theme/45-misc.css) and in a 400px card
|
||||
* that wraps `Authorization Required` onto two lines. The heading was the right SIZE all along
|
||||
* — only its level was wrong. `margin`, because the ramp gives an h1 a bottom margin and this
|
||||
* card sets its own rhythm. */
|
||||
.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); }
|
||||
/* a column, not a block: some auth plugins add a second action (Reset), and the
|
||||
* full-width buttons below carry margin:0 — they ended up glued together */
|
||||
.fs-login .cbi-page-actions {
|
||||
display: flex; flex-direction: column; gap: var(--fs-space-2-5);
|
||||
background: none; border: 0;
|
||||
padding: 0; margin: var(--fs-space-1-5) 0 0; text-align: start;
|
||||
}
|
||||
.fs-login .cbi-page-actions .btn,
|
||||
.fs-login .cbi-page-actions input[type="submit"] {
|
||||
width: 100%; box-sizing: border-box; min-height: var(--fs-ctl-h-lg); margin: 0; border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-accent); color: var(--fs-on-accent);
|
||||
border: 0; font-weight: var(--fs-weight-bold); font-size: var(--fs-type-lg);
|
||||
}
|
||||
.fs-login .cbi-page-actions .btn:hover { filter: var(--fs-hover-lift); }
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
@layer page {
|
||||
/* Every !important here fights an inline `style=` — nothing else can, an inline declaration
|
||||
* outranking every author rule in every layer. luci-mod-status's 29_ports.js writes the ports
|
||||
* grid, its tiles and their inner rows with inline declarations: `display`,
|
||||
* `grid-template-columns` and `margin-bottom` on the grid, `margin`/`min-width`/`max-width` on
|
||||
* the tile, `display` on the heads, `text-align`/`font-size` on the traffic cell. Each flag is
|
||||
* matched against the property that inline style really sets; one fighting only another
|
||||
* footstrap rule would be redundant, `page` being the last layer. No cascade layer can replace
|
||||
* the flags that remain — keep them. */
|
||||
|
||||
/* The `.cbi-title .label` pill: theme/35-alerts.css (one owner — a copy here had
|
||||
* drifted). Only the overview-specific chevron toggle stays.
|
||||
* index.js emits a span with a raw "Hide"/"Show" text node + data-style (expanded =
|
||||
* inactive, collapsed = active); data-indicator='poll-status' is unique to these
|
||||
* toggles. font-size:0 hides the word, ::after borders draw the chevron.
|
||||
*
|
||||
* The card HEADER is the disclosure control now (fs-overview.js wireDisclosure()); this pill is
|
||||
* marked aria-hidden there and stays only as its glyph, so the border and fill that made it read
|
||||
* as its own 26px button are dropped — 16px, matching the rail toggle's own chevron glyph
|
||||
* (theme/20-shell.css `.fs-railtoggle .fs-ico`), and --fs-faint rather than the pill's --fs-dim,
|
||||
* a step quieter since it is decoration rather than a target now. */
|
||||
.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);
|
||||
}
|
||||
/* The glyph itself is the Appearance fold's, to the byte — one chevron for "this opens", drawn
|
||||
* in two page files because the two hosts are a stock element keyed by attribute and one of
|
||||
* ours keyed by class, which no single selector list in one layer can carry. Pinned instead:
|
||||
* tools/mirror.mjs fails the build if the copies drift (styles/pages/80-appearance.css). */
|
||||
.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 */
|
||||
}
|
||||
|
||||
/* The header itself (fs-overview.js wireDisclosure() stamps role="button"): a hand cursor for a
|
||||
* pointer user, a focus ring for a keyboard one — the same ring every other interactive control
|
||||
* in this theme draws (theme/35-alerts.css `.cbi-title .label:focus-visible`, the pill's own
|
||||
* former ring, now the header's).
|
||||
*
|
||||
* Two selectors, not one: fs-overview.js's headerEl() reads `.cbi-title h3, :scope > h3` because
|
||||
* the wrapper itself differs by release — 25.12 renders `.cbi-title > h3`, 24.10 a bare `<h3>`
|
||||
* straight inside `.cbi-section`, no `.cbi-title` at all. `[role="button"]` is the anchor either
|
||||
* way, so a heading wireDisclosure() left untouched — one with nothing to collapse — is never
|
||||
* matched. Measured on a 24.10 FORM: without the second arm the attributes and the keyboard
|
||||
* worked but `cursor` stayed `auto`, `box-shadow` `none`, and focus drew only the UA outline.
|
||||
* On a live 24.10 the arm does not fire at all today, and that is not a fault in it: OpenWrt
|
||||
* 24.10.8 renders no hide/show control on an Overview card, so wireDisclosure() stamps nothing
|
||||
* there and 0 of the page 14 `<h3>`s carry the role (measured on owlab owrt2410). The arm
|
||||
* guards a SHAPE of markup, not a release — it is what keeps the styling with the behaviour on
|
||||
* whichever release grows the control next. */
|
||||
: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 a bare
|
||||
* `#view[data-page="…"]`: an id contributes (1,0,0), and this file's deepest chain (the
|
||||
* progressbar row's `.td:first-child` selector, further down) already carries the rest past
|
||||
* tools/css-metrics.mjs's ratchet on its own — `#view` on top of it measured [1,9,0], over the
|
||||
* ceiling of [1,7,0]. `:where(#view)` matches the exact same single element (an id is a valid
|
||||
* :where() argument) but contributes ZERO specificity, so nothing about which element matches
|
||||
* changes — including the `> div` case in 40-sshkeys.css, where a wider `:where(#view,
|
||||
* .fs-content)` was tried and rejected: `.fs-content` is #view's ANCESTOR, so its own `> div`
|
||||
* arm would have matched #view itself (a `<div>`, per fs-router.js's stageView()) rather than
|
||||
* #view's children — a different element, not the same one at lower specificity. Measured after:
|
||||
* [1,7,0], the ceiling now held by an unrelated selector elsewhere in the sheet. */
|
||||
|
||||
/* An empty `.cbi-section` LuCI still renders when a stock include has nothing to show: title
|
||||
* "-", the poll pill its only content, 72px tall on the live Overview (seventh there — a
|
||||
* fixture is not proof of position, docs/findings.md "An empty card with `-` as its title").
|
||||
* Real and cosmetic, so fs-overview.js's hideEmptyCard() marks it and this drops it. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section.fs-ovl-empty { display: none; }
|
||||
|
||||
/* Stock overview layout: fs-overview.js wraps the stock System / Memory / Storage
|
||||
* sections in .fs-ovl; this is the grid only — System left across both rows, Memory +
|
||||
* Storage right. Stock styling and data untouched. */
|
||||
/* the chrome already shows the page title, so the stock <h2> is a duplicate */
|
||||
/* the chrome already shows the page title, so the stock <h2> is a duplicate.
|
||||
* Scoped through .fs-content, not #view: the heading is a SIBLING of #view (a
|
||||
* dispatcher template emits it before the div), so it needs the OUTGOING page's
|
||||
* identity for the whole staging window, spared until commitStage the same way
|
||||
* fs-sheets spares a page's stylesheets — fs-router.js. */
|
||||
.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);
|
||||
/* no align-items: the default stretch is what levels the two columns' bottom edges */
|
||||
margin-bottom: var(--fs-card-gap); /* match stock .cbi-section spacing below the grid */
|
||||
}
|
||||
/* min-width:0 so a grid item shrinks to its TRACK, not to its content's min-width
|
||||
* (auto): a long System value (model/firmware) blew the single-column track past a
|
||||
* ~344px phone and clipped. */
|
||||
.fs-ovl > .fs-ovl-sys,
|
||||
.fs-ovl > .fs-ovl-mem,
|
||||
.fs-ovl > .fs-ovl-sto { margin: 0; min-width: 0; }
|
||||
.fs-ovl > .fs-ovl-sys { grid-column: 1; grid-row: 1 / 3; }
|
||||
.fs-ovl > .fs-ovl-mem { grid-column: 2; grid-row: 1; }
|
||||
.fs-ovl > .fs-ovl-sto { grid-column: 2; grid-row: 2; }
|
||||
@container fs-view (max-width: 800px) {
|
||||
.fs-ovl { grid-template-columns: 1fr; }
|
||||
.fs-ovl > .fs-ovl-sys,
|
||||
.fs-ovl > .fs-ovl-mem,
|
||||
.fs-ovl > .fs-ovl-sto { grid-column: 1; grid-row: auto; }
|
||||
}
|
||||
|
||||
/* Stock Port status (29_ports.js) reskin — pure CSS over the stock .ifacebox markup: icon-in-a-box
|
||||
* tiles become left-aligned cards (name + link dot, speed, TX/RX).
|
||||
*
|
||||
* Every rule is scoped to `.ifacebox:has(img[src*="/port_"])` because Network and Wireless reuse
|
||||
* the same .ifacebox classes and only Port tiles carry a port_*.svg icon. Do not unscope. Link
|
||||
* state is the icon src; child order: 1 name-head, 2 body (icon+speed), 3 zone bar, 4 body
|
||||
* (traffic). */
|
||||
/* The tracks pack the row instead of leaving air beside it, and every card is the same width.
|
||||
*
|
||||
* `repeat(auto-fit, minmax(126px, 200px))` reads as "shrink toward 126 when there are many
|
||||
* ports", and a grid does not work that way: `auto-fit` picks the track COUNT from the definite
|
||||
* MAX of the track function, so the count is always `floor(width / 200)` and the floor is
|
||||
* reached only when the column itself is narrower than that. An 11-port switch in a 1190px
|
||||
* column then gets 5 tracks of 200px in three rows with 150px of air beside every one.
|
||||
*
|
||||
* `minmax(92px, 1fr)` asks it the other way round: with an indefinite max the count comes from
|
||||
* the 92px min, so the row takes as many tiles as fit and `1fr` shares the remainder equally. */
|
||||
:where(#view)[data-page="admin-status-overview"] div:has(> .ifacebox img[src*="/port_"]) {
|
||||
/* 8px, not 10: at the floor below, the gap is what decides whether an 11-port switch takes one
|
||||
* row or ten-plus-one. A 1190px column fits 11 cards of 101px with 8px between them and 10
|
||||
* of 110px with 10px — the two px come off the gutter rather than off the card, because the
|
||||
* card's own floor is a measurement (see below) and the gap is not. No inline gap to fight,
|
||||
* so no flag. */
|
||||
gap: var(--fs-space-2);
|
||||
/* The floor is the traffic figure, and it is written as one: `▲ 1024.0 PiB` is the widest
|
||||
* string luci.js's `%1024.1mB` can produce, and the figures are `nowrap`, so a card too narrow
|
||||
* for one pushes it past its own padding instead of wrapping it. Measured rather than guessed:
|
||||
* stepping a card 1px at a time until the figure fits
|
||||
* on one line and the card stops overflowing gives 94px at Normal, 80 at Compact and 109 at
|
||||
* Large, so the text scales with the density axis and the gutter is already a token — each
|
||||
* setting gets its own answer (100 / 84 / 111) instead of one number that is wrong on two of
|
||||
* them. Held by a sweep of every width from 320 to 1900 in 10px steps at all three densities
|
||||
* with 24 cards of maximal content: 0 overflowing, 0 figures wrapped, narrowest card 93px. */
|
||||
--fs-port-min: calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--fs-port-min), 1fr)) !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
/* Flex-wrap, not a grid with a threshold, and that is the fix for issue #7's shifted layout.
|
||||
*
|
||||
* As `display: grid; grid-template-columns: 1fr auto`, "do those two still fit side by side?"
|
||||
* was answered by a @container threshold — a proxy for a question about the CONTENT, calibrated
|
||||
* on English: `no link` is ~45px and `нет соединения` ~100px, while the traffic figures are
|
||||
* `nowrap` by design, so on a Russian router a down port that had carried traffic needed ~193px
|
||||
* inside a 178px content box, and a grid does not wrap — the figures overflowed under the next
|
||||
* card. With the counters at zero the same card fitted.
|
||||
*
|
||||
* Flexbox asks the real question for free: the two cells sit on one row while they fit and the
|
||||
* traffic drops to its own row when they do not. */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) {
|
||||
margin: 0 !important;
|
||||
min-width: 0 !important;
|
||||
/* The cap half of the pair that sizes the tile (the 126px min is on the wrapper's tracks —
|
||||
* see the note there): 200px is where a 2-port device stops growing, so two ports do not
|
||||
* become two half-page cards. It carries a flag because stock writes `max-width:100px`
|
||||
* inline and the ImmortalWrt fork writes its own. */
|
||||
max-width: 200px !important;
|
||||
/* …and `width` too, which stock never writes but a fork does: ImmortalWrt 25.12 carries
|
||||
* `style="margin:.25em;width:100px"` instead of stock's min/max pair, so releasing only the
|
||||
* two stock properties leaves the fork's fixed width in charge and the tile draws itself
|
||||
* 100px wide inside a 200px track. Naming all three costs nothing on stock. */
|
||||
width: auto !important;
|
||||
/* …and that is only half of it: the same fork writes `align-items:center;justify-items:center` on
|
||||
* the grid CONTAINER, so a tile's `justify-self` resolves to `center` — and a centred item
|
||||
* with `width: auto` is sized fit-content rather than stretched to its track, so releasing
|
||||
* the fork's width leaves the tile as narrow as its content and the rows spill to the right.
|
||||
* Stating the self-alignment needs no flag: the fork's declarations are `*-items` on the
|
||||
* PARENT, which only supply a value when the item says `auto`, and a direct `*-self` wins on
|
||||
* the cascade proper. */
|
||||
justify-self: stretch;
|
||||
align-self: stretch;
|
||||
text-align: start;
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
/* the horizontal half is what --fs-port-min budgets for (the wrapper rule above), so it stays
|
||||
* --fs-space-3; the vertical half is one step tighter, because a port card is four short
|
||||
* lines and every px of leading between them shows. */
|
||||
padding: var(--fs-space-1-5) var(--fs-space-3);
|
||||
/* No `container-type` here, and its absence is the fix for a page that jumped every five seconds.
|
||||
* The card carried `container-type: inline-size` for two @container queries that were deleted
|
||||
* when the flex wrap below replaced their pixel threshold, and the containment was left
|
||||
* behind with nothing asking it anything.
|
||||
*
|
||||
* It was not free: the poll rewrites these cards once a tick, and a freshly built
|
||||
* size-contained box is laid out before the containment resolves, so everything below it
|
||||
* moved by the same amount on every tick. Bisected on the reporter's router down to this one
|
||||
* declaration.
|
||||
*
|
||||
* If a future rule needs a container query here, name the container on a wrapper the poll does
|
||||
* NOT replace, never on the card itself. */
|
||||
display: flex;
|
||||
/* `flex-direction: row` is NOT redundant: base/95-luci.css paints every .ifacebox
|
||||
* `display: inline-flex; flex-direction: column`, and the grid this rule used to declare
|
||||
* made that direction moot. Switching to flex woke it up — the name, the speed and the
|
||||
* figures stacked into a column, `flex-basis: 100%` became a HEIGHT, and flex-grow grew the
|
||||
* wrong axis. Cost an hour; state the axis. */
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
/* row-gap 0: every row inside the card carries its own spacing (the zone bar's margins, the
|
||||
* name's line box), and a gap on top of that is what made a five-line card 19px taller than
|
||||
* its content. */
|
||||
column-gap: var(--fs-space-2); row-gap: 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
/* drop the stock icon */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) img { display: none; }
|
||||
/* base/95-luci.css draws a border-bottom on EVERY .ifacebox-head; the port card carries
|
||||
* two heads (the name, child 1, and the zone-colour line, child 3), so that is two grey
|
||||
* dividers stacked around the colour line. Drop them — the colour line is the only
|
||||
* divider this card wants. Page layer beats base, no !important needed. */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) .ifacebox-head { border-bottom: 0; border-radius: 0; }
|
||||
/* Zone-colour bar (child 3): WAN red, LAN green — the stock Port-status indicator that
|
||||
* distinguishes the Internet port from the LAN ports (issue #13). Its inner .zonebadge segments
|
||||
* already carry firewall.getZoneColorStyle() inline and a `.cbi-tooltip` naming the zone, so
|
||||
* this only PLACES it: a full-width colour line on its own row between the name and the
|
||||
* speed/traffic bodies, where it reads as the card's divider. `order` puts it there — without it
|
||||
* the bodies fall in source order and the bar splits speed from traffic. No `overflow` or
|
||||
* `border-radius`: both clip the absolutely-positioned 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);
|
||||
}
|
||||
/* The name row, with no status dot: the speed line and the zone bar already report the carrier,
|
||||
* and a `-webkit-box` lays its children out as lines, so a dot could not stay in the flow — it
|
||||
* would join the name's text and be clipped with the second line. */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
|
||||
/* Its own row, and `width` rather than `flex-basis: 100%`: the two agree now that the card's
|
||||
* size containment is gone (see above), and this form says what it means without depending on
|
||||
* the card's sizing staying definite. */
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
/* Two lines of name, never three, and never a name that decides how wide every card is.
|
||||
*
|
||||
* As a flex row with the name on one `nowrap` line, the LONGEST interface name becomes the
|
||||
* minimum width of every tile on the page. Wrapping alone is not the answer either: a name
|
||||
* with no space and no hyphen to break at gets three ragged lines from `overflow-wrap:
|
||||
* anywhere` and the row grows to fit them.
|
||||
*
|
||||
* So: a `-webkit-box` clamped to two lines, the one shape that both wraps AND puts an
|
||||
* ellipsis where it stops (`text-overflow` alone only ever truncates a single line).
|
||||
* `line-clamp` is written beside the prefixed pair for the engines that have taken the
|
||||
* unprefixed property; the prefixed spelling is the one that works everywhere today and is in
|
||||
* the spec's own compat section.
|
||||
*
|
||||
* No second line is reserved: `min-height: 2lh` was tried and cost 19px of empty air in every
|
||||
* card whose name does not wrap (a 5-port device stood 126px against 107px), while the grid
|
||||
* levels the row either way. The explicit `line-height` stays — it is what makes a wrapped
|
||||
* name's two lines the theme's rhythm rather than the font's own default. */
|
||||
/* ONE LINE, cut with an ellipsis. base/95-luci.css already says `white-space: nowrap`; what
|
||||
* this adds is the pair that makes the overflow READABLE instead of clipped — and `min-width`,
|
||||
* because a nowrap box's automatic minimum size is its whole text, which would put the longest
|
||||
* interface name back in charge of how wide every card is. */
|
||||
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 name, to match the dim speed and the 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); }
|
||||
/* speed: green when linked, dim when not */
|
||||
: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;
|
||||
/* `flex-grow: 1` means "I take the slack", which pushes the traffic to the right edge while
|
||||
* the two share a row. `margin-left: auto` on the traffic is wrong: Chrome counts it when
|
||||
* breaking lines, so the figures take a row of their own even on a card they fit.
|
||||
*
|
||||
* The text WRAPS, and the automatic minimum size is what keeps the wrap tidy. This field used
|
||||
* to be `nowrap`, which holds for the speeds themselves (`1GbE`, `10 GbE`) and breaks on the
|
||||
* no-link label — one short word in English, three in most other languages. `нет соединения`
|
||||
* measured 127px in a card that gives it 127 and was CLIPPED, not ellipsised: the reader saw
|
||||
* `нет соедине`. A label that cannot be read is worse than a card one line taller.
|
||||
*
|
||||
* NO `min-width: 0` with it. That would let the flex line squeeze this box to 77px — the
|
||||
* width left beside the port image, which shares this box — and the label then broke mid-word
|
||||
* (`соедине` / `ния`). Left at `auto` the box keeps its min-content, which for wrapping text
|
||||
* is the longest WORD, so the label breaks between words and the short speeds, having nothing
|
||||
* to wrap at, do not move at all. `overflow-wrap` is the last resort for a language whose
|
||||
* single word is wider than the card. */
|
||||
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 runs. The 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 but never shrinks. Growing is what anchors the figures to the card's right edge on either
|
||||
* row, since `text-align: right` then has a box that reaches that edge; `margin-left: auto`
|
||||
* would do the same on a shared row but Chrome counts it when breaking lines.
|
||||
* `flex-shrink: 0` keeps the nowrap figures from being squeezed into an overflow.
|
||||
*
|
||||
* `flex-basis: 100%` is what makes the figures take a row of their OWN always, not only on a
|
||||
* card too narrow to share one: decided per card by the content, a port with zero counters
|
||||
* keeps speed and traffic side by side while the port beside it pushes them apart — two
|
||||
* cards of the same width reading as two different layouts. */
|
||||
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);
|
||||
/* 10px at Normal, a step below the theme's --fs-type-xs (11px), and it BUYS WIDTH: this figure
|
||||
* is the widest thing a port card must fit whole, so its size sets the card's floor — one pixel
|
||||
* here is 12px off every card, measured: the floor goes 106 → 94 at Normal, 92 → 80 at Compact
|
||||
* and 119 → 109 at Large. It stays on the density axis and off the type LADDER, which has no
|
||||
* rung here, because a figure read as a pair of numbers beside a label tolerates a step body
|
||||
* text would not. */
|
||||
/* The token, not a literal: --fs-type-2xs is one step below the ladder's smallest and exists
|
||||
* for these figures (02-tokens.css says why, and rounds it where round() exists). The flag
|
||||
* fights 29_ports.js's inline `font-size:80%`. */
|
||||
font-size: var(--fs-type-2xs) !important;
|
||||
color: var(--fs-dim);
|
||||
/* the tight leading, not the body one: these are two lines of figures read as a block, and
|
||||
* --fs-leading (1.5) spent 5px of card height on the space between them. */
|
||||
line-height: var(--fs-leading-tight);
|
||||
white-space: nowrap;
|
||||
/* Fights the element's own inline `text-align:left` (29_ports.js) so the up and down lines align
|
||||
* on their right edge and the units form a column. Scoped to a @container threshold it was
|
||||
* the pixel proxy the flex wrap above replaces: the cell now reaches the card's right edge
|
||||
* on whichever row it lands, so the alignment is one answer with no width to switch at. */
|
||||
text-align: end !important;
|
||||
}
|
||||
|
||||
/* Meter rows — any stock .table row carrying a .cbi-progressbar (Memory, Storage, CPU
|
||||
* load, Network connections, …): label + value on one line, bar below. Keyed off the
|
||||
* progressbar (:has), not per-section, so it covers every such row — including a
|
||||
* third-party one. The bar itself is theme/25-progressbar.css. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)):has(.cbi-progressbar) { display: block; }
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--fs-space-1) var(--fs-space-2-5);
|
||||
/* Halved from 8/10. A progressbar row is a LABEL over a 10px bar — the row is already
|
||||
* two lines tall, so the outer padding was doing the work of a gap twice over and Memory
|
||||
* and Storage read as mostly empty space. The bottom stays the larger of the two: the bar
|
||||
* sits on that edge, and equal padding reads as the bar hanging low in its row. */
|
||||
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
|
||||
border: 0;
|
||||
position: relative;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td {
|
||||
display: block;
|
||||
/* beats base's `.td[width="33%"]` with no flag: the includes set a width
|
||||
* ATTRIBUTE, not an inline style, so a later layer wins. */
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
/* label. Undoes the key/value `white-space: nowrap` on a first column: a mount point
|
||||
* ("192.168.3.26:/volume1/… (/mnt/hdd_isync)") is one unbreakable line, so the row's
|
||||
* min-content width blew past the card and Memory/Storage stopped shrinking on a phone.
|
||||
* padding-inline-end = the gutter for the value, which is pinned over the bar's far edge
|
||||
* (::after, out of flow — nothing else reserves space for it). */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
|
||||
grid-column: 1; grid-row: 1;
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
/* overflow-wrap dropped — base/40-tables.css breaks every cell; the copy that stood here
|
||||
* moved nothing against 267 matching cells. */
|
||||
padding-inline-end: min(230px, 50%);
|
||||
/* colour and weight are the shared label rule at the end of this file; the size is here
|
||||
* because it is this row's geometry, not its typography */
|
||||
font-size: var(--fs-type);
|
||||
}
|
||||
|
||||
/* On a narrow card the value takes its own line — a reserve cannot be made to fit there.
|
||||
*
|
||||
* The value is out of flow (the bar's ::after, pinned over its far edge), so the only thing
|
||||
* keeping it off the label is that padding, and the two are sized by different things: the
|
||||
* padding is a fraction of the CARD, the value is however wide LuCI's formatter made it.
|
||||
* `16.66 GiB / 16.66 GiB (100%)` measures 218px against 190px of reserve on a 380px phone card,
|
||||
* so the figure printed straight through the end of the label.
|
||||
*
|
||||
* Below the threshold the label keeps the whole line and the bar drops by one --fs-type-xs line,
|
||||
* which is the band the value then sits in. `fs-view` rather than a container of its own: adding
|
||||
* `container-type` to the card would size-contain a box the port tiles deliberately keep
|
||||
* uncontained (see the note by `padding` above). */
|
||||
@container fs-view (max-width: 560px) {
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .cbi-progressbar {
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
}
|
||||
/* bar cell — spans full width on row 2 */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:nth-child(2) {
|
||||
grid-column: 1 / -1; grid-row: 2;
|
||||
}
|
||||
|
||||
/* Drop the per-row dividers theme/30-tables.css adds (.cbi-section .table .td) —
|
||||
* cleaner card. No !important: that rule is `theme`, this file is `page`, so the later
|
||||
* layer already wins.
|
||||
*
|
||||
* Keyed on the table's SHAPE rather than on the three cards the grid happens to name: a
|
||||
* key/value table is one with no header row, which is the same test the meter rows above
|
||||
* already use. Naming `.fs-ovl-mem/-sto` left System ruled at 1px per row — nine dividers down
|
||||
* a card whose two columns are a name and its value, where the alignment already says which is
|
||||
* which — and gave a third-party include of the same shape nothing at all. A DATA table
|
||||
* (`.tr.table-titles` present: DHCP leases, the wifi station list) keeps its dividers: there
|
||||
* the row is one record among many and the rule is what separates the records. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .th,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr { border-bottom: 0; }
|
||||
/* Its own rule, not a fourth line in the list above: `.cbi-value` carries no `:has()`, and a
|
||||
* selector list is not forgiving — below the browser floor the whole list would be discarded,
|
||||
* dividers and all (npm run css-floor, .claude/rules/css.md). */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
|
||||
|
||||
/* The name in a key/value row is the QUIET half — the figure beside it is what the card is
|
||||
* read for. It came out at --fs-weight (600) in --fs-text, i.e. heavier and brighter than the
|
||||
* value it introduces, which is the ranking inverted: four bold lines down a Memory card
|
||||
* competing with the four numbers they label. --fs-dim at normal weight is what the System
|
||||
* table's first column already resolved to, so the meter cards now read as the same family.
|
||||
* Data tables are excluded by the same header-row test as the dividers above: there the first
|
||||
* column is a record's name, not a caption. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td:first-child {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* ---- the active interface box ----
|
||||
*
|
||||
* theme/45-misc.css fills `.ifacebox-head.active` with --fs-accent and inks it --fs-on-accent.
|
||||
* On a form that is one small badge; on the Overview it is a full-bleed bar of saturated colour
|
||||
* across the card — three of them at once here (IPv4 Upstream, radio0, radio1), each louder
|
||||
* than the data underneath and than the page's own controls. The state it encodes is "this
|
||||
* interface is up", which is a STATUS, so it is said in the status colour and in the text
|
||||
* rather than in a fill: --fs-good on the same --fs-panel2 every other head carries.
|
||||
*
|
||||
* Scoped to this page deliberately: the same head on Network -> Interfaces sits among many
|
||||
* boxes where the fill is doing real work telling them apart. */
|
||||
: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>, which theme/30-tables.css leaves at 700 — so
|
||||
* inside an interface box the captions (Protocol, Address, Gateway, DNS) are the boldest text
|
||||
* on the card and the addresses they introduce are not. Same inversion as the key/value rule
|
||||
* above, same answer; the value keeps --fs-text and needs 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);
|
||||
}
|
||||
|
||||
/* The meter's own figure, promoted to match the label it now outranks.
|
||||
*
|
||||
* theme/25-progressbar.css prints `attr(title)` above the bar at --fs-type-xs in --fs-dim,
|
||||
* which was the right pairing while the label beside it was 600 in --fs-text. With the label
|
||||
* moved down (above), a dim 11px figure left the row with nothing at full strength: on Memory
|
||||
* that is four bars whose only readable text is the caption. The number is what the card is
|
||||
* for, so it takes the body size and the body ink; the bar and the placement are untouched.
|
||||
*
|
||||
* Overview only. The same meter on Software and in the package manager sits in a form row
|
||||
* where the value is beside its own field label, not carrying the row on its own. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .cbi-progressbar::after {
|
||||
font-size: var(--fs-type);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
/* Row height in the Overview's DATA tables (DHCP leases, the wifi station list).
|
||||
*
|
||||
* theme/30-tables.css pads a data cell for a page that IS a table, where the row is the unit of
|
||||
* work and the padding is its hit target. On the Overview the same table is a card's contents,
|
||||
* read at a glance and never clicked except on its buttons, and eight leases at that height are
|
||||
* most of a screen: the DHCP card measures 740px against a System card of 440. One step down
|
||||
* the space ladder, so it follows the density axis like everything else, and only here. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .td,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .th {
|
||||
padding-block: var(--fs-space-1-5);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
@layer page {
|
||||
/* Software disk-space bar -> the overview Memory/Storage meter: label + value on one
|
||||
* line, bar below (the value is the .cbi-progressbar title, lifted out by ::after).
|
||||
* Scoped by #view[data-page], NOT by :has(#disk-space-label) — 24.10 ships no such id.
|
||||
* #view, not body: the attribute lives on the view itself so the outgoing page keeps its
|
||||
* own rules for the whole staging window (fs-router.js, commitStage).
|
||||
* `:where(#view)`, not bare `#view`: zeroes the id's specificity without changing which
|
||||
* element matches — 20-overview.css, top of `@layer page`, has the full reasoning and the
|
||||
* measured numbers (tools/css-metrics.mjs's ceiling). */
|
||||
: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); }
|
||||
|
||||
/* The package list's row SHAPE. #packages is a DATA table (`<table class="table" id="packages">`,
|
||||
* no .cbi-section-table class; fs-select.js tags it .fs-dt off its .tr.cbi-section-table-titles
|
||||
* header), so it takes the shared card from
|
||||
* theme/30-tables.css when measured too narrow; this only changes how a row READS, a package
|
||||
* being a name plus a few short facts, so label-left / value-right on one line beats the
|
||||
* half-width pairs a config row wants.
|
||||
*
|
||||
* No @container guard, and do not re-add one: overflow is the trigger, and fs-select.js
|
||||
* measures it. */
|
||||
/* ---- the package NAME is an identifier: do not let the column collapse under it ----
|
||||
*
|
||||
* base/40-tables.css gives every data cell `overflow-wrap: anywhere` (theme/30-tables.css
|
||||
* narrows it to `break-word` for as long as a data table is a table), and `anywhere` — unlike
|
||||
* `break-word` — also lowers the column's min-content to a single character. Version and Size
|
||||
* are `nowrap` and cannot shrink, so auto table layout takes the whole shortfall out of column
|
||||
* 1: measured with the filter on "app", the name column came out 81-101px — narrower than
|
||||
* Size (88px) — and 6 of the first 8 names rendered broken mid-word, a hyphen making it worse
|
||||
* by offering a legal break point.
|
||||
*
|
||||
* nowrap here does not mean "never wrap": it makes the overflow HONEST, which is what
|
||||
* fs-select.js's measurement needs — where the row genuinely does not fit, the table cards. It
|
||||
* is the same trap idTower() works around in fs-select.js: `anywhere` hides the overflow by
|
||||
* shredding a cell, so nothing is left for fit.overflows() to read. Verified 560-1440px: 0
|
||||
* broken names, no table overflow, and the card threshold unchanged. */
|
||||
#packages:not(.fs-stacked) .tr > .td:first-child { white-space: nowrap; }
|
||||
|
||||
/* `:not(...)` on the header rows, and it is load-bearing: this selector carries an ID, so it
|
||||
* is (1,1,0) in the PAGE layer — it beat theme/30-tables.css's
|
||||
* `.table.fs-stacked .tr.cbi-section-table-titles { display: none }` and put the column
|
||||
* header back on screen in card mode, where every cell already prints its own label. */
|
||||
#packages.fs-stacked .tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr) {
|
||||
display: block; padding: var(--fs-space-3) var(--fs-space-4);
|
||||
}
|
||||
/* ---- the value is not one flex item, and space-between tears it apart ----
|
||||
*
|
||||
* A card row is label-left / value-right, which `justify-content: space-between` spreads over
|
||||
* the ::before and the cell's content. But flex makes every ELEMENT its own item, and the
|
||||
* filter on this page wraps each match in `<ins>`, so a matched name is two items and
|
||||
* space-between pushes them to opposite edges — the package name read as `luc` … 85px …
|
||||
* `i-app-acl`.
|
||||
*
|
||||
* So do not distribute the free space BETWEEN the items — hand it all to the label, and
|
||||
* everything else packs flush right in DOM order, however many elements the value is. */
|
||||
#packages.fs-stacked .td {
|
||||
display: flex; align-items: baseline; justify-content: flex-end;
|
||||
gap: 0; padding: var(--fs-space-1) 0; border: 0; text-align: end;
|
||||
/* `min-width: 0` and `width: auto` are not restated: theme/30-tables.css sets both on
|
||||
* `.table.fs-stacked .td`, which this table is one of, and switching them off here moved
|
||||
* nothing across 500 matching cells. `text-align: end` above IS this file's own — it
|
||||
* overrides that rule's `start`. */
|
||||
flex: none;
|
||||
}
|
||||
/* An auto inline-end margin is what replaces space-between: it eats the whole free line, so the
|
||||
* value packs flush against the far edge as one unbroken run. The inline-end padding keeps a gutter when the row is
|
||||
* full and the auto margin collapses to nothing (the `gap` used to do that, but a gap applies
|
||||
* between EVERY pair of items — including the two halves of a highlighted name). */
|
||||
#packages.fs-stacked .td[data-title]::before {
|
||||
content: attr(data-title); flex: 0 0 auto; text-align: start; margin-bottom: 0;
|
||||
margin-inline-end: auto; padding-inline-end: var(--fs-space-3-5);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color); white-space: nowrap;
|
||||
}
|
||||
/* Column 4 = Description. NOT `[data-title="Description"]`, which is a translated string and
|
||||
* `tools/css-i18n.mjs` fails the build on one. It used not to match even in English: LuCI builds
|
||||
* these cells from the heading's `innerText` (ui.js, `data-title`), which is RENDERED text, and
|
||||
* `.th` was uppercased here — so the attribute said "DESCRIPTION" and the literal matched ZERO
|
||||
* elements on every router (measured). The uppercase is gone (theme/30-tables.css, issue #39) and
|
||||
* the attribute now carries the app's own case; the column index is still the right anchor.
|
||||
* Columns: 1 Package name | 2 Version | 3 Size | 4 Description | 5 actions. */
|
||||
/* `overflow-wrap` dropped: base/40-tables.css breaks every cell now and this copy moved nothing
|
||||
* against 100 matching cells. `word-break` has now gone the same way for a different reason: its
|
||||
* neutraliser in theme/30-tables.css stopped excusing carded tables, so this sat under a flag and
|
||||
* could not be read. Nothing renders differently — `break-word` is `word-break: normal` plus
|
||||
* `overflow-wrap: anywhere` and the theme already gives every stacked cell the second half;
|
||||
* measured at 390px, this cell is 322 x 103 px on either side of the change. */
|
||||
#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; }
|
||||
/* The "Disk space:" caption, keyed on WHAT IT SITS BESIDE rather than on `#disk-space-label`:
|
||||
* that id is 25.12+ only (25.12 emits `E('label',{'id':'disk-space-label'}, …)`, 24.10's
|
||||
* package-manager.js a bare `E('label',{}, …)`), so an id rule is dead on 24.10 and the caption
|
||||
* keeps base's 250px-wide label. The disk section is the only `label` immediately followed by a
|
||||
* `.cbi-progressbar`, which is the discriminator theme/90-responsive.css already uses for the
|
||||
* same element.
|
||||
*
|
||||
* Not `:has(:where(+ .cbi-progressbar))`: a leading combinator makes it a RELATIVE selector, and
|
||||
* only `:has()` accepts one — `:where()` takes a plain complex-selector list, so wrapping it
|
||||
* there makes the whole rule invalid and the browser drops it, which stylelint and css-metrics
|
||||
* both parse happily. */
|
||||
: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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@layer page {
|
||||
/* SSH-Keys renders its whole view as a single bare <div> in #view with no .cbi-section wrapper, so
|
||||
* its text sits frameless over the wallpaper while every other page gets an opaque card. There
|
||||
* is no section markup to hook, so that lone wrapper is given the same card (the values are
|
||||
* theme/30-tables.css's `.cbi-section`).
|
||||
*
|
||||
* Scoped to this page on purpose: a general "card any bare #view > div" was tried and reverted —
|
||||
* it also wrapped whole tabbed views (Startup). Other bare-view pages get their own entry
|
||||
* here.
|
||||
*
|
||||
* `:where(#view)`, not bare `#view`: zeroes the id's specificity while still matching the exact
|
||||
* same element, so `> div` still means "child of #view" — 20-overview.css, top of
|
||||
* `@layer page`, has the full reasoning, including why a wider `:where(#view, .fs-content)`
|
||||
* was rejected precisely because of this `>` combinator. */
|
||||
: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);
|
||||
}
|
||||
|
||||
/* An authorized_keys entry is a ~400-char base64 blob: the one dynlist that must not
|
||||
* take theme/60-inputs.css's 440px cap. Must live in `page` — in `base` it was dead,
|
||||
* since a layer beats specificity and theme's plain `.cbi-dynlist` capped it anyway. */
|
||||
:where(#view)[data-page="admin-system-admin-sshkeys"] .cbi-dynlist {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@layer page {
|
||||
/* DHCP leases. The .leases/.leases6 tables (JS-tagged .fs-dt) carry 7-8 mono columns, and the
|
||||
* theme's general rule lets any data cell break to fit, which splits an IPv4 or a MAC over two
|
||||
* lines. So: every cell on ONE line, and only the long opaque columns (DUID, the IPv6 list, the
|
||||
* hostname) may wrap.
|
||||
*
|
||||
* Do not re-add a card-stack copy here at a container threshold of its own: the nowrap IS this
|
||||
* table's ~736px floor, and a floor is what makes a table OVERFLOW when the room runs out —
|
||||
* fs-select.js measures that and sets .fs-stacked, so the threshold is discovered and the card
|
||||
* lives once, in theme/30-tables.css. `:not(.fs-stacked)` is the whole guard: once carded, the
|
||||
* cells must wrap again.
|
||||
*
|
||||
* `.lases` is not a typo here — it is upstream's spelling for the IPv4 table on 24.10
|
||||
* (`class="table lases"`), corrected to `leases` on 25.12+. Dropping it made this whole file
|
||||
* dead on 24.10: measured there with the theme active, every one of the five cells computed
|
||||
* `white-space: normal` — issue #7's exact symptom. Both spellings are matched here. */
|
||||
#view table.fs-dt:is(.leases, .lases, .leases6):not(.fs-stacked) .td { white-space: nowrap; }
|
||||
/* ---- never match a `data-title` VALUE: it is a translated, render-dependent string ----
|
||||
*
|
||||
* LuCI fills `data-title` from the column HEADING, so on a localised router the value is the
|
||||
* translation and an English literal in a selector never matches — the rule is dead in ~40
|
||||
* languages while looking alive here, which is how issue #7 survived.
|
||||
*
|
||||
* Worse, for tables whose rows LuCI builds from the heading's `innerText` the value is what the
|
||||
* heading RENDERS, so any transform the theme puts on `.th` rewrites the string the theme's own
|
||||
* CSS matches on. That is not hypothetical: `.th` carried `text-transform: uppercase` until
|
||||
* issue #39, and every such rule matched nothing on any router in any language.
|
||||
*
|
||||
* Anchor on the COLUMN instead: `tools/css-i18n.mjs` fails the build on any `[data-title="…"]`
|
||||
* value match, so this cannot come back. */
|
||||
|
||||
/* The long opaque columns, which may wrap. overflow-wrap:anywhere (not word-break) takes the
|
||||
* natural breaks first — the hyphens in a hostname — and only splits mid-token when nothing else
|
||||
* fits.
|
||||
*
|
||||
* ---- count from the END, never from the start ----
|
||||
*
|
||||
* The leading column is optional on 25.12+: `40_dhcp.js` opens both tables with an Interface
|
||||
* column only where the router serves DHCP through odhcpd, so every `:nth-child()` shifts by one
|
||||
* on exactly those routers and is invisible on the others. The TRAILING columns are identical
|
||||
* either way, so `:nth-last-child()` cannot shift.
|
||||
*
|
||||
* 25.12+ .leases [Interface] Hostname IPv4 MAC DUID IAID Remaining Static
|
||||
* -7 -6 -5 -4 -3 -2 -1
|
||||
* .leases6 [Interface] Hostname IPv6 DUID IAID Remaining Static
|
||||
* -6 -5 -4 -3 -2 -1
|
||||
* 24.10 .lases Hostname IPv4 MAC Remaining Static (no DUID/IAID column at all)
|
||||
* -5 -4 -3 -2 -1
|
||||
* .leases6 Host IPv6 DUID Remaining Static
|
||||
* -5 -4 -3 -2 -1
|
||||
*
|
||||
* so the hostname, the IPv6 list and DUID may wrap, while IPv4/MAC/IAID and the two short
|
||||
* trailing columns keep the nowrap above. BOTH tables are split on column COUNT, never on the
|
||||
* release and never on the class name: a one-character upstream fix to `lases` would otherwise
|
||||
* hand the 24.10 table the 25.12 column plan and silently un-fix issue #7. base/95-luci.css
|
||||
* states that as the rule for release-conditioned styling.
|
||||
*
|
||||
* The count test is wrapped in `:where()` so it costs NOTHING: `:has()` otherwise takes its
|
||||
* argument's specificity, and `.tr.table-titles > :nth-child(6)` alone pushed the selector to
|
||||
* [1,8,1], past the sheet's [1,7,0] ceiling. */
|
||||
#view table.fs-dt: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` is the whole rule now: it is what re-permits wrapping for these columns
|
||||
* against the nowrap above — and the nowrap is also what keeps IPv4/MAC unbroken, so the
|
||||
* column plan does not rest on overflow-wrap at all. The break itself is base's default for
|
||||
* every cell (base/40-tables.css); the copy that stood here moved nothing against 164 cells. */
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@layer page {
|
||||
/* Associated stations (Overview and Network -> Wireless render the same `.assoclist` table).
|
||||
*
|
||||
* No `white-space: nowrap` on the MAC column any more, though one stood here while
|
||||
* `overflow-wrap: anywhere` split `48:E1:5C:9F:26:20` at whatever character landed on the edge
|
||||
* of a ~103px column, every time (issue #5). A MAC has no break opportunity in it — a colon is
|
||||
* not one — so the honest floor theme/30-tables.css gives a data table keeps the whole address
|
||||
* in one piece, for every table that shows one rather than for this one.
|
||||
*
|
||||
* Do not nowrap the Signal and Rate columns either: that makes the modulation string
|
||||
* ("229 Mbit/s, 20 MHz, HE-MCS 9, HE-NSS 2") an unbreakable ~300px block that eats the row and
|
||||
* crushes the Network column. A wide cell that CAN break is the lesser evil, and that is what
|
||||
* rung 3 of fs-select.js's remedy ladder chooses when it has to choose: the widest breakable
|
||||
* column gives way, never the identity column. */
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@layer page {
|
||||
/* The filter bar, which LuCI writes as a run of inline text. Both log views build one —
|
||||
* `tools/views.js` (LogreadBox: the System Log and every third-party page built on it) and
|
||||
* `view/status/dmesg.js` (the Kernel Log) — emitting bare `<div style="margin-bottom:10px">`
|
||||
* rows of label/control pairs with nothing around a pair. Inline flow then breaks wherever the
|
||||
* line runs out, and on a phone that is between a label and the control it names — or inside
|
||||
* the label itself.
|
||||
*
|
||||
* One rule of layout, two tiers, no per-field exceptions. The rows are flex, so a wrap can only
|
||||
* fall BETWEEN items and a label is one unbreakable item; a field may shrink to share the line.
|
||||
*
|
||||
* Scoped by SHAPE: `data-page` differs between the two logs and a third-party box has neither,
|
||||
* while both wrap their rows in `#content_syslog` and a filter row is exactly one that opens
|
||||
* with a `<label>` — the scroll-to-end button's row and the log itself do not.
|
||||
*
|
||||
* Only the ROW gap is ours: the inline `margin: 0 5px` LuCI writes on the labels stays as the
|
||||
* horizontal spacing, so nothing here has to fight an inline style. */
|
||||
#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) {
|
||||
/* shrink, never grow. `max-width` is not the same lever as `min-width` and both are needed:
|
||||
* one lets a flex item go below its content, the other clamps a `<select>` that carries the
|
||||
* theme's own 210px width — measured at 360px, where both selects hung 3px past the view. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* THE TWO TIERS ARE DISJOINT CONTAINER QUERIES, not a base rule and an override: each states one
|
||||
* layout in full, so neither can win or lose against the other by source order — the trap the
|
||||
* first version of this file fell into, where a later generalisation silently out-specified two
|
||||
* rules that were meant to override it. */
|
||||
@container fs-view (width > 560px) {
|
||||
/* room for a filter and a half at least: the free-text filter takes what the named fields
|
||||
* leave. `input:not([type])` IS that filter, in both views: every other input under
|
||||
* `#content_syslog` states a type (`checkbox` for the four inverts and the sort flag, `number`
|
||||
* for Max rows and for the Kernel Log's boot-time range), and both views leave the text one
|
||||
* untyped — so nothing here keys on an id or has to know how many fields a row holds. */
|
||||
#content_syslog > div:has(> label) > input:not([type]) { flex: 1 1 12rem; }
|
||||
}
|
||||
@container fs-view (width <= 560px) {
|
||||
/* One field per line, its labels on the line above it. Wrapping alone fills each line greedily,
|
||||
* so a leading label rides the end of the previous line while the field it belongs to opens the
|
||||
* next — and no flex rule can bind a label to the field that follows it. A full-width basis
|
||||
* can: the field always starts a line, so whatever labels precede it sit together above it.
|
||||
* It costs one line per filter on a phone and holds for every row of both views without any
|
||||
* of them being named. A checkbox is not a field and keeps its 16px. */
|
||||
#content_syslog > div:has(> label) > :is(select, input:not([type="checkbox"])) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* System Log / Kernel Log: the whole log is a readonly `<textarea id="syslog">` in a bare div, not
|
||||
* in a .cbi-value-field. Stock bootstrap pairs its generic `input, textarea { width: 210px }`
|
||||
* with `#syslog { width: 100% }` and wins on specificity — but this theme's generic field box
|
||||
* lives in the `theme` layer, and a layer beats specificity, so the same pair in `base` loses and
|
||||
* the log rendered as a 210px column (measured on 25.12). The rule is absorbed here, above the
|
||||
* generic's layer. Mono at the data tables' size, `pre` because log lines are lines, and a
|
||||
* vertical-only resize grip — a sideways grip would just re-create the squeeze. */
|
||||
#syslog {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
color: var(--fs-text);
|
||||
margin-bottom: var(--fs-space-4);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-type);
|
||||
line-height: var(--fs-leading);
|
||||
white-space: pre;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
@layer page {
|
||||
/* The Appearance controls, appended to System -> System by fs-appearance.js.
|
||||
*
|
||||
* This was a popover on <body>, capped to the viewport because eighteen axes plus an upload
|
||||
* panel do not fit a floating card on a phone in landscape. As part of a page none of that
|
||||
* applies: the sections are ordinary .cbi-section cards, the width is the content column's and
|
||||
* the scroll is the document's — so the placement, the max-height clamp, the fade-in and the
|
||||
* [hidden] pair a floating card needed are all gone.
|
||||
*
|
||||
* What is left in the theme's own namespace — the `.fs-ap-*` folds, sections, previews and
|
||||
* version line below — lives in the page layer rather than the theme layer, because this is
|
||||
* the only page that draws it: an axis control is not chrome. */
|
||||
|
||||
.fs-ap { display: flex; flex-direction: column; gap: var(--fs-space-5); }
|
||||
/* The page note (fs-appearance.js). Only the outer margins: `.alert-message p` carries
|
||||
* --fs-space-1 top and bottom, which at the box's own edges adds to the panel's padding and
|
||||
* makes a two-sentence note look loose against every other alert on the theme. */
|
||||
.fs-ap-note p:first-child { margin-top: 0; }
|
||||
.fs-ap-note p:last-child { margin-bottom: 0; }
|
||||
/* Grouping headings INSIDE the tab pane, not cards: the pane is already inside the stock
|
||||
* .cbi-section card, and a second border around each group is a box in a box. A hairline above
|
||||
* each group does the same job — except for the first, which sits under the tab strip. */
|
||||
/* the heading row. On a plain group it holds the title; on a FOLDED one the whole row is the
|
||||
* disclosure button (fs-appearance.js) — full width, so the target is the heading rather than a
|
||||
* chevron nobody can hit on a phone. */
|
||||
/* the heading row spans the section on its own now that the rows stack */
|
||||
button.fs-ap-fold {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-3);
|
||||
inline-size: 100%; padding: 0; margin: 0;
|
||||
background: none; border: 0; cursor: pointer; text-align: start; color: inherit;
|
||||
}
|
||||
button.fs-ap-fold:hover h4 { color: var(--fs-accent); }
|
||||
button.fs-ap-fold:focus-visible { outline: none; box-shadow: var(--fs-focus-ring-solo); border-radius: var(--fs-radius-sm); }
|
||||
/* The chevron: the same box with a two-border ::after that the overview's card toggles use
|
||||
* (styles/pages/20-overview.css) — one
|
||||
* chevron for "this panel opens", drawn here and there rather than an SVG in one place and
|
||||
* borders in the other, with the glyph pinned as @mirror chevron/* so the copies cannot drift.
|
||||
*
|
||||
* It turns on the SAME attribute that tells a screen reader the state, so the two cannot
|
||||
* disagree. Rotated with `transform` rather than the individual `rotate` property, because that
|
||||
* is what the overview's copy does and the pair must look identical mid-animation. */
|
||||
.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); }
|
||||
/* Stock `.cbi-value` rows stack one per line and carry their own hairline, so a folded group needs
|
||||
* no layout of its own — it lays its rows out exactly like an unfolded section by doing nothing. */
|
||||
/* `hidden` is what keeps a closed group out of the tab order and the a11y tree, and
|
||||
* base/10-reset.css is what makes the attribute win over a class's `display` — for every
|
||||
* element, not just this one, so there is nothing to restate here. */
|
||||
/* the second heading inside the Colours fold — Surfaces shares the fold, not the heading. It
|
||||
* wears the SAME .fs-ap-head h4 as every other section title now; a third heading style on one
|
||||
* page (h4, folded h4, uppercase eyebrow h5) was a third of the raggedness. Only the space
|
||||
* above it is its own. */
|
||||
.fs-ap-sub { margin-top: var(--fs-space-4); }
|
||||
.fs-ap-head h4 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
}
|
||||
.fs-ap-section + .fs-ap-section { padding-top: var(--fs-space-4); border-top: var(--fs-hairline); }
|
||||
/* The rows are stock `.cbi-value` and this page states no layout for them at all: caption column,
|
||||
* field column and the hairline between rows come from base/30-forms.css and theme/60-inputs.css,
|
||||
* the rules every other LuCI form is drawn by. What stood here was a responsive two-column grid
|
||||
* of stacked cards with an uppercase eyebrow above each control: it fitted a floating popover,
|
||||
* and beside General Settings / Language and Style it made the theme's own tab the one page in
|
||||
* LuCI that did not look like LuCI. Deleting that grid is the change — do not restate a layout
|
||||
* for these rows. */
|
||||
/* The accordion switch is meaningless in the top layout — its sections are hover dropdowns,
|
||||
* already exclusive. Hidden HERE, not by a branch in the JS: the page is built once, so a branch
|
||||
* froze the control to whatever layout it loaded in and the live toggle could never change it. */
|
||||
:root[data-layout="top"] .fs-ap-submenus { display: none; }
|
||||
|
||||
/* Save-as-default / Reset row: two equal buttons, no status text — the Save button's own
|
||||
* label/disabled state is the status. They take their look from .btn / .cbi-button-action
|
||||
* (base + theme/55-buttons.css); a disabled .cbi-button-action already greys via base. */
|
||||
/* Buttons take the width of their own label, which is what every button on every other LuCI page
|
||||
* does — they were `flex: 1 1 9em` and stretched to fill the field, so three of them made one
|
||||
* long bar and "Choose SVG"/"Remove" each ran half the card. `wrap` keeps a narrow card honest:
|
||||
* an item that cannot fit moves to the next line instead of hanging past the edge, which on a
|
||||
* flex row is silent. */
|
||||
.fs-ap-actrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
/* Save and the two resets differ by one word and sit in one row, which is the shape a
|
||||
* destructive click lands on by accident. The gap says they are two groups; both resets also
|
||||
* ask for a second click. Zero-width, so it costs no height when the row wraps on a phone. */
|
||||
.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; }
|
||||
/* the primed Reset (second click confirms) reads as the destructive action it is */
|
||||
.fs-ap-armed { color: var(--fs-on-danger); background: var(--fs-danger); border-color: transparent; }
|
||||
/* Save's only visible failure line (a rejected save rpc — usually a stale session) — shown in
|
||||
* place of the absent status text, hidden until a save actually fails. */
|
||||
.fs-ap-err { margin-top: var(--fs-space-2); font-size: var(--fs-type-xs); line-height: 1.35; color: var(--fs-danger); }
|
||||
|
||||
/* ---- the colour axes ----
|
||||
* One row is a swatch, a hex field and the button back to the palette's own colour. */
|
||||
/* the same ceiling every other stretching control in a form row takes (02-tokens.css), so the
|
||||
* swatch + hex + Palette button end where the selects and the sliders end */
|
||||
.fs-colorctl { display: flex; flex-direction: column; gap: var(--fs-space-1-5); max-width: var(--fs-field-max); }
|
||||
.fs-color-row { display: flex; align-items: center; gap: var(--fs-space-2); }
|
||||
/* The native colour swatch. type="color" is drawn by the browser and cannot be restyled beyond
|
||||
* its box, so the box is what is styled: the inner well is stripped to the frame's edge on both
|
||||
* engines (WebKit nests two elements, Firefox one) or it paints a white margin inside our
|
||||
* border. Fixed 34px square so it lines up with the field beside it at every density. */
|
||||
input[type="color"].fs-color-swatch {
|
||||
flex: 0 0 auto; inline-size: 34px; block-size: 34px; padding: 0; cursor: pointer;
|
||||
background: none; border: 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); }
|
||||
/* the hex field is a VALUE, so it is mono and tabular like every other value in the theme —
|
||||
* and #0969da is exactly the kind of string a proportional face makes hard to read back */
|
||||
input[type="text"].fs-color-hex {
|
||||
flex: 1 1 auto; min-inline-size: 0;
|
||||
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-variant-numeric: tabular-nums;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.fs-color-clear { flex: 0 0 auto; font-size: var(--fs-type-xs); padding: var(--fs-space-1-5) var(--fs-space-2); cursor: pointer; }
|
||||
.fs-color-clear:disabled { cursor: default; }
|
||||
|
||||
/* What the chosen colour COSTS, measured live against the surface it is read on (fs-widgets'
|
||||
* contrastRatio). It is a readout, not a validation: the theme derives readable ink over a fill
|
||||
* but never silently corrects a colour the admin asked for, so this says the number and grades
|
||||
* it. Three grades, three colours — and the failing one is --fs-danger, which the admin may have
|
||||
* just recoloured, which is exactly the honest outcome. */
|
||||
/* It is LuCI's own help-text line now — `.cbi-value-description`, styled in base/95-luci.css,
|
||||
* which is where a sentence under a field belongs on every other page. What stays here is the
|
||||
* only thing stock has no opinion about: the GRADE's colour, and the tabular figures that keep
|
||||
* the ratio from jittering as it updates. */
|
||||
.fs-color-contrast { font-variant-numeric: tabular-nums; }
|
||||
.fs-contrast-aa { color: var(--fs-good); }
|
||||
.fs-contrast-aa-large { color: var(--fs-warn); }
|
||||
.fs-contrast-low { color: var(--fs-danger); }
|
||||
|
||||
/* THE PRESET CHIPS' rules were here — a wrapping row of pills, each painted in the accent it
|
||||
* would set, with ink derived from that colour's own lightness. The control is gone from
|
||||
* fs-appearance.js: it was the one thing on the page that looked like nothing else in LuCI, it
|
||||
* started at the card's edge instead of at the field column, and everything it did the Accent
|
||||
* row does with any colour rather than eight. */
|
||||
|
||||
/* The uploaded image's preview and the two buttons under it, both in an ordinary
|
||||
* `.cbi-value-field`. The preview is a thumbnail, not a banner: capped to the same field ceiling
|
||||
* as every other stretching control and to a fixed height, so the row keeps one shape whatever
|
||||
* the image's aspect is — full width makes an SVG tile look like a letterboxed photograph. */
|
||||
.fs-ap-bgprev {
|
||||
display: block; width: 100%; max-width: var(--fs-field-max); height: 84px; object-fit: cover;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
/* the preview follows the buttons in the field, so the gap belongs ABOVE it */
|
||||
margin-top: var(--fs-space-2);
|
||||
}
|
||||
.fs-ap-bgrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
.fs-ap-bgrow .btn { font-size: var(--fs-type); padding: var(--fs-space-2) var(--fs-space-2-5); cursor: pointer; }
|
||||
.fs-ap-bgrow .btn:disabled { cursor: default; }
|
||||
/* The JS hides a row that does not apply to the chosen wallpaper, the empty preview and a
|
||||
* not-yet-uploaded Remove with `el.hidden`; base/10-reset.css makes that attribute beat the
|
||||
* `display` a class sets, so none of it needs restating here any more. */
|
||||
/* Under the File wallpaper only the photo Dim is relevant — the router-identity Tint colours the
|
||||
* canvas the photo covers, so hide the Tint hue AND its strength there (fs-ap-tint marks both). */
|
||||
:root[data-wallpaper="file"] .fs-ap-tint { display: none; }
|
||||
/* and the strength is a multiplier on a chroma WE choose, so it only means anything in hue mode:
|
||||
* with the tint off there is nothing to scale, and on a hex canvas the colour is the one the
|
||||
* admin entered. (0,3,0) out-ranks the stock row's own display. Not named after Density: that is
|
||||
* the UI-density select four rows above, and one name for two rows is how the label itself went
|
||||
* wrong. */
|
||||
:root:not([data-tint="hue"]) .fs-ap-tintstr { display: none; }
|
||||
|
||||
/* the version line at the foot of the page */
|
||||
.fs-ap-footer {
|
||||
display: flex; flex-direction: column; gap: var(--fs-space-2);
|
||||
margin-top: var(--fs-space-0-5); padding-top: var(--fs-space-2-5); border-top: var(--fs-hairline);
|
||||
}
|
||||
.fs-ap-verrow { display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-2-5); }
|
||||
/* links to the project repo — reads as muted text until hovered */
|
||||
.fs-ap-version { 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; }
|
||||
/* the link's own box was 84x17 (owrt2512/390x844, Appearance tab), under the 24px floor of
|
||||
* WCAG 2.2 SC 2.5.8 on height. Same fix as `.cbi-checkbox > label[for]::before` in
|
||||
* theme/60-inputs.css: an invisible, absolutely-positioned overlay grows the hit box to
|
||||
* 88x25 without moving the text or growing `.fs-ap-verrow`, since the overlay is out of flow. */
|
||||
.fs-ap-version::before { content: ""; position: absolute; inset: -4px -2px; }
|
||||
|
||||
/* The line that names the missing catalogue package. Same weight as the version beside it —
|
||||
* it is a fact about this router, not a warning: nothing is broken, one package is absent.
|
||||
* Wraps, because the package name is 24 characters and the column is narrow at 390px. */
|
||||
.fs-ap-i18n {
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--fs-type-xs);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
/* the chip is base/20-typography.css's `code`; only the size follows the line it sits in, which
|
||||
* is a step smaller than body text */
|
||||
.fs-ap-i18n code { font-size: inherit; }
|
||||
|
||||
/* No rules here for a segmented control or a range slider of our own: the page draws its enums
|
||||
* with `ui.Select` and its numbers with `ui.RangeSlider`, which this theme already styles for
|
||||
* every other LuCI form. Two copies of a slider, one of them ours, was the thing to delete. */
|
||||
|
||||
/* No rules here for a hue slider either: the control is gone from fs-widgets' colorControl, two
|
||||
* controls for one value reading as two settings, and the half that could not reach a grey was
|
||||
* the slider. The axis still stores a hue and 03-palettes.css still rotates by one, so a saved
|
||||
* value goes on painting — but CSS for a control nobody renders is dead CSS in our own
|
||||
* namespace, which `npm run css-orphans` exists to keep out. */
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@layer page {
|
||||
/* Processes: the PID column, which must not split. No `white-space: nowrap` here any more — the
|
||||
* floor is general: theme/30-tables.css gives a data table `overflow-wrap: break-word` for as
|
||||
* long as it is a table, so every column keeps the width of its own widest unbreakable token,
|
||||
* and a PID is one token. (With `anywhere` on every cell instead, `384` rendered as "38/4" and
|
||||
* `4810` as "481/0" — 23 of 26 rows, measured at 1228px against a Russian catalogue: issue #32,
|
||||
* and issue #5 before it.) The one per-page rule that survives (pages/30-software.css) does so
|
||||
* because a hyphen IS a legal break opportunity and `break-word` will use it.
|
||||
*
|
||||
* The measurement that replaced the nowrap, on the router at 720px: the Command column reports
|
||||
* 353px of need against 126px of width, the table asks for 963px in 688px of room, and the
|
||||
* ladder marks exactly one column breakable — leaving the PID whole without a rule of its
|
||||
* own. */
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
@layer theme {
|
||||
/* ---- the chrome's floor: pin what a foreign rule can reach by INHERITANCE ----
|
||||
*
|
||||
* fs-sheets.js fences the chrome out of a third-party selector, so nothing foreign MATCHES a
|
||||
* chrome element. Inheritance is the way in a fence cannot close: a rule on `html` or `body` is
|
||||
* an ancestor, and excluding it is meaningless. Measured against a hostile flagged rule on
|
||||
* `html`: font-style reached 166 of the menu's 169 elements, word-spacing and text-align 157,
|
||||
* letter-spacing and text-transform 156, cursor 46 — while font-family, color, font-size,
|
||||
* line-height and font-weight reached none,
|
||||
* because the chrome already states those. The fix is to state the rest too.
|
||||
*
|
||||
* Inheritance is not a cascade competitor: it supplies a value only when no declaration matches
|
||||
* the element, so any declaration of ours — unflagged, at zero specificity — beats an inherited
|
||||
* `!important`. Hence no `!important` here.
|
||||
*
|
||||
* It keys off `[data-fs-chrome]`, the mark a chrome root carries in the markup (header.ut), not
|
||||
* off `.fs-sidebar`: the chrome is not one element, and a pin naming the menu leaves the skip
|
||||
* link and the search overlay inheriting straight from `html`. The fence (fs-sheets.js) reads
|
||||
* the same mark, so the two halves of zone 1's defence cannot cover different sets of elements,
|
||||
* and `npm run chrome-fence` derives the mark from the markup and holds both to it.
|
||||
*
|
||||
* It goes on the ROOT alone, and says so in the SELECTOR (`:not([data-fs-chrome] *)`) rather
|
||||
* than trusting whoever adds the next marker to have read this: the markers live in a template
|
||||
* with conditional blocks, so "is this one inside that one" is not a question a text scanner can
|
||||
* answer, and CSS can. Pinning descendants too is worse than useless — a direct declaration
|
||||
* beats an inherited one even when the inherited one is ours, so `.fs-label` loses the `nowrap`
|
||||
* it inherits and `text-align` is forced from `start` to `left` on 302 elements, which alone
|
||||
* breaks every RTL language LuCI ships. Hence `text-align: start`, never `left`.
|
||||
*
|
||||
* `:where()` keeps it at 0,0,0 so every existing chrome rule outranks it: the pin is a floor,
|
||||
* never a ceiling. Only inherited properties belong here. */
|
||||
:where([data-fs-chrome]:not([data-fs-chrome] *)) {
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-transform: none;
|
||||
text-indent: 0;
|
||||
text-align: start;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* Shared chrome — brand, logo, wordmark, logout, indicators. Same in both layouts;
|
||||
* only their PLACEMENT differs. Written twice once (here and under `.fs-topnav`) and
|
||||
* the copies had drifted: 32 vs 30px logo, 11 vs 10px brand gap, 10 vs 8px indicator
|
||||
* gap. The top-bar values won. A layout file may set placement on these (flex, order,
|
||||
* the rail collapse) — never their look. */
|
||||
|
||||
.fs-brand {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2-5);
|
||||
color: var(--fs-text); font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
.fs-logo {
|
||||
width: var(--fs-btn-size); height: var(--fs-btn-size); border-radius: var(--fs-radius);
|
||||
/* No accent fill — a bordered box, same look as the square buttons in the right
|
||||
* cluster (.fs-logout, .fs-themerow: 1px var(--fs-border)). The glyph is the OpenWrt
|
||||
* favicon mark (brand.ut), baked-colour, so color/currentColor is unused here. */
|
||||
border: var(--fs-hairline);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.fs-logo svg { width: 20px; height: 20px; }
|
||||
/* The OpenWrt ring flips with the theme mode like the SVG favicon does under
|
||||
* prefers-color-scheme — dark ring on the light UI, light ring on dark. The cyan arcs
|
||||
* stay baked (read on both). */
|
||||
.fs-logo-ring { fill: #002b49; }
|
||||
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
|
||||
|
||||
/* 700, not 800: the theme ships no faces, so 800 is whatever the reader's Manrope or system
|
||||
* stack has — 700 for most of them, synthesised for the rest. It already resolved to 700 back
|
||||
* when the 18 KB face was bundled and dropped for drawing six elements. Say so. */
|
||||
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
|
||||
|
||||
/* Skip link — the first tab stop: a keyboard/SR user jumps the whole menu instead of
|
||||
* tabbing through every section on every page. Off-screen until focused, never
|
||||
* display:none / visibility:hidden — that removes it from the tab order, which is the
|
||||
* one thing it exists for. Sits above everything, incl. an open popover. */
|
||||
.fs-skip {
|
||||
position: fixed; top: 8px; inset-inline-start: 8px; z-index: var(--fs-z-dropdown);
|
||||
padding: var(--fs-space-2-5) var(--fs-space-4); border-radius: var(--fs-radius);
|
||||
background: var(--fs-accent); color: var(--fs-on-accent);
|
||||
font-weight: var(--fs-weight-bold); font-size: var(--fs-type); text-decoration: none;
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
}
|
||||
/* Clipped, not translated off-screen: --fs-shadow-pop is offset 12px / blur 34px, so a
|
||||
* box parked at -68px still bled ~16px of DROP SHADOW into the viewport and smudged the
|
||||
* top-left corner of every page. clip-path clips the shadow too. Never display:none /
|
||||
* visibility:hidden — see above. */
|
||||
.fs-skip:not(:focus) {
|
||||
clip-path: inset(50%);
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
.fs-skip:focus {
|
||||
outline: 2px solid var(--fs-panel); outline-offset: 2px;
|
||||
}
|
||||
/* Visually hidden but PRESENT in the a11y tree — for the document <h1> and the live
|
||||
* region. hidden/display:none/visibility:hidden take the element out of the a11y tree
|
||||
* too: the h1 would fix no heading outline and the live region would never announce.
|
||||
* Clipping leaves a node readable to assistive tech while occupying no space.
|
||||
* nowrap: a long title clipped into a 1px column is read out line by line by some SRs. */
|
||||
.fs-sr {
|
||||
position: absolute;
|
||||
clip-path: inset(50%);
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the skip link's target <main> carries tabindex="-1" so the browser actually MOVES
|
||||
* focus there (Safari/Chrome refuse on a non-focusable target: they scroll but leave
|
||||
* focus in the menu) — but it must not then ring the whole page. */
|
||||
#maincontent:focus { outline: none; }
|
||||
|
||||
.fs-logout { display: flex; align-items: center; color: var(--fs-dim); }
|
||||
.fs-logout:hover { color: var(--fs-danger); }
|
||||
|
||||
.fs-indicators { display: flex; align-items: center; gap: var(--fs-space-2); }
|
||||
|
||||
/* The 34px square icon button (.fs-themerow / .fs-logout collapsed) is NOT declared
|
||||
* here: it only exists where the chrome is a bar or a rail, and those blocks
|
||||
* (20-shell.css, 50-toplayout.css) already give it its border, surface and size — an
|
||||
* unconditional copy here was a second definition they had to re-state anyway. */
|
||||
|
||||
/* Density steps for the main menu once it is a horizontal row —
|
||||
* fs-chrome.js (fitTabStrips) adds .fs-dense1/2 when the row would wrap (and strips them
|
||||
* in the vertical sidebar, so no media guard is needed). Padding is trimmed hard
|
||||
* first, the font only at the d2 floor. */
|
||||
.fs-sidebar ul.nav.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
|
||||
.fs-sidebar ul.nav.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-1-5); font-size: var(--fs-type); }
|
||||
|
||||
/* Menu primitives, once for both layouts (both render the menu as `ul.nav > li > a`):
|
||||
* the 19px line-height, `display: block` on the items and the `position: relative`
|
||||
* every submenu popup anchors to — all three used to be inherited from a navbar rule
|
||||
* set the theme no longer carries. */
|
||||
ul.nav { position: relative; margin: 0; list-style: none; }
|
||||
ul.nav > li { display: block; }
|
||||
ul.nav a { line-height: 19px; text-decoration: none; }
|
||||
|
||||
/* `.dropdown-menu` is emitted by nobody here — not by this theme, whose menu is a plain nested
|
||||
* `ul`, and not by luci-base. It stays styled under the coverage contract: it is theme
|
||||
* vocabulary (stock luci-theme-bootstrap carries 14 rules for it), so a third-party luci-app-*
|
||||
* is entitled to emit it and expect a themed result. Each item anchors its own nested popup,
|
||||
* hence position: relative.
|
||||
*
|
||||
* This comment used to claim ui.js emits it for the split Save & Apply button. It does not —
|
||||
* that button is a `.cbi-dropdown`, a different widget. */
|
||||
.dropdown-menu li { display: block; position: relative; }
|
||||
/* wins over `ul.nav a` above: the popup sets its own denser rhythm */
|
||||
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; }
|
||||
|
||||
/* Menu hover / active — one look, every layout. One markup (`ul.nav > li > a` + a
|
||||
* nested `ul`) serves the main list, the submenu (accordion / bar dropdown / rail
|
||||
* flyout), the mode menu and the Appearance row, so there are no per-layout copies to
|
||||
* keep in step. */
|
||||
.fs-sidebar ul.nav > li > a:hover,
|
||||
.fs-sidebar ul.nav ul a:hover,
|
||||
.fs-themerow:hover {
|
||||
background: var(--fs-panel2); color: var(--fs-text); opacity: 1;
|
||||
}
|
||||
.fs-sidebar ul.nav > li > 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);
|
||||
}
|
||||
/* ---- the fill marks the PAGE, and a category is not a page ----
|
||||
*
|
||||
* `li.active` is set on the SECTION containing the current page as well as on the page's own
|
||||
* row, so `li.active > a` hands the same accent plate to both — two identical plates, one above
|
||||
* the other, on System -> System (issue #37): measured before this rule, the open category and
|
||||
* the current leaf carried the same fill, the same ink and the same 13px/600, differing in 2px
|
||||
* of padding.
|
||||
*
|
||||
* The markup already says which is which: menu-footstrap.js puts `aria-current="page"` on the
|
||||
* LEAF alone, a section header being a disclosure control rather than a link to where you are.
|
||||
* So the plate stays with the page and the ancestor keeps the accent INK, while an ancestor
|
||||
* that is also the page is matched by `a.active` above and keeps the plate.
|
||||
*
|
||||
* Unscoped, like the rule above it: the accordion, the bar's dropdown and the rail's flyout are
|
||||
* one markup. */
|
||||
.fs-sidebar ul.nav > li.active > a:not(.active) { color: var(--fs-accent); }
|
||||
|
||||
/* Keyboard focus for the chrome controls, which otherwise have only the UA default.
|
||||
* --fs-focus-ring-solo, not --fs-focus-ring: none of these flips a border on focus, so the ring
|
||||
* is the entire indicator, and the tinted ring measures 1.15-1.29:1 against the sidebar — i.e.
|
||||
* tabbing the whole menu shows nothing at 3:1 (the two-ring contract in 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);
|
||||
}
|
||||
|
||||
/* The page-wide accent signals no widget rule covers, all following the palette:
|
||||
* ::selection the UA default (opaque blue) ignores palette and dark mode
|
||||
* caret-color inherits from :root into every field, in one declaration
|
||||
* accent-color the theme's own checkboxes are appearance:none drawings and never see it,
|
||||
* but a third-party app's native checkbox/radio/progress does */
|
||||
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@layer theme {
|
||||
/* Wallpaper axis, value `pattern`: an SVG the admin uploads, tiled behind the content and
|
||||
* recoloured by the theme, so the same file reads correctly in light and dark and under any
|
||||
* palette without being edited.
|
||||
*
|
||||
* A dedicated element rather than a `background-image`, because recolouring a rasterised image
|
||||
* takes `mask-image` — the SVG supplies the alpha, the element the colour — and a mask applies
|
||||
* to the element's whole rendering, children included, so it can never go on .fs-shell or
|
||||
* <body>. Hence .fs-pattern, emitted as the first child of <body> by header.ut and sysauth.ut
|
||||
* (the login page has no .fs-shell), whose only job is to be masked.
|
||||
*
|
||||
* z-index: -1 with a transparent body, because inside the root stacking context the paint order
|
||||
* is html's background, then negative-z children, then every block background: the layer sits
|
||||
* above the canvas and below the content, but body's own opaque --fs-bg would bury it. The
|
||||
* canvas therefore moves up to :root for this axis and body goes transparent; .fs-main and the
|
||||
* footer paint their own --fs-bg and follow for the same reason. The sidebar is deliberately
|
||||
* not excluded —
|
||||
* the bar is translucent and the pattern showing behind it is what makes the page read as one
|
||||
* sheet.
|
||||
*
|
||||
* Cards sit on opaque --fs-panel, so the pattern shows through the gaps only. */
|
||||
.fs-pattern { display: none; }
|
||||
|
||||
:root[data-wallpaper="pattern"] { background-color: var(--fs-bg); }
|
||||
:root[data-wallpaper="pattern"] body,
|
||||
:root[data-wallpaper="pattern"] .fs-main { background-color: transparent; }
|
||||
:root[data-wallpaper="pattern"] footer.fs-footer { background: transparent; }
|
||||
|
||||
/* `fixed`, so the pattern is one calm backdrop the page scrolls under rather than a strip that
|
||||
* slides with every table — the same argument the File photo's background-attachment makes.
|
||||
* --fs-pattern-url is `none` until something is uploaded (head.ut / fs-prefs.js), so the axis
|
||||
* selected with no file is a bare canvas rather than a broken tile. */
|
||||
:root[data-wallpaper="pattern"] .fs-pattern {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
opacity: var(--fs-pattern-strength);
|
||||
}
|
||||
/* THEME INK (the default). The file's own colours are discarded and only its alpha is used, so
|
||||
* an opaque background inside the SVG would mask as a solid sheet — which is exactly why the
|
||||
* admin can switch to the file's own colours below. Line art on transparency is the shape this
|
||||
* mode is for. */
|
||||
:root[data-wallpaper="pattern"]:not([data-pattern-ink="original"]) .fs-pattern {
|
||||
background-color: var(--fs-text);
|
||||
-webkit-mask-image: var(--fs-pattern-url);
|
||||
mask-image: var(--fs-pattern-url);
|
||||
-webkit-mask-repeat: repeat;
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-size: var(--fs-pattern-size) auto;
|
||||
mask-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
/* THE FILE'S OWN COLOURS: an ordinary tiled background, no mask. For artwork that carries its
|
||||
* own palette and would be flattened to one colour by the mask. */
|
||||
:root[data-wallpaper="pattern"][data-pattern-ink="original"] .fs-pattern {
|
||||
background-image: var(--fs-pattern-url);
|
||||
background-repeat: repeat;
|
||||
background-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
|
||||
/* No wash layer behind #view: the pattern shows through the gaps between the cards at
|
||||
* full strength. Every block that carries text paints an opaque --fs-panel of its own,
|
||||
* so nothing has to be dimmed globally. */
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@layer theme {
|
||||
/* The uploaded photo, the Wallpaper axis's `file` value. The bytes are router-side and shared,
|
||||
* and the served file is referenced by CSS `url()`, never inlined (uhttpd has no gzip). A photo
|
||||
* carries no bakeable opacity, so a scrim is mandatory for legibility: --fs-photo-scrim, a wash
|
||||
* derived from --fs-bg so it follows palette, mode and tint (03-palettes.css), painted as the
|
||||
* TOP background layer over the photo on the SAME element. That keeps it between image and
|
||||
* content with no ::before
|
||||
* and no z-index, and no backdrop-filter — a blur on a full-viewport surface is the most
|
||||
* expensive paint there is on a phone GPU, the scrim already carries the readability, and
|
||||
* reduced transparency turns the scrim opaque anyway (95-a11y-media.css).
|
||||
*
|
||||
* One-element rule, like the pattern: .fs-shell where there is chrome, the bare <body> on the
|
||||
* login page. Content blocks paint their own opaque --fs-panel and float above the scrim, so it
|
||||
* only has to carry the little text sitting directly on the canvas. --fs-login-bg-url is `none`
|
||||
* until a photo is uploaded. */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) {
|
||||
background-image: linear-gradient(var(--fs-photo-scrim), var(--fs-photo-scrim)), var(--fs-login-bg-url);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
/* fixed, so the photo is one calm backdrop the page scrolls under, not a strip that slides
|
||||
* with every table. */
|
||||
background-attachment: fixed;
|
||||
}
|
||||
/* .fs-main's opaque --fs-bg would cover the shell's single background layer — make it transparent
|
||||
* so the photo+scrim shows through the whole content area (15-wallpaper.css does the same for
|
||||
* the pattern). */
|
||||
:root[data-wallpaper="file"] .fs-main { background-color: transparent; }
|
||||
:root[data-wallpaper="file"] footer.fs-footer { background: transparent; }
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
@layer theme {
|
||||
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
|
||||
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
background-color: var(--fs-bg);
|
||||
font-family: var(--fs-font-sans);
|
||||
}
|
||||
/* placement only — the look lives in 10-chrome.css */
|
||||
.fs-brand { flex: 1 1 auto; min-width: 0; }
|
||||
/* A hostname is user-supplied and can outgrow any bar or column. `overflow-wrap: anywhere`
|
||||
* wraps it mid-word only when no better break exists (unlike word-break: break-all, a
|
||||
* normal hostname still breaks at its dots), and it lets the flex item's min-content size
|
||||
* shrink — which is what stops a long name pushing the menu out of the bar. It used to be
|
||||
* nowrap + ellipsis, which truncated the one string that identifies the router. */
|
||||
/* And a ceiling on the LINES, the width cap alone bounding the wrong axis: measured in the top
|
||||
* bar at 1280px, 8 characters give the ordinary 46px bar, 63 (the kernel's nodename limit) give
|
||||
* 73px, and a 4000-character hostname gave a 4273px tall bar. Three lines is past every real
|
||||
* hostname and still a bar. `-webkit-line-clamp` is the only cross-engine spelling today and
|
||||
* needs its two companions, where a max-height would slice a line in half. */
|
||||
.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()`: the INDIVIDUAL transform properties compose across
|
||||
* rules, so the RTL mirror below can set `scale` without either rule having to restate the
|
||||
* other's value. The fixed order is translate -> rotate -> scale, so a mirrored chevron still
|
||||
* points down when it opens. */
|
||||
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
|
||||
/* ---- the two chevrons are DIRECTION, and direction is what mirrors ----
|
||||
* A disclosure marker and the rail's collapse arrow both point along the reading axis, so with
|
||||
* the document flipped (head.ut stamps dir) both point back at themselves. Everything else in
|
||||
* the chrome mirrors from the logical properties; these two are glyphs and need 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: the default chrome, and the only copy of it ----
|
||||
*
|
||||
* The chrome is a bar unless something says otherwise: the TOP layout at every width, and the
|
||||
* SIDEBAR layout collapsed onto a phone. The vertical sidebar is the exception, written once
|
||||
* below as a single guarded override.
|
||||
*
|
||||
* This way round because the bar is needed when
|
||||
* (viewport <= 767px) OR (:root[data-layout="top"])
|
||||
* and CSS cannot OR a media query with an attribute selector in one selector, so writing the
|
||||
* bar under both guards means writing it twice — 55 of ~75 declarations identical, i.e. free to
|
||||
* drift. Inverted, each of the three chrome states is stated exactly once.
|
||||
*
|
||||
* The override wins on SPECIFICITY, never on source order:
|
||||
* :root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar (0,4,0) beats .fs-sidebar (0,1,0) */
|
||||
.fs-shell {
|
||||
display: flex; flex-direction: column;
|
||||
/* ---- `svh`, not `dvh`, and that is the phone's whole problem ----
|
||||
*
|
||||
* `dvh` tracks the viewport as browser UI comes and goes, and on iOS the URL bar slides
|
||||
* continuously WHILE THE USER SCROLLS, so a shell keyed on `dvh` is re-laid-out on every
|
||||
* frame of that animation — the shaking reported from an iPhone. `svh` is the small viewport
|
||||
* height, the value with the browser UI shown, and it does not move while the UI does. It is
|
||||
* also the right value here: this is a floor, so the shell must be at least one screenful
|
||||
* tall with the bar visible, and when the bar hides the page is free to be taller.
|
||||
*
|
||||
* The whole file follows that rule: a height that must not change under the reader uses
|
||||
* `svh`. `dvh` is for a thing that should GROW as the UI retracts. */
|
||||
min-height: 100svh; width: 100%;
|
||||
}
|
||||
.fs-sidebar {
|
||||
display: flex;
|
||||
width: 100%; height: auto;
|
||||
position: sticky; top: 0; z-index: var(--fs-z-sticky);
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center;
|
||||
gap: var(--fs-space-2-5); padding: var(--fs-space-2-5) var(--fs-space-4);
|
||||
background: var(--fs-bar-bg);
|
||||
border-inline-end: 0; border-bottom: var(--fs-hairline);
|
||||
/* the submenu popups anchor to this bar and must escape it */
|
||||
overflow: visible;
|
||||
}
|
||||
/* A hostname can be enormous, and three things must be true at once:
|
||||
* flex: 0 1 auto + min-width: 0 — the brand may SHRINK (0 0 auto lets it push the menu and
|
||||
* the right cluster out of the bar);
|
||||
* max-width — it must also be CAPPED: the bar is flex-wrap, so instead of squeezing the brand
|
||||
* flexbox wraps the other items away and lets it sit alone on a 600px line;
|
||||
* and the bar grows in height (min-height, 50-toplayout.css) to hold the extra line.
|
||||
* 30ch tracks the text, not the viewport: a wide screen licenses more MENU, not a wider
|
||||
* hostname. */
|
||||
.fs-brandrow { flex: 0 1 auto; order: 1; min-width: 0; max-width: min(100%, 30ch); padding: 0; }
|
||||
/* the bar has no section caption, no column to collapse, and its height follows its
|
||||
* content — so the flex filler has nothing to fill */
|
||||
.fs-navlabel, .fs-spacer, .fs-railtoggle, .fs-modemenu { display: none; }
|
||||
|
||||
/* the menu on its own full-width row beneath the brand. The DESKTOP bar lifts it back
|
||||
* onto the brand's row whenever it fits — MEASURED, not a breakpoint
|
||||
* (fs-chrome.js fitChrome adds .fs-bar-stack when it does NOT fit). */
|
||||
.fs-sidebar ul.nav {
|
||||
order: 3; flex: 1 1 100%;
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
|
||||
}
|
||||
/* The dropdown hangs off its OWN item, in every bar state (issue #19): `position: static` plus a
|
||||
* panel pinned at `left: 16px` anchors it to the bar's edge rather than to its trigger, so a
|
||||
* sidebar layout collapsed onto a bar opened a hover panel at the left edge while its trigger
|
||||
* sat mid-bar — measured at 760px, the item at x=253 and the panel at x=32. Travelling to the
|
||||
* panel leaves the <li>, nothing bridges the diagonal, and
|
||||
* the CSS hover closes it, so no submenu entry could be reached. A phone never shows it:
|
||||
* `hover: none` disables the hover rule outright. */
|
||||
.fs-sidebar ul.nav > li.has-sub { position: relative; }
|
||||
.fs-sidebar ul.nav > li > a {
|
||||
display: flex; align-items: center;
|
||||
padding: var(--fs-space-1-5) var(--fs-space-3); border-radius: var(--fs-radius);
|
||||
color: var(--fs-dim);
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
|
||||
}
|
||||
/* a bar item is its label: no icon, no chevron */
|
||||
.fs-sidebar ul.nav > li > a .fs-ico,
|
||||
.fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
|
||||
|
||||
/* submenu = a popup card under its own item: sized to its content rather than stretched
|
||||
* across the bar, and capped to the viewport so a long label wraps instead of overflowing.
|
||||
* `left: 0` = the item's left edge (the <li> above is the containing block); an item near
|
||||
* the right edge is nudged back inside by menu-footstrap.js clampDropdown, in every bar
|
||||
* state — the JS gate is the same question this rule asks, not a layout test. */
|
||||
.fs-sidebar ul.nav ul,
|
||||
.fs-sidebar ul.nav > li.open > ul {
|
||||
display: none;
|
||||
position: absolute; inset-inline: 0 auto; top: calc(100% + 8px); z-index: var(--fs-z-flyout);
|
||||
width: max-content; min-width: 200px; max-width: calc(100vw - 32px);
|
||||
margin: 0; padding: var(--fs-space-2);
|
||||
background: var(--fs-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. Belongs to the BAR, not to one layout: the hover
|
||||
* rule below is unguarded, so every state that can open a panel on hover needs it. */
|
||||
.fs-sidebar ul.nav > li.has-sub > ul::before {
|
||||
content: ""; position: absolute; inset-inline: 0; top: -10px; height: 10px;
|
||||
}
|
||||
/* hover only on a real pointer: a tap leaves :hover stuck on mobile browsers, which
|
||||
* would block tap-to-close */
|
||||
@media (hover: hover) {
|
||||
.fs-sidebar ul.nav > li.has-sub:hover > ul { display: block; }
|
||||
}
|
||||
.fs-sidebar ul.nav > li.has-sub.open > ul { display: block; }
|
||||
/* a submenu link is the same link in every layout — only its PADDING differs (denser
|
||||
* inside a popup than in the accordion), which is all the vertical override restates. */
|
||||
.fs-sidebar ul.nav ul a {
|
||||
display: block; padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
}
|
||||
|
||||
/* right cluster: the poll pill, then Appearance and Log out as square icon buttons.
|
||||
* LuCI's #indicators (poll "Refreshing", unsaved changes) collapses when empty, so it
|
||||
* only shows when there is something to report. */
|
||||
.fs-sidebar #indicators {
|
||||
display: flex; flex-wrap: wrap; gap: var(--fs-space-1-5);
|
||||
order: 2; margin-inline-start: auto; padding: 0;
|
||||
}
|
||||
|
||||
.fs-themerow {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto; order: 2; margin-inline-start: auto;
|
||||
/* see the :not(:empty) rule below — two auto margins would SPLIT the free space between
|
||||
* them instead of pushing one cluster to the bar's far edge */
|
||||
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
background: var(--fs-bar-ctl-bg); color: var(--fs-dim);
|
||||
font: inherit; font-weight: var(--fs-weight); font-size: var(--fs-type); line-height: 1.1;
|
||||
text-align: start; cursor: pointer;
|
||||
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
|
||||
}
|
||||
/* ---- when the right cluster runs out of room: .fs-ind-compact, then .fs-bar-actrow ----
|
||||
*
|
||||
* Both are set by fs-chrome.js (fitCluster) by measurement, and both belong to the BAR rather
|
||||
* than to a layout: the top layout is a bar at every width and the sidebar layout becomes one on
|
||||
* a phone. Under a data-layout="top" guard they miss the common case — a phone in the default
|
||||
* sidebar layout, whose bar then shows the full pills with Log out wrapped alone onto a second
|
||||
* row at the left edge.
|
||||
*
|
||||
* Step 1, .fs-ind-compact: the pills lose their prose and become icon squares matching the other
|
||||
* cluster controls, which frees ~200px and on the top layout is often enough to keep the MENU on
|
||||
* the brand's row as well.
|
||||
* Step 2, .fs-bar-actrow: the cluster still does not fit, so the brand row takes the full width
|
||||
* and the whole cluster moves down together, keeping the right edge for free from the auto
|
||||
* inline-start margin above. max-width must be released along with the flex basis, or the 30ch
|
||||
* cap leaves the brand short of the row it was just given and the cluster rides back up. */
|
||||
.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: var(--fs-bar-ctl-bg);
|
||||
}
|
||||
/* on a square button the BORDER carries the state — the fill and the ink come from the
|
||||
* shared hover/active groups in 10-chrome.css */
|
||||
.fs-themerow:hover,
|
||||
.fs-themerow[aria-expanded="true"] { border-color: var(--fs-accent); }
|
||||
.fs-logout:hover { border-color: var(--fs-danger); }
|
||||
.fs-themerow .fs-label, .fs-logout .fs-label { display: none; }
|
||||
.fs-themerow .fs-ico,
|
||||
.fs-logout .fs-ico { width: 18px; height: 18px; flex: 0 0 auto; }
|
||||
.fs-sidebar ul.nav .fs-ico { flex: 0 0 auto; }
|
||||
|
||||
/* ---- the vertical sidebar: the single override, the only thing that is not a bar ----
|
||||
* Guarded positively (never write a `:not([data-layout=…])` guard). Everything the bar declared
|
||||
* above must be answered here, which is what makes it checkable.
|
||||
*
|
||||
* When it applies is not "viewport >= 768px": what matters is how much room is left for the
|
||||
* CONTENT once the sidebar takes its cut, and that cut is not constant — 224px expanded, 68px as
|
||||
* a rail — so the two give way at different widths, which one breakpoint cannot express.
|
||||
* `data-narrow` is set by fs-chrome.js (fitShell) when the content column would fall below
|
||||
* --fs-content-min, measured from the sidebar's ACTUAL cut.
|
||||
*
|
||||
* `min-width: 521px` is not a second guess but a floor keeping the chrome correct with JS
|
||||
* DISABLED, where no data-narrow is ever written: below 521 no cut leaves 500px anyway, so it
|
||||
* states the measurement's conclusion in advance. */
|
||||
@media (min-width: 521px) {
|
||||
/* Scroll model for the desktop sidebar layout: the shell is pinned to the viewport and the
|
||||
* CONTENT column scrolls inside itself, so the sidebar is a STATIC element that stays put —
|
||||
* never position:sticky. That is the fix for issue #7: a sticky sidebar is a composited GPU
|
||||
* layer, and with overflow:visible (needed so the rail flyouts escape sideways) every hover
|
||||
* repaint left a 1px seam of the flyout at the layer's edge until a full repaint cleared it.
|
||||
* A static sidebar is not a layer, so there is nothing to leave stale, and it drops the
|
||||
* z-index juggling too: sticky always makes a stacking context, while a static sidebar makes
|
||||
* none, so the flyouts resolve --fs-z-flyout in the root stacking context. Confined to the
|
||||
* desktop sidebar; 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;
|
||||
/* `contain: paint`, the fix for issue #12: .fs-shell is height:100svh with overflow:hidden and
|
||||
* .fs-main is its own overflow:auto scroller, so Chrome never grows the document past the
|
||||
* viewport — but Firefox propagates ~158px of an out-of-flow descendant's scrollable
|
||||
* overflow past both overflow ancestors up to the initial containing block, painting a
|
||||
* second, whole-page scrollbar beside .fs-main's own (measured on the Overview, where the
|
||||
* .fs-ovl grid makes the column ~2800px tall: html.scrollHeight 1058 against a 900px
|
||||
* viewport, and 900 once contained). overflow:hidden does not stop it: it hides the extra
|
||||
* scrollbar while the document still scrolls those 158px, dragging the fixed sidebar
|
||||
* off-screen. Paint containment makes .fs-main the containing block for that descendant
|
||||
* and clips it. LuCI modals are position:fixed on <body>, not inside .fs-main, so the new
|
||||
* containing block does not re-base them. No-op in Chrome. */
|
||||
contain: paint;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
|
||||
width: var(--fs-sidebar-w); height: 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. The hide pair existed twice (here and 50-toplayout.css),
|
||||
* byte-identical and unpinned — the drift shape @mirror exists for, folded instead. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu:not(:empty):not(.single) { display: flex; margin-top: var(--fs-space-1); }
|
||||
|
||||
/* vertical main nav */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #modemenu {
|
||||
order: 0; flex: 0 1 auto;
|
||||
flex-direction: column; flex-wrap: nowrap; align-items: normal; gap: var(--fs-space-0-5);
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub { position: relative; }
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
|
||||
display: flex; align-items: center; gap: var(--fs-space-3);
|
||||
padding: var(--fs-space-2-5) var(--fs-space-3);
|
||||
/* Bold is the root level's own signal, and the one the type scale can promise:
|
||||
* `--fs-weight-bold` has a face everywhere, while `--fs-weight-normal` has none of its own
|
||||
* — so lightening the CHILDREN instead looks like hierarchy on a system stack and like
|
||||
* nothing at all on a router whose admin installed a 600/700 subset. Only the accordion is
|
||||
* deep enough to need the distinction. */
|
||||
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;
|
||||
}
|
||||
/* and the bar's hover bridge goes with the popup. The panel is static here, so the
|
||||
* bridge's containing block falls back to the <li> — an invisible 10px strip sitting
|
||||
* ABOVE the section, i.e. over the previous menu item, swallowing its clicks. The rail
|
||||
* (below) is a popup again but flies its panel out sideways, and brings its own
|
||||
* horizontal bridge (li.has-sub::after). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
|
||||
/* ---- the open section is a group, and has to look like one ----
|
||||
*
|
||||
* An indent alone does not say where a long section ends: after ten children the next root
|
||||
* category reads as an eleventh child (issue #37). A hairline down the group's reading edge
|
||||
* draws that boundary and the wider bottom margin separates the last child from the next
|
||||
* category.
|
||||
*
|
||||
* No `:not([data-rail="true"])` guard: in the rail this same `ul` becomes a flyout card,
|
||||
* whose rule below restates `border` and `margin` as shorthands at higher specificity, so
|
||||
* neither declaration here survives into it. */
|
||||
: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 not one of a row of buttons but one of a stack of
|
||||
* ROWS — Appearance and Log out below it run the sidebar's full width. A pill floating
|
||||
* at 87px in a 195px column read as a stray chip; give it its neighbours' width. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators .label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
|
||||
flex: 1 1 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* THE APPEARANCE CONTROLS USED TO LIVE HERE, as a popover on <body> plus the segmented and
|
||||
* slider controls it was built from. They are a PAGE now (System -> Appearance) and their CSS
|
||||
* moved with them, to styles/pages/80-appearance.css — a page's styles belong in the page layer,
|
||||
* and the shell no longer draws any of it. */
|
||||
|
||||
/* ---- collapsed sidebar (icon rail) ----
|
||||
* <html data-rail="true">, persisted in localStorage and re-applied before paint by head.ut.
|
||||
* Labels collapse and a section's submenu becomes a flyout anchored right of its icon, opened on
|
||||
* hover or on focus — which is what a tap on the section link produces on touch.
|
||||
*
|
||||
* The guard must be exactly the vertical sidebar's: same media floor, same :not([data-narrow]).
|
||||
* It once said `min-width: 768px` against the vertical block's 521, and between the two the
|
||||
* vertical rules applied without the rail ones, so the sidebar sprang back to its full 224px —
|
||||
* it EXPANDED as the window shrank — while the JS measurement was subtracting the rail's 68px.
|
||||
* The rail is a MODE of the vertical sidebar and can never be visible under conditions the
|
||||
* vertical sidebar is not. */
|
||||
@media (min-width: 521px) {
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar {
|
||||
width: var(--fs-rail-w); padding: var(--fs-space-5) var(--fs-space-2-5);
|
||||
/* the default `overflow-y: auto` computes overflow-x to `auto` as well,
|
||||
* which would clip the flyouts; the rail is short enough not to scroll */
|
||||
overflow: visible;
|
||||
/* NO z-index / no sticky: the sidebar is a static element (see the scroll-model note
|
||||
* above), so it is not a composited layer and its overflow:visible flyouts leave no
|
||||
* stale seam (issue #7). The flyouts still cover the content on their own --fs-z-flyout
|
||||
* in the root stacking context. */
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brandrow { flex-direction: column; gap: var(--fs-space-2-5); padding: 0 0 var(--fs-space-5); }
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brand { flex: 0 0 auto; justify-content: center; }
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-railtoggle .fs-ico { rotate: 180deg; }
|
||||
|
||||
/* only the rail's own labels collapse — a `.fs-label` also exists on every
|
||||
* submenu link, and those live inside the flyout where they must stay */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-wordmark,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-navlabel,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-modemenu,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a > .fs-label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow > .fs-label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout > .fs-label,
|
||||
/* The chevron is an affordance for a section you can UNFOLD; a rail item has no label and no
|
||||
* accordion — its children fly out sideways — so it says nothing and crowds the icon.
|
||||
*
|
||||
* The selector must be this long: the vertical override turns the chevron back on at
|
||||
* (0,6,3), so a plainer `.fs-sidebar .fs-chevron` here is only (0,6,0) and loses. Match its
|
||||
* shape and add the rail attribute: (0,7,3). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
|
||||
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout {
|
||||
justify-content: center; gap: 0; padding: var(--fs-space-2-5) 0;
|
||||
}
|
||||
|
||||
/* leaf sections have no flyout — show their name as a tooltip bubble */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a::after {
|
||||
content: attr(data-label);
|
||||
position: absolute; inset-inline-start: 100%; top: 50%; transform: translateY(-50%);
|
||||
margin-inline-start: var(--fs-space-2-5); z-index: var(--fs-z-flyout);
|
||||
padding: var(--fs-space-1-5) var(--fs-space-2-5); border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-panel); border: 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). The hover rule is gated on a real
|
||||
* pointer: mobile browsers leave :hover stuck on the tapped element, which would block
|
||||
* tap-to-close. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub > ul {
|
||||
display: none;
|
||||
position: absolute; inset-inline-start: 100%; top: -8px; margin: 0; margin-inline-start: var(--fs-space-2-5);
|
||||
min-width: 196px; padding: var(--fs-space-2); z-index: var(--fs-z-flyout);
|
||||
background: var(--fs-panel); border: 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, so the collapsed icon still names its section.
|
||||
*
|
||||
* It must UNDO the bar's hover bridge (issue #22): this same ::before is the bar's invisible
|
||||
* 10px hover bridge above the panel, so re-pointing `content` at the title while leaving
|
||||
* `position`, `inset` and `height` in force renders the heading as a 10px-tall absolute box
|
||||
* above the flyout, clipped in half by the panel's own overflow. The rail brings its own
|
||||
* bridge as `li.has-sub::after`. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
|
||||
content: attr(data-title);
|
||||
position: static; inset: auto; height: auto;
|
||||
padding: var(--fs-space-1) var(--fs-space-3) var(--fs-space-2);
|
||||
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
|
||||
}
|
||||
/* bridge the 10px gap so the pointer can travel icon -> flyout without a hover drop */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub::after {
|
||||
content: ""; position: absolute; inset-inline-start: 100%; top: 0;
|
||||
width: 12px; height: 100%;
|
||||
}
|
||||
|
||||
/* Collapsed rail: swap the "Refreshing" word for a green refresh glyph — a masked SVG tinted
|
||||
* with --fs-good, so it follows the palette. Only poll-status becomes an icon; other
|
||||
* indicators keep their pill.
|
||||
*
|
||||
* These two must carry the FULL guard, not the [data-rail] half alone: head.ut stamps
|
||||
* data-rail from localStorage pre-paint while data-narrow is written later by an async
|
||||
* module, so on a narrow phone a rail user has every other rail rule excluded by the floor
|
||||
* while these two match — the pill paints as a bare green glyph inside the phone bar until
|
||||
* the modules land, permanently if they fail.
|
||||
*
|
||||
* The box is --fs-ctl-h-sm rather than --fs-btn-size: one step under the bar's compact pill,
|
||||
* the rail being a column of icons rather than a row of equal squares. A literal here would
|
||||
* silently opt out of the Density axis. */
|
||||
: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. A spinner promises something you are WAITING for; this is a
|
||||
* poll running quietly in the background forever, and a permanently spinning glyph
|
||||
* makes an idle router look busy. The spin also broke the click that pauses the poll —
|
||||
* a target that never stops moving cannot be hit. */
|
||||
}
|
||||
|
||||
/* paused: the same glyph gone quiet. Without this the rail kept saying GREEN while the poll
|
||||
* was stopped — the one thing the indicator exists to tell you, and it was lying. (The pill
|
||||
* gets this for free: its --fs-good only applies to [data-style="active"].) */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
|
||||
background: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* Every other indicator — "Unsaved Changes: N" and whatever a third-party app shows. Its label
|
||||
* is prose and the rail is 68px: that pill wants 86px, wraps onto three lines and hangs 34px
|
||||
* past the rail's edge over the content (issue #14). The pill is not ours to shorten, so it
|
||||
* becomes a square in the poll glyph's box showing the COUNT alone, lifted into
|
||||
* data-fs-badge by fs-chrome.js because a text node cannot be reached by a selector.
|
||||
* `font-size: 0` kills the prose without removing it: the label stays in the DOM for a
|
||||
* screen reader and in the `title` for the pointer.
|
||||
*
|
||||
* What the badge says is decided in the JS, so this stays one rule per box: as two CSS rules
|
||||
* it needs an extra attribute and a `:not()` to tell them apart, which pushes the sheet's
|
||||
* widest selector past the css-metrics 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. It grows from the poll glyph's square and stops at the
|
||||
* rail's inner width. */
|
||||
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;
|
||||
}
|
||||
/* The top bar's compact step paints the same badge (the .fs-ind-compact rule above, whose
|
||||
* copy of this paint is mirror-pinned to it) and CANNOT share this rule: that one applies at
|
||||
* every width, while this one must stay inside the 521px floor —
|
||||
* below it a rail user has data-rail stamped pre-paint but data-narrow not yet written, so a
|
||||
* merged rule would strip the phone bar's indicator to a bare digit until the modules land. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
|
||||
/* @mirror ind-badge/paint */
|
||||
content: attr(data-fs-badge);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
|
||||
/* @endmirror */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- main column ---------- */
|
||||
.fs-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--fs-bg);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.fs-sidebar #indicators:empty { display: none; }
|
||||
.fs-indicators { flex-wrap: wrap; }
|
||||
.fs-content { padding: var(--fs-space-5) var(--fs-content-pad); flex: 1 1 auto; width: 100%; max-width: var(--fs-content-max); margin: 0 auto; }
|
||||
|
||||
/* ---- the staging area a client navigation renders into ----
|
||||
* fs-router.js renders the incoming page into a second `#view` inside this wrapper, first in
|
||||
* tree order, and moves its children into the live one when the render completes, so the page on
|
||||
* screen is never emptied while the next one loads.
|
||||
*
|
||||
* Hidden but LAID OUT, and the three declarations are not interchangeable. `visibility: hidden`
|
||||
* takes it off the screen while keeping the box: several views size themselves from the element
|
||||
* they render into, and `display: none` would hand them a zero width they keep for the life of
|
||||
* the instance. `height: 0` stops the page under it from moving, and `overflow: clip` keeps the
|
||||
* content overflowing that zero height from painting — `clip` rather than `hidden`, since a
|
||||
* scroll container would be a new scrollport in the middle of the content column. */
|
||||
.fs-staging { visibility: hidden; height: 0; overflow: clip; }
|
||||
|
||||
/* ---- and the hairline that says a slow navigation is under way ----
|
||||
* Shown only once a navigation outlives 150 ms (fs-router.js), so a warm click never flashes it.
|
||||
*
|
||||
* Fixed, at the top of the viewport, and a child of <body> — all three load-bearing. In the
|
||||
* content column it is a flex item of `.fs-main`, and a column flex container shrinks its 2px to
|
||||
* zero (measured: height 0 in the sidebar layout, and with `margin-bottom: -2px` the content
|
||||
* painted over what was left); and it cannot live inside `.fs-shell` either, that box carrying
|
||||
* `contain: paint`, which
|
||||
* makes a fixed descendant position against the shell and be clipped by it. The top of the
|
||||
* viewport is also where a browser puts its own progress.
|
||||
*
|
||||
* `transform` and `opacity` only, both composited, so this cannot cost the render it is
|
||||
* reporting on. Reduced motion keeps the bar and drops its animation. */
|
||||
#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 (absorbed
|
||||
* from base, which keeps only the flex skeleton), so a third-party view emitting its own
|
||||
* <footer> stays themed too. */
|
||||
footer {
|
||||
padding: var(--fs-space-3-5) var(--fs-content-pad); border-top: var(--fs-hairline);
|
||||
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
|
||||
/* Centre the flex LINE, not just the text: base makes the footer a flex container
|
||||
* (base/50-chrome.css) and declares no `justify-content`, so the initial `flex-start` packs
|
||||
* the single <span> at the start and leaves text-align nothing to centre, the span
|
||||
* shrinking to its content. */
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
/* And space the lines, because the two links become adjacent TARGETS the moment the line wraps.
|
||||
* The footer is one sentence carrying the LuCI link and the OpenWrt link; at the width where
|
||||
* it breaks they land on consecutive lines, and with the inherited leading that is 16px
|
||||
* between their centres — inside the 24px envelope WCAG 2.2 SC 2.5.8 requires around a
|
||||
* target smaller than 24x24, which an inline link always is. Found by tools/live-audit.mjs on
|
||||
* CI's runner and not on the stands: the wrap width depends on text metrics, so the same
|
||||
* 568px viewport breaks the line there and not here. 26px, not 24: the criterion is that the
|
||||
* two circles must not meet, so 24 is the boundary rather than a pass.
|
||||
*
|
||||
* A FLOOR, not a fixed number, and not a bare ratio either: `--fs-type` follows the Density
|
||||
* axis, so a plain `26px` would be the one leading in this file that ignores it, while a
|
||||
* unitless `2` follows the axis straight back under 24px at Compact (11.7px x 2 = 23.4) —
|
||||
* where the wrap this rule exists for is most likely. `max()` is both: 26px at the default
|
||||
* and at Compact, 29.9px at Large. */
|
||||
line-height: max(26px, calc(var(--fs-type) * 2));
|
||||
}
|
||||
footer a { color: var(--fs-dim); }
|
||||
footer a:hover { color: var(--fs-accent); }
|
||||
|
||||
/* the phone bar is the base above; only the content gutter is width-driven */
|
||||
@media (max-width: 767px) {
|
||||
.fs-content { padding: var(--fs-space-4); }
|
||||
}
|
||||
|
||||
|
||||
/* Notifications — ui.addNotification prepends .alert-message as the first child of
|
||||
* #maincontent (.fs-main). Order it above the content and give it card spacing. */
|
||||
.fs-main > .alert-message {
|
||||
order: 1; margin: var(--fs-space-4) auto 0;
|
||||
width: calc(100% - var(--fs-content-pad) * 2);
|
||||
max-width: calc(var(--fs-content-max) - var(--fs-content-pad) * 2);
|
||||
}
|
||||
.fs-main > .fs-content { order: 2; }
|
||||
.fs-main > .fs-footer { order: 3; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* width:auto, not the base width above: that calc subtracts the DESKTOP gutter
|
||||
* (--fs-content-pad, 28px a side) while .fs-content's own gutter is 16px here — the
|
||||
* alert came out 24px narrower than the content and hugged the left (measured at 700px:
|
||||
* insets 16/40 where the content sits 16/16). auto + the side margins matches the
|
||||
* content column exactly at any width in this tier. */
|
||||
.fs-main > .alert-message { margin: var(--fs-space-3) var(--fs-space-4) 0; width: auto; }
|
||||
}
|
||||
|
||||
/* Phone: alerts (e.g. the sysupgrade banner, injected by ui.addNotification as a
|
||||
* `.fs-main > .alert-message` — a SIBLING of #view, not a child) lay their action buttons
|
||||
* in a nowrap flex row, so on a ~344px screen the trailing Dismiss button was shoved past
|
||||
* the panel and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main so it covers
|
||||
* those top-level alerts and any inside #view. */
|
||||
@media (max-width: 767px) {
|
||||
.fs-main .alert-message { flex-wrap: wrap; }
|
||||
.fs-main .alert-message > * { min-width: 0; margin-inline-start: 0; }
|
||||
/* the action row is a nested flex that stayed nowrap, so the first button's label got
|
||||
* clipped when Dismiss sat beside it. Wrap the inner rows; let labels break. */
|
||||
.fs-main .alert-message div { flex-wrap: wrap; min-width: 0; }
|
||||
.fs-main .alert-message .btn,
|
||||
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
|
||||
}
|
||||
|
||||
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
|
||||
.fs-indicators .label,
|
||||
.fs-indicators [data-indicator],
|
||||
#indicators .label,
|
||||
#indicators [data-indicator] {
|
||||
/* An indicator sits in a ROW of controls — in the bar, followed by Search and Log out — so it
|
||||
* must look like one of them or the cluster reads as three unrelated things. The same
|
||||
* --fs-btn-size height (a box sized by its font's line-box comes out 28px, visibly the odd
|
||||
* one out) and the same --fs-radius corner, not --fs-radius-pill: a capsule beside two
|
||||
* rounded squares announces itself as a different kind of object, which it is not. Width
|
||||
* stays the content's. */
|
||||
display: inline-flex; align-items: center;
|
||||
min-height: var(--fs-btn-size);
|
||||
text-transform: none;
|
||||
font-weight: var(--fs-weight); font-size: var(--fs-type);
|
||||
/* the bar's control surface, not the panel: this pill sits in the same row as the square
|
||||
* buttons and wears what they wear. Verified on the live DOM rather than by reading —
|
||||
* ui.js emits the indicator as a bare `<span data-indicator=…>` with NO class, so a rule
|
||||
* written against `.label` (as one here briefly was) matches nothing at all. */
|
||||
background: var(--fs-bar-ctl-bg); background-image: none;
|
||||
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 `header
|
||||
* [data-indicator]` rule carries geometry only, and this theme emits no <header> at all),
|
||||
* so "Refreshing" showed a text caret. */
|
||||
.fs-indicators [data-indicator][data-clickable],
|
||||
#indicators [data-indicator][data-clickable] { cursor: pointer; }
|
||||
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so
|
||||
* it is a control now and takes the control ring the buttons beside it take (55-buttons.css)
|
||||
* rather than the UA outline it was left with. `:focus-visible`, not `:focus`: a pointer press
|
||||
* on the pill should not draw a ring, the same rule every other control here follows. */
|
||||
.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,118 @@
|
||||
@layer theme {
|
||||
/* One thin meter for every .cbi-progressbar (overview Memory/Storage/Network, Software
|
||||
* disk space, package manager …): slim rounded track, accent fill, and the LuCI title
|
||||
* value floated just above the bar's right edge (mono/dim) instead of stuffed inside a fat
|
||||
* pill. No per-page overrides. */
|
||||
.cbi-progressbar {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
background: var(--fs-track);
|
||||
border: 0;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
overflow: visible;
|
||||
min-width: 120px;
|
||||
margin: 0;
|
||||
}
|
||||
.cbi-progressbar > div {
|
||||
height: 100%;
|
||||
background: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
transition: width var(--fs-dur-fill);
|
||||
}
|
||||
/* Colour carries the reading too, not length alone: `data-fs-level` is set from JS
|
||||
* (menu-footstrap-common.js, FS_METER_WARN/FS_METER_DANGER — 80/92%, a decision, not a
|
||||
* measurement) on every poll tick. Solid tokens, matching the accent fill above — no
|
||||
* color-mix, so no static twin is 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);
|
||||
/* the gap is a scale step, not a literal 4: every other gap in the theme scales with the
|
||||
* density axis and this one sat outside it. Identical at the default density. */
|
||||
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;
|
||||
}
|
||||
|
||||
/* The value sits BESIDE the bar wherever the bar already shares its line with a name.
|
||||
*
|
||||
* The default above puts it over the bar's far edge, which assumes the Overview's shape: the
|
||||
* meter owns a full-width line and its name sits on the line above, so the value lands at the
|
||||
* far end of that name's line and the two read as one row. Two shapes are not that, and CSS
|
||||
* cannot ask whether a row carries its own label, so each is named:
|
||||
*
|
||||
* `.cbi-value-field > .cbi-progressbar` the meter IS the field of a CBI form row, where the
|
||||
* value otherwise lands against the row divider rather than beside its own bar
|
||||
* `.table:has(.tr.table-titles) .td …` a meter COLUMN in a multi-column data table, where
|
||||
* the floated value lands on the row divider too
|
||||
*
|
||||
* Both want the same geometry and differ in one number, so the geometry is written once and
|
||||
* `--fs-meter-value` is the knob: a form row holds `-140.0 dBm`, half again as wide as a table
|
||||
* cell's `100% (1.2 GiB)`. The Overview and the package manager keep the value above the bar,
|
||||
* their meters being neither shape. */
|
||||
/* ONE RULE PER SHAPE, not one selector list for both, and the split is not cosmetic: the table
|
||||
* half needs `:has()`, and an engine that cannot parse a selector discards the WHOLE rule —
|
||||
* siblings included. Grouped, the form row (which needs nothing new) lost its placement on such
|
||||
* an engine along with the cell that was unreachable there anyway. The declarations they share
|
||||
* are @mirror-pinned instead, so the two copies cannot drift. */
|
||||
.cbi-value-field > .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
/* @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);
|
||||
/* @endmirror */
|
||||
--fs-meter-value: var(--fs-space-10);
|
||||
/* narrower than this it stops reading as a meter at all; the column gives up the rest. */
|
||||
min-width: 60px;
|
||||
}
|
||||
.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 */
|
||||
}
|
||||
|
||||
/* …and back above the bar once that column becomes a card.
|
||||
*
|
||||
* The rule above puts the value beside the bar because a table column shares its row with other
|
||||
* columns. A carded row is not that: `.fs-stacked` gives each cell half the card, so "beside"
|
||||
* is outside the cell and the value runs across the neighbouring column and over the label
|
||||
* above it (reported twice from a phone, at every density).
|
||||
*
|
||||
* So the card puts it back over the bar's far edge and buys it a line: the bar takes a top
|
||||
* margin of one --fs-type-xs line, so the value sits between the label and the bar instead of
|
||||
* on either. 6-9px of overlap before, none after.
|
||||
*
|
||||
* The selector carries `:has(.tr.table-titles)` for specificity alone — that is what the
|
||||
* beside-rule above matches on, and `:has()` counts its argument, which makes it (0,6,0), so a
|
||||
* plainer selector here loses (measured: `margin-inline-end` still 40px). The title row is
|
||||
* still in the DOM when the table is carded; the card only hides it. */
|
||||
.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,834 @@
|
||||
@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); }
|
||||
/* a bare <legend>; the ones inside a .cbi-section are covered below. The ink is the heading
|
||||
* ramp's (45-misc.css) — legend is kept out of that group so this rule stays its one owner;
|
||||
* no line-height here: base's `fieldset legend` sets 1 and every real legend sits in a
|
||||
* fieldset, so stating leading here would silently repaint them all through the layer. */
|
||||
legend { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
|
||||
|
||||
/* overview (luci-mod-status) DOM:
|
||||
* .cbi-section > .cbi-title>h3 + div > table.table (.tr/.td) */
|
||||
|
||||
/* section title + collapse control */
|
||||
.cbi-section > .cbi-title { margin-bottom: var(--fs-space-3); }
|
||||
.cbi-section > .cbi-title h3,
|
||||
.cbi-section > h3:first-child,
|
||||
.cbi-section > legend {
|
||||
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
margin: 0; padding: 0; border: 0;
|
||||
}
|
||||
/* A bare section title (no .cbi-title wrapper) sat flush against the content below it —
|
||||
* on System → System the "System Properties" h3 touched the tab strip. Give it the same
|
||||
* 12px the .cbi-title wrapper already carries above. Excludes .cbi-title h3: that variant
|
||||
* gets its gap from the wrapper's own margin-bottom, so flagging the inner h3 too would
|
||||
* double it. */
|
||||
.cbi-section > h3:first-child,
|
||||
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
|
||||
|
||||
/* A second heading inside the same card — the one that follows a table rather than opening the
|
||||
* section — has no air at all: on Status -> Overview the stock includes stack `table + h3 +
|
||||
* table` inside one .cbi-section, and the heading reads as another row of the table above it.
|
||||
* The rule above cannot cover it, `:first-child` being what excludes the one that opens the
|
||||
* card. The two values are the ladder's own: a heading is separated from what came before by
|
||||
* more than from what it introduces. */
|
||||
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
|
||||
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* ---- tables in cards ----
|
||||
* Discriminator: a key/value include (System, Memory) has NO id; a data table has one
|
||||
* (status_leases) or the JS tag .fs-dt. Key/value is not itself a card — no frame, no clip;
|
||||
* a data table keeps the `.table` frame below. Nested (`.table .table`, `.cbi-value .table`)
|
||||
* and the config-diff dialog (`.cbi-modal .table`) go with key/value. */
|
||||
.cbi-section .table:not([id]):not(.fs-dt),
|
||||
.cbi-section-node .table:not([id]):not(.fs-dt),
|
||||
.cbi-modal .table, .table .table, .cbi-value .table {
|
||||
border: 0; border-radius: 0; overflow: visible; background: none;
|
||||
}
|
||||
.cbi-section .table { margin: 0; width: 100%; }
|
||||
/* A data table straight in #view (no section: package-manager, Startup) misses the width above,
|
||||
* so it sized to preferred width and overflowed the content column — which base already fixes
|
||||
* for EVERY `.table` (`width: 100%`, base/40-tables.css), and has for as long as both rules have
|
||||
* existed. Switched off against the 24 tables that match it across eight pages: no computed
|
||||
* style moved, so the rule only restated a value one layer down. Removed. */
|
||||
/* Config-table fields fill their cell — moved from base with the generic field box in
|
||||
* theme/60-inputs.css (in base these beat its 210px in-layer; left behind they would lose
|
||||
* to it by layer), together with the numeric-cell refinement below them. */
|
||||
.table.cbi-section-table input[type="password"],
|
||||
.table.cbi-section-table input[type="text"],
|
||||
.table.cbi-section-table textarea,
|
||||
.table.cbi-section-table select,
|
||||
.table.cbi-section-table .cbi-select {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-table-cell select,
|
||||
.table.cbi-section-table .td.cbi-section-table-cell .cbi-select { width: inherit; }
|
||||
/* The filter row (form.js renderHeaderRows) is the one place a field cannot claim the width it
|
||||
* wants: it lives in a table HEADER cell, so the column is as wide as the DATA under it. The
|
||||
* rule above makes it fill that cell; what does not fit is the TEXT, LuCI writing the
|
||||
* placeholder as `Filter ` plus the column title, so a long translated title is sliced
|
||||
* mid-glyph (issue #12). The flag column is the worse half: form.js writes `width: 30px` inline
|
||||
* there, of which the theme's padding ate 24, leaving less than one glyph.
|
||||
*
|
||||
* A denser box and denser type buy back ~20px per column; the ellipsis changes the FAILURE, a
|
||||
* placeholder too long for its column now ending instead of being cut through a letter. Nothing
|
||||
* is lost to it — the column title says the same word directly above. No !important, so the
|
||||
* inline 30px still stands: it is upstream's sizing for a one-character field. */
|
||||
.table.cbi-section-table .cbi-section-filter {
|
||||
padding: var(--fs-space-1) var(--fs-space-1-5);
|
||||
font-size: var(--fs-type-xs);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* width:100% cannot shrink a table below min-content — one long unbreakable value holds the
|
||||
* floor. Breaking values reflows it into the column instead of scrolling, and that break is the
|
||||
* DEFAULT for every cell (base/40-tables.css), so a copy here would say nothing the cascade does
|
||||
* not.
|
||||
*
|
||||
* A rule on this property is back a few lines down, and it is the INVERSE rather than a copy: a
|
||||
* data table gets `break-word` while it is still a table, so its columns keep a floor and an
|
||||
* overflow can be seen. Read that block before touching either. DHCP leases need finer control
|
||||
* (only DUID/IPv6 may break) and get it from `white-space: nowrap`: pages/50-leases.css. */
|
||||
.cbi-section .table .td,
|
||||
.cbi-section .table .th {
|
||||
padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-2-5) 0;
|
||||
border: 0; border-bottom: var(--fs-hairline);
|
||||
line-height: 1.45; vertical-align: middle;
|
||||
text-align: start;
|
||||
}
|
||||
/* Row actions hug the far edge. Base says so too but loses to the `text-align: start` above (an
|
||||
* earlier layer), so restate it. `.cbi-section-table-cell` is the discriminator:
|
||||
* form.TableSection's action cells carry it (firewall zones, port forwards); the hand-built
|
||||
* ones of Wireless (`.td.middle`) and DHCP leases do not, and stay left. */
|
||||
.cbi-section .table .td.cbi-section-table-cell.cbi-section-actions { text-align: end; }
|
||||
.cbi-section .table .tr:last-child .td { border-bottom: 0; }
|
||||
/* Case is the app's, not the theme's: `text-transform: uppercase` stood here and printed LuCI's
|
||||
* own `IPv4 address` as `IPV4 ADDRESS`, which reads as a typo in a column heading rather than as
|
||||
* a style (issue #39). Everything else that made these read as headings — the smaller size, the
|
||||
* weight, the faint ink, the tracking — stays. Four rules carry the same decision: the title row
|
||||
* below, the card label (mirrored into theme/65-dropdown.css) and the alert's table in
|
||||
* theme/35-alerts.css. */
|
||||
.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): leases, wifi, processes, routes … ---
|
||||
* Symmetric padding so cells clear the table's own contour (below).
|
||||
*
|
||||
* `:is([id], .fs-dt)` is one discriminator at ONE specificity: written as two selector lists the
|
||||
* two names for "data table" land at different weights — `.cbi-section .table[id] .td` (0,4,0)
|
||||
* against `table.fs-dt .td` (0,2,1), the latter losing to the key/value default (0,3,0) — so a
|
||||
* table known only by the JS tag keeps the flush-left padding, with no mono face, no row hover
|
||||
* and no header rule. Status -> Routing emits exactly that shape. */
|
||||
.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 floor, and it belongs to the one tier a measurement reads ----
|
||||
*
|
||||
* `base/40-tables.css` breaks every value `anywhere`, the one break value that also lowers the
|
||||
* cell's min-content contribution — to a single character. That is what keeps a table nobody
|
||||
* measures inside its card, and it is also a LIE told to the engine: with no floor, auto table
|
||||
* layout is free to starve a column and the browser answers "it fits", truthfully and uselessly.
|
||||
*
|
||||
* So the floor goes back exactly where it is READ: a data table, while it is still a table.
|
||||
* `break-word` breaks the same values in the same places — the two differ only in whether the
|
||||
* break counts towards min-content (css-text-3 §5.4) — so nothing about the rendering changes;
|
||||
* what changes is that the column can no longer be starved below it, and an overflow becomes
|
||||
* visible to `fit.overflows()`.
|
||||
*
|
||||
* `:not(.fs-stacked)` is the whole guard, and it is the state the fitter measures in (it strips
|
||||
* the class first, fs-fit rule 1). Once carded, every cell has the row to itself.
|
||||
*
|
||||
* base/40-tables.css is deliberately NOT touched: `break-word` there was tried and recorded as a
|
||||
* failure (a table stayed 355px wide in a 346px column, and .fs-main clips), a tier with no card
|
||||
* and no scroll having nowhere to put the overflow. */
|
||||
.table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; }
|
||||
/* …and the ONE column the fitter decided may be shredded rather than card the whole table. Written
|
||||
* by fs-select.js on the cells of a single column, stripped before every measurement. (0,5,0) — it
|
||||
* beats the floor above on specificity, not on source order. */
|
||||
.table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; }
|
||||
|
||||
/* ---- and the one flag, because the view writes its shredding inline ----
|
||||
*
|
||||
* `luci-mod-status`'s processes.js emits `E('span', {'style': 'word-break: break-word'}, …)`.
|
||||
* `word-break: break-word` is the deprecated alias defined as "`word-break: normal` plus
|
||||
* `overflow-wrap: anywhere`" (css-text-3 §5.1), so it lowers the cell's min-content to a single
|
||||
* character from an INLINE declaration no cascade layer can outrank: measured at 720px, the
|
||||
* Command column sat at 126px against a widest token of 353px and forcing the column's
|
||||
* `overflow-wrap` any of three ways changed nothing, the floor being erased inside the span.
|
||||
* Neutralised, the same table asks for 963px in 688px of room, which is the truth.
|
||||
*
|
||||
* This re-times the view's intent rather than overruling it: the column may still shred, but
|
||||
* when the fitter marks it (`.fs-td-break`, one rung before carding) rather than always. Buttons
|
||||
* and inputs are excluded — base gives them `break-all` deliberately, and a control label is not
|
||||
* a value being read.
|
||||
*
|
||||
* The `!important` is the sanctioned kind: it fights an inline `style=`, the one adversary a
|
||||
* layer cannot reach.
|
||||
*
|
||||
* A CARD IS NOT EXEMPT, though `:not(.fs-stacked)` stood here. The inline alias damages the
|
||||
* MEASUREMENT, and a carded table is not measured — but it is not the only adversary. LuCI's
|
||||
* luci-mod-dashboard ships `.Dashboard … .devices-info .tr .td:first-child { word-break:
|
||||
* break-all }` in view/dashboard/css/custom.css with no layer around it, and `break-all` splits a
|
||||
* token that would have fitted: measured at 390px, `t3e-syp.home.iot` over six lines and the
|
||||
* caption `HOSTNAME` cut as `HOSTNA`/`ME`. A card's own breaking is unchanged — `.td` below keeps
|
||||
* `overflow-wrap: anywhere`, so a token too long for the line still breaks. */
|
||||
.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. */
|
||||
|
||||
/* ---- the header row stays while the rows go past ----
|
||||
*
|
||||
* Processes renders 114 rows and Connections a few hundred: past the first screenful the column
|
||||
* headings are gone and every value is an unlabelled string of digits. The card view solves that
|
||||
* by printing the heading above each value; a table that FITS had no equivalent.
|
||||
*
|
||||
* On the CELLS, not the row: `position: sticky` needs a box with a background, and in the
|
||||
* separated border model a row's background does not travel with a sticky cell — the heading
|
||||
* would float over the rows with the values showing through. So each `.th` sticks and carries
|
||||
* the header's own fill.
|
||||
*
|
||||
* What it sticks TO depends on which box scrolls. In the vertical sidebar `.fs-main` is its own
|
||||
* scroller and nothing overlaps its top edge, so the offset is 0. In the bar layouts the
|
||||
* DOCUMENT scrolls under a sticky bar whose height is not `--fs-bar-h` — it grows when the brand
|
||||
* wraps, when the menu takes a row or when the cluster does, all decided by measurement — so
|
||||
* fs-chrome.js publishes `--fs-bar-live` and this reads it, with the designed height as the
|
||||
* fallback for the frame before the chrome has been fitted.
|
||||
*
|
||||
* And a dialog is neither, which is why every one of these selectors names its root. The roots
|
||||
* ride in `:where()` so they cost nothing: spelled out they weigh (1,9,0) against the (1,7,0)
|
||||
* ceiling tools/css-metrics.mjs enforces, while the cell rule that has to win is (0,6,0). */
|
||||
: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);
|
||||
}
|
||||
|
||||
/* ---- a measurement must not throw the reader ----
|
||||
*
|
||||
* `fitTables()` decides a data table's tier by taking its marks OFF and asking the engine
|
||||
* (fs-fit rule 1), and the reading forces layout, so for that pass the document really is laid
|
||||
* out with a table several screens taller than the one on screen.
|
||||
*
|
||||
* Scroll anchoring turns that into a visible bug: the browser keeps the node under the reader
|
||||
* still by adjusting the offset, and re-picks that node whenever the offset changes for any
|
||||
* other reason — which, while the user scrolls, is every frame. The pick can land inside a table
|
||||
* in the un-stacked state the fitter is about to undo, and the next frame the engine faithfully
|
||||
* corrects the offset by the difference. Measured on the Overview at 390x844 with the poll
|
||||
* driving real re-renders: 18 movements nobody asked for in 1500 frames, the worst 1877px,
|
||||
* always a pair.
|
||||
*
|
||||
* So the tables the fitter re-stamps are declared ineligible as anchors — and only they, because
|
||||
* every other node staying eligible is what keeps the good half of anchoring working. NOT on the
|
||||
* scroll container, which is the answer that suggests itself and is worse: with
|
||||
* `html, body, #maincontent { overflow-anchor: none }` the same run still moved 12 times, up to
|
||||
* 788px, having thrown that compensation away as well. */
|
||||
.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 around the whole data table. A real <table> needs border-collapse:separate or the
|
||||
* radius is silently dropped. Scoped to #view too: Startup renders a .table.fs-dt in a bare
|
||||
* #view wrapper with no section, and drew frameless under a section-scoped selector. */
|
||||
.cbi-section .table[id], #view .table[id],
|
||||
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, table.fs-dt {
|
||||
border-collapse: separate; border-spacing: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* EXPLICIT, not an omission: the generic `.table` rule below still says `overflow: hidden`
|
||||
* — which clipped the last column of a wide table — and not repeating it here does not
|
||||
* stop it applying, which is what kept the bug alive. The corner cells (below) round the
|
||||
* frame instead, and long values wrap so the table shrinks to fit. */
|
||||
overflow: visible;
|
||||
}
|
||||
/* The frame is overflow:visible, so nothing clips the square corner cells to its radius and
|
||||
* they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill.
|
||||
* `#view .table[id]` is listed because the frame rule draws the border there too.
|
||||
*
|
||||
* `:is(.tr.table-titles, .thead)` because a header is any of the shapes fs-select.js adopts:
|
||||
* `.tr.table-titles` is the bare-th-row markup, `.thead` covers a real `<thead>` whether it
|
||||
* holds its `<th>`s directly (E()-built, no `<tr>`) or through one — adoptMarkup() never adds
|
||||
* `.tr` to that inner row, so a class-only selector has to reach the `.th` by descent, not by
|
||||
* child. Without `.thead` the header kept its square top corners under any real `<thead>`: the
|
||||
* DHCP and Wi-Fi dashboard tables measured a first/last `.th` radius of 0 against the frame's
|
||||
* 12px. */
|
||||
.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 a single-cell .tr.placeholder. Spanning it across the table is
|
||||
* base's job (base/40-tables.css); what is left here is the FRAMED table's own flavour — the
|
||||
* plate reads as prose, left-aligned like the rows it stands in for, and carries the frame's
|
||||
* bottom corners so the rounding does not cut across it. */
|
||||
.cbi-section .table[id] .tr.placeholder .td,
|
||||
#view .table[id] .tr.placeholder .td,
|
||||
table.fs-dt .tr.placeholder .td {
|
||||
/* the framed flavour pads its cells `--fs-space-2-5` a side (above), and that shorthand is in
|
||||
* THIS layer, so it overrides base's placeholder padding and made the plate 4px taller than
|
||||
* the row reserved for it — measured on Wireless as 67px inside 63px, printing below the
|
||||
* frame's bottom border. Put base's number back for this one cell. */
|
||||
padding-block: var(--fs-space-2);
|
||||
text-align: start;
|
||||
color: var(--fs-dim); font-family: var(--fs-font-sans);
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
/* --- KEY/VALUE tables: System, Memory ---
|
||||
* "no id" is not enough: form.TableSection/GridSection also render `.table.cbi-section-table`
|
||||
* with no id, where a 40%/nowrap first column starves the data columns. So exclude it and .fs-dt
|
||||
* explicitly.
|
||||
*
|
||||
* Also exclude the realtime graph legend: `[style*="table-layout"]` is the one no-id `.table`
|
||||
* LuCI hands an inline `table-layout:fixed`, where a 40% first column would only starve the
|
||||
* other five. A DDNS-style multi-column status table is not excluded and keeps the key/value
|
||||
* first column. */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child {
|
||||
color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) {
|
||||
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
|
||||
}
|
||||
/* Below ~560px of container width the 40%/nowrap column above starves the value: measured on
|
||||
* System -> System at 390px content width, Firmware Version wrapped onto 5 lines and Kernel
|
||||
* Version/Architecture onto 2 each, most of the 130px label column sitting empty. Stack it like
|
||||
* the other card idioms in this file — label as an eyebrow above its value — instead of holding
|
||||
* a fixed-fraction column at any width. A width win, not a height one: measured on the gallery's
|
||||
* key/value specimen (a 340px .cbi-map, docs/gallery.html), stacking cost the card +44.9% height
|
||||
* (202.6 -> 293.5px) for the value column widening x1.67 (183.6 -> 306px) and Firmware Version's
|
||||
* line count dropping from two to one. (The five lines above are the live 390px page, where the
|
||||
* value is longer than the specimen carries — the shape is the same, the count is not.)
|
||||
*
|
||||
* 560 is the second threshold named on fs-content; the config table's card (theme/65-dropdown.css)
|
||||
* is the first, at 960, for a row full of widgets that bake in a layout width. This one holds two
|
||||
* plain text cells, so it can stay two-column down to a narrower width before the label needs the
|
||||
* row to itself.
|
||||
*
|
||||
* `:first-child:first-child` / `:last-child:last-child` double the tail pseudo-class to outrank
|
||||
* the two rules above at identical specificity otherwise — the `.hidden.hidden` idiom
|
||||
* (theme/60-inputs.css) — rather than win on source order, which the next edit to either rule
|
||||
* could invert.
|
||||
*
|
||||
* The value's right-flush alignment above is a two-column device: with the label gone from the
|
||||
* row, right-flushing floats the value away from its own caption. Left-aligned instead, so the
|
||||
* card reads top-to-bottom as caption then value, matching every other stacked cell in this file. */
|
||||
@container fs-content (max-width: 560px) {
|
||||
/* Stacked, the row's own `.td, .th` rule above (line 97) still spends a FULL cell padding
|
||||
* on each half of the pair — 10px top + 10px bottom on the caption, the same on the value,
|
||||
* plus a hairline under EACH — 40px of padding-block and two dividers for one caption/value
|
||||
* pair, so the gap inside a pair (caption to its own value) equalled the gap between pairs
|
||||
* and the pair read as two rows, not one. Same fix as the meter row's
|
||||
* (pages/20-overview.css): move the rhythm off the CELLS onto the ROW. `--fs-space-1 0
|
||||
* --fs-space-1-5` — 4px + 6px, a quarter of the 40px it replaces — is the pair's own outer
|
||||
* padding, and the hairline moves with it so a stack outside the Overview page (which zeroes
|
||||
* it separately) draws ONE divider per pair instead of one under the caption as well. With
|
||||
* the cells at `padding: 0`, caption and value sit flush against each other — nothing between
|
||||
* them but line-height — and the record boundary is the one line the reader can see. */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):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(.tr.table-titles)):not([style*="table-layout"]) .tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):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(.tr.table-titles)):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 (status/realtime/*) ---
|
||||
* The one no-id `.table` LuCI renders with inline `table-layout:fixed;width:100%` and six cells
|
||||
* per row. `fixed` splits the row into six equal columns, so on a phone each is ~1/6 of the
|
||||
* content width and the labels overflow onto the value beside them. Stock luci-theme-bootstrap
|
||||
* folds the six cells into label/value PAIRS on a narrow screen; do the same. `display:flex` on
|
||||
* the row takes the cells out of the fixed table layout, so the inline `table-layout` no longer
|
||||
* bites. Keyed on that inline attribute so only this legend is touched. */
|
||||
@media (max-width: 767px) {
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .tr {
|
||||
display: flex; flex-wrap: wrap;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .td {
|
||||
flex: 1 1 50%; min-width: 0; width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* remove double card when a section nests a table-only include */
|
||||
.cbi-section .cbi-section { border: 0; box-shadow: none; padding: 0; margin: 0; background: transparent; }
|
||||
|
||||
/* generic links/labels inside cards */
|
||||
.cbi-section .ifacebox, .cbi-section .zonebadge { border-radius: var(--fs-radius); }
|
||||
/* moved from base with the generic field box (same layer arithmetic as the cell rules above) */
|
||||
.zonebadge input { width: 6em; }
|
||||
/* The device/MAC badge inside the Network status box kept base's flat 4px, reading as square
|
||||
* against the card's --fs-radius corners (issue #5.4). Scoped to the status box: the small
|
||||
* firewall zone .ifacebadge chips want to stay tight. Follows the Rounding axis via the token. */
|
||||
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
|
||||
/* …and the head has to round WITH the box: it sits inside the box's 1px border and carries the
|
||||
* zone colour as an inline background, so its own corners are what you see, and a 3px head
|
||||
* inside a 10px box cuts straight through the rounding with the zone colour spilling past the
|
||||
* corner (issue #7). Minus the 1px border it is inset by; max() because the Rounding axis goes
|
||||
* down to 0, where a bare subtraction is negative — an invalid declaration, silently dropped. */
|
||||
.cbi-section .ifacebox > .ifacebox-head {
|
||||
--fs-head-radius: max(0px, calc(var(--fs-radius) - 1px));
|
||||
border-radius: var(--fs-head-radius) var(--fs-head-radius) 0 0;
|
||||
}
|
||||
|
||||
/* Inactive tab panes. Base's [data-tab-title] rule sets height:0/overflow:hidden, collapsing only
|
||||
* the CONTENT box — padding, border and margin still draw empty bars, and a pane left in flow
|
||||
* inflates scrollHeight (Interfaces scrolled ~585px past the footer, Network -> DNS another
|
||||
* ~750px). Zeroing the box does both jobs, and hiding the pane with `display: none` on top of
|
||||
* that buys nothing (Interfaces scrollHeight 1039 either way).
|
||||
*
|
||||
* It is `visibility`, not `display: none`, because a display:none pane has no width and a view
|
||||
* is entitled to measure one: stock channel_analysis.js builds its channel axis from
|
||||
* `graph.offsetWidth` once, in a rAF, so a pane that starts inactive reads 0 and the 5GHz graph
|
||||
* stays a collapsed blob for the life of the page. `visibility: hidden` keeps the pane laid out
|
||||
* at full width while still taking it out of the a11y tree and the tab order, and
|
||||
* height:0 + overflow:hidden keep it out of the scroll.
|
||||
*
|
||||
* `position: relative` is what makes that overflow hold: an absolutely positioned descendant is
|
||||
* clipped only by an overflow ancestor in its own containing-block chain, and a collapsed pane of
|
||||
* ordinary markup has none — the theme supplies them itself, the toggle switch parking its real
|
||||
* checkbox at `position: absolute` (theme/60-inputs.css). Measured: DHCP and DNS scrolled 1841px
|
||||
* against 1110px of content on 24.10 and 1925 vs 1439 on 25.12, i.e. ~500-700px of empty page
|
||||
* under the footer (issue #10).
|
||||
*
|
||||
* Hence any [data-tab-title], not just .cbi-section ones. */
|
||||
[data-tab-title]:not([data-tab-active="true"]) {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
/* 6%, not 12%. The list inside is bold --fs-danger on a tint OF --fs-danger, so the tint
|
||||
* eats its own contrast: 12% measured 4.4:1 (axe-core). At 6% every palette clears 4.5
|
||||
* (4.87 / 4.95 / 5.28 / 6.09) and the box still reads red. */
|
||||
.cbi-section-error { border-color: var(--fs-danger); background: color-mix(in srgb, var(--fs-danger) 6%, var(--fs-panel)); color: var(--fs-text); }
|
||||
.cbi-section-error ul li { color: var(--fs-danger); }
|
||||
|
||||
/* denser data tables (dhcp static leases, section-tables) */
|
||||
.cbi-section-table .th, .cbi-section-table .td,
|
||||
/* The `::before` is the GridSection name CELL on 24.10, where form.js renders none and base
|
||||
* generates the column from `data-title` (base/95-luci.css). It is not a `.th`/`.td`, so every
|
||||
* cell rule below has to name it too, or the column it draws lines up with nothing. Listing
|
||||
* `.tr::before` unqualified is safe: base names exactly the rows that get one, and a
|
||||
* pseudo-element whose content is `none` generates no box. */
|
||||
.table.cbi-section-table .th, .table.cbi-section-table .td,
|
||||
.table.cbi-section-table .tr::before { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; }
|
||||
/* The zero padding-left above is right only INSIDE a card, where the .cbi-section supplies that
|
||||
* gutter itself. Outside one it is wrong — Status -> Firewall renders `.table.cbi-section-table`
|
||||
* in a bare <div>, so the table draws its own frame and the header text ends up glued to that
|
||||
* frame's rounded corner (issue #7). A table carrying its own frame pads its own cells. */
|
||||
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .th,
|
||||
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .td { padding: var(--fs-space-2-5) var(--fs-space-3); }
|
||||
.cbi-section-table .tr.cbi-section-table-titles .th,
|
||||
.cbi-section-table .tr.table-titles .th { font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); line-height: 1.25; }
|
||||
|
||||
/* Standalone data tables (Startup, Processes, Routes) -> rounded card. Inside a CBI section
|
||||
* the section already IS the card, so the frame is stripped there (above): no double border. */
|
||||
.table {
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--fs-panel);
|
||||
}
|
||||
/* Separators go on the CELLS, not on the rows, and that is not a preference: the frame above needs
|
||||
* `border-collapse: separate` (a collapsed table silently drops the radius), and in the separated
|
||||
* model a row's border is not painted at all — on the .tr these rules paint nothing, and what
|
||||
* draws the lines is base's per-cell `border-top`, which also runs along the table's top edge
|
||||
* across the frame's rounded corners (issue #7). base no longer sets it, and the lines are
|
||||
* declared here, where they paint. Remove one and the other is not a fallback.
|
||||
*
|
||||
* `:not(:last-child)` lives IN the body-row selector: a trailing bottom border draws a straight
|
||||
* line the full width and pokes past the frame's rounded bottom corners. */
|
||||
.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); }
|
||||
/* Because the separator above is a per-CELL border, an app that nudges one cell with
|
||||
* `position: relative; top: …` (luci-mod-dashboard's `.devices-info .td:nth-child(3)`) drags
|
||||
* that cell's border-bottom out of line with its row neighbours — a step, not a slanted line,
|
||||
* since each cell paints its own hairline. Bootstrap never shows this because its rows are
|
||||
* `border-collapse: collapse`, with no per-cell border to displace. The app's rule lands in the
|
||||
* same fenced `theme` layer (fs-sheets.js), so an ID root outranks its 7 classes on specificity
|
||||
* alone and needs no `!important`; `top` AND `bottom`, not just `top: 0`, so this also cancels
|
||||
* a `bottom` offset and leaves `position: relative` itself untouched for any
|
||||
* absolutely-positioned descendant. `top`/`bottom`, not the `inset-block` shorthand: the sheet
|
||||
* has never used a logical inset property before and `css-floor` has no support decision for
|
||||
* one, where the physical pair is already on the floor. Measured: offset .td top -3px -> 0, its
|
||||
* border-bottom back level with its neighbours'. */
|
||||
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
|
||||
/* descendant combinator (not >) so a real <table>'s browser-inserted <tbody> is covered
|
||||
* too — one rule themes every data table, div- or table-based. */
|
||||
.table .tr.table-titles, .table .tr.cbi-section-table-titles {
|
||||
background: transparent; /* drop the base panel2 header bar — match the dashboard's faint-label + underline look */
|
||||
}
|
||||
.table .tr.table-titles .th, .table .tr.cbi-section-table-titles .th,
|
||||
.table .tr.cbi-section-table-titles::before {
|
||||
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking);
|
||||
background: transparent;
|
||||
}
|
||||
/* The row's NAME, which must read the same on both releases: 25.12 renders a real
|
||||
* `.td.cbi-section-table-titles`, 24.10 base's `::before`. Both are a LABEL, not a value, so
|
||||
* both take the UI face — the surrounding value cells are mono, and base makes this one bold, so
|
||||
* with no 700 mono face shipped every rule name is drawn in synthetic bold mono, smearing the
|
||||
* grid on the one string in the row that is a name. Specificity (0,3,0), not source order, is
|
||||
* what beats the (0,1,0) mono rule. */
|
||||
.table.cbi-section-table .td.cbi-section-table-titles,
|
||||
.table.cbi-section-table .tr.cbi-section-table-row::before {
|
||||
font-family: var(--fs-font-sans); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
}
|
||||
.table .tr:not(.table-titles):not(.cbi-section-table-titles) {
|
||||
transition: background var(--fs-dur);
|
||||
}
|
||||
/* Row hover ONLY on real data/config tables. A plain layout `.table` — Diagnostics is one
|
||||
* <tr> of input+button spanning the whole card, with no header row, so fs-select never tags
|
||||
* it .fs-dt — lit the ENTIRE block on hover (issue #5.3), and a data table's placeholder row
|
||||
* ("no active leases") lit up too (issue #5.2, plus :not(.placeholder) above). Data tables
|
||||
* hover per-cell above; config tables (.cbi-section-table, never .fs-dt) hover data rows here. */
|
||||
/* :not(.placeholder) — a config table's "This section contains no values yet" row carries
|
||||
* .cbi-section-table-row too, so it lit up under the pointer exactly as the data tables' did
|
||||
* before the rule above excluded theirs (issue #15: reported on UPnP, but every empty
|
||||
* GridSection had it — firewall's NAT rules, IP sets, static routes). A hover cue promises a
|
||||
* row you can act on; there is no row. */
|
||||
.table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); }
|
||||
/* keep section/modal tables flush (no forced hover-card look) */
|
||||
.cbi-section .table > .tr, .cbi-modal .table > .tr { border-radius: 0; }
|
||||
|
||||
/* .fs-dt is set by fs-select.js on a #view .table that has a header row and is NOT a
|
||||
* .cbi-section-table — replacing a live `:has(.tr.table-titles):not(.cbi-section-table)`
|
||||
* the style engine re-evaluated on every mutation of these polled tables (processes,
|
||||
* routes, leases). */
|
||||
|
||||
/* ---- the card stack: one copy, every data table, no @container threshold ----
|
||||
*
|
||||
* Three @container thresholds meant two copies of these declarations, CSS being unable to share
|
||||
* a block across thresholds — 55 duplicated declarations, free to drift. All three really asked
|
||||
* "does it OVERFLOW?", which the browser can compute: fs-select.js measures it and sets
|
||||
* .fs-stacked, so every table finds its own width, a third-party app's included. Only the
|
||||
* judgement a measurement cannot make survives, in fs-select.js: "too cramped to be a table".
|
||||
*
|
||||
* A config table (.cbi-section-table) is deliberately not here and keeps its @container
|
||||
* (theme/65-dropdown.css): its rows hold widgets that bake in a width from the layout they were
|
||||
* rendered in, so un-collapsing one to read it CHANGES what is read — the firewall zone table
|
||||
* then claimed 1747px where it needs 1190px. Do not "finish the job" by measuring it.
|
||||
*
|
||||
* Shape = stock LuCI's phone layout: label ABOVE its value, cell = half the row. Label:value on
|
||||
* one line with the value right-flushed costs a leases row 7 full-width lines and gives the
|
||||
* value ~40% of the width; half-width pairs fold it into 4. Values stay left-aligned.
|
||||
*
|
||||
* ONE CONTRACT — do not split it again. The stack is MEASURED and fires at ANY width, while
|
||||
* `display: block` and the `.hide-xs`/`.hide-sm` drops once lived only inside
|
||||
* `@media (max-width: 767px)` (theme/90-responsive.css). The window between the two is real: the
|
||||
* sidebar layout's content column is (viewport - 224 - 56), so from ~768 to 860px it is already
|
||||
* below CRAMPED while that query is off. Measured at 790/820/850px, status_leases stacked with
|
||||
* `display: table` still in force and wifi_assoclist_table rendered all five of its .hide-xs
|
||||
* cells; at 760px both were correct. So they key off the CLASS. */
|
||||
/* ---- a table nobody has answered for yet takes no room ----
|
||||
*
|
||||
* A poll tick REPLACES these tables, and a fresh one carries no marks: for the moment between
|
||||
* landing and being stamped it is laid out as a full-width table, several screens taller than
|
||||
* the card stack it is about to be. The engine re-anchors on that intermediate and throws the
|
||||
* reader 612px out and back, twice per tick. fs-select.js answers in a MutationObserver
|
||||
* microtask, i.e. before paint — but only if nothing forces layout in between, and an app that
|
||||
* reads a width right after rendering does exactly that.
|
||||
*
|
||||
* So the table waits outside the layout until it has an answer. `display: none`, not
|
||||
* `visibility: hidden`, because a hidden box still occupies its height.
|
||||
*
|
||||
* Guarded on the fitters being alive: `:root[data-fs-fit]` is written by fs-fit.js and by
|
||||
* nothing else, so a document where that file never ran shows every table exactly as before this
|
||||
* rule existed. A stylesheet that can hide content on its own is one that can hide it
|
||||
* forever. */
|
||||
: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: fs-select.js writes it on a table that has
|
||||
* overflowed, before it decides to card, because the view author has already said which columns
|
||||
* are expendable and a table with two fewer columns often fits. Same declaration, so the two
|
||||
* selectors share the block, and the class is what makes the drop a MEASURED decision rather
|
||||
* than a viewport guess (theme/90-responsive.css keeps that copy for tables that never card). */
|
||||
.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; }
|
||||
/* align-items: stretch (the default), NOT flex-start: the cells carry a per-cell
|
||||
* border-bottom, so when one cell of a pair wraps (a 2-line hostname beside a 1-line MAC)
|
||||
* a flex-start neighbour stays short and its separator paints 52px higher — a broken line
|
||||
* mid-card (measured). Stretched, both cells take the flex line's height and the
|
||||
* separators meet; content still sits at the top (the cells are blocks). */
|
||||
/* A `<tfoot>` with no `.tr` inside it IS the row — see the footer block below for which view
|
||||
* writes which shape.
|
||||
*
|
||||
* TWO rules, not one list: a selector list is not forgiving, so a browser without `:has()`
|
||||
* (Firefox before 121) throws away the WHOLE list, and `.table.fs-stacked .tr` — every card on
|
||||
* every stacked table — loses `display: flex` with it. Split, the `:has()` half degrades alone.
|
||||
* The duplicated body is under `css-dup`'s three-declaration floor, so it will not be flagged;
|
||||
* the two must change together. */
|
||||
.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 (LuCI writes it per cell as data-title).
|
||||
*
|
||||
* Pinned: the config table (theme/65-dropdown.css) needs these exact declarations under its
|
||||
* @container guard, and CSS cannot share a block between a class and a container query — the two
|
||||
* guards do not OR. tools/mirror.mjs keeps the copies byte-identical; edit one and the build
|
||||
* fails until you edit the other, a duplicate detector going quiet once two copies differ. */
|
||||
.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 is the fourth member here, and it has to carry `[data-title]` in its own selector
|
||||
* to win: `.table.fs-stacked tfoot .td::before` is (0,3,1) against the caption rule's (0,4,0) and
|
||||
* loses wherever it is written. Measured on the wifi footer with the titles stamped: `content`
|
||||
* computed to "SSID" over `Total:`. With the attribute it is (0,4,1) and (0,5,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; }
|
||||
/* The row's buttons are not a label/value pair — full width below the pairs.
|
||||
* PINNED with the config table's copy — see above. */
|
||||
.table.fs-stacked .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* LuCI groups a row's buttons in ONE inner <div> (startup.js wraps all five), so the td's own
|
||||
* flex-wrap above never fires — the div is a single flex item, and .fs-main's overflow-x:clip
|
||||
* CUT the tail button (Startup at 390px, measured: the div needs 350px in a 324px cell and
|
||||
* "Stop" vanished). Same trap the config table hit on the interfaces row; PINNED to its copy
|
||||
* in theme/65-dropdown.css. */
|
||||
.table.fs-stacked .td.cbi-section-actions > * {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* Nothing in a half-width cell may force the row wider than the viewport: .fs-main is
|
||||
* overflow-x:clip, so an over-wide child is CUT, not scrolled to. */
|
||||
.table.fs-stacked .td > *,
|
||||
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* ---- the footer, in both shapes upstream writes it ----
|
||||
*
|
||||
* A `<tfoot>` is still a table-footer-group inside a `display: block` table, so in a card it drew
|
||||
* as a box beside the cards: measured at 390px on the station list, 186px wide against 354.
|
||||
* `20_lan.js` builds `tfoot > tr > td` and needs only the footer out of the way, the `.tr` above
|
||||
* already being a flex row; `30_wifi.js` appends the `<td>`s to the `<tfoot>` through E(), where
|
||||
* no parser runs and no row is implied, so there the footer has to become the row — that arm is
|
||||
* up with the `.tr` rule. `:has(> .tr)` tells them apart.
|
||||
*
|
||||
* An empty footer cell goes entirely: a card is a list of facts and an empty cell is not one. The
|
||||
* captions are suppressed up in the `content: none` group, for the specificity reason stated
|
||||
* there. */
|
||||
.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; }
|
||||
|
||||
/* --- the DATA table's flavour (L.ui.Table, tagged .fs-dt) ---
|
||||
* Clip rows to the frame's corners: zebra paints its background on the .tr and the frame is
|
||||
* overflow:visible, so a striped row's square background pokes past the corners. Safe once
|
||||
* stacked — a flex-wrap row fits the width, so there is no horizontal column left to clip, which
|
||||
* is the only thing overflow:visible protected. `#view .table.fs-dt.fs-stacked` (1,3,0) outranks
|
||||
* the frame's `#view .table[id]` (1,2,0) on specificity, not on order.
|
||||
*
|
||||
* `#modal_overlay` as well, because the FRAME already reaches a dialog and this clip did not: a
|
||||
* data table inside a dialog's `.cbi-section` takes the frame from `.cbi-section .table[id]`
|
||||
* above and has the same corners with the same zebra painted square across them. */
|
||||
#view .table.fs-dt.fs-stacked,
|
||||
#modal_overlay .table.fs-dt.fs-stacked { overflow: hidden; }
|
||||
/* `:not(.placeholder)` — the plate is out of flow (base/40-tables.css) and its row exists only to
|
||||
* reserve the height it needs. Padding is subtracted from that reservation (border-box), so the
|
||||
* card's row inset left 41px of the 63px the plate is: measured on Wireless as 10px of text
|
||||
* hanging below the frame once the table stacked. A row with no rows in it has nothing to inset. */
|
||||
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
|
||||
/* ---- the pair width, and the adversary specificity cannot reach ----
|
||||
*
|
||||
* Half the row is what makes the card a card. It was `.fs-stacked .td` at (0,2,0), deliberately
|
||||
* low so the unguarded `.table .td.col-N` below, at (0,3,0), could beat it on specificity rather
|
||||
* than source order — which holds against every rule in a LAYER and none outside one.
|
||||
* luci-mod-dashboard ships `@media (max-width: 640px) { .Dashboard … .tr .td { flex: 2 2 10% } }`
|
||||
* unlayered, and that beats every layered rule at any specificity: measured at 390px, four cells
|
||||
* of a station row at 81px on one line, `flex-basis` computing to 10% — the pairs never formed.
|
||||
*
|
||||
* Hence the flag, the sanctioned kind. Spent narrowly, and by SELECTOR because a flag would
|
||||
* outrank these two families as well: `.cbi-section-actions` is a full-width line of buttons,
|
||||
* `[class*="col-"]` is the view author's own weight. The third `:not()` is not one of those — it
|
||||
* is how this rule loses to the meter below, both being flagged: without it (0,5,0) beats (0,4,0)
|
||||
* and the meter silently keeps half a line. */
|
||||
.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: `.cbi-progressbar::after` prints the bar's title above its right
|
||||
* edge (25-progressbar.css), which in a card is where the cell's own caption sits — measured at
|
||||
* 390px, `SIGNAL STRENGTH` and `RSSI: 65% (-57dBm)` drawn over each other. Beside the bar instead
|
||||
* would be paid out of the bar, too short at half a row to read as one. */
|
||||
.table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; }
|
||||
|
||||
/* ---- column weights: one unguarded block, unguarded on purpose ----
|
||||
* A weight (1-10) is a share of a CARDED row — the JS says which cell is a narrow badge and
|
||||
* which the wide status text; without it every cell falls back to the flat 50% above.
|
||||
*
|
||||
* Do not copy them under a guard. `flex` applies only to a FLEX ITEM, and a `.td` becomes one
|
||||
* exactly when its `.tr` turns into a flex container — by either mechanism (`.fs-stacked`,
|
||||
* measured at any width, or the config table's @container, which can card on a 1200px desktop).
|
||||
* Anywhere else they are inert, so one copy serves all three tiers. Basis N*30px, as in stock,
|
||||
* so the weights compose the same way. */
|
||||
.table .td.col-1, .table .th.col-1 { flex: 1 1 30px; }
|
||||
.table .td.col-2, .table .th.col-2 { flex: 2 2 60px; }
|
||||
.table .td.col-3, .table .th.col-3 { flex: 3 3 90px; }
|
||||
.table .td.col-4, .table .th.col-4 { flex: 4 4 120px; }
|
||||
.table .td.col-5, .table .th.col-5 { flex: 5 5 150px; }
|
||||
.table .td.col-6, .table .th.col-6 { flex: 6 6 180px; }
|
||||
.table .td.col-7, .table .th.col-7 { flex: 7 7 210px; }
|
||||
.table .td.col-8, .table .th.col-8 { flex: 8 8 240px; }
|
||||
.table .td.col-9, .table .th.col-9 { flex: 9 9 270px; }
|
||||
.table .td.col-10, .table .th.col-10 { flex: 10 10 300px; }
|
||||
|
||||
/* general table cell padding — compact but readable (Startup/Processes/etc) */
|
||||
.table .td { padding-top: var(--fs-space-2); padding-bottom: var(--fs-space-2); }
|
||||
|
||||
/* ---- the last resort: a foreign <table> the card stack cannot claim ----
|
||||
*
|
||||
* fs-select.js tags any `#view table` that HAS a header row as `.table.fs-dt`, so it is measured
|
||||
* and cards like every other data table. What is left here is the table with no header at all: a
|
||||
* log, a statistics matrix, a layout table. Those must not be carded even if they overflow — a
|
||||
* card prints `data-title` above each value, and a matrix has no titles to print, so stacking one
|
||||
* produces a column of numbers with nothing saying what they are. Comparison is the point of
|
||||
* that shape; it scrolls instead.
|
||||
*
|
||||
* `display: block` plus `overflow-x: auto` on the table ITSELF, not on a wrapper: the theme
|
||||
* cannot add markup around someone else's element, and the block box is what gives it a scroll
|
||||
* container. The rows still lay out as a table, a `display: block` <table> wrapping its children
|
||||
* in an anonymous table box.
|
||||
*
|
||||
* Unguarded by a media query, because the viewport was never the fact that mattered: a narrow
|
||||
* panel or a split window overflows at any width, and a phone report is only where it was
|
||||
* noticed first.
|
||||
*
|
||||
* A dialog is the worst place to leave it out, hence the `#modal_overlay` half of the selector:
|
||||
* the overlay is the scroll container (base/60-modal.css) and the dialog is centred in it with
|
||||
* `margin: auto`, so an over-wide child scrolls the dialog's own edge off the screen instead of
|
||||
* scrolling itself. Measured at 390px with a four-column `nowrap` log table: in #view it scrolls
|
||||
* inside itself (358px shown of 636px, page scroll 0); in a dialog it rendered 636px wide in
|
||||
* 317px of room and gave the overlay 282px of horizontal scroll. */
|
||||
/* `.fs-xscroll` is the same outcome reached the other way: fs-select.js writes it on a `.table`
|
||||
* with no header row that has been measured to overflow — the shape a bare `<table>` selector
|
||||
* cannot reach, a third-party app being free to render its log as `<div class="table">`. A class
|
||||
* rather than a selector, and measured rather than declared, for a spec-level reason:
|
||||
* `overflow-x: auto` forces `overflow-y` to compute to `auto` too (css-overflow-3 §3.1), so a
|
||||
* table given this becomes a clip for every popup inside it, and ui.js then walks up from an open
|
||||
* dropdown, finds the TABLE as the scroll parent and sizes the list against it. The fitter
|
||||
* refuses the class to any table holding a widget, so this rule needs no `:has()` ladder. */
|
||||
#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;
|
||||
/* the scroll gesture stops at this table instead of dragging the page sideways */
|
||||
overscroll-behavior-x: contain;
|
||||
/* the scrollbar IS the affordance here. A gradient "there is more to the right" shadow is the
|
||||
* usual trick and does not work on these tables: it has to paint on the scroll container,
|
||||
* behind rows that carry their own zebra fill, so it shows through every second row only. */
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ---- a table of controls stacks: it is a form laid out as a table ----
|
||||
*
|
||||
* Written by fs-select.js on a header-less table that overflows and holds a control. Scrolling
|
||||
* one is not an option — the popup inside would be clipped by the scroll container it just
|
||||
* became — and doing nothing leaves it cut: at 320px of room the row needed 338 and the button
|
||||
* was past `.fs-main`'s clip with no way to reach it.
|
||||
*
|
||||
* `display: block` on all three levels, the one place this theme wants it: the rows are a form,
|
||||
* so there is nothing to compare across them and nothing to keep in columns. Each control takes
|
||||
* the width of its line and the table cannot exceed its parent by construction. Nothing here
|
||||
* creates a scroll container, so an open 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 and carried its 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); }
|
||||
|
||||
/* ---- what makes a scrolled table readable: the row keeps its name ----
|
||||
*
|
||||
* Scrolling a table sideways takes the identity column away first, leaving a screenful of values
|
||||
* belonging to nothing. Pinning the first cell of every row is the one thing a card view gives
|
||||
* for free and a scrolled table does not.
|
||||
*
|
||||
* The cells need their own background (a row's does not travel with a sticky cell in the
|
||||
* separated model, as for the header above) and a hairline on the inline end, so the pinned
|
||||
* column reads as an edge rather than as text sliding over text. `z-index: 2` keeps it over the
|
||||
* sticky header's `1` where the two cross, at the corner cell. */
|
||||
#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 reaches the scroll box by tabbing to it (fs-select.js gives it tabindex and a
|
||||
* name); it has to show that it has focus — 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,219 @@
|
||||
@layer theme {
|
||||
/* Alert panel: a left accent stripe on a panel card; the type variants recolour the frame.
|
||||
* `background: var(--fs-panel)` used to be repeated per variant to out-specify base's
|
||||
* gradients — a layer does that for free (theme beats base whatever the specificity), so
|
||||
* one rule does it. */
|
||||
.alert-message {
|
||||
padding: var(--fs-space-3-5) var(--fs-space-4);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: var(--fs-hairline);
|
||||
/* logical: the stripe marks the READING start of the alert, so it swaps sides with the
|
||||
* document (LuCI ships ar/fa/he catalogues) — physical, it sat on the far edge in RTL. */
|
||||
border-inline-start: 3px solid var(--fs-accent);
|
||||
/* The surface is OPAQUE and the status tint rides on top of it. A variant that replaces the
|
||||
* background with `var(--fs-*-fill)` paints an 18% wash with nothing behind it — invisible on
|
||||
* a flat canvas, and transparent to the wallpaper under Appearance -> Wallpaper, where the
|
||||
* tile reads straight through every status alert.
|
||||
*
|
||||
* So --fs-panel stays the background-COLOUR for every variant and the tint is a
|
||||
* background-IMAGE layer over it. The named `-fill` step is unchanged, so an untinted router
|
||||
* sees the same pixels, and the local `--fs-alert-tint` is what keeps this one rule instead
|
||||
* of six `background:` overrides that each have to re-state the panel. */
|
||||
background-color: var(--fs-panel);
|
||||
background-image: linear-gradient(var(--fs-alert-tint, transparent), var(--fs-alert-tint, transparent));
|
||||
color: var(--fs-text);
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
/* ---- status lives in the stripe and the surface; the ring is always neutral ----
|
||||
*
|
||||
* Split between `border-color` (all four sides, including the 3px stripe) and
|
||||
* `border-left-color` (the stripe alone), four of seven alerts wear a coloured ring and three a
|
||||
* grey one — a hierarchy nobody designed, with the difference between "not applied yet" and
|
||||
* "this is about to fail" hanging on one pixel of frame.
|
||||
*
|
||||
* The surface carries it instead, at the ladder's named `-fill` step, which
|
||||
* docs/design-system.md describes as the callout surface — exactly what an alert is. Not a fresh
|
||||
* `color-mix()`: an unnamed level is how four spellings of one tint appeared here before.
|
||||
*
|
||||
* `:not(.btn)` is load-bearing: `admin/system/flash` renders its confirm control as
|
||||
* `<label class="btn alert-message danger">`, a BUTTON wearing the alert classes, and without
|
||||
* the guard the tint out-ranks `.btn.danger` on source order: the "Force upgrade" control faded
|
||||
* from its danger fill (rgb(234,122,116)) to a pale wash. */
|
||||
.alert-message.warning:not(.btn) {
|
||||
border-color: var(--fs-border); border-inline-start-color: var(--fs-warn);
|
||||
--fs-alert-tint: var(--fs-warn-fill);
|
||||
}
|
||||
.alert-message.error:not(.btn), .alert-message.danger:not(.btn) {
|
||||
border-color: var(--fs-border); border-inline-start-color: var(--fs-danger);
|
||||
--fs-alert-tint: var(--fs-danger-fill);
|
||||
}
|
||||
.alert-message.success:not(.btn) {
|
||||
border-inline-start-color: var(--fs-good);
|
||||
--fs-alert-tint: var(--fs-good-fill);
|
||||
}
|
||||
/* `.info` is a STATUS and tints like the others; a bare .alert-message stays a flat panel, because
|
||||
* a note is not a status.
|
||||
*
|
||||
* `.notice` is grouped HERE rather than with .success, where it is pixel-identical — same fill,
|
||||
* same rail, no other channel — so one status word carries two meanings while the theme's own
|
||||
* `.label.notice` is accent and `.cbi-tooltip.notice` neutral. What settles the direction is
|
||||
* what LuCI means by it: ui.js emits `notice spinning` for the "applying configuration changes"
|
||||
* banner, i.e. IN PROGRESS rather than done, and stock luci-theme-bootstrap keeps green for
|
||||
* success alone. Informational, so accent. */
|
||||
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
|
||||
border-inline-start-color: var(--fs-accent);
|
||||
--fs-alert-tint: var(--fs-accent-fill);
|
||||
}
|
||||
|
||||
/* ---- a spinning alert is a glyph and a message, side by side ----
|
||||
*
|
||||
* The spinner is a `::before` on the element carrying `.spinning` (base/95-luci.css), and it is
|
||||
* `inline-block` so that it sits in the flow of the button labels and `em.spinning` texts that
|
||||
* usually host it. This host is neither: `ui.changes.displayStatus()` builds the apply dialog as
|
||||
* a BLOCK box whose content is a BLOCK element, so the glyph opens a line box of its own and the
|
||||
* message goes underneath it.
|
||||
*
|
||||
* Grid rather than flex, because the caller decides how many children there are and flex would
|
||||
* lay a second one out beside the first: `displayStatus()` passes one `<p>` today but builds two
|
||||
* elements for its non-spinning states, and `addNotification()` takes `spinning` from anyone.
|
||||
* Two columns — glyph, then everything else. Measured over the three shapes a caller can
|
||||
* produce: the content started 24.6 / 20.6 / 19.5px BELOW the glyph's line and now starts on it.
|
||||
*
|
||||
* The notification variant is deliberately untouched: ui.js writes `style="display:flex"` on the
|
||||
* element itself, and an inline style beats a layered rule, so those keep their own flex row. */
|
||||
.alert-message.spinning {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
/* centres the glyph against the FIRST row rather than pinning it to that row's top */
|
||||
align-items: center;
|
||||
/* `.alert-message`'s own `gap` above is the row gap between stacked children; the column gap
|
||||
* is the glyph's own spacing and is the smaller step, so it is stated rather than inherited
|
||||
* from that shorthand. */
|
||||
column-gap: var(--fs-space-2);
|
||||
}
|
||||
/* the glyph is a grid item now, so its inline margin would double the column gap; and it is
|
||||
* pinned to row 1 so that it stays 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 (the document had no heading above h4 at all); h4 is
|
||||
* here because a third-party alert may use it, and coverage only grows. h5/h6 joined for the
|
||||
* same reason — base styled them and the theme did not, so an alert heading looked like an
|
||||
* alert heading down to h4 and then fell back to base's. */
|
||||
.alert-message :is(h2, h3, h4, h5, h6) { margin: 0 0 var(--fs-space-1); font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
|
||||
/* the alert's own content reset — moved from base with the heading ramp (45-misc.css): its
|
||||
* `line-height: inherit` beat the ramp's tight leading in-layer, and left behind it would
|
||||
* lose to the ramp by layer (measured: alert headings dropped 21 -> 16.8px) */
|
||||
.alert-message h4,
|
||||
.alert-message h5,
|
||||
.alert-message pre,
|
||||
.alert-message ul,
|
||||
.alert-message li,
|
||||
.alert-message p {
|
||||
border: none;
|
||||
line-height: inherit;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
/* --fs-text, not --fs-dim: the paragraph IS the message. The line below already gives every
|
||||
* ul/ol/li/pre inside an alert full-strength ink, so a two-sentence alert had its first
|
||||
* sentence muted and its bullet list not — and on the tinted surface above, dim is the value
|
||||
* that measured 4.06:1 when the Reset button had it (55-buttons.css). */
|
||||
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
|
||||
/* …and the body elements an alert may carry. Absorbed from base/90-widgets.css, same values:
|
||||
* the theme had claimed only <p> out of that rule's six selectors. */
|
||||
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
|
||||
/* inner table (firmware Current/Available) — flatten so it does not read as a nested card
|
||||
* on the alert panel; subtle row dividers only */
|
||||
.alert-message .table { margin: var(--fs-space-2-5) 0; background: transparent; border: 0; box-shadow: none; border-radius: 0; }
|
||||
/* --fs-text, not --fs-eyebrow-color: everywhere else an eyebrow sits on --fs-panel, but here the
|
||||
* surface is a status tint, and the muted ink does not survive it — measured on the firmware
|
||||
* table over the warning fill, hicontrast/light: 3.35-3.36:1 against AA's 4.5. The eyebrow still
|
||||
* reads as one from its tracking and size; only the ink changes. */
|
||||
.alert-message .table .th,
|
||||
/* The title-row selectors are not decoration: `.table .tr.table-titles .th` in 30-tables.css is
|
||||
* FOUR classes (0,4,0) and out-ranks the plain one above (0,3,0), so without these the header
|
||||
* kept the eyebrow ink no matter what this rule said — being later in the sheet buys nothing
|
||||
* against a heavier selector, which is the point of the "never win on source order" rule. */
|
||||
.alert-message .table .tr.table-titles .th,
|
||||
.alert-message .table .tr.cbi-section-table-titles .th { color: var(--fs-text); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); }
|
||||
/* the column gap is --fs-space-3 like every other cell in the theme; --fs-space-3-5 here was the
|
||||
* only 14px cell gap in the sheet, and nothing above defends it — the comment covers flattening
|
||||
* the frame, not the rhythm. (The row density stays --fs-space-2: a table inside an alert is a
|
||||
* dense readout, same as the config tables that state it deliberately.) */
|
||||
.alert-message .table .th, .alert-message .table .td { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; background: transparent; border-color: color-mix(in srgb, var(--fs-border) 60%, transparent); }
|
||||
.alert-message .table .td { color: var(--fs-text); }
|
||||
|
||||
/* The validation tooltip (ui.js pops it on a .cbi-tooltip-container). Its colour words were the
|
||||
* one status surface the theme never claimed, and nothing could see it: the gallery rendered a
|
||||
* plain `.cbi-tooltip` only, and an un-rendered widget shows no diff, which reads as "already
|
||||
* dead". The gallery renders all four now.
|
||||
*
|
||||
* The fill stays SOLID, unlike `.label` below: a tooltip floats over arbitrary content, so a
|
||||
* border carrying the status would have to read against whatever happens to be behind it. The
|
||||
* ink is the role's own `--fs-on-*`, which 03-palettes.css sizes per palette and per mode — a
|
||||
* global white failed AA on seven of the eight dark-palette fills. */
|
||||
/* One elevation for the widget. base lit it with a raw 2px halo in the hairline colour, which
|
||||
* measures 1.08-1.86:1 against the surfaces a tooltip lands on, while the theme overrode the
|
||||
* single instance somebody happened to look at. A tooltip floats over ARBITRARY content — the
|
||||
* same reason the colour variants below stay opaque — so it is the last surface that should be
|
||||
* separated from the page by a hairline. */
|
||||
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
|
||||
/* base reveals it on :hover alone. The container is `cursor: help` and carries no tabindex in
|
||||
* anything LuCI ships, so nothing can focus it today and this matches nothing — but the
|
||||
* focusability belongs to ui.js, and a third-party app that gives its own tooltip container a
|
||||
* tabindex gets a keyboard-reachable tooltip the day it does. */
|
||||
.cbi-tooltip-container:focus-within .cbi-tooltip:not(:empty) { inset-inline-start: auto; opacity: 1; }
|
||||
.cbi-tooltip.info { background: var(--fs-accent); border-color: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.cbi-tooltip.success { background: var(--fs-good); border-color: var(--fs-good); color: var(--fs-on-good); }
|
||||
.cbi-tooltip.error { background: var(--fs-danger); border-color: var(--fs-danger); color: var(--fs-on-danger); }
|
||||
.cbi-tooltip.notice { background: var(--fs-panel2); border-color: var(--fs-border); color: var(--fs-text); }
|
||||
|
||||
/* The `.label` pill. LuCI core barely uses it, but any luci-app-* may reach for it, so the
|
||||
* theme owns it in tokens (base's copy is absorbed). `.important` is the conventional name
|
||||
* for what we call accent; `.danger` has no counterpart there and is added because
|
||||
* packages use it. See docs/gallery.html. */
|
||||
.label {
|
||||
display: inline-block; padding: var(--fs-space-1) var(--fs-space-2); border-radius: var(--fs-radius-pill);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight); line-height: 1.4;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
color: var(--fs-dim); text-shadow: none; text-transform: none;
|
||||
/* carried over from the absorbed styles/base .label rule */
|
||||
white-space: nowrap; margin: .125em 0 .125em .4em;
|
||||
}
|
||||
/* The variants set no background, deliberately: they inherit the opaque --fs-panel2 from `.label`
|
||||
* above. A translucent tint of their own colour under text of that colour drags the background
|
||||
* toward the text and eats the contrast it needs, and being translucent it renders differently
|
||||
* on every surface, so no single percentage is safe — measured across palette x mode, every
|
||||
* variant landed at 3.4-4.4:1. On --fs-panel2 the palette guarantees >=4.5 by construction, and
|
||||
* the coloured border carries the status just as loudly. */
|
||||
.label.important, .label.notice { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.label.warning { border-color: var(--fs-warn); color: var(--fs-warn); }
|
||||
.label.success { border-color: var(--fs-good); color: var(--fs-good); }
|
||||
.label.danger, .label.error { border-color: var(--fs-danger); color: var(--fs-danger); }
|
||||
a.label:link, a.label:visited { color: var(--fs-dim); }
|
||||
a.label:hover { text-decoration: none; color: var(--fs-text); }
|
||||
|
||||
/* The show/hide toggles in a section title are .label pills too, but clickable. SINGLE
|
||||
* owner of the pill look: pages/20-overview.css used to restate it (page layer, unscoped —
|
||||
* so it silently won everywhere) with drifted values; these are the ones that were actually
|
||||
* rendering. The trailing overrides (text-transform … box-shadow) kill the uppercase /
|
||||
* margin / min-width / badge decorations base used to put on these pills. */
|
||||
.cbi-title .label {
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight);
|
||||
padding: var(--fs-space-1) var(--fs-space-3); border-radius: var(--fs-radius-pill);
|
||||
background: var(--fs-panel2); border: 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; }
|
||||
/* These pills toggle section visibility — interactive, so keyboard focus must show. */
|
||||
.cbi-title .label:focus-visible {
|
||||
outline: none; border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* Surface stays OPAQUE (--fs-panel2 inherited from the base pill): accent text on
|
||||
* --fs-accent-soft is the accent-on-its-own-tint AA failure measured at 4.21:1 in the open
|
||||
* dropdown (theme/65-dropdown.css) — the border carries the accent instead. */
|
||||
.cbi-title .label.notice { opacity: 1; color: var(--fs-accent); border-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
@layer theme {
|
||||
/* ---- tab strips: one component, four entry points ----
|
||||
* LuCI emits a strip under four selectors with no markup difference worth honouring:
|
||||
* #tabmenu ul.tabs page tabs, rendered into the server shell
|
||||
* ul.cbi-tabmenu.mode mode switcher (Available / Installed / Updates)
|
||||
* .tabs view tabs (Routing -> IPv4 / IPv6)
|
||||
* .cbi-tabmenu CBI form tabs (System -> General / Logging / …)
|
||||
* They agree on everything except the active marker: `.active` for `ul.tabs`, `.cbi-tab` for
|
||||
* every `.cbi-tabmenu` flavour. All four render as pills in a card. */
|
||||
#tabmenu { margin-bottom: 0; }
|
||||
|
||||
/* the card */
|
||||
#tabmenu ul.tabs,
|
||||
ul.cbi-tabmenu.mode,
|
||||
.tabs,
|
||||
.cbi-tabmenu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-1);
|
||||
list-style: none;
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
padding: var(--fs-space-2);
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
/* kills a folder-tab baseline gradient (the 1px x 29px background image the strip
|
||||
* conventionally carries) by zeroing its size rather than restating it — base's copy is
|
||||
* absorbed, but any gradient a view or third-party app sets is sized away too */
|
||||
background-size: 0 0;
|
||||
}
|
||||
|
||||
/* a strip nested inside a .cbi-section card -> soft inset row, no frame (a card inside a
|
||||
* card reads as a bug) */
|
||||
.cbi-section .tabs,
|
||||
.cbi-section .cbi-tabmenu:not(.mode):not(.map) {
|
||||
background: var(--fs-panel2);
|
||||
padding: var(--fs-space-1-5);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#tabmenu ul.tabs > li,
|
||||
ul.cbi-tabmenu.mode > li,
|
||||
.tabs > li,
|
||||
.cbi-tabmenu > li {
|
||||
height: auto;
|
||||
margin: 0; padding: 0;
|
||||
border: 0; background: none;
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* the theme's own page tabs and the mode switcher never carry a sentence-length label, so
|
||||
* `none` is right for them — `ul.cbi-tabmenu.mode > li` below wins specificity over the plain
|
||||
* `.cbi-tabmenu > li` rule that follows, so the mode pills stay `none` regardless of order. */
|
||||
#tabmenu ul.tabs > li,
|
||||
ul.cbi-tabmenu.mode > li,
|
||||
.tabs > li {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* the PLAIN CBI form tabs (ui.js, not the mode switcher): adblock's Overview page names one
|
||||
* "Настройки межсетевого экрана", measured 293px in a 264px column at 320px/Large — 28.6px past
|
||||
* it, section clipped by 30px; EN measured 0. The `a` already carries `white-space: nowrap;
|
||||
* overflow: hidden; text-overflow: ellipsis` (base/50-chrome.css) — it never engaged because the
|
||||
* li's `max-width: none` left its automatic minimum size at the label's full content width, so
|
||||
* flexbox never shrank it. `100%` is smaller than that content width once the column is, which
|
||||
* is exactly the case that broke; everywhere the tab already fits, this is a no-op. */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ---- keyboard focus: a ring that survives the ACTIVE pill ----
|
||||
*
|
||||
* base/50-chrome.css drew it as `outline: 2px solid var(--fs-accent)` inset by -2px, which is
|
||||
* invisible on exactly the tab most likely to be focused, the active pill's own background being
|
||||
* that same accent (measured on the router: both rgb(86,157,245)).
|
||||
*
|
||||
* One rule for both states, on --fs-focus-ring-solo: a tab flips nothing else on focus, so the
|
||||
* ring IS the indicator, and the tinted --fs-focus-ring measures 1.15-1.29:1 against the strip —
|
||||
* the first version of this fix took that tint for the inactive pill and swapped one invisible
|
||||
* indicator for another.
|
||||
* -solo's 2px surface gap is also what lets one value serve the active pill. box-shadow, not
|
||||
* outline, because the strip clips and a shadow is not clipped by the scroll container. */
|
||||
#tabmenu ul.tabs > li > a:focus-visible,
|
||||
ul.cbi-tabmenu.mode > li > a:focus-visible,
|
||||
.tabs > li > a:focus-visible,
|
||||
.cbi-tabmenu > li > a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* Density steps for a crowded strip: fs-chrome.js (fitTabStrips) MEASURES whether the tabs
|
||||
* wrap to a second row and escalates .fs-dense1 -> .fs-dense2, mutually exclusive (dense1 is
|
||||
* removed the moment dense2 is added, so a pill is never trimmed by both at once). Both steps
|
||||
* give the pill the SAME padding (--fs-space-1 --fs-space-2) and, where the rule sets it at
|
||||
* all, the SAME font-size (--fs-type) — dense1 does not touch font-size, dense2 restates it;
|
||||
* neither actually shrinks it further, since --fs-type is already the strip's own base size.
|
||||
* Only the strip's own `gap` is touched by a dense rule, and only at d2; past that floor the
|
||||
* strip may wrap. The id in the first selector matches the #tabmenu rule's specificity. */
|
||||
#tabmenu ul.tabs.fs-dense1 > li > a,
|
||||
.tabs.fs-dense1 > li > a,
|
||||
.cbi-tabmenu.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
|
||||
|
||||
#tabmenu ul.tabs.fs-dense2,
|
||||
.tabs.fs-dense2,
|
||||
.cbi-tabmenu.fs-dense2 { gap: var(--fs-space-1); }
|
||||
#tabmenu ul.tabs.fs-dense2 > li > a,
|
||||
.tabs.fs-dense2 > li > a,
|
||||
.cbi-tabmenu.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-2); font-size: var(--fs-type); }
|
||||
|
||||
/* map-level tabs keep the body font size instead of base's 16.5px/bold */
|
||||
.cbi-tabmenu.map > li { font-size: var(--fs-type); font-weight: var(--fs-weight); }
|
||||
/* the little "n errors" badge on a tab */
|
||||
.cbi-tab-disabled[data-errors]::after { background: var(--fs-danger); }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
@layer theme {
|
||||
/* The realtime graphs size their drawing from #view, not from the box they draw into:
|
||||
* `width = document.querySelector('#view').offsetWidth - 2`, in every realtime view and in the
|
||||
* luci-app-*-status copies of them. That assumption holds only in a theme whose .cbi-section has
|
||||
* no gutter; ours is a card, 16px of padding plus a 1px border, so the canvas comes out 34px
|
||||
* narrower than the drawing — and the views draw NEWEST-first from the right, so the freshest
|
||||
* samples are the ones falling off the edge. It reads as a phone bug only because the loss is
|
||||
* absolute: 34px of a 1124px desktop plot is 3%, of a 322px phone plot 10%.
|
||||
*
|
||||
* So bleed the graph box back out to the card's border edge, making the canvas exactly
|
||||
* `#view - 2` again. The bleed is padding plus border, derived from --fs-card-pad rather than
|
||||
* restated, or narrowing the gutter would silently re-clip every graph.
|
||||
*
|
||||
* The `!important` fights the inline `width: 100%` the view writes on the box: nothing in a
|
||||
* cascade layer can outrank an inline declaration. */
|
||||
#view .cbi-section > div[style]:has(> svg) {
|
||||
width: auto !important;
|
||||
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
|
||||
}
|
||||
|
||||
/* …and the same box's frame, which the same inline style paints BLACK: every realtime view writes
|
||||
* `border:1px solid #000;background:#fff` on it. Black is a colour no palette of this theme
|
||||
* contains, so on a dark surface the graph reads as a framed cut-out, and the frame lands
|
||||
* exactly on the card's own border edge because of the bleed above (issue #32).
|
||||
*
|
||||
* Wider than the bleed rule on purpose: channel_analysis.js hangs off a bare [data-tab] div and
|
||||
* must not be bled, but its border is just as black, so this asks only "an inline-styled box
|
||||
* wrapping an <svg>".
|
||||
*
|
||||
* Only the COLOUR moves. The 1px stays, being the graph's own edge, and `background: #fff` is
|
||||
* left alone: the <svg> is sized to the box's content edge, so the white never paints. Only an
|
||||
* author `!important` outranks an inline declaration, and `border-color` alone is enough — the
|
||||
* width and style already agree with what the theme would ask for. */
|
||||
#view div[style]:has(> svg) {
|
||||
border-color: var(--fs-border) !important;
|
||||
}
|
||||
|
||||
/* WCAG 2.2 SC 1.4.1 (Use of Color, A): inside a block of text a link may not be identified
|
||||
* by COLOUR ALONE — it needs a non-colour cue or 3:1 against the surrounding text, and
|
||||
* --fs-accent on --fs-text measures 1.4:1 in dark mode. So: underline links in prose.
|
||||
* Scoped to prose deliberately — nav links, buttons, pills and table cells are identified
|
||||
* by position and shape, and underlining them would be noise. */
|
||||
p a, dd a,
|
||||
.cbi-value-description a,
|
||||
.alert-message a:not(.btn):not(.cbi-button) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* A <strong>/<b> is a LABEL, and a label is UI text, so it takes the UI face even on a monospaced
|
||||
* surface. LuCI builds every status readout as `<strong>MAC:</strong> ac:1f:6b:…`: the strong
|
||||
* names the datum, the text after it IS the datum. The theme puts mono on those surfaces, the
|
||||
* strong inherits it, and <strong> also means weight 700 — so the label asks for a bold mono
|
||||
* face that exists for no other reason: 227 elements across seven pages, and 20 KB of a
|
||||
* 600-weight mono face fetched to draw the word "MAC:".
|
||||
*
|
||||
* It must SET the face, not merely be excluded from the mono rule: an excluded element still
|
||||
* INHERITS its parent's. Excluding it was the first attempt and changed nothing.
|
||||
*
|
||||
* pre/code/#syslog/textarea are exempt: there a <strong> is part of the monospaced CONTENT. */
|
||||
:is(strong, b):not(pre *, code *, #syslog *, textarea *) {
|
||||
font-family: var(--fs-font-sans);
|
||||
}
|
||||
|
||||
/* Same argument, two holdouts. `.ifacebadge` is a BADGE — chrome, not a value — sitting in a mono
|
||||
* cell, and `.ifacebadge-active` makes it bold; sans keeps the active/inactive weight cue (WCAG
|
||||
* 1.4.1, that distinction not being carried by colour) with faces the page already has.
|
||||
* `<code>`/`<pre>` hold a LITERAL, which does not inherit its container's emphasis.
|
||||
*
|
||||
* The second selector is not redundant: the mono rule in theme/60-inputs.css is (0,1,0), a TIE
|
||||
* with a bare `.ifacebadge`, and a tie is settled by source order, which 60-inputs would win.
|
||||
* Win on specificity instead, at (0,2,0). */
|
||||
.ifacebadge,
|
||||
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
|
||||
code, pre { font-weight: var(--fs-weight-normal); }
|
||||
|
||||
/* ---- the poll's `fade-in` is not animated at all, and that is the whole rule ----
|
||||
*
|
||||
* `fade-in` is upstream's class and luci-mod-status re-marks with it on EVERY poll tick — each
|
||||
* section, and the wrapper holding all of them — so anything this theme hangs on that class
|
||||
* replays on every tick across the entire content column.
|
||||
*
|
||||
* A fade plus a 4px slide moved the page under the reader; the fade alone still pulsed the whole
|
||||
* column on every tick, reported from an iPhone as shaking that happens WITHOUT SCROLLING AT
|
||||
* ALL, which is what named it — a poll tick is the only thing that happens on a still page.
|
||||
*
|
||||
* So the theme animates nothing on it. The class stays in the markup, being upstream's, and the
|
||||
* `fade-in` keyframes in base/95-luci.css stay for the coverage reason stated there. */
|
||||
.cbi-title-ref { color: var(--fs-accent); }
|
||||
.close { color: var(--fs-text); text-shadow: none; opacity: .6; }
|
||||
.close:hover { opacity: 1; }
|
||||
.ifacebox { background: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.ifacebox .ifacebox-head { background: var(--fs-panel2); color: var(--fs-text); }
|
||||
/* The active-interface highlight lives in THIS layer, next to the head it overrides: the
|
||||
* same rule in base was dead — a layer beats specificity, and the plain `.ifacebox-head`
|
||||
* above repainted `.active` along with the rest. */
|
||||
.ifacebox .ifacebox-head.active { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.ifacebadge { background: var(--fs-panel2); border-color: var(--fs-border); }
|
||||
.ifacebadge-active { border-color: var(--fs-accent); }
|
||||
.zonebadge-empty { border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* The @container PLUMBING for the responsive tables — only WHICH boxes are query containers, under
|
||||
* what names. The card rules are not here: a CBI section table cards under `@container
|
||||
* fs-content (max-width: 992px)` (theme/65-dropdown.css — 992, not the row's own 960px floor,
|
||||
* because the query reads THIS element, one level above the `.cbi-section` that spends
|
||||
* `--fs-card-pad` before the table ever sees the width), and a data table cards by MEASUREMENT
|
||||
* (.fs-stacked, fs-select.js) at any width.
|
||||
*
|
||||
* Query the CONTENT width, not the viewport: the sidebar eats width, so a viewport breakpoint
|
||||
* stacks too late or too early depending on layout.
|
||||
*
|
||||
* Named, deliberately — an unnamed @container binds to the nearest ancestor that happens to have
|
||||
* a container-type, so giving container-type to any new component would silently re-target every
|
||||
* unnamed query underneath it:
|
||||
* fs-view the overview's grid (pages/20-overview.css, @container 800)
|
||||
* fs-content "whatever content column I am in", carried by .cbi-map, #view and .modal (below),
|
||||
* so a query resolves to the nearest of the three. */
|
||||
.cbi-map { container-type: inline-size; container-name: fs-content; }
|
||||
#view { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
|
||||
/* A modal is not inside #view — ui.js appends #modal_overlay to <body> — so every @container query
|
||||
* above resolves to nothing in there and no table stacks: on a phone the Edit-interface dialog
|
||||
* rendered its form at desktop widths and cut the inputs off. Carry both names so a dialog
|
||||
* stacks exactly like the page, a third-party app's modal included. */
|
||||
.modal { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
|
||||
/* pager (« prev · text · next ») — generic, not page-scoped: any .pager gets an inline
|
||||
* centred row with compact square buttons. */
|
||||
.pager.center { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2-5); padding: var(--fs-space-3-5) 0; }
|
||||
.pager .text { flex: 0 0 auto; font-size: var(--fs-type); color: var(--fs-dim); }
|
||||
|
||||
/* The heading ramp, absorbed from base wholesale (docs/css.md). Stock LuCI hands h2 to a page title
|
||||
* and h3/legend to a section title, and the heading-card rule below plus theme/30-tables.css
|
||||
* repaint exactly those two, so most of the ramp is only ever rendered by a third-party
|
||||
* luci-app-* — styled anyway, coverage being a contract. Every step is a scale token and the
|
||||
* ramp is monotonic. `legend` is deliberately not in the group: theme/30-tables.css owns it, and
|
||||
* a member here would fight that at equal specificity, i.e. on source order. */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--fs-weight-bold);
|
||||
color: var(--fs-text);
|
||||
/* 1.5 is prose leading; a heading is a label and looks slack at it. */
|
||||
line-height: var(--fs-leading-tight);
|
||||
}
|
||||
h1 { margin-bottom: var(--fs-space-4); font-size: var(--fs-type-2xl); }
|
||||
h2 { font-size: var(--fs-type-xl); }
|
||||
h3 { font-size: var(--fs-type-lg); }
|
||||
h4 { font-size: var(--fs-type); }
|
||||
h5 { font-size: var(--fs-type); }
|
||||
h6 { font-size: var(--fs-type-xs); color: var(--fs-dim); text-transform: uppercase; }
|
||||
|
||||
/* Heading card (h2 + .cbi-map-descr): everything sitting directly on the page background
|
||||
* gets a .cbi-section card's frame, so a page reads as a stack of panels instead of loose
|
||||
* text floating over the (possibly patterned) background. */
|
||||
.cbi-map > h2,
|
||||
#view > h2 {
|
||||
background: var(--fs-panel);
|
||||
border: 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);
|
||||
}
|
||||
/* a heading followed by a description: split the card across the two siblings (no wrapper
|
||||
* element exists to hold both) */
|
||||
.cbi-map > h2:has(+ .cbi-map-descr),
|
||||
#view > h2:has(+ .cbi-map-descr) {
|
||||
border-bottom: 0;
|
||||
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
|
||||
padding-bottom: var(--fs-space-1);
|
||||
margin-bottom: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cbi-map > h2 + .cbi-map-descr,
|
||||
#view > h2 + .cbi-map-descr {
|
||||
background: var(--fs-panel);
|
||||
border: 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);
|
||||
}
|
||||
|
||||
/* An action-button row (a `.right` wrapper holding Add/Save) sitting directly under a field
|
||||
* had no gap above it — the apk "Repo Public Keys -> Add key" paste box, and every other
|
||||
* paste-a-value-then-submit layout, pinned its button to the textarea. Scoped to a `.right`
|
||||
* immediately following a form control, so it never touches right-aligned text or cells. */
|
||||
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
|
||||
margin-top: var(--fs-space-2-5);
|
||||
}
|
||||
|
||||
/* A button row belonging to the PAGE TITLE rather than to any section. Nothing gives it a bottom
|
||||
* margin, so it sits flush against the first .cbi-section below and reads as part of that card
|
||||
* instead of as a page-level action; the same 16px every .cbi-section uses.
|
||||
*
|
||||
* And a top margin for the same reason at the other edge, the row sitting directly under the
|
||||
* page heading with nothing between them. Half the bottom gap, because a heading and its own
|
||||
* action belong together. */
|
||||
.cbi-title-buttons { margin-block: var(--fs-space-2) var(--fs-card-gap); }
|
||||
|
||||
/* The per-section Delete button (`.cbi-section-remove`, what luci-base renders for every NAMED
|
||||
* section: SQM's queues, the firewall's zones, any app that lets you add and drop instances) sat
|
||||
* flush against whatever follows it — usually that section's own tab bar. Reported upstream on
|
||||
* SQM, but nothing here is about SQM: it is one class luci-base gives every such section, and
|
||||
* the gap belongs to the pattern rather than to the page. */
|
||||
.cbi-section-remove { margin-bottom: var(--fs-space-2); }
|
||||
|
||||
/* A top-level block in a map that is not a section still keeps the page's rhythm.
|
||||
*
|
||||
* A view may return a bare widget where a section is expected — `s.render = () => new
|
||||
* ui.Textarea(...).render()` hands the map a plain `<div>` with no `.cbi-section` — so nothing
|
||||
* gives that block the gap every section carries and the next section starts against it. Stock
|
||||
* bootstrap measures the same 0px, but there a section is a stretch of page and here it is a
|
||||
* CARD, so the missing gap reads as two cards fused.
|
||||
*
|
||||
* Written against what a map CONTAINS rather than against one app: the exclusions are the
|
||||
* children luci-base itself puts there and which own their spacing already — the map heading and
|
||||
* its description, the action bar, a tabbed map's tab bar and pane, and sections.
|
||||
*
|
||||
* `:not(:empty)` because a map can hold a placeholder that renders nothing. */
|
||||
.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,90 @@
|
||||
@layer theme {
|
||||
/* ---- the top-layout bar: :root[data-layout="top"], every width ----
|
||||
*
|
||||
* A DELTA, not a layout. The bar itself — sticky, blurred, row, popup submenus, square icon
|
||||
* buttons, right cluster — is the unguarded base in 20-shell.css, the bar being what the chrome
|
||||
* IS unless the vertical sidebar overrides it. This file adds what the top-layout bar wants on
|
||||
* top of that:
|
||||
* - a designed bar height, and padding aligned to the content column;
|
||||
* - the menu on the brand's row instead of wrapped beneath it.
|
||||
*
|
||||
* No `@media` floor: "does the menu fit on the brand's row?" is a property of the CONTENT (5
|
||||
* sections stock, eleven with a few luci-app-*), not of the viewport, so the whole bar is
|
||||
* MEASURED — fs-chrome.js shrinks the pills, collapses the poll pill to an icon, then stacks the
|
||||
* menu onto its own row, at any width. The under-item dropdown and its clamp therefore apply at
|
||||
* every width too. */
|
||||
:root[data-layout="top"] .fs-sidebar {
|
||||
/* min-height, not height: the bar has a designed height but must be able to GROW —
|
||||
* a long hostname wraps onto a second line inside the capped brand (20-shell.css),
|
||||
* and a fixed height would clip it. */
|
||||
min-height: var(--fs-bar-h); height: auto;
|
||||
gap: var(--fs-space-3);
|
||||
/* nowrap while the menu rides the brand's row. Without it flexbox wraps the BAR, carrying the
|
||||
* menu onto a second line by itself while its own pills stay in one row — a stacked bar in
|
||||
* everything but the styling. It also destroys the fits-on-one-row measurement, a menu handed
|
||||
* a whole row of course fitting. Forbidding the wrap forces the menu to share the row and to
|
||||
* wrap its OWN pills when it cannot, which is the signal fitChrome measures. */
|
||||
flex-wrap: nowrap;
|
||||
/* align the bar's contents to the .fs-content column, so the brand and the menu line
|
||||
* up with the headings and the cards below */
|
||||
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
|
||||
}
|
||||
|
||||
/* the menu rides the brand's row; its flex:1 opens the gap that pushes the right-hand
|
||||
* cluster to the content column's right edge */
|
||||
:root[data-layout="top"] .fs-sidebar ul.nav {
|
||||
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
|
||||
}
|
||||
:root[data-layout="top"] .fs-sidebar #indicators,
|
||||
:root[data-layout="top"] .fs-themerow,
|
||||
:root[data-layout="top"] .fs-logout { order: 3; margin-inline-start: 0; }
|
||||
|
||||
/* The under-item dropdown anchor, its hover bridge and the clamp are NOT here: they belong to
|
||||
* the BAR and live unguarded in 20-shell.css. They used to be this file's, and that WAS issue
|
||||
* #19 — the sidebar layout collapsed onto a bar is the same chrome, and it inherited a panel
|
||||
* pinned to the bar's left edge with no bridge, unreachable by hover. This file is the top
|
||||
* layout's delta; a rule every bar state needs is not a delta. */
|
||||
|
||||
/* the mode strip (only rendered when a router has more than one top-level mode) takes
|
||||
* its own full-width row under the bar */
|
||||
/* :not(:empty):not(.single) instead of a hide pair — the bar's base rule (20-shell.css)
|
||||
* already hides the strip; see the sidebar twin there for why the pair was folded. */
|
||||
:root[data-layout="top"] .fs-modemenu:not(:empty):not(.single) {
|
||||
display: flex; order: 4; flex: 1 1 100%; margin: 0; gap: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* the bar replaces the sidebar's 22px top padding with the top-nav's 26px */
|
||||
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
|
||||
|
||||
/* ---- the menu does not fit on the brand's row: .fs-bar-stack ----
|
||||
*
|
||||
* Set by fs-chrome.js (fitChrome), not by a media query: whether the menu fits beside the brand
|
||||
* depends on how many sections the router HAS, so the width at which the bar must stack is a
|
||||
* property of the content and a hard-coded breakpoint gets it wrong in both directions. The JS
|
||||
* shrinks the pills first, then collapses the poll pill, and stacks only when even that still
|
||||
* overflows. */
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack {
|
||||
flex-wrap: wrap; height: auto; padding: var(--fs-space-2-5) var(--fs-space-4);
|
||||
}
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack ul.nav {
|
||||
order: 3; flex: 1 1 100%; margin-inline-start: 0;
|
||||
}
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow,
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-logout { order: 2; }
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow { margin-inline-start: auto; }
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
|
||||
/* Two .fs-themerow buttons, Search then Appearance: only the FIRST rendered far-edge item may
|
||||
* keep the auto inline-start margin, exactly as in the base bar, or flexbox splits the free
|
||||
* space between the two autos and Search drifts to mid-bar. The base `.fs-themerow ~
|
||||
* .fs-themerow` guard is out-specified by the auto rule above (0,4,0 against 0,2,0), so it must
|
||||
* be restated here. Bites when #indicators is :empty — right after an SPA nav, before
|
||||
* the poll pill returns. */
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
|
||||
|
||||
/* The right cluster's own escalation — .fs-ind-compact and .fs-bar-actrow — is NOT here: it
|
||||
* belongs to the BAR, and the sidebar layout is a bar too once fitShell has stamped
|
||||
* data-narrow. Both live with the bar in 20-shell.css; this file is only the top layout's
|
||||
* delta, and the menu's .fs-bar-stack above is the part that really is top-only. */
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
@layer theme {
|
||||
/* Buttons placed straight in a field, not in a .control-group (app action rows
|
||||
* Enable/Disable, Start/Restart/Stop): inline-block buttons only get whitespace spacing
|
||||
* horizontally, so on wrap the rows collapsed together. Wrapping flex with a real gap. */
|
||||
.cbi-value-field:has(> .cbi-button) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* The button. The native `input` types are in the selector list on purpose: LuCI's own views
|
||||
* always use <button class="cbi-button">, but a third-party luci-app-* may emit a bare
|
||||
* <input type="submit">, and base only gives those width/height: auto — coverage is a
|
||||
* contract (docs/gallery.html). `.btn` and `.cbi-button` are interchangeable in LuCI markup
|
||||
* (a row-action button usually carries both), so every rule here names both. */
|
||||
.cbi-button, .btn,
|
||||
input[type="button"], input[type="submit"], input[type="reset"] {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
/* auto, stated: theme/60-inputs.css's generic `input` box says 210px x 32px, and a base
|
||||
* reset would lose to it by layer — a button sizes to its label */
|
||||
width: auto; height: auto;
|
||||
min-height: var(--fs-ctl-h);
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
font-family: var(--fs-font-sans); font-weight: var(--fs-weight); line-height: 1.1;
|
||||
color: var(--fs-text);
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
box-shadow: none; text-shadow: none;
|
||||
/* border-COLOR, not `border`: the shorthand animates border-width too — a layout
|
||||
* animation on every hover instead of a paint one (absorbed from base with the ring) */
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* bare `button` rides along: a third-party app's unclassed <button> hovered the same border
|
||||
* cue in base's old glow rule, and border-color is a no-op on its border: 0 box — harmless
|
||||
* there, meaningful the moment the app gives it a border */
|
||||
.cbi-button:hover, .btn:hover, button:hover,
|
||||
input[type="button"]:hover, input[type="submit"]:hover, input[type="reset"]:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* Keyboard focus, not decoration: without this a button shows nothing on focus. The
|
||||
* `box-shadow: none` above kills any stray focus glow (theme beats base regardless of
|
||||
* specificity), and the UA outline only ever shows on :focus-visible, which this rule restyles.
|
||||
* A plain mouse :focus deliberately shows nothing — a glow rings every clicked button, and the
|
||||
* pressed cue below already answers the click. Same --fs-focus-ring as every field. */
|
||||
.cbi-button:focus-visible, .btn:focus-visible,
|
||||
input[type="button"]:focus-visible, input[type="submit"]:focus-visible, input[type="reset"]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* Solid variants sit ON --fs-accent/--fs-good/--fs-danger, so an accent-tinted ring is invisible
|
||||
* against them: ring in the surface colour with an accent halo. Written out as a literal ending
|
||||
* in --fs-accent-soft, that halo was a 10-15% tint at 1.15-1.29:1 and the only thing it painted
|
||||
* was the 2px gap; --fs-focus-ring-solo is the same geometry with a solid accent, under one
|
||||
* name. */
|
||||
.cbi-button-positive:focus-visible, .cbi-button-action:focus-visible,
|
||||
.cbi-button-negative:focus-visible, .cbi-button-remove:focus-visible,
|
||||
.cbi-button-apply:focus-visible, .cbi-button-save:focus-visible,
|
||||
.btn.primary:focus-visible, .btn.danger:focus-visible,
|
||||
.btn.error:focus-visible, .btn.success:focus-visible, .btn.info:focus-visible {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* Pressed state. The `box-shadow: none` above would erase any press cue, so the inset shadow
|
||||
* is declared here — the sole copy (base's identical one was absorbed). */
|
||||
.cbi-button:active, .btn:active, .btn.active,
|
||||
input[type="button"]:active, input[type="submit"]:active, input[type="reset"]:active {
|
||||
box-shadow: inset 0 2px 4px color-mix(in srgb, var(--fs-border) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Solid variants — three fills, not six. `.cbi-button-*` is what LuCI's own views emit and
|
||||
* `.btn.<colour-word>` what a third-party luci-app-* tends to emit, and base paints those words
|
||||
* with gradients that are dead: the plain `.btn` rule above sets background, border and colour
|
||||
* in a later layer, so an unclaimed `.btn.danger` renders as a neutral grey button with no
|
||||
* colour cue. Coverage is a contract, so the theme claims them — in the SAME rule as their
|
||||
* .cbi-button-* twin, because written apart a recolour lands on one half and not the other. */
|
||||
.cbi-button-positive, .btn.success {
|
||||
background: var(--fs-good); border-color: transparent; color: var(--fs-on-good);
|
||||
}
|
||||
.cbi-button-action, .cbi-button-action.important, .btn.primary, .btn.info {
|
||||
background: var(--fs-accent); border-color: transparent; color: var(--fs-on-accent);
|
||||
}
|
||||
.cbi-button-negative, .cbi-button-negative.important, .cbi-button-remove,
|
||||
.btn.danger, .btn.error {
|
||||
background: var(--fs-danger); border-color: transparent; color: var(--fs-on-danger);
|
||||
}
|
||||
/* This hover must stay AHEAD of the page-action Save rule below: at equal specificity its
|
||||
* `border-color: transparent` would erase Save's outline on hover. (.cbi-button-remove is
|
||||
* deliberately absent — it never had the lift, and the row-action rules colour it their
|
||||
* own way.) */
|
||||
.cbi-button-positive:hover, .cbi-button-action:hover, .cbi-button-apply:hover,
|
||||
.cbi-button-save:hover, .cbi-button-negative:hover,
|
||||
.btn.success:hover, .btn.info:hover, .btn.danger:hover, .btn.error:hover {
|
||||
filter: var(--fs-hover-lift); border-color: transparent;
|
||||
}
|
||||
.cbi-button-reset, .cbi-button-link { background: transparent; border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* Size words. The `.btn` rule above re-sets padding and radius from the token scale in a
|
||||
* later layer, so a size word that carried only a font-size (as base's absorbed copy did)
|
||||
* came out with default-button padding. Theme owns .btn.large/.small in full now, geometry
|
||||
* on the radius scale rather than base's hardcoded 6px. */
|
||||
.btn.large { font-size: var(--fs-type-lg); min-height: var(--fs-ctl-h-lg); padding: var(--fs-space-2) var(--fs-space-4); border-radius: var(--fs-radius); }
|
||||
.btn.small { font-size: var(--fs-type-xs); min-height: var(--fs-ctl-h-sm); padding: var(--fs-space-1) var(--fs-space-2-5); border-radius: var(--fs-radius-sm); }
|
||||
|
||||
.cbi-section .table[id] .td .btn,
|
||||
.cbi-section .table[id] .td .cbi-button { padding: var(--fs-space-1) var(--fs-space-2-5); font-size: var(--fs-type); }
|
||||
.alert-message .btn, .alert-message .cbi-button {
|
||||
border-radius: var(--fs-radius); padding: var(--fs-space-2) var(--fs-space-3); font-weight: var(--fs-weight);
|
||||
background: var(--fs-panel2); border: var(--fs-hairline); color: var(--fs-text);
|
||||
}
|
||||
.alert-message .btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Page action bar (.cbi-page-actions) — the Save/Apply footer. base renders a tall
|
||||
* half-empty plate with bottom-only radius; make it a compact rounded action bar. */
|
||||
.cbi-page-actions {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: var(--fs-space-2) 0;
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
text-align: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--fs-space-2);
|
||||
/* The design call: WRAP the bar rather than let its items shrink into it. Save/Reset are
|
||||
* plain text with no ellipsis, so their min-content protects them — a no-wrap row's shrink
|
||||
* factor lands entirely on the Save & Apply split control, whose caption DOES have an
|
||||
* ellipsis to give (theme/65-dropdown.css) and so absorbs the whole squeeze. Measured on the
|
||||
* gallery's own fixture (EN "Save & Apply"): a no-wrap row cut it by up to 87px at 320px/
|
||||
* Normal and 119px at 320px/Large, worse than the numbers this bug was filed against
|
||||
* (43-68px on a live page, where the sidebar leaves less room than the gallery's isolated
|
||||
* card) — same fault, same direction, this fixture just has less room to give.
|
||||
* `flex-wrap: wrap` sends the split control (or Save+Reset) to its own line once they no
|
||||
* longer fit one, so the caption is squeezed only by its OWN box, never by a sibling's:
|
||||
* the same fixture measures 0px cut at every width/density this bug was filed against, RU
|
||||
* or EN, after this one line. The caption still owns an ellipsis for the genuine edge it
|
||||
* cannot solve — a full RU sentence at Large density in a 320px column has nowhere left to
|
||||
* grow even alone on its line (measured: 33px still short there) — and that is the right
|
||||
* place for it to give, not a sibling button's unrelated width. */
|
||||
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 the same step of the ladder as Save (-soft at rest, -fill on hover), so the two read
|
||||
* as equally weighted secondary actions beside the solid Save & Apply — but off the DANGER role,
|
||||
* not the accent one: Reset discards your unsaved changes. (An opaque --fs-panel2 was the first
|
||||
* attempt and weighed MORE than Save's translucent 10-15% tint: a solid surface and a tint do
|
||||
* not read as the same fill, whatever their lightness.) The label is --fs-text for the same
|
||||
* reason as Save's — --fs-dim on
|
||||
* --fs-danger-soft measures 4.49:1 in the worst palette x mode block, under AA by a hundredth.
|
||||
* The role lives in the fill and the border. */
|
||||
.cbi-page-actions .cbi-button-reset {
|
||||
background: var(--fs-danger-soft);
|
||||
border: 1px solid var(--fs-danger-line);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
.cbi-page-actions .cbi-button-reset:hover {
|
||||
background: var(--fs-danger-fill);
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* There is no `.spinning` padding ladder here, and it must not come back: seven selectors
|
||||
* reserving room for an absolutely-positioned glyph, each climbing a class above whatever else
|
||||
* padded that element. The ladder lost twice in the field (issues #15 and #22), and every loss
|
||||
* looks the same — the spinner draws on top of the label, silently; #22 lost to a 0,5,0
|
||||
* data-table rule. base/95-luci.css puts the
|
||||
* glyph IN THE FLOW instead, so it reserves its own space. */
|
||||
|
||||
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) in a table or a .cbi-section-actions container.
|
||||
* Compact so tables stay dense: neutral outline by default, with the variants colouring the ones
|
||||
* that carry meaning. The neutral :hover comes FIRST on purpose — last, it greys the coloured
|
||||
* variants back out on hover, which only an !important on their colours hides. Equal
|
||||
* specificity, so a variant hover must be written after it.
|
||||
*
|
||||
* WCAG 2.2 SC 2.5.8: a pointer target must be >= 24x24 px, or spaced so 24px circles centred on
|
||||
* adjacent targets do not overlap. At 28px tall with a 1px margin either side the circles DO
|
||||
* overlap, so the exemption does not apply; 32px clears it. */
|
||||
.cbi-section-actions .cbi-button, .cbi-section-actions .btn,
|
||||
.cbi-section-table .cbi-button {
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); font-size: var(--fs-type);
|
||||
/* `.td .cbi-button, .td .btn` dropped (task 0151): the action cells this was filed against
|
||||
* (form.TableSection's `.td.cbi-section-table-cell.cbi-section-actions`, per 30-tables.css's
|
||||
* own discriminator) are already reached by `.cbi-section-actions .cbi-button`/`.btn` below —
|
||||
* `.cbi-section-actions` sits on the .td itself, so the descendant selector matches with no
|
||||
* `.td` needed. What the bare `.td` reached and those two do not is every OTHER button in
|
||||
* every OTHER cell: measured collateral, 14 buttons including the icon-only rail drag handle
|
||||
* and a plain-cell "Reserve IP" (Wireless's hand-built `.td.middle`, DHCP leases — neither
|
||||
* carries `.cbi-section-actions`), `overflow: hidden` re-baselining an inline-block and
|
||||
* growing the DHCP leases rows 50px -> 50.84px (10 rows at 1280) for nothing that clipped.
|
||||
* base/95-luci.css sizes each button `flex: 1 1 4em` inside `.td.cbi-section-actions`. A
|
||||
* DEFINITE flex-basis is what let the fix below regress the desktop: with `overflow: hidden`
|
||||
* alone, the automatic minimum for a definite basis resolves to 0 in EVERY row regardless of
|
||||
* room, so `flex-grow` divides by count instead of by need even where nothing needed to
|
||||
* shrink — measured live on Firewall Zones at 1440px, three buttons that read 37.66/97.73/
|
||||
* 89.69px in v0.14.11 converged to 75.02/75.03/75.03px, 11-21px of caption ellipsised for
|
||||
* zero gain (same row total, 225.08px, either way). `flex-basis: auto` is the one-property
|
||||
* fix: it hands sizing back to CONTENT, so the automatic minimum is the caption's own
|
||||
* min-content again and `flex-grow` only ever redistributes genuine slack — reproduced
|
||||
* locally (docs/gallery.html's Startup fixture, unconstrained): auto restores the exact
|
||||
* v0.14.11 widths (101.58/137.72/112.08/230.25px) at 1440, 0px moved, with `overflow: hidden`
|
||||
* left in place. `overflow: hidden` + the ellipsis below still answer the original fault —
|
||||
* a row genuinely narrower than its captions' combined content now shrinks BELOW their
|
||||
* min-content, not before, and the caption gives (ellipsis), never the neighbour's box.
|
||||
* Sweep-measured on real captions at every density: 14-19px on Startup, 12-13px on
|
||||
* "Клонировать", 16-18px on "Перезапустить"/"Перезагрузить", 5px on "Принудительно
|
||||
* завершить" — 0 with the English caption in the same row. `.cbi-page-actions`'s
|
||||
* `flex-wrap: wrap` (above) does not reach these — that selector is the Save/Apply footer,
|
||||
* not a table row, and this is a different container with no line to wrap onto. */
|
||||
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; background-image: none;
|
||||
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;
|
||||
}
|
||||
/* THE HOVER INK IS --fs-text, NOT THE ROLE COLOUR, and that is the rule this file already
|
||||
* states twice with measurements: text of colour C on a translucent tint of C eats its own
|
||||
* contrast (Save: accent on accent-soft = 4.25:1, an AA failure; the alert `.label` chip: 3.4-4.4
|
||||
* across the palettes). The row actions were the last place it survived — the role still reads,
|
||||
* from the fill and the border, which is exactly how Save and Reset carry theirs. */
|
||||
.td .cbi-button-action:hover, .td .cbi-button-edit:hover, .td .cbi-button-apply:hover,
|
||||
.cbi-section-actions .cbi-button-action:hover {
|
||||
background: var(--fs-accent-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-accent-line-hi);
|
||||
}
|
||||
/* An enabled/positive toggle in a table cell (System -> Startup) rendered as blue text on a
|
||||
* green fill = unreadable. Green outline instead, matching the red-outline negative button. */
|
||||
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
|
||||
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
|
||||
}
|
||||
/* …and the border-colour the other two variants already had. Without it the green outline stayed
|
||||
* at -line while action and negative brightened to -line-hi, so hovering the positive toggle
|
||||
* read as less responsive than its neighbours in the same row. */
|
||||
.td .cbi-button-positive:hover, .cbi-section-actions .cbi-button-positive:hover {
|
||||
background: var(--fs-good-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-good-line-hi);
|
||||
}
|
||||
.td .cbi-button-negative, .td .cbi-button-remove,
|
||||
.cbi-section-actions .cbi-button-remove, .cbi-section-actions .cbi-button-negative {
|
||||
color: var(--fs-danger); border-color: var(--fs-danger-line); background: transparent;
|
||||
}
|
||||
.td .cbi-button-negative:hover, .td .cbi-button-remove:hover,
|
||||
.cbi-section-actions .cbi-button-negative:hover {
|
||||
background: var(--fs-danger-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* Page actions: Save & Apply is THE primary action, Save is demoted so only one button reads
|
||||
* as primary. `--on-color` drives the spinner mask in styles/base. */
|
||||
.cbi-page-actions .cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown .cbi-button-apply {
|
||||
background: var(--fs-accent);
|
||||
background-image: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--fs-on-accent);
|
||||
--on-color: var(--fs-on-accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
/* Save carries a TINT at rest, not a bare outline: beside the solid Save & Apply a fully
|
||||
* transparent button reads as disabled rather than secondary. The steps come off the role ladder
|
||||
* (02-tokens.css) — -soft (12%) at rest, -fill (18%) on hover, one step up the same ladder.
|
||||
*
|
||||
* The label is --fs-text, not --fs-accent: never put text of colour C on a translucent tint of C
|
||||
* (docs/design-system.md). The tint drags the surface toward the text and eats its own contrast
|
||||
* — accent on accent-soft measures 4.25:1, an AA failure. The role is carried by the fill and
|
||||
* the border.
|
||||
*
|
||||
* `filter: none` cancels the shared hover's --fs-hover-lift: on a translucent tint a brightness
|
||||
* shift washes the fill out instead of deepening it. */
|
||||
.cbi-page-actions .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
|
||||
background: var(--fs-accent-soft);
|
||||
background-image: none;
|
||||
border: 1px solid var(--fs-accent-line);
|
||||
color: var(--fs-text);
|
||||
--on-color: var(--fs-on-accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
.cbi-page-actions .cbi-button-save:hover {
|
||||
background: var(--fs-accent-fill);
|
||||
border-color: var(--fs-accent-line-hi);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Buttons that are part of a compound field widget. */
|
||||
.cbi-dynlist > .add-item > button,
|
||||
.cbi-dynlist > .add-item > .cbi-button-add {
|
||||
/* min-width was a bare 38px — the same number --fs-ctl-h already states. A restated value
|
||||
* is the drift this ladder exists to prevent, and it also sat out the Density axis. */
|
||||
flex: 0 0 auto; width: auto; min-width: var(--fs-ctl-h);
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); margin: 0;
|
||||
border: 0; border-inline-start: 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: these were the only buttons in the theme set in bare pixels
|
||||
* (40 wide x 34 high), and 34 was the one control height that appears in no scale — the ladder
|
||||
* is --fs-ctl-h-sm/-h/-h-lg = 30/38/44, plus the derived FIELD height (--fs-control-h, 32).
|
||||
* A literal also opts out of the Density axis: every other control shrinks with
|
||||
* --fs-density-box and the pager did not. Square because the glyph is an arrow, not a label. */
|
||||
.pager .btn.prev, .pager .btn.next {
|
||||
flex: 0 0 auto; width: var(--fs-ctl-h); min-width: var(--fs-ctl-h); height: var(--fs-ctl-h); padding: 0;
|
||||
border-radius: var(--fs-radius); border: 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,921 @@
|
||||
@layer theme {
|
||||
/* Value rows.
|
||||
* display:flex + align-items:baseline come from base — do not restate, and do not switch to
|
||||
* center: a row's field can be taller than its control when a `.cbi-value-description` sits
|
||||
* under it, and center then pushes the label to the middle of control+description, visibly
|
||||
* below the control. baseline lines the label up with the field's first line for every
|
||||
* widget. */
|
||||
.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 in a field (Local Time: date input + Sync buttons) need breathing room —
|
||||
* the raw <br> collapses them. Exclude the password reveal group: it must stay seamless. */
|
||||
.cbi-value-field > .control-group { margin-top: var(--fs-space-2); }
|
||||
.cbi-value-field > .control-group:not(:has(.cbi-input-password)) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center;
|
||||
}
|
||||
/* The hairline separates one value row from the NEXT one, so it is dropped whenever no value row
|
||||
* follows — not only at the end of the section. A section ending with a table or an action bar
|
||||
* otherwise draws the line across the top of whatever comes next, cutting through a framed
|
||||
* table's rounded corner. `:last-child` was the special case of this. */
|
||||
.cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; }
|
||||
/* A toggle/checkbox field has no text baseline: `.cbi-checkbox` is inline-flex with no line box,
|
||||
* so its synthesised baseline is the box's BOTTOM edge and align-items:baseline floats the
|
||||
* toggle ~5px above the label. Centre those rows instead — but only where no help sentence sits
|
||||
* under the control, since centring then drops the label to the middle of control+description,
|
||||
* the regression the comment above warns about. */
|
||||
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
|
||||
align-items: center;
|
||||
}
|
||||
/* The generic control transition — absorbed from base's transition group. ONLY the
|
||||
* transition: control typography stays in base on purpose (see the note there — as a
|
||||
* bare-element theme rule it out-layered `.zonebadge-empty`'s smaller font, measured
|
||||
* 10.8 -> 13px on the firewall). border-COLOR, not `border`: the shorthand animates
|
||||
* border-width too — a layout animation on every hover instead of a paint one. */
|
||||
input, button {
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
|
||||
/* keep base's padding-top (--fs-space-1) — it is tuned for base's align-items: baseline. */
|
||||
.cbi-value-title {
|
||||
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
/* stated, not inherited: the control rule above hands every label `line-height: normal`,
|
||||
* and this one is TEXT — dropping it silently hands the label the font's own default
|
||||
* (measured: 18px -> normal, off the scale entirely). Absorbed from base with the rule
|
||||
* that made it necessary. */
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
/* Mono is for the VALUES a field holds. The exclusions:
|
||||
* buttons are chrome, not data — excluded here rather than relying on 55-buttons.css being
|
||||
* concatenated later. `:where()` keeps the exclusion at zero specificity, so the <select>
|
||||
* rule below still wins its own font.
|
||||
* .cbi-value-description is LuCI's inline HELP SENTENCE and lives inside .cbi-value-field.
|
||||
* `strong`/`b` — LuCI writes `<strong>MAC:</strong> ac:1f:6b:…`, so the strong is the LABEL
|
||||
* and the text after it the value. Feeding the label to the mono rule makes it mono AND
|
||||
* weight 700, a face of its own: 227 elements over seven pages in bold mono, and 20 KB of a
|
||||
* 600-weight mono face fetched to draw them.
|
||||
* The label rule itself is in theme/45-misc.css and must ASSIGN the sans face: a <strong> merely
|
||||
* excluded here still INHERITS mono from .cbi-value-field — excluding it alone was measured and
|
||||
* changed nothing. */
|
||||
.cbi-value-field,
|
||||
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
|
||||
/* text-wrap: the help sentence is a <div>, so base's prose rule (which reaches `p` and the
|
||||
* section/map descriptions) never covered it — and this is the one that wraps most, since it
|
||||
* sits in the field column. Same reasoning as there. */
|
||||
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
|
||||
|
||||
/* A text field in a CBI form is not a fixed 210px: that width is base's, and the same 210px
|
||||
* holds far less here — the field is monospaced and padded --fs-space-3 a side instead of 4,
|
||||
* so a server URL was clipped mid-domain while the .cbi-dynlist directly under it is 440px
|
||||
* wide (issue #5). Elastic, with the dynlist's own
|
||||
* ceiling so the two line up. Number/date/time keep the fixed width: they hold short values, and
|
||||
* a 440px box for a port number reads as a mistake. */
|
||||
.cbi-value-field input[type="text"],
|
||||
.cbi-value-field input[type="url"],
|
||||
.cbi-value-field input[type="search"] { width: 100%; max-width: var(--fs-field-max); }
|
||||
/* A .cbi-value is designed to hold ONE title+field pair, and base lays it out as a flex row. Some
|
||||
* apps cram several pairs into one .cbi-value, and the flex row then flows them side by side
|
||||
* until the fields overflow the card. Give each title and each field its own full-width row —
|
||||
* the stacked layout base already uses on a phone — scoped by `:has()` to the abnormal
|
||||
* multi-PAIR case only, so every normal single-field row keeps its label-beside-field layout.
|
||||
*
|
||||
* The test is the second TITLE, not the second field, and that is the whole of this rule's
|
||||
* safety: `:has()` is live, so keying on a second field means any app that appends one at
|
||||
* runtime — a status line, an output pane, a spinner holder — flips the row from "label beside
|
||||
* field" to "everything stacked" under the user's finger. Measured: pressing a button in such a
|
||||
* row moved that button 31px down. */
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-title,
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-field {
|
||||
flex: 1 1 100%;
|
||||
margin-inline-start: 0;
|
||||
text-align: start;
|
||||
}
|
||||
/* fields inside a table cell fill it — moved from base with the generic 210px box below:
|
||||
* in base these beat the box in-layer, left behind they would lose to it by layer */
|
||||
.td > input[type="text"],
|
||||
.td > input[type="password"],
|
||||
.td > select,
|
||||
.td > .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-dynlist > .add-item > .cbi-dropdown { width: 100%; }
|
||||
.cbi-section-create > * > input { width: 100%; }
|
||||
|
||||
/* --- live components that bypassed the theme --- */
|
||||
/* A CBI row is a <div class="cbi-value"> on a form and a <td class="cbi-value-field"> in a
|
||||
* section table — same <var>, and the theme had claimed only the table half. */
|
||||
div.cbi-value var, .td.cbi-value-field var { color: var(--fs-accent); }
|
||||
.td.cbi-value-field var.cbi-tooltip-container { border-bottom-color: var(--fs-accent); }
|
||||
.cbi-filebrowser { border-color: var(--fs-border); background: var(--fs-panel); border-radius: var(--fs-radius-lg); }
|
||||
.cbi-filebrowser > ul > li:hover { background: var(--fs-panel2); }
|
||||
.cbi-filebrowser .upload { border-top-color: var(--fs-border); }
|
||||
|
||||
/* A file-browser row must hold its own buttons, and the row CLIPS what does not fit rather than
|
||||
* showing it. base sizes the row by proportion (name `flex: 10`, actions `flex: 3`), which is
|
||||
* sound for base's own ~20px buttons in a text-height row; this theme's button is 36px tall with
|
||||
* 14px of side padding and a file row carries up to three of them, so at 23% of the row they do
|
||||
* not fit — and `.cbi-filebrowser > *` is `overflow: hidden`, so a button touches the clip box
|
||||
* on both axes and loses its rounded corners, which reads as a broken button.
|
||||
*
|
||||
* So: size the action column to its CONTENT and let the name give way (a name can ellipsize, a
|
||||
* button cannot), give the children a real gutter, and space the rows. It wraps rather than
|
||||
* clips when even 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`, not `0 0 auto`: the actions block is three buttons (Deselect / Download / Delete) and
|
||||
* it may WRAP but may not shrink, so on a 320px screen it kept its one-line width and stuck 23px
|
||||
* out of the browser box — measured in the gallery at 320px, where `Delete` ended past the card.
|
||||
* Shrinking is what lets its own `flex-wrap` fire; `min-width: 0` is what lets shrinking reach
|
||||
* below the buttons' min-content, which is the pair every flex item in this theme needs. */
|
||||
.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; }
|
||||
/* moved from base with the generic 210px box (same layer arithmetic as the .td rules) */
|
||||
.cbi-filebrowser .upload > div > input { width: 100%; }
|
||||
|
||||
/* Form widgets — toggles, file input. Applies to every CBI form. */
|
||||
|
||||
/* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the
|
||||
* checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words
|
||||
* (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox]
|
||||
* + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its own
|
||||
* margin: 0 outranks this rule by specificity ((0,2,1) vs this rule's (0,2,1) on a DIFFERENT
|
||||
* ancestor class, so the two never both match one element: `.cbi-checkbox` is never `.btn`). */
|
||||
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
|
||||
|
||||
/* checkbox -> toggle switch, drawn on the INPUT itself.
|
||||
*
|
||||
* Was drawn on label[for] with the real input hidden (position:absolute; opacity:0; width/
|
||||
* height:0) — forum.openwrt.org/t/251930 post #141: luci-app-modemdata (and any third-party CSS
|
||||
* built the way bootstrap expects, styling the native input) injects an unlayered
|
||||
* `label[for] { display: none }` flagged important, scoped to its own markup, which left
|
||||
* NOTHING visible, since the input it left alone was the invisible one. Drawing on the input
|
||||
* means an app that hides label[for] hides an already-empty placeholder, not the switch.
|
||||
*
|
||||
* The pill, the knob and the knob's travel are all on --fs-density-box. As bare literals they
|
||||
* make LuCI's most common control the only one in the theme that ignores the Density axis, while
|
||||
* every button and field around it moves: measured, the switch held 40x22 at Compact and at
|
||||
* Large while the fields beside it went 38 -> 32.3 -> 43.7. The switch is a box that holds a
|
||||
* knob, which is the
|
||||
* box tier by definition. Local names rather than new global tokens: one component needs one
|
||||
* number and three things derived from it.
|
||||
*
|
||||
* flex-shrink: 0 below (task 0145): `.cbi-checkbox` is the flex row, and a caption after the
|
||||
* pill — `label.cbi-checkbox > input + label[for] + TEXT`, package-manager.js's and banip's real
|
||||
* markup — makes the input (now the real, sized flex item; label[for] no longer is one, below) a
|
||||
* SECOND, shrinkable flex item. Unshrunk it stays exactly --sw-w; measured shrunk on the live
|
||||
* Install dialog on both stands when this box was still label[for]'s (task 0145): RU/Normal/390px
|
||||
* gave a used width of 30.17px against 40 declared and a knob 6.83px past the pill's right edge,
|
||||
* EN/Large/360px 36.22 against 46 declared and a 6.47px overhang — flex-shrink: 0 alone took both
|
||||
* to -3.00px and -3.31px (the knob safely inside the pill, not flush with it). The flex row and
|
||||
* its sibling caption are unchanged by which element carries the pill, so the guard carries over.
|
||||
*
|
||||
* label[for] stays in the DOM (ui.js and every third-party app that builds this markup emit it)
|
||||
* but draws and occupies nothing: `[for]` still discriminates it from the SECOND label
|
||||
* Checkbox.render() appends when `tooltip` is set (form.Flag.tooltip — e.g. luci-app-firewall's
|
||||
* zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon, which must keep
|
||||
* rendering. */
|
||||
/* `overflow-wrap: anywhere`: the caption after the pill is a plain text flex item, which already
|
||||
* wraps at spaces on its own — this is only for the one WORD that does not fit even at the row's
|
||||
* own full width. Measured on this exact caption (see the pill's own comment below): pseudo-loc
|
||||
* grows "неиспользуемые" (14 letters) to 20, wide enough on its own to push the whole row 24.6px
|
||||
* past `.cbi-value-field` at 320px/Large with no scroller — a real Russian compound this length
|
||||
* is not implausible (the theme already ships an untranslated one), so the row should absorb it
|
||||
* by breaking the word, not by escaping its column. */
|
||||
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
|
||||
/* label[for] takes over the input's old trick (position:absolute, zero box, opacity:0): out of
|
||||
* flow and out of the flex item count, so `.cbi-checkbox`'s `gap` still opens on only the SAME
|
||||
* two neighbours it always did — see the tooltip-container note below. */
|
||||
.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: none` and this compound's other unstated properties (color, the invalid
|
||||
* --bd-color/--fg-color custom props) still come from the bare `input[type="checkbox"]`
|
||||
* rule further down, written for a third-party app's raw markup: harmless to share, since
|
||||
* this input never reads --bd-color/--fg-color. The parts of that rule that WOULD paint
|
||||
* over this switch (the drawn box, the mark, the focus ring) are scoped with
|
||||
* `:not(:where(.cbi-checkbox > input))` at their own selector instead of being undone here —
|
||||
* cheaper than restating every property this rule already states for the opposite reason,
|
||||
* and it is one property, not eight, that both rules would otherwise fight over. */
|
||||
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;
|
||||
}
|
||||
/* The invisible overlay stretching the hit target to 44x30 (WCAG 2.5.8: the pill is 22px tall,
|
||||
* under the 24px floor) — moved here from label[for] unchanged. position:absolute, so it adds
|
||||
* nothing to layout; the knob keeps ::after.
|
||||
*
|
||||
* `content`/`position` come from the raw checkbox's own shared `::before, ::after { position:
|
||||
* absolute; content: "" }` rule further down — left UNSCOPED and reused here rather than
|
||||
* restated, since both pseudo-elements need exactly those two values anyway. The two rules
|
||||
* that would otherwise paint a drawn box and a mark ON this pseudo (their own selectors,
|
||||
* further down) are scoped with `:not(:where(.cbi-checkbox > input))` instead, so nothing is left to
|
||||
* override here.
|
||||
*
|
||||
* The inset stays literal while the pill above scales: this is an accessibility floor, and a
|
||||
* floor that shrinks with a density setting is not one. At Compact the target is still 38x26.7,
|
||||
* over the minimum. */
|
||||
.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` is a layout property, so the knob re-laid out the row on
|
||||
* every frame of the slide. translateX is composited. */
|
||||
transform: translateX(0);
|
||||
transition: transform var(--fs-dur-move), background var(--fs-dur);
|
||||
}
|
||||
/* The tooltip-container label (above) is unstyled by the switch rules and falls back to the
|
||||
* browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still
|
||||
* needs a gap from the switch, and `.cbi-checkbox`'s own `gap` above gives it one instead of a
|
||||
* margin declared here: label[for] inside that flex row is `position: absolute` (above), so it
|
||||
* is never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox`
|
||||
* with just the switch input has one flex item (the input itself) and gets no gap at all, while
|
||||
* the tooltip fixture's input+tooltip-label pair gets exactly one. --fs-space-2 (8px), same
|
||||
* number the removed margin used — the gap the theme already uses beside an inline checkbox
|
||||
* glyph (`.btn > input[type="checkbox"]` above). The flex-item COUNT and the gap token are the
|
||||
* same as before this rule moved from label[for] to the input; only which element is the
|
||||
* absolute one swapped. */
|
||||
.cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
|
||||
/* The travel is derived rather than hand-written, so it cannot drift out of step with the three
|
||||
* sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole subtlety: --sw-w is a
|
||||
* border-box width, while an absolutely-positioned ::after is placed against the PADDING box, so
|
||||
* the knob has 2px less room and deriving without that term lands it off-centre — the travel
|
||||
* comes out 20px where symmetry wants 18. The border does not scale with density, so the term
|
||||
* stays literal. */
|
||||
.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 bare fallback ring further down (`input:focus`, for every untyped/unlisted input, and its
|
||||
* `input[type="checkbox"]:focus::before` twin) is scoped with `:not(:where(.cbi-checkbox > input))` at
|
||||
* its own selector, so a plain click on this real, visible input rings nothing — as it never
|
||||
* did before this input was the drawn element, since only :focus-visible below is meant to. */
|
||||
/* The <input> IS the switch now, so the ring goes there directly, or the most common control in
|
||||
* LuCI shows nothing under keyboard focus.
|
||||
*
|
||||
* -solo, because on the CHECKED switch — half the switches on any page — neither other channel
|
||||
* exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`,
|
||||
* so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the
|
||||
* same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */
|
||||
.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 — the drawing, absorbed from base whole: it undoes the generic `input` box
|
||||
* below, and left in base every one of those overrides would lose to that rule by layer. The box
|
||||
* is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number,
|
||||
* so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the
|
||||
* .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets.
|
||||
*
|
||||
* `.cbi-checkbox > input` matches the switch's own input too: this base rule stays unscoped for
|
||||
* it, since the switch's own rules above want its `appearance: none` and read neither
|
||||
* --bd-color nor --fg-color. But the drawn box, the mark and the focus ring below DO paint over
|
||||
* the switch once its input is a real, visible element rather than 0x0 — each of those three
|
||||
* selectors carries `:not(:where(.cbi-checkbox > input))` for that reason alone; undoing them instead,
|
||||
* property by property, on the switch's own rules costs more than the one exclusion clause
|
||||
* each needs. */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
--bd-color: var(--fs-border);
|
||||
--fg-color: var(--fs-text);
|
||||
/* 16px on the grid, but on the BOX tier, not the space tier. `var(--fs-space-4)` was the
|
||||
* same 16px at Normal and the wrong multiplier everywhere else: space moves to .65 at
|
||||
* Compact and stays 1 at Large, so the tick box went to 10.4px (smaller than any other
|
||||
* control on the page — measured) and then refused to grow at Large. A checkbox is a box
|
||||
* that has to HOLD an icon, which is what --fs-density-box is defined for. */
|
||||
--box: calc(16px * var(--fs-density-box));
|
||||
--mark-inset: 3px;
|
||||
/* ---- the drawing is 16px; the target is 24px ----
|
||||
*
|
||||
* WCAG 2.2 SC 2.5.8 lets an undersized target pass on SPACING — 24px centre to centre — which
|
||||
* is what a 16px tick box has always relied on. That holds while the theme decides the layout
|
||||
* and stops the moment a third-party app lays out its own rows, putting this checkbox and a
|
||||
* link inside the 24px. A target that passes only because of somebody else's markup is not a
|
||||
* target that passes.
|
||||
*
|
||||
* So the ELEMENT is 24px and the drawing stays 16px, centred inside it: the two pseudo
|
||||
* elements are already absolutely positioned, so centring costs `inset: 0; margin: auto`.
|
||||
* Nothing moves visually — the box, the tick and the focus ring are all drawn by the pseudo
|
||||
* elements, and the element itself has neither background nor border.
|
||||
*
|
||||
* `max()`, not a flat 24px: the Large density scales `--box` to 18.4px, and a target must
|
||||
* never SHRINK when the user asks for a bigger UI. */
|
||||
--hit: max(24px, var(--box));
|
||||
appearance: none;
|
||||
width: var(--hit);
|
||||
height: var(--hit);
|
||||
/* …and the target does not cost a row its height: the element is 24px but occupies its old
|
||||
* `--box` in layout, the negative margin pulling the extra area back out of the flow so the
|
||||
* box overflows itself symmetrically. Measured on a third-party listing: rows went 42px ->
|
||||
* 51px with the target simply grown, and 43px with the margin, the target still answering to
|
||||
* a hit test at its centre. */
|
||||
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 are NOT reset: the generic input box's values land on an
|
||||
* appearance-none 16px box whose pseudo-elements are absolute — invisible, and what the
|
||||
* widget has always computed */
|
||||
}
|
||||
input[type="checkbox"]::before,
|
||||
input[type="checkbox"]::after,
|
||||
input[type="radio"]::before,
|
||||
input[type="radio"]::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
|
||||
input[type="radio"]::before {
|
||||
/* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with
|
||||
* a fixed size is the one centring that needs no translate and therefore behaves the same in
|
||||
* RTL, where an `inset-inline-start: 50%` + `translate: -50%` pair would push the drawing the
|
||||
* wrong way. */
|
||||
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);
|
||||
}
|
||||
/* each box states its own radius — one shared 2px re-declared to 50% for the radio would
|
||||
* be a same-selector shadow inside this layer */
|
||||
input[type="checkbox"]::before { border-radius: 2px; }
|
||||
input[type="radio"],
|
||||
input[type="radio"]::before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* keyboard/click focus rings on the DRAWN box — the theme's one ring token, replacing
|
||||
* base's old generic glow; :focus (not :focus-visible) keeps the pre-absorption behaviour
|
||||
* of ringing a clicked box. Both states (plain and invalid) are declared once, further
|
||||
* down this file, in the selector lists every other same-body ring joins — see the two
|
||||
* `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
|
||||
input[type="radio"]::after {
|
||||
/* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark
|
||||
* centres the same way rather than measuring 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. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in
|
||||
* base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid).
|
||||
* It replaces a solid filled path that was byte-identical to luci-theme-bootstrap's — and to
|
||||
* luci-theme-openwrt-2020's, which is the same project's artwork, not evidence the drawing is
|
||||
* generic. A mask keys on ALPHA, so a stroked path masks exactly as a filled one did. */
|
||||
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");
|
||||
-webkit-mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
background: var(--fg-color);
|
||||
}
|
||||
input[type="radio"]:checked:after {
|
||||
/* the radio's dot — filled, because a ring would read as "not selected". OURS: our grid,
|
||||
* our radius (the fill is the one shape a stroke cannot express). */
|
||||
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6.5' fill='%23000'/></svg>");
|
||||
}
|
||||
input[type="checkbox"].cbi-input-invalid,
|
||||
input[type="radio"].cbi-input-invalid {
|
||||
--bd-color: var(--fs-danger);
|
||||
--fg-color: var(--fs-danger);
|
||||
}
|
||||
|
||||
/* The generic field box, absorbed from base's `input, textarea, select, …` group; each other
|
||||
* member lives with its component rule now. Bare `input` keeps the historical 210px box as the
|
||||
* fallback for every type the exhaustive text-input list below does not name — and for a
|
||||
* typeless <input>, which IS a text input. The widget types that must NOT wear this box carry
|
||||
* their own theme rules: a base override would lose to this rule by layer whatever its
|
||||
* specificity. */
|
||||
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 was NOT moved here: the elastic
|
||||
* .cbi-value-field width above already out-layered it (measured on the live router: the
|
||||
* field rendered 440px, not 300) — the rule was dead, and a dead rule is deleted, not
|
||||
* relocated */
|
||||
|
||||
/* native file input — the `initial`/auto resets are absorbed from base: the generic box
|
||||
* above would otherwise dress the native chooser in a 210px bordered field */
|
||||
input[type="file"] {
|
||||
color: var(--fs-dim); font-size: var(--fs-type);
|
||||
padding: initial;
|
||||
border: initial;
|
||||
line-height: initial;
|
||||
box-shadow: none;
|
||||
/* no !important: this out-specifies the generic `input { width: 210px }` on its own.
|
||||
* height/background are NOT reset: the chooser keeps the generic box's control height
|
||||
* and panel tint, as it always has. */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* native <select> — custom chevron + theme (matching the text inputs). The chevron data-URI
|
||||
* lives in 03-palettes.css per palette/mode block, because a URI cannot read var(); it
|
||||
* replaced a hardcoded grey that ignored palette and dark mode. */
|
||||
select, select.cbi-input-select, .cbi-value-field select {
|
||||
/* unprefixed only: the floor (Chrome 108/Firefox 101/Safari 15.4) clears unprefixed
|
||||
* `appearance` everywhere (Chrome 84, Firefox 80, Safari 15.4 — docs/css.md). -webkit- was
|
||||
* 96 B across these four rules for nothing this sheet still has to run on. */
|
||||
appearance: none;
|
||||
background-color: var(--fs-panel2);
|
||||
background-image: var(--fs-select-chevron);
|
||||
/* right inset from the scale, not a literal 12 — the .cbi-select shell below states the same
|
||||
* position and two spellings of one number is how they would drift apart */
|
||||
/* two 6px bands forming the V, the far one a band-width further in; --fs-space-3 stays the
|
||||
* single spelling of the inset, as the note above says */
|
||||
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/display absorbed from base's generic field box: a select's natural width is its
|
||||
* longest option, so without the fixed box two selects in one form column ragged-edge;
|
||||
* display stated because Chromium's base-select UA default is inline-flex, not the
|
||||
* inline-block the widget has always computed */
|
||||
width: 210px;
|
||||
display: inline-block;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* group-then-refine, directly under its group: the listbox variant must NOT take the closed
|
||||
* control's one-line height. A SURFACE token, not an ink one: this once read --fs-on-accent
|
||||
* (the ink meant to go ON an accent fill), which resolves to plain white and painted a white
|
||||
* listbox in dark mode. `height: inherit` absorbed from base with the width above — without
|
||||
* it the fixed box would cap a multi-row listbox at one line. */
|
||||
select[multiple] { background-color: var(--fs-panel2); color: var(--fs-text); height: inherit; }
|
||||
/* :focus joins the shared accent ring in the text-input group below. */
|
||||
/* absorbed from base's reset: 100% (= inherit) keeps the option on its select's size, and
|
||||
* only <option> needs the 2em leading — the other controls carry line-height: normal above */
|
||||
option { font-size: 100%; line-height: 2em; }
|
||||
select option { background: var(--fs-panel); color: var(--fs-text); }
|
||||
|
||||
/* Native <select> drop list: the OS renders it, so the `select option` colours above are ignored
|
||||
* on most platforms. Chromium's customisable-select (`appearance: base-select`) renders the
|
||||
* picker in the DOM, so it can be themed like the .cbi-dropdown menu; @supports-gated, and
|
||||
* browsers without it keep the styled closed control plus the native list.
|
||||
*
|
||||
* Scoped to `.cbi-value-field select` only: a third-party app can mount a raw <select> outside
|
||||
* the CBI form and drive it its own way, and early Chrome builds mis-render such a select as a
|
||||
* single-item list. Those sit outside .cbi-value-field, so this scope leaves them native. */
|
||||
@supports (appearance: base-select) {
|
||||
.cbi-value-field select { appearance: base-select; }
|
||||
.cbi-value-field select::picker-icon { display: none; }
|
||||
::picker(select) {
|
||||
appearance: base-select;
|
||||
background: var(--fs-panel); border: 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);
|
||||
/* no `color` here: `select option` above already sets --fs-text, and this restated it
|
||||
* (switched off against 174 options, nothing moved). `background: transparent` is NOT the
|
||||
* same — it deliberately undoes that rule's --fs-panel so the row's own hover shows. */
|
||||
background: transparent; min-height: 0;
|
||||
}
|
||||
.cbi-value-field select option::checkmark { display: none; }
|
||||
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
|
||||
/* Opaque surface + inset rail, same shape as the open .cbi-dropdown's selected row
|
||||
* (theme/65-dropdown.css): accent text on --fs-accent-soft measured 4.21:1 in dark,
|
||||
* under AA — the tint of the accent eats its own contrast. */
|
||||
.cbi-value-field select option:checked {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
}
|
||||
|
||||
/* .cbi-select — the legacy luci-compat shell (a div around a native <select>), absorbed from base
|
||||
* whole: the generic field box, the shell's own paint and a focus ring on the theme's token. The
|
||||
* shell is a DIV, so display and width must be stated. `:not(select)` because the class also
|
||||
* appears ON a bare <select>: a replaced element cannot draw the ::before chevron, so shell
|
||||
* paint there means a gradient with no glyph. */
|
||||
/* It paints like the themed <select>, being the same control in an older shell — never a
|
||||
* skeuomorphic surface of its own: a gradient from panel to border is lighter at the bottom in
|
||||
* the dark palettes, and nothing else in the theme has a gradient at all. The geometry below is
|
||||
* the `select` rule above, property for property, so a legacy shell and a modern select line up
|
||||
* in one form column. This is the non-JS fallback: the people who see it already have something
|
||||
* broken. */
|
||||
.cbi-select:not(select) {
|
||||
display: inline-block;
|
||||
width: 210px;
|
||||
/* `height`, NOT `min-height` — the inner select fills the shell with `height: 100%`, and a
|
||||
* percentage against an auto-height parent has no definite containing block to resolve
|
||||
* against, so it falls back to content height: measured, the select collapsed 30px -> 15px
|
||||
* inside a 38px shell, text at the top and half the hit area gone. The themed `select` can
|
||||
* use a floor because it IS the control; a wrapper has to state a height for its child. */
|
||||
height: var(--fs-ctl-h);
|
||||
color: var(--fs-text);
|
||||
font-size: var(--fs-type);
|
||||
border: 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);
|
||||
}
|
||||
/* The disabled cue used to live ENTIRELY on the ▾ plate: base/30-forms.css dims
|
||||
* `.cbi-select[disabled]::before` and never the shell itself. With the plate gone the state had
|
||||
* no visual at all, so the shell carries it now. */
|
||||
.cbi-select[disabled]:not(select) {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* .focus and .cbi-input-invalid.focus join the shared accent/danger ring rules further down
|
||||
* this file — same two bodies as every other :focus/:focus-within ring on this token. */
|
||||
/* the native <select> inside the shell: invisible, filling it, clearing the ▾ plate on the
|
||||
* right. The inset is a scale step; the right margin is the plate's width, so it stays
|
||||
* em-relative to the glyph it clears. Every themed-select declaration above (chevron,
|
||||
* padding, min-height) must be answered here, or the inner select paints like a free-standing
|
||||
* one inside the 32px shell. */
|
||||
.cbi-select select,
|
||||
.cbi-select select:focus {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
/* clears the CHEVRON, which the shell now paints on its own background — the same
|
||||
* --fs-space-8 inset the themed select uses. `margin-right: .6em` used to clear the ▾ plate
|
||||
* instead, and an em-relative margin against a px-positioned glyph is how the two drifted. */
|
||||
padding: 0 var(--fs-space-8) 0 var(--fs-space-3);
|
||||
font-size: var(--fs-type);
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* spinner / loading */
|
||||
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
|
||||
|
||||
/* Text inputs and other field widgets, rounded and themed to match the selects and dropdowns
|
||||
* (base leaves them at radius 3 and white). Excludes checkbox/radio and file, handled above.
|
||||
*
|
||||
* The type list must be EXHAUSTIVE: an omission does not fall back to "unstyled", it falls back
|
||||
* to BASE, which still paints a stock white 3px-radius field beside a themed one. */
|
||||
.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);
|
||||
}
|
||||
/* width/font-size absorbed from base's generic field box, same numbers — a textarea has
|
||||
* always sat at the fixed 210px (the elastic rule above names only text/url/search) */
|
||||
/* `max-width: 100%` beside the width, because a textarea is the one field whose intrinsic width
|
||||
* comes from an ATTRIBUTE (`cols`, default 20) rather than from the box: measured at a 300px
|
||||
* viewport, two textareas in the gallery stood 19px outside their card while every other control
|
||||
* in the same column fitted. The 210px is the designed width and still applies wherever there is
|
||||
* room for it. */
|
||||
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type); }
|
||||
/* A textarea's native scrollbar is a square strip and a rounded corner does not clip it, so on a
|
||||
* scrolling config field the grey bar pokes past the radius as a square notch. Fix: a slim
|
||||
* self-rounded thumb floated off the edges (transparent border plus background-clip:
|
||||
* padding-box), a transparent track, and a track margin keeping the thumb clear of the corners.
|
||||
*
|
||||
* Deliberately no `scrollbar-width`/`scrollbar-color`: either makes Chromium switch to the
|
||||
* standard scrollbar and ignore this whole ::-webkit block — and that standard bar is the
|
||||
* square, unclipped one. Firefox has no ::-webkit and clips natively.
|
||||
*
|
||||
* 16px wide so ::-webkit-resizer, sized off the scrollbar, gets a bigger grab area; the 5px
|
||||
* transparent thumb border still leaves it 6px visible. */
|
||||
textarea::-webkit-scrollbar { width: 16px; height: 16px; }
|
||||
textarea::-webkit-scrollbar-track { background: transparent; margin: var(--fs-space-4) 0 var(--fs-space-2-5); }
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background: var(--fs-dim); border-radius: var(--fs-radius-pill);
|
||||
border: 5px solid transparent; background-clip: padding-box;
|
||||
}
|
||||
textarea::-webkit-scrollbar-thumb:hover { background: var(--fs-text); }
|
||||
/* The bottom-right square where the vertical bar meets the resize grip defaulted to white
|
||||
* and poked past the rounded corner — transparent, so the frame shows. */
|
||||
textarea::-webkit-scrollbar-corner { background: transparent; }
|
||||
/* The resize grip highlights the frame's bottom-right corner — an accent arc tracing the frame's
|
||||
* own radius, with no straight legs. Two borders plus the corner radius draw the arc, and the
|
||||
* legs vanish because the box is shrunk to exactly the radius: the resizer is the scrollbar's
|
||||
* 16px, so a `16px − radius` margin leaves a radius-sized box whose right and bottom border IS
|
||||
* the quarter curve. (A mask would give a shorter arc, but ::-webkit-resizer ignores mask, and a
|
||||
* baked-in SVG colour would not follow the palette.) */
|
||||
/* PHYSICAL on purpose, and mirrored by hand below: ::-webkit-resizer is a UA-drawn corner whose
|
||||
* side follows the writing direction, and it does not accept logical properties — with only the
|
||||
* LTR rule the arc drew on the wrong corner in an RTL document (a stray quarter-circle on the
|
||||
* left of every textarea). */
|
||||
textarea::-webkit-resizer {
|
||||
background: transparent;
|
||||
border-right: 2px solid var(--fs-accent);
|
||||
border-bottom: 2px solid var(--fs-accent);
|
||||
border-bottom-right-radius: var(--fs-radius);
|
||||
margin: calc(16px - var(--fs-radius)) 0 0 calc(16px - var(--fs-radius));
|
||||
}
|
||||
:root[dir="rtl"] textarea::-webkit-resizer {
|
||||
border-right: 0; border-left: 2px solid var(--fs-accent);
|
||||
border-bottom-right-radius: 0; border-bottom-left-radius: var(--fs-radius);
|
||||
margin: calc(16px - var(--fs-radius)) calc(16px - var(--fs-radius)) 0 0;
|
||||
}
|
||||
|
||||
.cbi-input-text:hover, .cbi-input-password:hover, .cbi-input-textarea:hover,
|
||||
input[type="text"]:hover, input[type="password"]:hover, input[type="number"]:hover,
|
||||
input[type="email"]:hover, input[type="url"]:hover, input[type="search"]:hover,
|
||||
textarea:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback
|
||||
* for the types the list does not name and for a typeless <input>; on a bare checkbox/radio it
|
||||
* lands on the drawn box UNDER the ::before ring below — coincident boxes, one visible ring, as
|
||||
* the old glow behaved. `:not(:where(.cbi-checkbox > input))` excludes the toggle switch, whose input is
|
||||
* a real, visible element with its own :focus-visible-only ring (above) — a plain click on it
|
||||
* must ring nothing. `select:focus` and the keyboard-removable dynlist chip (ui.js gives it a
|
||||
* tabindex) join here rather than repeating the same three declarations under their own
|
||||
* selector: one shared body instead of three, 149 B off cascade.css. */
|
||||
.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;
|
||||
}
|
||||
/* The plain accent ring above, restated for four components whose selector cannot join that
|
||||
* list without changing what it matches (a pseudo-element, a legacy shell class, two
|
||||
* `:focus-within` containers) — same two declarations, one shared body instead of four, 186 B
|
||||
* off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a
|
||||
* `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the
|
||||
* floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one
|
||||
* selector. `:not(:where(.cbi-checkbox > input))` on the checkbox part for the same reason as the plain
|
||||
* ring above: the toggle switch's own :focus-visible rule is the only one allowed to ring it. */
|
||||
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);
|
||||
}
|
||||
|
||||
/* Invalid state keeps the danger cue. `.cbi-value-error input` is the Lua-CBI path and belongs
|
||||
* HERE, not in base: base does declare a red border-color for it, but the rule above sets the
|
||||
* `border` SHORTHAND, and a shorthand in a later layer wipes the longhand out regardless of
|
||||
* specificity — so every third-party app on luci-compat rendered the field in --fs-border grey
|
||||
* and had lost its field-error cue while the modern .cbi-input-invalid path worked.
|
||||
*
|
||||
* `.cbi-input-invalid:focus` is not redundant with the rule it sits beside: the generic
|
||||
* `input:focus` above is (0,1,1) and would repaint a focused invalid field accent, and
|
||||
* `.cbi-input-invalid:focus` at (0,2,0) is what stops it — the TYPED `:focus` rules above are
|
||||
* (0,2,1) and still outrank this, so a typed invalid field focuses accent, exactly as before
|
||||
* the absorption. The checkbox/radio `::before` ring and the legacy `.cbi-select` shell need
|
||||
* the identical danger body for the same reason on their own elements — one shared rule
|
||||
* instead of four, 186 B off cascade.css. */
|
||||
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);
|
||||
}
|
||||
/* The same rejected value shown by a ui.Dropdown rather than an <input>: the widget itself and,
|
||||
* when it is closed, the <li> that displays the value. base carried these two and the theme only
|
||||
* the input, so half of one state lived in a layer the theme cannot reach. */
|
||||
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
|
||||
|
||||
/* readonly keeps its faded border — moved from base with the generic box (its border-color
|
||||
* longhand would lose to the box's `border` shorthand by layer). The doubled [readonly] is
|
||||
* a deliberate specificity step: at (0,1,1) this ties the typed input rules above and the
|
||||
* cascade would rest on file order — in base the same tie made the fade silently LOSE to
|
||||
* the typed border, so a readonly text field never faded at all; (0,2,1) states the intent. */
|
||||
:is(input, select, textarea)[readonly][readonly] {
|
||||
border-color: color-mix(in srgb, var(--fs-border) calc(var(--fs-disabled-opacity) * 100%), transparent);
|
||||
pointer-events: auto;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* dynamic list: keep base's layout (inline-flex column of chips + one add-item row) — do NOT
|
||||
* set display/flex-wrap here or the add row breaks. Only colours, rounding, a compact +
|
||||
* button, and no wrapping box: capped narrow so the chip's × sits near the text instead of
|
||||
* floating across a full-width field. */
|
||||
.cbi-dynlist {
|
||||
gap: var(--fs-space-1-5);
|
||||
background: transparent; border: 0; padding: 0;
|
||||
/* `min(240px, 100%)`, not a bare 240: the floor is there so a chip's × sits near its text
|
||||
* rather than floating across a full-width field, and it becomes a defect the moment the
|
||||
* column is narrower than the floor — measured in the gallery at a 320px viewport, the list
|
||||
* held 240px inside a 210px field and every chip, the add row and its input stood 29px
|
||||
* outside the card. A floor that exceeds the room is not a floor, it is an overflow. */
|
||||
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);
|
||||
}
|
||||
/* a chip is keyboard-removable (ui.js gives it a tabindex): :focus joins the shared accent
|
||||
* ring in the text-input group above. */
|
||||
/* The chip's × plate is a BUTTON in base's paint group (`.item::after, .btn, .cbi-button`) —
|
||||
* absorbed with it, bare as base had it (coverage: an .item outside a dynlist keeps its
|
||||
* plate). The machinery — content/position/edges/align — stays in base with the widget. */
|
||||
.item::after {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(var(--fs-panel), var(--fs-panel) 25%, var(--fs-panel2));
|
||||
padding: 0 var(--fs-space-3);
|
||||
color: var(--fs-text);
|
||||
line-height: 2em;
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* the hover cue is the lift, not a colour: the plate's fill is a gradient (see
|
||||
* --fs-hover-lift in 02-tokens.css) — replaces base's old focus-glow-on-hover */
|
||||
.item:hover::after { filter: var(--fs-hover-lift); }
|
||||
.cbi-dynlist > .item::after {
|
||||
/* display/padding absorbed from base's machinery rule: the bare plate above says
|
||||
* inline-block / space-3, and by layer it would beat base's refinement */
|
||||
display: inline-flex;
|
||||
padding: 0 var(--fs-space-2);
|
||||
color: var(--fs-danger);
|
||||
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
|
||||
}
|
||||
/* text add-item = one bordered input group (input + + button attached) */
|
||||
.cbi-dynlist > .add-item {
|
||||
align-items: stretch;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm); overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* :focus-within joins the shared accent ring in the text-input group above. */
|
||||
.cbi-dynlist > .add-item > input,
|
||||
/* `width: auto` keeps the flex basis in charge: this input sits INSIDE .cbi-value-field, so the
|
||||
* elastic width above would otherwise hand it 100% of the field on top of `flex: 1 1 auto`. */
|
||||
.cbi-dynlist > .add-item > .cbi-input-text {
|
||||
flex: 1 1 auto; min-width: 0; width: auto;
|
||||
border: 0; background: transparent;
|
||||
box-shadow: none; min-height: var(--fs-ctl-h-sm); border-radius: 0;
|
||||
}
|
||||
|
||||
/* password reveal: input + ∗ button as ONE seamless rounded group.
|
||||
* `gap: 0` is load-bearing: base gives every .control-group a gap, and this is the one group
|
||||
* whose members must touch — the group draws the single border and clips them. */
|
||||
.control-group:has(> .cbi-input-password) {
|
||||
display: inline-flex; align-items: stretch; gap: 0;
|
||||
background: var(--fs-panel2);
|
||||
border: 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);
|
||||
}
|
||||
/* :focus-within (wrapped in :is() for the shared list) joins the accent ring in the
|
||||
* text-input group above. */
|
||||
/* base pulls .cbi-section-create up 3px to hug a table border, which glues the "Add" button
|
||||
* to the last row — give it a real gap instead. */
|
||||
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* `.hidden` must win over every component rule in this layer. An enumerated list
|
||||
* (`.cbi-value.hidden`, `.cbi-section.hidden`, …) only ever covers the elements someone has
|
||||
* already been bitten by, and the one it misses is the Enter-key submit every legacy Lua page
|
||||
* carries: luci-compat opens each form with `<input type="submit" class="hidden">`, and an
|
||||
* attribute selector counts as a class, so `input[type="submit"]` at (0,1,1) beats a bare
|
||||
* `.hidden` at (0,1,0) and a stray "Save" button floats above the page title (issue #12).
|
||||
* Doubling the class states the invariant once at (0,2,0) instead of chasing elements. An
|
||||
* `!important` would do it too, and would invert the layer order for every future override.
|
||||
* `fs-sheets.js` (rehostIntoThemeLayer) re-hosts a foreign app's own sheet into `@layer theme`,
|
||||
* where it now competes on specificity instead of losing to an unlayered rule by layer order
|
||||
* alone: measured on owrt2512 with luci-app-splify2 26.9, `<aside class="hidden lg:flex …">`
|
||||
* (Rail.tsx) stayed `display: none` at 1440/1920px because `.hidden.hidden` at (0,3,0) — see
|
||||
* below — outranked `.lg\:flex` at (0,1,0). `:not([class*=":"])` excludes any class list that
|
||||
* carries a Tailwind/UnoCSS responsive variant (the colon in `lg:flex`, `md:block`, `sm:inline`)
|
||||
* from the invariant above; LuCI itself never emits a class containing `:`, so nothing stock
|
||||
* is affected. Specificity moves to (0,3,0) — `:not()` counts its argument's attribute
|
||||
* selector, (0,1,0) — still clear of `input[type="submit"]` at (0,1,1) (issue #12, unchanged). */
|
||||
.hidden.hidden:not([class*=":"]) { display: none; }
|
||||
/* RangeSlider (UIRangeSlider) — the one LuCI widget base leaves to native browser rendering.
|
||||
* Token-driven track/thumb.
|
||||
* The INPUT box is the WCAG 2.2 SC 2.5.8 target (24px min): it used to be the 6px track
|
||||
* itself, measured 258x6 on owrt2512/390x844 (System > System). Box and paint split here —
|
||||
* a transparent 24px box, the 6px bar drawn by the track pseudo-element inside it.
|
||||
*
|
||||
* A flat `height: 24px` DOES move the row, and not through `.cbi-range-slider`'s own cross
|
||||
* size — that stayed 29.5px throughout, already pinned by the value chip beside it. It moves
|
||||
* through the enclosing LINE BOX: `.cbi-value-field` lays this element out as ordinary inline
|
||||
* content, and the line's height is anchored on where `.cbi-range-slider` (inline-flex, no
|
||||
* baseline-participating child) reports ITS baseline to the ancestor line — which tracks the
|
||||
* first flex item's own edge and moved with the input's box. Measured before either fix below:
|
||||
* `.cbi-value-field` +2.75px (Chromium) / +2.5px (Firefox), `.cbi-value` +1.5px / +1.0px,
|
||||
* track/thumb ~1.25px lower.
|
||||
*
|
||||
* First half of the fix is the checkbox's own trick (`--hit`/`--box` below): grow the box for
|
||||
* the hit target, then pull the extra area back out of flow with a symmetric negative margin
|
||||
* so the box overflows itself around its old centre instead of pushing something taller. 6px
|
||||
* was the box before this rule; -9px is (6px - 24px) / 2. This is what keeps a BARE range
|
||||
* input (no `.cbi-range-slider` wrapper, no taller sibling — a third-party app's own markup)
|
||||
* from growing its own row, the same way the checkbox's margin protects a row with no toggle
|
||||
* label beside it. Measured on its own, though, it left THIS row's growth above unchanged: the
|
||||
* line-box placement of `.cbi-range-slider` tracks the input's own edge, not the flow space the
|
||||
* margin trick frees up, so the second half below is still needed on top of it. */
|
||||
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 the thumb on the INPUT's box, not the track: (6 - 16) / 2 = -5px pulls
|
||||
* it back onto the 6px track. Firefox centres ::-moz-range-thumb on the track itself. */
|
||||
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;
|
||||
}
|
||||
/* -solo: the track sets `border: 0` and `outline: none`, so there is no second channel here
|
||||
* either — the tinted ring was the whole indicator on a 6px bar. Set on the track pseudo,
|
||||
* not the input, so the ring still hugs the 6px bar rather than the 24px hit box. */
|
||||
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); }
|
||||
/* THE SAME CEILING THE ELASTIC TEXT INPUTS TAKE, and the reason is the whole point of the
|
||||
* token: a `.cbi-value-field` is `flex: 1`, so `width: 100%` here meant the slider ate the
|
||||
* entire card while the select two rows above stopped at 210px and the readout chip was flung
|
||||
* to the card's right edge. Every stretching control in a form row now ends at one x.
|
||||
*
|
||||
* `vertical-align: middle` answers the second half of the 24px-target growth explained above
|
||||
* `input[type="range"]`. Default (`baseline`) places this inline-flex box in its line by the
|
||||
* edge of its first flex item — the range input — so that edge moving 6px -> 24px still pushed
|
||||
* `.cbi-value-field`'s line taller even with the margin trick already in place. `middle` places
|
||||
* the box by its own total height instead, which the margin trick already held at its old
|
||||
* 29.5px (pinned by the value chip, unaffected by the input's growth), decoupling the two.
|
||||
* Measured on the gallery slider fixture, both engines, this rule plus the margin trick against
|
||||
* HEAD: `.cbi-value-field` +0.000px in both, `.cbi-value` -0.187px (Chromium) / -0.183px
|
||||
* (Firefox) — under a fifth of a CSS px, not a pixel a screen can render differently. Box
|
||||
* unchanged at 258x24. */
|
||||
.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 face is bundled, so 600 either drags a 20 KB file back in or is faked by
|
||||
* synthetic emboldening, which smears a monospace grid.
|
||||
* The chip already reads as emphatic from its surface, border and accent ink. */
|
||||
font-weight: var(--fs-weight-normal); font-size: var(--fs-type); white-space: nowrap;
|
||||
}
|
||||
.cbi-range-slider-calc, .cbi-range-slider-calc-units { color: var(--fs-dim); font-size: var(--fs-type); }
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
@layer theme {
|
||||
/* Generic .dropdown-menu popup. Nothing in luci-base emits it — the split "Save & Apply" button is
|
||||
* a `.cbi-dropdown`, a different widget — so this is coverage vocabulary: stock
|
||||
* luci-theme-bootstrap styles it, so a third-party luci-app-* may emit it and expect a themed
|
||||
* result — theme/10-chrome.css claims the same class for the same reason. Not the theme's nav
|
||||
* menu, which is a plain nested <ul>.
|
||||
* Hover text is accent to match the open cbi-dropdown list: this is a form control's menu. */
|
||||
.dropdown-menu { background-color: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.dropdown-menu a { color: var(--fs-text); text-shadow: none; }
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu a:focus-visible { background: var(--fs-panel2); background-image: none; color: var(--fs-accent); }
|
||||
|
||||
/* Split "Save & Apply": a plain dropdown in the action bar carries no frame of its own.
|
||||
*
|
||||
* The guard is the same `:not(.btn):not(.cbi-button)` the widget rules below use, and it is
|
||||
* load-bearing: the split control's outer div IS the button, and the class on it is the one
|
||||
* ui.js picked for the SELECTED mode — `cbi-button-apply`, or `cbi-button-negative` the moment
|
||||
* the user picks "Apply unchecked" (luci.js's ComboButton `classes` map). Unguarded, this rule
|
||||
* (0,2,0) erases that variant's fill while its ink survives from the variant rule that sets no
|
||||
* background: a button painted 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 — `.cbi-button-action` and the rest carry
|
||||
* theirs from theme/55-buttons.css, and `.cbi-button-apply` is filled only where it is THE
|
||||
* primary action, which is this bar. Painted by SPECIFICITY — it used to be a rule written
|
||||
* afterwards, i.e. a fix that depended on source order. */
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button {
|
||||
background: var(--fs-accent);
|
||||
background-image: none;
|
||||
border: 0;
|
||||
color: var(--fs-on-accent);
|
||||
}
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply:hover,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button:hover { filter: var(--fs-hover-lift); }
|
||||
|
||||
/* ---- cbi-dropdown widget: the JS select used across all forms ----
|
||||
* Restyled to the design dropdown (panel2, rounded, chevron, focus ring) with a themed open
|
||||
* list. A native <select> is styled separately.
|
||||
*
|
||||
* The widget's MACHINERY is here too, absorbed from base whole. ui.js drives it purely through
|
||||
* attributes ([open], [multiple], [empty], [more], [display], [selected]) and the rules below
|
||||
* turn those into a working combobox. The display state machine is UNFLAGGED: upstream resolved
|
||||
* the states with !important, but flag-vs-flag falls back to specificity-then-order anyway, so
|
||||
* the machine below states the same resolution openly. The flags came in with the absorbed
|
||||
* widget from a theme with no @layer, which needed them to beat its own bare `ul` rule. */
|
||||
.cbi-dropdown {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
height: auto;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
.cbi-dropdown img {
|
||||
/* constrain a vector icon of any intrinsic size to the text line */
|
||||
max-height: 1.6em;
|
||||
width: auto;
|
||||
}
|
||||
.cbi-dropdown > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
/* clears the button's own side inset so the label starts on its text, not on its border.
|
||||
* Unflagged: (0,3,1) over the rule above's (0,1,1) — it only ever needed a flag to beat that
|
||||
* rule's flag. */
|
||||
.cbi-dropdown.btn > ul:not(.dropdown),
|
||||
.cbi-dropdown.cbi-button > ul:not(.dropdown) {
|
||||
margin: 0 0 0 var(--fs-space-3);
|
||||
}
|
||||
.cbi-dropdown.btn.spinning > ul:not(.dropdown),
|
||||
.cbi-dropdown.cbi-button.spinning > ul:not(.dropdown) {
|
||||
margin: 0;
|
||||
}
|
||||
.cbi-dropdown > ul.preview {
|
||||
display: none;
|
||||
}
|
||||
/* no `display` here: `.open` states its own below, and `.more` is display: none until the
|
||||
* [multiple][more]/[multiple][empty] showers flip it — one declaration per state, or the
|
||||
* audit's same-selector shadow check fires on this very pair */
|
||||
.cbi-dropdown > .open,
|
||||
.cbi-dropdown > .more {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
/* margin only: base also gave the button's chevron a .5em padding and a border-left divider,
|
||||
* but both were DEAD on arrival — the theme's `.open` rule below zeroes padding and border in
|
||||
* a later layer. A dead declaration is deleted, not relocated. */
|
||||
.cbi-dropdown.btn > .open,
|
||||
.cbi-dropdown.cbi-button > .open {
|
||||
margin-inline-start: .5em;
|
||||
}
|
||||
/* display only: the placeholder's ink is the theme's own `li[placeholder]` --fs-placeholder
|
||||
* below — the same token base/30-forms.css gives the attribute */
|
||||
.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;
|
||||
/* the ellipsis above did nothing: a flex item's automatic minimum floors it at its OWN
|
||||
* content width until `min-width` says otherwise, so `overflow: hidden` never engaged and a
|
||||
* value longer than the box ran past it instead of eliding — measured at 320px, RU cut
|
||||
* mid-glyph 132px (Compact) / 225px (Normal) / 280px (Large) on https-dns-proxy, 66px on
|
||||
* nlbw/config, 70px inside the network interface-edit modal. No `title`/`aria-label` here on
|
||||
* purpose: the full string stays in the DOM and the accessibility tree, so a screen reader
|
||||
* still gets it whole — a `title` is not announced on touch, and an `aria-label` carrying the
|
||||
* SHORTENED text would violate WCAG 2.5.3 (the accessible name must contain the visible
|
||||
* text). */
|
||||
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 ----
|
||||
* Every shower out-specifies the hider it overrides: li[display] (0,2,2) over the li hider
|
||||
* (0,1,2); [open] > ul.dropdown > li (0,3,2) likewise; the placeholder rules (0,5,2 and 0,6,2)
|
||||
* over the placeholder hider (0,4,2); the form rule (0,4,3) over its hider (0,1,3). [empty]'s
|
||||
* block (0,2,2) loses to [open]'s flex (0,3,2) deliberately — an open empty list still lays out
|
||||
* as a popup — which is the resolution the old flags produced among themselves. */
|
||||
.cbi-dropdown > ul > li[display],
|
||||
.cbi-dropdown[open] > ul.preview,
|
||||
.cbi-dropdown[open] > ul.dropdown > li,
|
||||
.cbi-dropdown[multiple] > ul > li > label,
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li,
|
||||
.cbi-dropdown[multiple][more] > .more,
|
||||
.cbi-dropdown[multiple][empty] > .more {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
/* The one state the old flags actually carried: a closed optional single dropdown with an empty
|
||||
* value. ui.js hands the [display] attribute to the placeholder row itself, and the placeholder
|
||||
* hider (0,4,2) out-specifies the generic li[display] shower (0,2,2), so unflagged the closed
|
||||
* control renders EMPTY. `:not([open])` keeps this out of the open list, where the
|
||||
* [optional][open] rule below owns the row. */
|
||||
.cbi-dropdown:not([open]):not(.btn):not(.cbi-button) > ul > li[placeholder][display] {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
.cbi-dropdown[empty] > ul > li,
|
||||
.cbi-dropdown[optional][open] > ul.dropdown > li[placeholder] {
|
||||
display: block;
|
||||
}
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li > form {
|
||||
display: flex;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; }
|
||||
/* `min-width` because the thing that overflows an open row is a CHILD with an inline width, which
|
||||
* wrapping the row cannot reach: form.js's RichListValue writes the description block with an
|
||||
* inline `min-width:25vw`, assuming the list grows to fit — true on a theme that leaves
|
||||
* ul.dropdown unbounded, false here (capped at min(92vw, 420px), 90-responsive.css). The inline
|
||||
* floor is a VIEWPORT fraction, so the wider the screen the worse it gets: at 2559px, 25vw is
|
||||
* 640px inside a 406px row and the row's own `overflow: hidden` cut the sentence mid-word
|
||||
* (issue #15). An author !important is the only thing that outranks an inline declaration. */
|
||||
.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);
|
||||
/* absorbed from base's generic field box with the width below. The fixed height is
|
||||
* load-bearing next to the min-height: without it a closed dropdown grows to its
|
||||
* tallest child (the Save & Apply split control carries a 40px button — measured,
|
||||
* 38 -> 42px on the gallery). */
|
||||
height: var(--fs-control-h);
|
||||
font-size: var(--fs-type);
|
||||
transition: var(--fs-field-transition);
|
||||
/* Width absorbed from base/30-forms.css, where it was grouped with .cbi-dynlist's — so the
|
||||
* theme sized the dynlist and base still sized the dropdown, two halves of the same
|
||||
* decision in two layers. Same numbers, and they are NOT the dynlist's (240/440): a
|
||||
* dropdown holds one value, a dynlist a column of them. */
|
||||
min-width: 210px;
|
||||
max-width: 400px;
|
||||
width: auto;
|
||||
/* The widget's own box. In base these two carried `!important` — not to beat an inline
|
||||
* style, but to beat base's OWN generic form-field rule, which sets `display: inline-block`
|
||||
* and `padding: 4px` on this very selector at a higher specificity (0,3,0 vs 0,1,0). A later
|
||||
* LAYER beats it for free, which is exactly what the layer split is for. */
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
}
|
||||
/* …and the button dropdown (the Save & Apply split control), where the flag was beating the
|
||||
* theme's own `.cbi-button` padding. Specificity does it: 0,2,0 over 0,1,0. */
|
||||
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { display: inline-flex; padding: 0; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button):hover { border-color: var(--fs-dim); }
|
||||
.cbi-dropdown:focus, .cbi-dropdown[open] {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* `.cbi-dropdown > ul.dropdown > li` is the same menu row on a BUTTON dropdown (the Save & Apply
|
||||
* split control), which the `:not(.btn):not(.cbi-button)` guard excludes — so that menu kept
|
||||
* base's .25em rows while every other dropdown got these. One menu, one row. */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li,
|
||||
.cbi-dropdown > ul.dropdown > li {
|
||||
padding: var(--fs-space-2) var(--fs-space-3); color: var(--fs-text);
|
||||
font-family: var(--fs-font-sans);
|
||||
/* one menu row: a grid step, which is what a line of text at this scale rounds to. NOT
|
||||
* `calc(--fs-type * --fs-leading)` — that is 19.5px, and a half-pixel on a BOX edge is
|
||||
* the thing the scale exists to stop. Absorbed with the machinery above. */
|
||||
min-height: var(--fs-space-5);
|
||||
}
|
||||
/* …and the row's CONTENTS, which is where that font is otherwise lost. A MULTI-select wraps every
|
||||
* item in `<form><label><input type=checkbox> text</label></form>`, and `.cbi-value-field *` in
|
||||
* theme/60-inputs.css hands mono to every descendant of a field — matching that form and label
|
||||
* directly, while the rule above only reaches the `<li>`. So the same widget draws two fonts
|
||||
* depending on one option: a single-select list sans, every multi-select list mono, in the open
|
||||
* menu and in the closed control alike.
|
||||
*
|
||||
* It is a LABEL, not a value — the distinction `<strong>` gets in theme/45-misc.css — and mono
|
||||
* spacing is what makes a wordy option list look stretched. `inherit`, not the token again: the
|
||||
* row above is the one place the menu's font is chosen. */
|
||||
.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; }
|
||||
/* The same ink the `placeholder` attribute takes in base/30-forms.css — "-- Please choose --" is
|
||||
* the absence of a value, not one.
|
||||
*
|
||||
* It repeats the row rule's two selectors instead of writing `> ul > li[placeholder]` once,
|
||||
* because that shorter form (0,2,2) LOSES to the row's own `color: var(--fs-text)` (0,3,2) and
|
||||
* the placeholder row rendered in the value's ink on every form dropdown — dead from the day it
|
||||
* was written, and only visible once `placeholder-ink` measured the row. Each selector here
|
||||
* out-specifies its counterpart above: (0,4,2) over (0,3,2), (0,3,2) over (0,2,2). */
|
||||
.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 in theme/60-inputs.css: in base these beat it in-layer, left behind they would lose
|
||||
* to it by layer and render as free-standing 210px fields inside the open list */
|
||||
.cbi-dropdown > ul > li input[type="text"] { height: var(--fs-space-5); }
|
||||
.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child { width: 100%; }
|
||||
|
||||
/* Custom chevron in the toggle (stock text/svg arrow hidden). `.open` is the only hit target that
|
||||
* opens the list — on a button dropdown (.cbi-dropdown.btn, e.g. Diagnostics) a click anywhere
|
||||
* else RUNS the button. `font-size: 0` collapsed the stock `line-height: 2em`, so the span
|
||||
* measured 28x0 and the chevron (an absolute ::after) sat outside its own box: aiming at it hit
|
||||
* the button. Hence a real box, chevron centred in it. */
|
||||
.cbi-dropdown > .open {
|
||||
display: flex;
|
||||
font-size: 0; line-height: 0; border: 0;
|
||||
position: relative; padding: 0;
|
||||
align-self: stretch;
|
||||
min-width: var(--fs-btn-size); min-height: var(--fs-ctl-h-sm);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* `.more` is the "…" a multi-value dropdown shows when its chips overflow. base wrote it in ONE
|
||||
* rule with `.open`; the theme took `.open` for the chevron above and left its twin behind, so
|
||||
* the two halves of the same toggle strip sat in different layers. Same geometry, absorbed —
|
||||
* and hidden by default here (the [multiple][more]/[multiple][empty] showers reveal it),
|
||||
* with base's muted ink. */
|
||||
.cbi-dropdown > .more { line-height: 2em; padding: 0 .3em; color: var(--fs-dim); display: none; }
|
||||
.cbi-dropdown > .open > * { display: none; }
|
||||
.cbi-dropdown > .open::after {
|
||||
content: ""; position: absolute; inset: 0; margin: auto;
|
||||
width: 7px; height: 7px;
|
||||
border-right: 2px solid var(--fs-dim); border-bottom: 2px solid var(--fs-dim);
|
||||
transform: translateY(-2px) rotate(45deg); /* optical centre of a "v" */
|
||||
}
|
||||
/* A BUTTON dropdown draws that chevron on the button's own FILL, where --fs-dim is a grey nobody
|
||||
* measured against it — on the accent-filled Save & Apply it reads as a smudge rather than an
|
||||
* arrow. currentColor takes whatever ink the variant already set, so there is no second list of
|
||||
* variants to keep in sync. The form-control dropdown keeps the muted chevron on purpose: there
|
||||
* the arrow is an affordance beside the value, not the content. */
|
||||
.cbi-dropdown.btn > .open::after,
|
||||
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
|
||||
|
||||
/* open list -> design popover (this was once stated four times over; these are the values
|
||||
* that were winning). */
|
||||
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
/* what makes it a popup at all — out of flow, stacked from the --fs-z-* scale;
|
||||
* absorbed with the machinery */
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: var(--fs-z-dropdown);
|
||||
transition: max-height var(--fs-dur-move);
|
||||
overflow-y: auto;
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
padding: var(--fs-space-1-5);
|
||||
/* the gap that detaches the popover from its control. It was DEAD until the `ul` margin
|
||||
* flags above came out (they out-ranked it from a lower specificity), so the list sat 1px
|
||||
* off the control — the one value that moved when they went. */
|
||||
margin-top: var(--fs-space-1-5);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li {
|
||||
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2) var(--fs-space-3);
|
||||
border-inline-start: 0; color: var(--fs-text);
|
||||
position: relative;
|
||||
/* An OPEN row wraps; only the closed preview ellipsizes. `.cbi-dropdown > ul > li` sets nowrap
|
||||
* plus ellipsis, which is right for the collapsed control — one line showing the current
|
||||
* value — and wrong for the menu: the list is capped at min(92vw, 420px), so an option
|
||||
* carrying a description runs out of box and is cut mid-word, with no ellipsis because the
|
||||
* cut falls inside a nested element (issue #15). Raising the cap instead would let one
|
||||
* verbose option decide the width of every menu on the page. */
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
/* no border-bottom divider: on a rounded box it curls up at the corners. The straight
|
||||
* inset strip below (::after) is the divider, so the hover pill stays rounded. */
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:not(:last-child)::after {
|
||||
content: ""; position: absolute; inset-inline: 11px; bottom: 0;
|
||||
height: 1px; background: var(--fs-border);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:hover,
|
||||
.cbi-dropdown[open] > ul.dropdown > li.focus { background: var(--fs-panel2); color: var(--fs-accent); }
|
||||
/* After hover on purpose: hovering the selected row must still read as selected. The surface is
|
||||
* OPAQUE (--fs-panel2) and the accent is carried by the inset rail, not by a tint of itself:
|
||||
* accent text on --fs-accent-soft measures 4.21:1 in dark, under AA — the tint drags the
|
||||
* background toward the text and eats its own contrast. axe never saw it, no gallery case
|
||||
* rendering an OPEN dropdown with a chosen value. */
|
||||
.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 */
|
||||
}
|
||||
/* The focused row wears the theme's ring, not the browser's: ui.js focuses a row as it opens the
|
||||
* menu, and the UA answers a programmatic focus with `outline: auto` — a system-blue capsule
|
||||
* around the top row of every dropdown that already has a value.
|
||||
*
|
||||
* Inset rather than the token's outer 3px, because the menu clips its own overflow and an outer
|
||||
* ring is cut on the first and last rows. The rail rides INSIDE this ring so a focused row that
|
||||
* is also selected keeps it — a shorthand cannot merge with the one below, so the two are listed
|
||||
* together and `--fs-row-rail` keeps that a reference rather than a third copy.
|
||||
*
|
||||
* Both selectors carry `[selected]`-level weight on purpose: the selected-row paint above is
|
||||
* (0,4,2) and a bare `li:focus-visible` would tie with it, leaving the focus ring to source
|
||||
* order. */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* A MULTI-select row says "chosen" with its CHECKBOX, so it does not also need the rail. The rail
|
||||
* is `inset 2px 0 0` on a row with an 8px radius, so it draws as an ARC rather than a bar —
|
||||
* fine as the single-select cue it was designed for, one row at a time, and wrong down a column
|
||||
* of them, where three chosen rows read as a broken frame. The checkbox is a non-colour cue in
|
||||
* its own right, which is what WCAG 2.2 SC 1.4.1 asks of "selected"; a single-select row has
|
||||
* none and keeps the rail.
|
||||
*
|
||||
* Written as an override rather than by narrowing the paint below, because those three
|
||||
* declarations are pinned byte-identical to `select option:checked` in theme/60-inputs.css
|
||||
* (tools/mirror.mjs) — a native `<select multiple>` option has no checkbox, so the rail is still
|
||||
* the only cue there. `:not(:focus-visible)` keeps this off the focused row. */
|
||||
.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); }
|
||||
/* The dropdown's "add item" field is a text input and takes the same radius as every other
|
||||
* one (theme/60-inputs.css). It said 8px here, and won or lost on source order. */
|
||||
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius); color: var(--fs-text); }
|
||||
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, so the
|
||||
* wrapper stays transparent: no double frame. */
|
||||
.cbi-dynlist > .add-item:has(> .cbi-dropdown) {
|
||||
background: none; border: 0; overflow: visible; box-shadow: none;
|
||||
}
|
||||
|
||||
.cbi-section-table .td .cbi-dropdown,
|
||||
.cbi-section-table .td .cbi-input-select,
|
||||
.cbi-section-table .td select,
|
||||
.cbi-section-table .td input[type="text"] {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Below ~960px of available width a multi-dropdown row no longer fits, so each config row cards.
|
||||
* Same shape as the fs-dt stack (theme/30-tables.css): label above its cell, a read-only cell
|
||||
* half the row, a widget cell the whole width.
|
||||
*
|
||||
* A @container, not the measurement the data tables use, and it must stay one: these rows are
|
||||
* full of widgets that bake in a width from the layout they were rendered in, so un-collapsing
|
||||
* one to take a reading CHANGES what is read — the firewall zone table then claimed 1747px
|
||||
* where it needs 1190px. Do not "finish the job" by measuring it.
|
||||
*
|
||||
* 992, not 960: the query reads `.cbi-map` (theme/45-misc.css), one level above the `.cbi-section`
|
||||
* that actually holds the table, and never subtracts what that level spends — `--fs-card-pad` on
|
||||
* both sides, 32px at the density every page but Compact ships. Measured live (task 0163):
|
||||
* firewall/zones' `.cbi-map` sat at 968px, 936px once the section's own padding was out, and its
|
||||
* six RU headers needed 1017px — 968 cleared the un-adjusted 960px check and the table never
|
||||
* carded, clipping the header row 49-65px with no scroller anywhere near it (English's shorter
|
||||
* headers happened to fit the same 968px, which is why only RU showed it). 992 = 960 + 32 makes
|
||||
* the check ask what it always meant to: whether the SECTION's own content, not the map's, has
|
||||
* cleared 960px. */
|
||||
@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 — a card repeats every label, so the header is a duplicate that
|
||||
* only runs off the right edge. form.js wraps titles and descriptions in a `.thead`, while the
|
||||
* Lua CBI that many third-party apps still render through emits a bare
|
||||
* `tr.cbi-section-table-titles` with no .thead, which is why hiding only `.thead` left
|
||||
* luci-app-openvpn's header on screen. `.tr.table-titles` is deliberately not listed:
|
||||
* it comes from L.ui.Table, which never renders a .cbi-section-table, and is hidden by the
|
||||
* fs-dt stack in theme/30-tables.css. */
|
||||
.table.cbi-section-table .thead,
|
||||
.table.cbi-section-table .tr.cbi-section-table-titles,
|
||||
.table.cbi-section-table .tr.cbi-section-table-descr { display: none; }
|
||||
/* no align-items: flex-start — stretch keeps a pair's cells (and their separators) the
|
||||
* same height when one wraps; see the data card's .tr in theme/30-tables.css. */
|
||||
.table.cbi-section-table .tr {
|
||||
display: flex; flex-wrap: wrap; width: auto;
|
||||
border: 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); }
|
||||
/* `.table.cbi-section-table`, not `.cbi-section-table`: the desktop cell padding is written as
|
||||
* `.table.cbi-section-table .td` and outranks a class-lighter selector, so a plainer one here
|
||||
* silently keeps the desktop padding.
|
||||
*
|
||||
* margin: 0 is load-bearing. The Lua CBI gives a table CELL the same `.cbi-value-field` class
|
||||
* a FORM field carries, and base indents that class by 20px, so a stacked cell measures
|
||||
* 50% + 20px, two no longer fit on a line, and each pair-cell wraps to its own. */
|
||||
.table.cbi-section-table .td {
|
||||
flex: 1 1 100%; min-width: 0; margin: 0;
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
|
||||
/* same as the data card's cells (theme/30-tables.css): a carded cell holds opaque
|
||||
* tokens (a real-length IPv6 GUA is 39 chars with no break point — colons are not
|
||||
* break opportunities), and without `anywhere` one ran 156px past a 360px viewport
|
||||
* on the Interfaces page. overflow-wrap inherits, so the inner spans get it too;
|
||||
* white-space beats the `.nowrap` class LuCI puts on some cells. */
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* A cell that only READS a value (dummy text, a flag, one button) is short, so two fit on a
|
||||
* line; anything with a real widget keeps the full width (the 100% basis above) — a dropdown
|
||||
* at half a phone's width is unusable. Both spellings of each widget: `CBI.DummyValue` from
|
||||
* form.js, `dvalue` from the Lua CBI's typename. */
|
||||
.table.cbi-section-table .td[data-widget="CBI.DummyValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.FlagValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.Button"],
|
||||
.table.cbi-section-table .td[data-widget="dvalue"],
|
||||
.table.cbi-section-table .td[data-widget="fvalue"],
|
||||
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
|
||||
/* PINNED to the data table's copy in theme/30-tables.css: the same card, needed under a CLASS
|
||||
* there (.fs-stacked, measured) and under this @container here, and CSS cannot OR the two
|
||||
* guards. tools/mirror.mjs holds the copies byte-identical — a duplicate detector goes QUIET
|
||||
* once they differ, exactly when it should shout. */
|
||||
.table.cbi-section-table .td[data-title]::before {
|
||||
/* @mirror table-card/label */
|
||||
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* The row's buttons are not a label/value pair: full width, each button stretching to
|
||||
* share the line (Edit | Delete). PINNED — see above. (These six declarations were the
|
||||
* data table's six in a DIFFERENT ORDER, so a grep could not tell they were one rule.) */
|
||||
.table.cbi-section-table .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
|
||||
/* flex-wrap: LuCI groups the row's buttons in ONE inner <div>, so the td's own wrap
|
||||
* (above) never fires — the div is a single flex item. Four buttons are ~330px; on a
|
||||
* 320px phone the carded interfaces row overflowed its section by 17px. PINNED to the
|
||||
* data table's copy (theme/30-tables.css), which hit the same trap on Startup. */
|
||||
.table.cbi-section-table .td.cbi-section-actions > * {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
.table.cbi-section-table .td .cbi-dropdown,
|
||||
.table.cbi-section-table .td select,
|
||||
.table.cbi-section-table .td input[type="text"] { width: 100%; min-width: 0; }
|
||||
.table.cbi-section-table .td .zone-forwards { width: 100%; }
|
||||
}
|
||||
|
||||
/* ---- widget popups must escape the table card ----
|
||||
* The standalone-table card (theme/30-tables.css) clips row backgrounds to its rounded corners
|
||||
* with `overflow: hidden`, but a form table also holds .cbi-dropdown widgets whose open list is
|
||||
* absolutely positioned — the clip crops it at the table's edge. So drop the clip where a widget
|
||||
* lives; the `.cbi-dropdown[open]` z-index above lifts the list over the following cards.
|
||||
*
|
||||
* Dropping it squares the frame off, though: a real <table> inherits `border-collapse: collapse`,
|
||||
* which ignores border-radius — the card's corners came from the clip, not the border. The
|
||||
* separated model restores them, and at zero spacing it lays out identically. */
|
||||
.table:has(.cbi-dropdown),
|
||||
.table:has(.cbi-dynlist) {
|
||||
overflow: visible;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@layer theme {
|
||||
/* base/60-modal.css draws the dialog's elevation in --fs-panel2, which in footstrap light is the
|
||||
* same hex as --fs-bg: the shadow under the top of the z-stack drawn in the exact colour of the
|
||||
* page behind it (1.00:1 light, 1.27 dark — a glow, never a shadow, panel2 being lighter than
|
||||
* the canvas). Every other floating surface in the theme takes --fs-shadow-pop. Unscoped
|
||||
* `.modal`, not `#modal_overlay .modal`: .modal is reachable outside the overlay, and the layer
|
||||
* beats base at equal specificity. */
|
||||
.modal { box-shadow: var(--fs-shadow-pop); }
|
||||
|
||||
/* The uci-changes diff takes its tints from the ladder in base (--fs-*-fill for the block,
|
||||
* --fs-*-line for its border) and nowhere else — a second set here paints the same widget at two
|
||||
* strengths depending on whether it is inside the modal, which it always is. Only geometry is
|
||||
* left here: small rounding on the diff boxes and legend swatches.
|
||||
*
|
||||
* The popup's own radius is not restated: base rounds a modal to `var(--fs-radius-lg)` with a
|
||||
* comment saying a modal is a card. */
|
||||
|
||||
/* moved from base with the heading ramp (45-misc.css): the `line-height: normal` here beat
|
||||
* the ramp's tight leading in-layer for a dialog's h4 title, and left behind it would lose
|
||||
* to the ramp by layer (measured: modal headings dropped normal -> 15.6px) */
|
||||
.modal > * {
|
||||
line-height: normal;
|
||||
margin-bottom: .5em;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* an alert dropped into a dialog keeps the alert's own rhythm. In base this resolved by
|
||||
* FILE ORDER (90-widgets after 60-modal, both 0,1,0) — here it wins on specificity, so the
|
||||
* cascade no longer rests on position. */
|
||||
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
|
||||
/* moved from base with the generic field box in theme/60-inputs.css: a dialog's log/output
|
||||
* textarea fills the dialog, and base's width:100% would lose to the 210px box by layer */
|
||||
.modal > pre,
|
||||
.modal > textarea {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* A config-form modal (Bridge device options, firewall rule …) is only 600px wide by
|
||||
* default — cramped for a form with a table (Bridge VLAN filtering, whose .cbi-map container
|
||||
* then falls under the 992px card threshold and cards even on a big screen). Fluid via vw:
|
||||
* widens toward 1100 on desktop (the container clears 992 and tables stay tables), stays
|
||||
* narrow (cards) on tablet/phone. */
|
||||
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
|
||||
|
||||
/* A modal must never gain a HORIZONTAL scroll: #modal_overlay is the scroll container
|
||||
* (base/60-modal.css, `overflow: auto`) and the dialog is centred in it with `margin: auto`, so
|
||||
* one over-wide child scrolls the modal's own
|
||||
* left edge off the screen — the dialog still there, just unreachable-looking. Measured at
|
||||
* 360px: the overlay wanted 634px of scroll against 530px of room.
|
||||
*
|
||||
* Two children do it, both stock LuCI markup:
|
||||
* - `<label class="btn">` — flash.js writes each checkbox row as one, and `.btn` is
|
||||
* `white-space: pre`, which is right for a button's own label and wrong for a sentence, so
|
||||
* it cannot wrap at any width. Scoped to `label`; a real button keeps `pre`.
|
||||
* - a `<li>` carrying a checksum, one unbreakable 64-character token. */
|
||||
/* …and `overflow-wrap` with it: `white-space: normal` lets the sentence wrap at its spaces, which
|
||||
* is not enough for a label ending in a ~200px path token — in any dialog narrower than that the
|
||||
* label still runs past the box. Measured in the gallery at a 320px viewport: the label was 277px
|
||||
* inside a 264px column, 13px out.
|
||||
*
|
||||
* `min-width: 0` is the other half, and without it the wrap never fires: `.btn` is `inline-flex`,
|
||||
* so the label is a flex CONTAINER whose automatic minimum size is its content's min-content.
|
||||
*
|
||||
* `anywhere` rather than `break-word`, which follows the tier doctrine rather than contradicting
|
||||
* it (base/40-tables.css states it for cells): `break-word` leaves min-content at the widest
|
||||
* token, and the widest token here is wider
|
||||
* than the dialog at 320px, while the anonymous text flex item cannot be given a `min-width` of
|
||||
* its own. A dialog label has no card to fold into. */
|
||||
#modal_overlay .modal label.btn { white-space: normal; overflow-wrap: anywhere; min-width: 0; }
|
||||
#modal_overlay .modal li { overflow-wrap: anywhere; }
|
||||
#modal_overlay .uci-dialog div > ins,
|
||||
#modal_overlay .uci-dialog div > del,
|
||||
#modal_overlay .uci-dialog div > var { border-radius: var(--fs-radius-sm); }
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
/* same token as the diff blocks above — a literal 4px sat outside the Rounding axis */
|
||||
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); overflow: hidden; }
|
||||
|
||||
/* A changed option is `<var><ins>uci set …</ins></var>`: the outer plate is rounded above and the
|
||||
* coloured fill is the child, and `overflow: hidden` does not round it, because the child never
|
||||
* reaches the corner it would be clipped at — the parent holds it 3px in (1px border plus 2px
|
||||
* padding). So the plate has rounded corners with a square block inside them, and the legend's
|
||||
* swatches are squares inside circles (issue #14). The child takes the parent's radius minus
|
||||
* that inset, which makes the two curves concentric; `max()` keeps it at 0 rather than negative
|
||||
* when Rounding is turned down. */
|
||||
#modal_overlay .uci-dialog var > ins,
|
||||
#modal_overlay .uci-dialog var > del {
|
||||
border-radius: max(0px, calc(var(--fs-radius-sm) - 3px));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
@layer theme {
|
||||
/* ==================================================================
|
||||
* The page-search palette. Markup is built by fs-search.js and parented to <body>:
|
||||
* the vertical .fs-sidebar is `overflow: auto`, which would clip a child of the trigger,
|
||||
* and the overlay has to cover the whole viewport anyway.
|
||||
* ================================================================== */
|
||||
|
||||
/* The scrim. --fs-scrim is the theme's one colour literal (plain black at .7) on purpose:
|
||||
* it is the absence of light behind a dialog, not a tint of any palette token. */
|
||||
.fs-search-ov {
|
||||
position: fixed; inset: 0; z-index: var(--fs-z-popover);
|
||||
/* `display` here is what the `hidden` attribute has to beat — base/10-reset.css does that for
|
||||
* every element now, so this file no longer restates it for the overlay alone. */
|
||||
display: flex; justify-content: center;
|
||||
/* not centred vertically: the box grows DOWNWARD as results come in, and a centred one
|
||||
* would slide the input out from under the user's eyes on every keystroke */
|
||||
align-items: flex-start;
|
||||
padding: max(8vh, var(--fs-space-6)) var(--fs-space-4) var(--fs-space-4);
|
||||
background: var(--fs-scrim);
|
||||
animation: fs-fade var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
/* The dialog itself. It is a SCROLL CONTAINER, and that is not decoration: without a cap the
|
||||
* box grows past a short viewport (a phone in landscape is ~380px tall) and the rows at the
|
||||
* bottom become unreachable — a `position: fixed` popup cannot be scrolled by the page. */
|
||||
.fs-search-box {
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
width: 100%; max-width: 560px; max-height: min(70dvh, 560px);
|
||||
background: var(--fs-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 input carries no frame of its own — the ROW is the field and the box around it is the
|
||||
* dialog. Two classes (0,2,0) on purpose: the generic field box in 60-inputs.css is
|
||||
* `input[type="text"], …` (0,1,1) and outranks a single class in the same layer whatever the
|
||||
* source order — measured, it painted this input with the page-field border, the panel2 fill,
|
||||
* 8px/11px of padding and a focus ring inside the dialog's own frame.
|
||||
*
|
||||
* 16px rather than the theme's body size, deliberately: a font-size below 16px makes iOS Safari
|
||||
* zoom the page when the field takes focus, and this one takes focus 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) — this row is not measured by
|
||||
* `placeholder-ink`, the gallery having no search dialog, and 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 both pointer and keyboard: `.active` is set by fs-search.js for the
|
||||
* aria-activedescendant row AND on pointermove, so the mouse and the arrow keys can never
|
||||
* disagree about which row Enter would open. */
|
||||
.fs-search-opt.active { background: var(--fs-accent-soft); color: var(--fs-accent); }
|
||||
.fs-search-opt-title { flex: 0 1 auto; font-weight: var(--fs-weight-bold); font-size: var(--fs-type); }
|
||||
/* the breadcrumb: which section owns the page — the whole point of searching for it */
|
||||
.fs-search-opt-path {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
color: var(--fs-faint); font-size: var(--fs-type); text-align: end;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fs-search-opt.active .fs-search-opt-path { color: inherit; }
|
||||
|
||||
/* "Recently visited" — the caption over the rows */
|
||||
.fs-search-note {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--fs-space-2-5) var(--fs-space-3-5) var(--fs-space-0-5);
|
||||
color: var(--fs-eyebrow-color);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* …and the box's EMPTY STATE when there are no rows at all ("No pages found"), which reads as
|
||||
* a sentence, not as an eyebrow label. :has() rather than a sibling combinator: the note sits
|
||||
* ABOVE the list it describes, so `+` cannot reach back to it. */
|
||||
.fs-search-box:has(.fs-search-list:empty) .fs-search-note {
|
||||
padding: var(--fs-space-6) var(--fs-space-3-5) var(--fs-space-7);
|
||||
text-align: center; text-transform: none; letter-spacing: 0;
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
@layer theme {
|
||||
/* Phone overflow safety net (<=767px). .fs-main uses overflow-x: clip, so anything wider than the
|
||||
* viewport is silently CUT on the right rather than scrolled to. A device sweep found the
|
||||
* offenders below; each fix lets the control or table shrink or wrap. Tablets were already
|
||||
* clean. */
|
||||
/* A pair of buttons an app packs into one `inline-flex` must be allowed to wrap its labels.
|
||||
*
|
||||
* `white-space: pre` on a button is stock — luci-theme-bootstrap sets it too — and it holds
|
||||
* while the label is short. Ours are wider than stock at the same job: measured on
|
||||
* ssclash's split button at 320, `Сохранить и перезагрузить конфигурацию` is 295px against
|
||||
* stock's 245, because this theme's face is wider than the system stack even at a smaller
|
||||
* size (13px vs 14px), and the 14px side padding adds 12 of the 50. The app's wrapper is
|
||||
* `inline-flex`, so it takes the buttons' max-content — 324px inside a 288px column — and
|
||||
* overflows by 36. Stock fits only because its buttons are narrower.
|
||||
*
|
||||
* Narrowing the buttons would be re-typesetting the theme to suit one app. Letting the label
|
||||
* wrap costs nothing where it already fits — a short label has no wrap point — and turns the
|
||||
* one case that does not into two lines. Scoped to the width where it happens: at 390 the
|
||||
* same page is clean.
|
||||
*
|
||||
* `min-width: 0` with it, or the automatic minimum keeps the button at its longest word and
|
||||
* the flex line cannot shrink it at all.
|
||||
*
|
||||
* `word-break: normal` is the third half of it. base/10-reset.css gives every button
|
||||
* `break-all` — carried over from the fork, and inert while the label cannot wrap at all. The
|
||||
* moment it can, `break-all` takes precedence over word boundaries and the label breaks mid-word
|
||||
* (`Сохранить и перезагрузить ко` / `нфиг`). `overflow-wrap: break-word` keeps the escape hatch
|
||||
* for a single word wider than the button, which is what `break-all` was there for. */
|
||||
@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) {
|
||||
/* The foreign-<table> scroll fallback used to live here, guarded by this query. It does not
|
||||
* any more: whether a table fits is a property of its CONTENT and its COLUMN, never of the
|
||||
* viewport — the same argument the fit engine is built on — and a 400px-wide panel on a
|
||||
* 1600px desktop needs it exactly as much as a phone does. It is unguarded now, in
|
||||
* theme/30-tables.css, next to the card stack it is the fallback FOR. */
|
||||
|
||||
/* Must stay INSIDE this @media, for specificity within one layer: the 400px cap lives in
|
||||
* theme/65-dropdown.css on `.cbi-dropdown:not(.btn):not(.cbi-button)` — (0,3,0) against this
|
||||
* rule's (0,1,0) — so unscoped this would lose to it everywhere and cap nothing. Inside the
|
||||
* phone tier it is what the narrow width needs: a dropdown must never exceed its field. */
|
||||
.cbi-dropdown { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* Software page: stack each labelled control. base gives its labels a min-width: 250px that
|
||||
* overflows a ~360px screen, and `.controls` is a flex ROW, so its columns cram side by side
|
||||
* and overlap. Scoped by data-page, not by an id: on 24.10 the "Disk space:" label ships with
|
||||
* no id, so a `:has()` scope silently misses the whole page there. !important because
|
||||
* package-manager.js injects an unlayered inline style block, which outranks every layer.
|
||||
* Scoped by `#view[data-page]`, not `body[data-page]`: the attribute lives on the view
|
||||
* itself so the outgoing page keeps its own rules for the whole staging window (fs-router.js,
|
||||
* commitStage) — the same bug and the same fix as styles/pages/30-software.css, discovered
|
||||
* here separately because this narrow-viewport tier is invisible to a gate run at desktop
|
||||
* width (tools/spa-parity.mjs's narrow-viewport pass).
|
||||
* `:where(#view)`, not bare `#view`: zeroes the id's specificity while matching the exact
|
||||
* same element — 20-overview.css, top of `@layer page`, has the full reasoning and the
|
||||
* measured numbers. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls { display: block !important; }
|
||||
/* `:not(.pager)` — the page reuses `.controls` for the « / "Displaying 1-100 of 7677" / »
|
||||
* pager, and its .pager is a <div> too. Blocking it stacked those three onto three lines
|
||||
* (reported). Only the LABELLED control groups want stacking; the pager is a row. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
|
||||
/* …and the pager itself: a centred row that stays one. `.text` is the only part that may
|
||||
* give way, so the two buttons keep their size instead of being squeezed. */
|
||||
: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; }
|
||||
/* the bar's ::after value ("63% used …") normally floats above the bar, but the "Disk
|
||||
* space:" label already sits there — on a phone the long value text overlapped label +
|
||||
* bar. Drop it BELOW. The bar is a fixed 10px box, so a static ::after cannot push the
|
||||
* next control: reserve space with margin-bottom and place the value absolutely in it.
|
||||
* The disk section is the only `label` immediately followed by a `.cbi-progressbar`. */
|
||||
: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` — the group's TITLE label ("Filter:", "Actions:", "Display LuCI
|
||||
* translation packages:"), not every label on the page. Written as plain `.controls label`
|
||||
* it also blocked the three radio labels nested a level deeper, and `filtered / all / none`
|
||||
* came out one per line. They are a set of three short choices: they belong on one row. */
|
||||
: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); }
|
||||
/* …and the row they sit in. Stock separates them with text nodes, which is enough to
|
||||
* keep them apart but not to keep them from splitting mid-choice — a flex row with a real
|
||||
* gap wraps between labels instead of inside one. */
|
||||
: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%; }
|
||||
|
||||
/* The Diagnostics stack is not here: a page name and a viewport are neither of the facts that
|
||||
* matter. Any app can put a field, a select and a button in one header-less
|
||||
* `<div class="table">`, and what decides whether that row fits is the ROOM — at 320px the
|
||||
* row needs 338px, and inside a narrow panel at 900px of viewport it needs the same.
|
||||
* fs-select.js writes `.fs-rowstack` on any such table that overflows, at any width, and
|
||||
* theme/30-tables.css carries the declarations. */
|
||||
|
||||
/* No `table.fs-dt { display: block; width: 100% }` here — it sat here to stop a data table
|
||||
* that still fitted as a table from exceeding its column, and its absence is what makes the
|
||||
* measured design work at phone widths at all: `display: block` on a <table> DISCARDS the
|
||||
* table formatting
|
||||
* context, so the rows are wrapped in an anonymous table sized to the block and the table can
|
||||
* never be wider than its parent — which means it can never overflow, which means
|
||||
* `fit.overflows()` can never see anything below 767px. Measured on Processes at 720px with
|
||||
* such a rule in place: the Command column sat at 126px against a widest token of 353px, and
|
||||
* forcing that column's `overflow-wrap` three different ways produced the same 126px and the
|
||||
* same "the table fits" every time. */
|
||||
|
||||
/* generic caps: an input/select must never exceed its field, and long key/value strings
|
||||
* in the Overview cards must break. */
|
||||
.cbi-value-field input, .cbi-value-field select, .cbi-value-field .cbi-dropdown { max-width: 100%; }
|
||||
/* …and once the label is on its own line the control takes the line it was given — a
|
||||
* field sized to its content left half the row empty and made the form read as a ragged
|
||||
* column (stock does the same on a phone). checkbox/radio/file keep their intrinsic size:
|
||||
* a full-width checkbox is a hit target the size of the screen. */
|
||||
.cbi-value-field select,
|
||||
.cbi-value-field input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
|
||||
.cbi-value-field .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-value-field .cbi-dynlist,
|
||||
.cbi-value-field .cbi-select { width: 100%; }
|
||||
/* min-width only: breaking a long value is base/40-tables.css's default for every cell now,
|
||||
* and this copy moved nothing when switched off against 126 matching cells. `min-width: 0` is
|
||||
* NOT redundant — switching that off moved 12 — so it stays. */
|
||||
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
|
||||
|
||||
/* Stack each field: label on its own line, control and description full-width below. Side by side
|
||||
* on the desktop squeezes the field — and any .cbi-value-description inside it — into a
|
||||
* ~120px column on a phone, so one sentence of help wraps to 8 lines. The row already
|
||||
* flex-wraps; these two full-width children are what stack it.
|
||||
*
|
||||
* Not scoped to #view, and that is the point: ui.js appends a modal to <body>, so an
|
||||
* `#view .cbi-value` rule leaves every DIALOG in the desktop layout and on a phone the
|
||||
* Edit-interface form pushes its inputs past the dialog's right edge. */
|
||||
/* `start` undoes base's `text-align: end` (the label hugs its field when the two share a
|
||||
* row); stacked, it goes back to the reading edge. Logical for the same reason base's is. */
|
||||
.cbi-value > .cbi-value-title { flex: 1 1 100%; text-align: start; padding-top: 0; }
|
||||
.cbi-value > .cbi-value-field { flex: 1 1 100%; min-width: 0; }
|
||||
|
||||
/* The stock phone contract every luci-app-* is written against. These classes are emitted by
|
||||
* LuCI's own JS and by third-party apps that copied them, and they are styled nowhere but
|
||||
* bootstrap's mobile.css — so a theme that ignores them renders a phone layout its own app JS
|
||||
* believes it has already handled. */
|
||||
/* A column the emitting JS asks to DROP on a phone: the wireless Associated Stations table marks
|
||||
* its MAC column .hide-xs, and stock LuCI shows Network/Host/Signal/Rate and no MAC.
|
||||
*
|
||||
* The `.table .td.hide-xs` half is not redundant: the stacking rules select the cell as
|
||||
* `table.fs-dt .td` (0,2,1) and `.cbi-section-table .td` (0,2,0), both outranking a bare
|
||||
* `.hide-xs` (0,1,0), so the cell keeps their
|
||||
* `display: block` and stays visible. Stock LuCI wins this with !important; one class more
|
||||
* wins it without one. */
|
||||
.hide-sm, .hide-xs,
|
||||
.table .td.hide-sm, .table .td.hide-xs,
|
||||
.table .th.hide-sm, .table .th.hide-xs { display: none; }
|
||||
/* The `.col-N` weights used to live here, inside the phone tier. They are UNGUARDED now,
|
||||
* in theme/30-tables.css: `flex` is inert on anything that is not a flex item, and a `.td`
|
||||
* becomes one exactly when its table cards — so one copy covers the phone tier, the
|
||||
* measured stack at any width and the config table's @container, with no second copy to
|
||||
* keep in step. See the block there. */
|
||||
|
||||
/* iOS Safari zooms the page in when a focused control's text is under 16px and never zooms
|
||||
* back out — the theme's 13px inputs did that on every form. Stock LuCI needs !important
|
||||
* for it; here the theme layer is enough. */
|
||||
input, textarea, select, .cbi-dropdown > ul > li { font-size: var(--fs-type-lg); }
|
||||
}
|
||||
|
||||
/* Dropdown list: anchored to its widget, never full-bleed.
|
||||
*
|
||||
* ui.js has a touch branch (`'ontouchstart' in window`) that stretches the open list across the
|
||||
* viewport with INLINE offsets — `ul.style.left = ${-rect.left}px`, `ul.style.right =
|
||||
* ${rect.right - vpWidth}px` — which assume the list's containing block is the
|
||||
* viewport. It is not: .cbi-dropdown is position: relative, so a negative `left` drags the list
|
||||
* off to the side and clips the labels. An author !important is the only thing that outranks an
|
||||
* inline declaration, so pin it back under the widget. Harmless on desktop, where ui.js sets
|
||||
* only top/bottom/max-height.
|
||||
*
|
||||
* Only left/right carry the flag — those are the two properties ui.js writes inline. The widths
|
||||
* never appear in an inline style (ui.js only CLEARS `ul.style.width`, on close), so they win
|
||||
* from this layer on their own. */
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: auto !important;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
max-width: min(92vw, 420px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
@layer theme {
|
||||
/* prefers-reduced-motion — a medical accommodation: vestibular disorders make sliding, fading and
|
||||
* spinning UI genuinely nauseating. Honoured globally rather than per component; every
|
||||
* transition and keyframe in this theme is decoration and none carries information.
|
||||
*
|
||||
* The !important here is the documented exception, and not the same kind as the ones that fight
|
||||
* an inline `style=`: a cascade layer cannot help, since styles/base sets transitions too and
|
||||
* !important INVERTS the layer order, so only an important declaration reaches all of them at
|
||||
* once. This is the canonical form as published by the WCAG working group. DO NOT COPY THE FLAG
|
||||
* OUT OF THIS BLOCK.
|
||||
*
|
||||
* .01ms rather than 0s: a zero duration means `transitionend`/`animationend` never fire, and JS
|
||||
* that waits for one (LuCI's modal teardown does) would hang. `scroll-behavior` is on the list
|
||||
* because smooth scrolling is motion too, and a jump is what this user asked for. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* prefers-reduced-transparency — the user asked the OS for opaque surfaces. Every surface in the
|
||||
* theme is opaque already: no blur, an opaque bar, opaque pop surfaces. The one thing left is the
|
||||
* wash over an uploaded photo. */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
:root {
|
||||
/* the uploaded background is a translucent wash over a photo — exactly the effect this
|
||||
* user asked the OS to drop. Make the scrim opaque so the canvas is flat, not a photo
|
||||
* ghosting through (16-login-bg.css lays it over the image as one background layer). */
|
||||
--fs-photo-scrim: var(--fs-bg);
|
||||
}
|
||||
}
|
||||
|
||||
/* prefers-contrast — the user asked the OS for MORE contrast but, unlike forced-colors, left the
|
||||
* palette to us. Same mechanism as reduced-transparency: re-state the tokens and every rule that
|
||||
* reads them follows. What moves and why:
|
||||
* - the hairlines: --fs-border is the most-read line in the theme and sits at ~20% of the
|
||||
* text's contrast, so it is pulled toward --fs-text. The role hairlines (-line/-line-hi, a
|
||||
* 40/55% tint of their role) go to the FULL role colour — a tint exists to be quiet, which
|
||||
* is the opposite of what this user asked for.
|
||||
* - secondary text: --fs-dim toward --fs-text, and --fs-faint most of the way there too
|
||||
* (10-11px eyebrow labels are where AA erodes first). Not all the way ONTO --fs-dim: the two
|
||||
* are the export tier's --text-color-medium and -low, so collapsing them republishes one
|
||||
* colour twice to every app reading the gradation. 82% is a window tools/export-tier.mjs
|
||||
* measures rather than takes on trust: below 80 the fainter ink drops under AA on footstrap
|
||||
* dark's --fs-panel2 (4.51:1 at 80), above 82 the high-to-low ramp falls under the 0.10
|
||||
* spread the tier promises (0.098 at 84); 82 clears both, at 4.68:1 and 0.110.
|
||||
* - --fs-placeholder, in BOTH modes. It is the one token the theme ships deliberately under AA
|
||||
* (3.99-4.48:1 light, 3.43-6.05:1 dark): a hint has to move far enough not to read as a
|
||||
* typed value, and a dark palette has only 6.45:1 of ink to spend. A reader who asked the OS
|
||||
* for more contrast has said which of the two they want, so the mixes go back to the
|
||||
* AA-clearing 62% and 80% — 4.98:1 and 4.61:1, at 37% and 20% of the ink-to-field range
|
||||
* instead of 44% and 34%. Only --fs-placeholder-mix moves: the mix itself is spelt once, in
|
||||
* 03-palettes.css. `placeholder-ink` measures this pass as well.
|
||||
* - the focus ring: --fs-focus-ring is a tint of the accent (--fs-accent-soft, 10% light and
|
||||
* 15% dark), so at "more contrast" it carries the accent at 60% instead. Its sibling
|
||||
* --fs-focus-ring-solo is already the solid accent and needs no restatement here. */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--fs-border: color-mix(in srgb, var(--fs-text) 45%, var(--fs-bg));
|
||||
--fs-dim: color-mix(in srgb, var(--fs-text) 85%, var(--fs-bg));
|
||||
--fs-faint: 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-warn-line: var(--fs-warn); --fs-warn-line-hi: var(--fs-warn);
|
||||
--fs-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
|
||||
/* the placeholder is the one token shipped under AA (see the note above); this reader
|
||||
* asked for contrast, so both modes buy the AA ink back, at the percentages that clear
|
||||
* it. Light here, dark in the block below — per mode exactly as in 03-palettes.css. */
|
||||
--fs-placeholder-mix: 62%;
|
||||
}
|
||||
:root[data-darkmode="true"] { --fs-placeholder-mix: 80%; }
|
||||
}
|
||||
|
||||
/* forced-colors (Windows High Contrast) — the OS replaces every colour the theme picked, and
|
||||
* background-color on most elements is forced to the system canvas, so any state carried by
|
||||
* BACKGROUND alone vanishes: the active tab pill, the checked toggle and the selected dropdown
|
||||
* row all look exactly like their inactive siblings, which is a correctness bug rather than a
|
||||
* cosmetic one. Re-state those states with a border or outline, which forced-colors keeps, in
|
||||
* system colour keywords so they follow the user's scheme. */
|
||||
@media (forced-colors: active) {
|
||||
/* Active tab, selected dropdown row, checked toggle: outline the state.
|
||||
*
|
||||
* `.ifacebox-head.active` and Apply join the list rather than getting a rule of their own,
|
||||
* being the same KIND of state — carried by a background, with no word to fall back on — and
|
||||
* the same repair. "This port is up" was indistinguishable from a dead port, and Save & Apply
|
||||
* did not read as a BUTTON at all: it is a .cbi-dropdown wrapper, so with its fill forced to
|
||||
* Canvas only the label inside the <li> kept a highlight.
|
||||
*
|
||||
* Not extended to the alert and .label variants: encoding severity in border-style is not a
|
||||
* scale anyone can learn, and the honest repair is a word or an icon in the markup, which is
|
||||
* ui.js's. */
|
||||
#tabmenu ul.tabs > li.active > a,
|
||||
ul.cbi-tabmenu.mode > li.cbi-tab > a,
|
||||
.tabs > li.active > a,
|
||||
.cbi-tabmenu > li.cbi-tab > a,
|
||||
.cbi-dropdown > ul > li[selected],
|
||||
.ifacebox .ifacebox-head.active,
|
||||
.cbi-page-actions .cbi-button-apply {
|
||||
outline: 2px solid Highlight;
|
||||
outline-offset: -2px;
|
||||
color: HighlightText;
|
||||
background: Highlight;
|
||||
forced-color-adjust: none; /* keep the fill: it IS the state */
|
||||
}
|
||||
/* Which page am I on: the menu's active item carries its state in exactly the two things forced
|
||||
* colors drops, a 10%-alpha accent background and the box-shadow rail. Measured with the query
|
||||
* emulated, all twelve #topmenu links reported the same weight, shadow, border and colour, so
|
||||
* the current page was not marked at all. `aria-current="page"` is on the link, so this was a
|
||||
* sighted-user loss only. The rail comes back as a real border, which forced colors keeps. */
|
||||
#topmenu li.active > a {
|
||||
border-inline-start: 3px solid Highlight;
|
||||
padding-inline-start: calc(var(--fs-space-3) - 3px);
|
||||
}
|
||||
/* the toggle switch is a bare rounded box in forced colors — give the knob and
|
||||
* the track an edge so on/off is distinguishable at all. Scoped via `input[type="checkbox"]`,
|
||||
* matching 60-inputs.css, which now draws the switch there: an `<input>` selector cannot
|
||||
* reach ui.Checkbox's `tooltip` label (the hint-icon one, unstyled otherwise, built with no
|
||||
* `for` attribute) by construction, so the old `[for]` discriminator is no longer needed to
|
||||
* keep that label from growing a border it does not own. */
|
||||
.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; }
|
||||
/* the focus ring is a box-shadow everywhere in this theme, and box-shadow is
|
||||
* dropped entirely in forced-colors — so keyboard focus would be invisible */
|
||||
:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@layer theme {
|
||||
/* ---- print ----
|
||||
* A LuCI page ends up on paper or in a PDF as documentation, and without this block it prints
|
||||
* the dark chrome, the sticky bar and — in the sidebar layout — ONE page, the shell being
|
||||
* height:100svh with overflow:hidden there.
|
||||
*
|
||||
* No `!important`: tools/audit.py allows the flag only in its listed files, and nothing here
|
||||
* fights an inline style. Where a rule must beat a same-layer
|
||||
* chrome rule it wins on SPECIFICITY — a repeated class at (0,4,0), one step over the layout
|
||||
* guards' (0,3,1) — never on this file sorting after 20-shell.css. */
|
||||
@media print {
|
||||
/* Ink-friendly surfaces whatever palette/mode is active: this block sits in the
|
||||
* theme layer, so it outranks every tokens-layer palette block without naming
|
||||
* data-darkmode or the palettes. Every rule reads the tokens, so one re-statement
|
||||
* re-paints the whole page. */
|
||||
:root {
|
||||
--fs-bg: #fff; --fs-panel: #fff; --fs-panel2: #f4f4f4;
|
||||
--fs-text: #000; --fs-dim: #333; --fs-faint: #555;
|
||||
--fs-border: #bbb; --fs-track: #ddd;
|
||||
--fs-shadow-pop: none;
|
||||
--fs-bar-bg: #fff;
|
||||
}
|
||||
/* Navigation, the search overlay and the action buttons cannot be used on paper. The
|
||||
* Appearance popover was on this list; it is a PAGE now, and a page prints — an admin
|
||||
* printing System -> Appearance wants what is on it. */
|
||||
.fs-sidebar.fs-sidebar.fs-sidebar.fs-sidebar,
|
||||
.fs-skip.fs-skip,
|
||||
.fs-search-ov.fs-search-ov,
|
||||
.cbi-page-actions.cbi-page-actions,
|
||||
.cbi-tabmenu.cbi-tabmenu,
|
||||
footer { display: none; }
|
||||
/* Undo the sidebar layout's scroll frame — the ONE-page bug above. */
|
||||
.fs-shell.fs-shell.fs-shell.fs-shell { height: auto; overflow: visible; }
|
||||
.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
|
||||
.fs-content.fs-content { max-width: none; padding: 0; }
|
||||
/* same element as 15-wallpaper.css, later in the layer — the pattern stays off the paper */
|
||||
.fs-pattern.fs-pattern { display: none; }
|
||||
/* and the uploaded photo — ink, not decoration, on paper (16-login-bg.css) */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) { background-image: none; }
|
||||
/* keep a card in one piece where the page allows it */
|
||||
.cbi-section { break-inside: avoid; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{% include('themes/footstrap/partials/footer') %}
|
||||
@@ -0,0 +1,184 @@
|
||||
{#
|
||||
Copyright 2008 Steven Barth <steven@midlink.org>
|
||||
Copyright 2012 David Menting <david@nut-bolt.nl>
|
||||
Copyright 2008-2022 Jo-Philipp Wich <jo@mein.io>
|
||||
Copyright 2026 footstrap theme
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
|
||||
i18n, and the rule the other templates point at. A msgid is a GLOBAL name: LuCI serves one merged
|
||||
catalogue — load_catalog() loads every *.<lang>.lmo 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 || '';
|
||||
|
||||
/* Optional companion packages announce themselves in the theme's own config —
|
||||
`uci add_list footstrap.settings.plugin=<module>` from the package's uci-defaults — rather
|
||||
than being named in this tree. The chrome requires each name and knows nothing else about it,
|
||||
so the edge points inwards exactly as fs-router.onNavigate does.
|
||||
|
||||
Whitelisted to the shape of a LuCI module name before it is printed into a <script>: writing
|
||||
that file needs root, but a value that reaches an inline script is checked anyway. */
|
||||
let fs_plugins = fsd.plugin ?? [];
|
||||
if (type(fs_plugins) != 'array')
|
||||
fs_plugins = [ fs_plugins ];
|
||||
fs_plugins = filter(fs_plugins, (m) => type(m) == 'string' && match(m, /^[a-zA-Z0-9._-]{1,48}$/));
|
||||
|
||||
http.prepare_content('text/html; charset=UTF-8');
|
||||
-%}
|
||||
|
||||
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults, fs_plugins }) %}
|
||||
|
||||
{#
|
||||
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 }}">
|
||||
{#
|
||||
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,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,473 @@
|
||||
{#
|
||||
<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 : '';
|
||||
/* 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 _cw = int(fs_defaults.content_width);
|
||||
let _sd_cwidth = (fs_defaults.content_width != '' && _cw >= 1280 && _cw <= 3840) ? _cw : -1;
|
||||
/* 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 _rr = int(fs_defaults.rounding);
|
||||
/* rounding 0 is SHARP corners, a real choice, so '' (unset) must map to -1, not 0 */
|
||||
let _sd_round = (fs_defaults.rounding != '' && _rr >= 0 && _rr <= 20) ? _rr : -1;
|
||||
/* login_bg is the cache-bust TOKEN for the uploaded background (the cgi-upload md5), not the
|
||||
image and not a path — the served path is fixed. Whitelisted to hex so a hand-edited config
|
||||
cannot inject markup into the inline script that builds the url(). */
|
||||
let _lb = fs_defaults.login_bg;
|
||||
let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : '';
|
||||
/* the Tint's strength 0-200 (% of the designed chroma). Unset -> -1, like rounding, because 0
|
||||
is a real value. */
|
||||
let _ts = int(fs_defaults.tint_strength);
|
||||
let _sd_tstr = (fs_defaults.tint_strength != '' && _ts >= 0 && _ts <= 200) ? _ts : -1;
|
||||
/* the File photo's scrim opacity 0-100; unset -> -1, 0 being a real value (a transparent
|
||||
scrim) */
|
||||
let _pd = int(fs_defaults.photo_dim);
|
||||
let _sd_pdim = (fs_defaults.photo_dim != '' && _pd >= 0 && _pd <= 100) ? _pd : -1;
|
||||
/* the uploaded pattern's cache-bust token, whitelisted like login_bg and for the same reason;
|
||||
the served path is fixed */
|
||||
let _pt = fs_defaults.pattern;
|
||||
let _sd_pat = (type(_pt) == 'string' && match(_pt, /^[a-f0-9]{6,64}$/)) ? _pt : '';
|
||||
/* the pattern's tile edge in px and the layer's opacity 0-100; unset -> -1, since 0 is a real
|
||||
strength and 0 px is not a tile */
|
||||
let _ps = int(fs_defaults.pattern_size);
|
||||
let _sd_psize = (fs_defaults.pattern_size != '' && _ps >= 40 && _ps <= 1600) ? _ps : -1;
|
||||
let _pst = int(fs_defaults.pattern_strength);
|
||||
let _sd_pstr = (fs_defaults.pattern_strength != '' && _pst >= 0 && _pst <= 100) ? _pst : -1;
|
||||
/* ---- the fonts ----
|
||||
The theme carries no faces, 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 _ft = fs_defaults.fonts;
|
||||
let _sd_ftok = (type(_ft) == 'string' && match(_ft, /^[a-f0-9]{6,64}$/)) ? _ft : '';
|
||||
/* ---- writing direction ----
|
||||
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops, 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.__fsPlugins={{ sprintf('%J', fs_plugins) }};</script>
|
||||
<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.querySelector(':root'),
|
||||
sd = (window.__fsSD || {}).darkmode;
|
||||
let saved = null;
|
||||
try { saved = localStorage.getItem('fs-darkmode'); } catch (e) {}
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
/* data-darkmode is what this theme's CSS reads; data-theme 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.querySelector(':root'),
|
||||
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.querySelector(':root').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.querySelector(':root').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,230 @@
|
||||
{#
|
||||
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" />
|
||||
</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>
|
||||
(() => {
|
||||
const input = document.getElementsByName('luci_password')[0];
|
||||
|
||||
if (input)
|
||||
input.focus();
|
||||
|
||||
/* 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
+121
@@ -0,0 +1,121 @@
|
||||
#!/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. Naming it
|
||||
# `i18n/` stops luci.mk emitting a per-language package, which mattered while a self-updater
|
||||
# resolved the theme by name and took head -1 (issue #6); that updater is retired and owfeed builds
|
||||
# one artifact per format either way, so the rename only cost the catalogue its visibility to the
|
||||
# translation platform.
|
||||
#
|
||||
# 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. It used to be installed inside the fetch branch
|
||||
# only — so the LUCI_SRC branch had none at all — and the three mktemps below were cleaned by hand
|
||||
# at each success path. With `set -eu`, any failure in between (perl choking on a template, i.e.
|
||||
# exactly the stale-.pot session this script exists for) leaked them. `fetched` is what the trap
|
||||
# removes, so the LUCI_SRC path never deletes the user's own checkout.
|
||||
fetched=''; fresh=''; old_ids=''; new_ids=''
|
||||
# shellcheck disable=SC2064 # expand nothing now: the names are assigned as the script proceeds
|
||||
trap 'rm -f "$fetched" "$fresh" "$old_ids" "$new_ids"' EXIT INT TERM
|
||||
|
||||
scanner=''
|
||||
if [ -n "${LUCI_SRC:-}" ] && [ -f "$LUCI_SRC/build/i18n-scan.pl" ]; then
|
||||
scanner="$LUCI_SRC/build/i18n-scan.pl"
|
||||
else
|
||||
scanner="$(mktemp)"; fetched="$scanner"
|
||||
curl -sfL --proto '=https' --proto-redir '=https' "$SCANNER_URL" -o "$scanner" || {
|
||||
echo "update-po: cannot fetch $SCANNER_URL — set LUCI_SRC to a luci checkout" >&2
|
||||
exit 1
|
||||
}
|
||||
# the pin says WHICH script; this says it is that script and nothing else
|
||||
echo "$SCANNER_SHA256 $scanner" | sha256sum -c - >/dev/null || {
|
||||
echo "update-po: i18n-scan.pl checksum mismatch — refusing to run it" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
mkdir -p po/templates
|
||||
fresh="$(mktemp)"
|
||||
# htdocs = the theme JS, ucode = the templates, root = the rpcd ACL title
|
||||
perl "$scanner" htdocs ucode root > "$fresh"
|
||||
|
||||
if [ "$CHECK" = 1 ]; then
|
||||
# Compare msgid AND msgctxt (line-number comments churn on every edit and say nothing).
|
||||
# The CONTEXT is part of the key — po2lmo hashes "ctxt\1msgid" — so a .pot carrying the
|
||||
# same msgids with the context dropped describes a different catalogue, which comparing
|
||||
# msgid alone waved through.
|
||||
old_ids="$(mktemp)"; new_ids="$(mktemp)"
|
||||
grep '^msgid\|^msgctxt' "$POT" | sort > "$old_ids"
|
||||
grep '^msgid\|^msgctxt' "$fresh" | sort > "$new_ids"
|
||||
if ! cmp -s "$old_ids" "$new_ids"; then
|
||||
echo "update-po: $POT is STALE — a string was added or removed without rerunning ./update-po.sh" >&2
|
||||
diff "$old_ids" "$new_ids" | grep '^[<>]' >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rc=0
|
||||
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