update 2026-10-27 11:22:48

This commit is contained in:
action committed 2026-10-27 11:22:48 +08:00
1 parent a4d6470641
commit 63a5dffe55
4907 files changed
+2036791

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+146
View File
@@ -0,0 +1,146 @@
#
# Copyright (C) 2026
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-footstrap
# luci.mk keys the Build/Prepare hook name on LUCI_NAME, which defaults to the checkout's
# directory name — a differently-named checkout symlinked into the feed would silently skip
# the CSS build. Pin it.
LUCI_NAME:=luci-theme-footstrap
# Version is git-derived; never hardcode PKG_VERSION. luci.mk falls back to PKG_SRC_VERSION,
# which it always defines, when nothing here sets it — see docs/package.md for why no override
# lives here.
LUCI_TITLE:=Footstrap Theme
# +luci-base is the WHOLE list. Nothing shipped fetches at run time; `curl` is not in OpenWrt's
# default set (8 MB devices), so fall back to uclient-fetch, jsonfilter, sha256sum or do without.
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
# Else luci.mk claims MAINTAINER "OpenWrt LuCI community" and URL openwrt/luci.
LUCI_MAINTAINER:=Ivan Kvashonkin <vizzlef@gmail.com>
LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap
# CSS ships verbatim: luci.mk's csstidy mangles :has(), color-mix() and nested calc(). build-css.sh
# minifies.
LUCI_MINIFY_CSS:=0
#
# JS: the released package is built by owfeed (tools/stage.sh), not by this Makefile through the
# SDK, so LUCI_MINIFY_JS keeps luci.mk's jsmin. jsmin's one-character lookback does not know `n` or
# `>`, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299): wrap a
# regex literal there in parentheses.
# Apache-2.0, not a free choice: styles/base began as a fork of luci-theme-bootstrap's cascade.css,
# the ucode templates derive from LuCI's and a few JS helpers are verbatim copies; the notices
# travel with it. No OFL-1.1: the theme ships no Font Software.
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`: it re-execs rpcd, which drops a plugin that is absent mid-write until the next
# reload, and this package registers no rpcd object (rpcd re-reads acl.d at every login). The postinst
# ends a session only when a fresh login would grant it more. Comments inside a define ship into the
# router's script store (1984 of 3239 B), so the bodies are code only (`set -f`: the unquoted
# `$$(uci -q get …)` lists must split, not glob a `*`). docs/package.md, "Why postinst/postrm never
# call `rpcd reload`".
define Package/luci-theme-footstrap/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true
set -f
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
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
u=$$(ubus call session get "{\"ubus_rpc_session\":\"$$s\"}" 2>/dev/null | grep -o '"username": *"[^"]*"' | cut -d'"' -f4)
[ -n "$$u" ] || continue
perm=$$(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.write"); do
[ "$$grp" = luci-theme-footstrap ] && { echo write; break 2; }
done
for grp in $$(uci -q get "$$sect.read"); do
[ "$$grp" = luci-theme-footstrap ] && { echo read; break 2; }
done
done)
[ -n "$$perm" ] || continue
[ "$$perm" = read ] && ubus call session access "{\"ubus_rpc_session\":\"$$s\",\"scope\":\"uci\",\"object\":\"footstrap\",\"function\":\"read\"}" 2>/dev/null | grep -q true && continue
ubus call session destroy "{\"ubus_rpc_session\":\"$$s\"}" >/dev/null 2>&1 || true
done
}
}
exit 0
endef
# opkg runs the OLD package's postrm with "upgrade" on a version upgrade; it must change nothing
# there, since reverting mediaurlbase flipped upgrading 24.10 users back to bootstrap (apk never
# runs it on upgrade). Removal restores bootstrap only if its media dir and header.ut exist, drops
# /etc/footstrap (the uploaded login background) and, as in the postinst, no rpcd reload.
define Package/luci-theme-footstrap/postrm
#!/bin/sh
case "$$1" in *upgrade*) exit 0 ;; esac
[ -n "$${IPKG_INSTROOT}" ] || {
uci -q delete luci.themes.Footstrap
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
rm -rf /etc/footstrap >/dev/null 2>&1 || true
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true
}
endef
# Runs right after luci.mk copies the sources into PKG_BUILD_DIR, so it rewrites the build copy
# only. cascade.css is generated, not committed, and styles/ is not in luci.mk's copy list:
# build-css.sh reads it from $(CURDIR) (cat and awk only, so it runs on a buildbot). The git-derived
# version is stamped into fs-version.js unconditionally; only an un-built checkout keeps
# '0.0.0-dev'. The catalogue is luci.mk's business: `po/` is what LUCI_LANGUAGES globs.
define Build/Prepare/luci-theme-footstrap
# PKG_LICENSE_FILES resolves against $(PKG_BUILD_DIR), which luci.mk fills with only src/ luasrc/
# htdocs/ root/ ucode/; the copy is metadata only, luci.mk never installs it.
$(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/
$(SHELL) $(CURDIR)/build-css.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css
# Private `--fs-*` names are 13% of the sheet; runs BEFORE the templates strip, which takes the
# comments it reads.
$(SHELL) $(CURDIR)/mangle-tokens.sh \
$(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \
$(PKG_BUILD_DIR)/htdocs/luci-static/resources $(PKG_BUILD_DIR)/ucode
# Strips `{# … #}`, whole-line `/* … */` and the indentation from the templates (-27.2 KB of 47.2).
# dev-sync.sh does not run this, so a router being debugged keeps its comments and indentation.
$(SHELL) $(CURDIR)/strip-build.sh templates $(PKG_BUILD_DIR)/ucode
# The same for the shell under root/ (about half of uci-defaults and the CGI are comments);
# whole-line `#` only.
$(SHELL) $(CURDIR)/strip-build.sh shell $(PKG_BUILD_DIR)/root
# The SVG favicon's comment and the manifest's indentation; awk only, so no node on a buildbot.
$(SHELL) $(CURDIR)/strip-build.sh assets $(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: the usual ../../luci.mk resolves only inside the luci feed.
include $(TOPDIR)/feeds/luci/luci.mk
# Load-bearing, not boilerplate: include/scan.mk finds packages by GREPPING Makefiles for
# `call (Build/DefaultTargets|BuildPackage|KernelPackage)`. luci.mk calls BuildPackage, which the
# grep cannot see, so without the literal line below the package gets no CONFIG_PACKAGE_* symbol and
# `make package/<name>/compile` fails with "No rule to make target".
# call BuildPackage - OpenWrt buildroot signature
+59
View File
@@ -0,0 +1,59 @@
# luci-theme-footstrap (the package)
A LuCI theme for OpenWrt **24.10.3 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`. Everything
else is a **client** axis on the System → Footstrap page: layout (top bar by default, or the side
menu), mode, palette, density, wallpaper, rounding and every colour. The colours are the accent, the
three status colours and the four surfaces. The axes live in `localStorage` plus attributes and
inline properties on `:root`. Nothing is written to the router until "Save to router" is pressed. There are no per-layout or per-mode themes.
There is one menu renderer too, `renderMainMenu` in `fs-chrome.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
The main parts; the full tree is in [`../docs/package.md`](../docs/package.md) "Source tree".
```
Makefile luci.mk; Build/Prepare (CSS, mangle, strip, version)
styles/ CSS source: layers tokens / base / theme / pages
build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css
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, no wallpaper
htdocs/luci-static/resources/ menu-footstrap*.js, fs-*.js, view/footstrap/appearance.js
root/ uci-defaults, the conffile, menu.d, the ACL, keep.d, the pattern CGI
```
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
System → Footstrap page shows the version it is running and makes no network call to do it.
**Do not edit `cascade.css`.** `build-css.sh` generates it from `styles/`, and git ignores it.
Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`.
## Working on a router
The normal dev stand is the disposable routers `../owlab.yaml` defines, driven by `owlab`. See
[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over
ssh:
```sh
./dev-sync.sh [host] # push over ssh, default host router2512 (registers the theme but does NOT activate it)
ssh router2512 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit luci; rm -f /tmp/luci-indexcache*'
ssh router2512 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back
```
Before pushing, see [`../docs/verifying.md`](../docs/verifying.md). The rules and the traps are in
[`../docs/conventions.md`](../docs/conventions.md); packaging is in
[`../docs/package.md`](../docs/package.md).
+107
View File
@@ -0,0 +1,107 @@
#!/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
#
# NOT how the release is built (`./tools/stage.sh && owfeed build` is). This exercises the other
# path: the Makefile, luci.mk, jsmin and the SDK's packaging, for anyone building without owfeed.
set -eu
# Release and gcc string are baked into the SDK filename and downloads.openwrt.org keeps only the
# current point release, so this goes stale: keep it at owfeed.lock's 25.12 `point:`. Override with
# OPENWRT_RELEASE and OPENWRT_SDK_FILE.
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 signing channel, derived from REL (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
wget -q --https-only -O sdk.tar.zst "$SDK_URL"
# Signature first, checksum under it: the toolchain lands inside the built package. `sha256sums`
# alone proves nothing, it is served unsigned beside the tarball (docs/conventions.md). The
# ed25519 signature, checked with a key pinned from another host (github.com/openwrt/keyring),
# is what holds. Fails CLOSED. Every pin comes from luci-upstream.pin.
. "$THEME_DIR/luci-upstream.pin"
[ -n "${OPENWRT_KEYRING_PIN:-}" ] || { echo "build-apk: OPENWRT_KEYRING_PIN missing from luci-upstream.pin" >&2; exit 1; }
# OPENWRT_SDK_KEY_<channel without dot>, through eval: /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 "build-apk: 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 "build-apk: 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 "build-apk: 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 "build-apk: no sha256 published for $SDK_FILE" >&2; exit 1; }
GOT="$(sha256sum sdk.tar.zst | cut -d' ' -f1)"
[ "$WANT" = "$GOT" ] || { echo "build-apk: 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"
# a worktree can hold 0600/0700 (git stores 100644); uhttpd answers 403 on a file that is not world-readable
chmod -R u=rwX,go=rX "$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
+330
View File
@@ -0,0 +1,330 @@
#!/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: uhttpd serves CSS with no gzip, so every byte is a wire byte. Comments and
# whitespace go, and five spellings shrink (docs/css.md, "The build"): [a="b"] -> [a=b], the space
# before !important, ::before -> :before, the spaces beside * and / in a declaration, and a flex
# triple with a keyword form (1 1 auto -> auto). Nothing else in a selector or declaration is
# rewritten, which is why csstidy stays off (it mangles :has() and color-mix()). Needs only cat and
# awk, so an OpenWrt buildbot can run it.
set -eu
D="$(cd "$(dirname "$0")" && pwd)"
OUT=""
DEV=0
for a in "$@"; do
case "$a" in
--dev) DEV=1 ;;
# Else a typo like `--devv` becomes the output file name.
-*) 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 leaves it behind.
trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT
trap 'exit 130' INT TERM
mkdir -p "$(dirname "$OUT")"
# One `@layer X{` per LAYER, not per FILE: 37 file wrappers are 542 bytes. The wrapper comes from
# the DIRECTORY, so each file must open with the layer its directory means or it is silently
# re-layered. A file with no wrapper (00-header.css: banner and layer-order statement) is copied
# through and must come before the wrapped ones, or it lands inside the block.
emit_layer() {
layer="$1"; shift
body="$TMP.layer"
: > "$body"
for f in "$@"; do
if head -1 "$f" | grep -q '^@layer '; then
head -1 "$f" | grep -q "^@layer $layer {\$" || {
echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2
exit 1; }
# the file's own wrapper: its first line, and the bare `}` where brace depth returns to 0.
# Anything but blank or comment after that `}` is refused: the source leaves it unlayered
# and the build would move it into the block, a silent cascade change.
awk -v F="$f" 'NR == FNR {
if (end) {
l = $0
if (inc) { if (sub(/^.*\*\//, "", l)) inc = 0; else next }
gsub(/\/\*.*\*\//, "", l)
if (match(l, /\/\*/)) { inc = 1; l = substr(l, 1, RSTART - 1) }
if (!bad && l ~ /[^ \t\r]/) bad = NR
next
}
# depth, string- and comment-aware: a `{` in a multi-line comment or in content:"{"
# is not a block (the same walk as brace_count below)
l = $0; n = length(l)
for (i = 1; i <= n; i++) {
ch = substr(l, i, 1)
if (inc) { if (ch == "*" && substr(l, i + 1, 1) == "/") { inc = 0; i++ } continue }
if (q != "") { if (ch == "\\") i++; else if (ch == q) q = ""; continue }
if (ch == "\"" || ch == "'"'"'") { q = ch; continue }
if (ch == "/" && substr(l, i + 1, 1) == "*") { inc = 1; i++; continue }
if (ch == "{") d++; else if (ch == "}") d--
}
if (NR > 1 && d == 0) end = NR; next }
END { if (bad) { printf "build-css: %s:%d: a rule after the @layer wrapper would be moved into the layer; put it inside\n", F, bad > "/dev/stderr"; exit 1 } }
FNR > 1 && FNR != end' "$f" "$f" >> "$body"
elif [ -s "$body" ]; then
echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2
echo "build-css: swallowed into the $layer block instead of staying above it." >&2
exit 1
else
cat "$f"
fi
done
if [ -s "$body" ]; then
printf '@layer %s {\n' "$layer"
cat "$body"
printf '}\n'
fi
# not covered by the EXIT trap: the success path runs `trap - EXIT` first
rm -f "$body"
}
# glob expands in filename order
{
emit_layer tokens "$D"/styles/*.css
emit_layer base "$D"/styles/base/*.css
emit_layer theme "$D"/styles/theme/*.css
emit_layer page "$D"/styles/pages/*.css
} > "$TMP"
# Comments, and — unless told not to — the whitespace CSS ignores anyway, in one string-aware
# pass rather than two chained ones. squeeze=0 (the pre-compression rule count below; a
# `--dev` build skips the pass and keeps the raw sheet) strips comments and trims each line;
# squeeze=1 also does the ~9.5 KB whitespace pass. Why a scan has to be string-aware, what the
# whitespace pass leaves alone and why, and the sed bug this replaced: docs/css.md, "The build".
squeeze() {
awk -v SQUEEZE="$2" '
# The exact flex triples that have a shorter keyword form (shorthand initial values: grow 1
# shrink 1 basis auto). Bare 0 is left alone, since `flex:0` means grow 0.
function flexv(v, imp) {
if (v ~ /!important$/) { imp = "!important"; v = substr(v, 1, length(v) - 10) }
if (v == "0 0 auto") v = "none"
else if (v == "1 1 auto") v = "auto"
else if (v == "0 1 auto") v = "0 auto"
else if (v ~ /^1 1 ([0-9]+\.?[0-9]*|\.[0-9]+)(%|[A-Za-z]+)$/) v = substr(v, 5)
return v imp
}
BEGIN { q = ""; inc = 0; banner = 0; lastc = ""; buf = ""; lastreal = ""; d = 0; st = 0; fl = 0 }
{
line = $0
# a banner already open on an earlier line: nothing on a pure continuation line can
# change q or inc, so the whole line is banner content until its own closing "*/".
if (banner) {
trimmed = line
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
print trimmed
lastc = ""; lastreal = ""
if (index(line, "*/")) banner = 0
next
}
# The line BREAK a squeeze pass is about to swallow is whitespace, and a declaration
# may be wrapped across it. Feed it to the whitespace-run logic below as a leading
# space: it survives only where a space would and is dropped next to { } ; , : —
# lastc == "" means output is already at the start of a line, with nothing to glue to.
if (SQUEEZE && lastc != "" && q == "" && !inc) line = " " line
out = ""; i = 1; n = length(line); opened = 0
while (i <= n) {
c = substr(line, i, 1)
if (inc) { # inside a stripped /* ... */
if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue }
i++; continue
}
if (q != "") { # inside a "..." or '"'"'...'"'"' string
out = out c
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
if (c == q) q = ""
lastreal = ""
i++; continue
}
if (c == "\"" || c == "'"'"'") {
# [attr="ident"] -> [attr=ident]: the same selector, 2 bytes less, 394 sites. Only a
# whole ident closed on this line right before `]`; "--" stays quoted, since
# mangle-tokens.sh reads an unquoted --fs- as a name to rename.
if (SQUEEZE) {
j = index(substr(line, i + 1), c)
v = substr(line, i + 1, j - 1)
if (j && substr(line, i + j + 1, 1) == "]" && out ~ /\[[-A-Za-z0-9_]+[~|^$*]?=$/ &&
v ~ /^-?[A-Za-z_][-A-Za-z0-9_]*$/ && !index(v, "--")) {
out = out v; lastreal = ""; i += j + 1; continue
}
}
q = c; out = out c; lastreal = ""; i++; continue
}
if (c == "/" && substr(line, i + 1, 1) == "*") {
if (substr(line, i + 2, 1) == "!") {
# a genuine banner open: reached only with q=="" and inc==0 AT THIS
# CHARACTER, the same guard strip_comments used before this pass absorbed
# it — so a "/*!" that is really bytes inside a string or an already-open
# ordinary comment never lands here. Copied raw from the marker on, byte
# for byte: it is an Apache-2.0 attribution, not formatting.
banner = 1
rest = substr(line, i)
out = out rest
if (index(rest, "*/")) banner = 0
opened = 1
break
}
inc = 1; i += 2; continue
}
if (SQUEEZE) {
if (c == " " || c == "\t") { # collapse a run of whitespace to one space
while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++
# the last char EMITTED, which on a continuation line lives on the
# previous output line — hence lastc, not just `out`.
prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "")
# Drop the space next to a delimiter; otherwise it may be a combinator. `>` joins:
# outside a string it is the child combinator or a range `>`
# (`@container (width > 560px)`), both valid unspaced. `~` and `+` do not:
# `[attr~=v]` and `calc(100% - 10px)` are ambiguous without bracket depth.
# `!` joins too: `red!important` is the same declaration (35 sites).
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
continue
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">" || nxt == "!")
continue
# Inside a declaration only + and - need their spaces: calc(1px * 2) -> calc(1px*2),
# 16 / 9 -> 16/9. Not before a comment opener, and `/*` or `*/` is never joined.
if (bk[d] == "D" && (prev == "*" || prev == "/" || nxt == "*" || (nxt == "/" && substr(line, i + 1, 1) != "*")) &&
(prev nxt) != "/*" && (prev nxt) != "*/")
continue
out = out " "; lastreal = " "
continue
}
# ::before/::after -> :before/:after, the CSS2 form every engine still parses and
# serialises as `::`; the first colon of 93 pseudo-elements. Not `::before-x`.
if (c == ":" && substr(line, i + 1, 1) == ":") {
w = (substr(line, i + 2, 6) == "before") ? 6 : (substr(line, i + 2, 5) == "after") ? 5 : 0
if (w && substr(line, i + 2 + w, 1) !~ /[-A-Za-z0-9_]/) { i++; continue }
}
# bk[d] is the kind of the open block: G holds rules (@layer @media @supports @container
# @keyframes), D holds declarations (a style rule, @font-face, a keyframe step). st: a
# statement has started, pk: its kind. The value rewrites cannot tell a nested rule from a
# declaration, so a rule inside a D block fails the build.
if (c == "{") {
if (bk[d] == "D") { printf "build-css: a rule nested in a declaration block (docs/css.md, \"The build\")\n" > "/dev/stderr"; exit 1 }
bk[++d] = (st ? pk : "D"); st = 0
} else if (c == "}") { d--; st = 0 }
else if (c == ";") st = 0
else if (!st) {
st = 1
pk = (substr(line, i, 24) ~ /^@(layer|media|supports|container|scope|starting-style|[-a-z]*keyframes)/) ? "G" : "D"
}
# flex: vs is the offset of the value in buf out; it is rewritten when the declaration ends
if (c == ":" && bk[d] == "D" && out ~ /flex$/ && (buf out) ~ /(^|[{;])flex$/) { fl = 1; vs = length(buf) + length(out) + 1 }
if (fl && (c == ";" || c == "}")) {
fl = 0; ft = buf out; fv = substr(ft, vs + 1); fw = flexv(fv)
if (fw != fv) { buf = ""; out = substr(ft, 1, vs) fw }
}
# The last `;` of a block is dropped here, inside the string-aware scanner: a sed
# `s/;}/}/` cannot see strings and turns `content: ";}"` into `content: "}"`.
# The `;` may already sit in the previous output line, so text is held in `buf`
# until the rule closes.
if (c == "}") {
if (length(out) && substr(out, length(out), 1) == ";")
out = substr(out, 1, length(out) - 1)
else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";")
buf = substr(buf, 1, length(buf) - 1)
}
out = out c; lastreal = c; i++
} else {
out = out c; i++
}
}
if (opened) {
trimmed = out
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
print trimmed
lastc = ""; lastreal = ""
next
}
if (SQUEEZE) {
buf = buf out
if (length(out)) lastc = substr(out, length(out), 1)
# newline only after a closing brace — one rule per line. lastreal, not lastc: a
# line ending in a QUOTED `}` (content: "}") is not the end of a rule, and
# flushing there would split the rule and lose the space before its next token.
if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" }
} else {
sub(/^[ \t]+/, "", out)
sub(/[ \t]+$/, "", out)
if (length(out)) print out
}
}
END { if (SQUEEZE) { if (length(buf)) print buf; else printf "\n" } }
' "$1"
}
# Fail loudly rather than let an unbalanced block ship.
#
# String-aware, for the same reason the comment stripper is: a brace inside a CSS STRING is not a
# block, so a counter that gsub()s over the raw line makes a perfectly valid rule fail the build.
# Measured, all three shapes: `content: ";}"`, `content: "{"`, and a data-URI carrying `;}`. It
# fails closed, so nothing is ever corrupted; what it costs is a hunt for an imbalance that is not
# there.
brace_count() {
awk '
BEGIN { q = "" }
{
line = $0; n = length(line); i = 1
while (i <= n) {
ch = substr(line, i, 1)
if (q != "") { # inside a string
if (ch == "\\") { i += 2; continue } # escape: skip the pair
if (ch == q) q = ""
i++; continue
}
if (ch == "\"" || ch == "'"'"'") { q = ch; i++; continue }
if (ch == "{") o++
else if (ch == "}") c++
i++
}
}
END {
if (o != c) { printf "build-css: %s: unbalanced braces (%d { vs %d })\n", FILENAME, o, c > "/dev/stderr"; exit 1 }
if (o < 100) { printf "build-css: %s: suspiciously few rules (%d)\n", FILENAME, o > "/dev/stderr"; exit 1 }
print o
}' "$1"
}
# Always brace-check a COMMENT-STRIPPED, UNSQUEEZED copy, --dev included: counting braces in the
# raw file made a stray "{" in prose fail the build on perfectly valid CSS.
squeeze "$TMP" 0 > "$TMP.min"
RULES_BEFORE=$(brace_count "$TMP.min") || exit 1
rm -f "$TMP.min"
if [ "$DEV" -eq 0 ]; then
squeeze "$TMP" 1 > "$TMP.min"
mv "$TMP.min" "$TMP"
# Recount on what ships: the squeeze is the pass most able to corrupt the sheet. The rule COUNT,
# not brace balance, catches a truncated write: a cut right after the base layer closes is still
# balanced against the true rule count, and RULES_BEFORE comes from the untruncated sheet.
RULES_AFTER=$(brace_count "$TMP") || exit 1
if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then
echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2
exit 1
fi
fi
# --dev: $TMP keeps comments and formatting (only the @layer wrappers are merged), for reading on a
# router rather than shipping.
SIZE=$(wc -c < "$TMP" | tr -d ' ')
mv "$TMP" "$OUT"
trap - EXIT
echo "build-css: $SIZE bytes -> $OUT"
+124
View File
@@ -0,0 +1,124 @@
#!/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
# System -> Footstrap page, not a theme entry. This registers the theme; it does NOT activate it.
set -eu
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, the logo (SVG + PNGs), the app icon and the manifest; no wallpapers.
scp -qr "$D"/htdocs/luci-static/$N/* "$R":/www/luci-static/$N/
# scp carries a worktree's 0600 across, and uhttpd answers 403 on a file that is not world-readable
ssh "$R" "chmod -R u=rwX,go=rX /www/luci-static/$N /usr/share/ucode/luci/template/themes/$N"
# Every resource JS goes as a TAR OF THE TREE, never by name or `scp *.js`: luci.mk copies htdocs/
# wholesale, so a file listed by hand, or one in a subdirectory, ships in the package and never
# reaches the dev router. Every path under resources/ is ours to place. --mode is GNU tar (a
# checkout's umask would otherwise set the router's modes); bsdtar on macOS refuses it, so use gtar.
tar -C "$D/htdocs/luci-static/resources" --mode=u=rwX,go=rX -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 System -> Footstrap page shows a real version instead of "(dev)". The FILE
# NAME is part of the contract: FS_VERSION lives in fs-version.js, so moving it changes 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: owfeed bundles it in the release, so a dev router gets it only from this sync.
# po2lmo is a luci-base host tool, so this is best-effort: without it strings stay English. Build it
# with make -C modules/luci-base/src po2lmo (plural_formula.c is generated by contrib/lemon).
# The basename is `footstrap`, as luci.mk and owfeed.yml ship it (luci-i18n-footstrap-<lang>); the
# old `footstrap-theme.*.lmo` of an earlier dev sync is removed, or a stale catalogue could win.
if command -v po2lmo >/dev/null 2>&1; then
ssh "$R" "mkdir -p /usr/lib/lua/luci/i18n && rm -f /usr/lib/lua/luci/i18n/footstrap-theme.*.lmo"
for po in "$D"/po/*/*.po; do
[ -e "$po" ] || continue
lang="$(basename "$(dirname "$po")")"
po2lmo "$po" "/tmp/footstrap.$lang.lmo"
scp -q "/tmp/footstrap.$lang.lmo" "$R":/usr/lib/lua/luci/i18n/
rm -f "/tmp/footstrap.$lang.lmo"
done
else
echo " (po2lmo not found — skipping the translation catalogue; strings stay English)"
fi
# root/ -> / as a TREE with a GLOB of top-level dirs, never a list of names (luci.mk installs root/
# wholesale; a hand-written `usr` list missed root/etc/config/footstrap). tar, not `scp -r`: its
# merge onto an existing /usr is ambiguous. Two subtrees are excluded by behaviour, handled below:
# - etc/uci-defaults runs from /tmp; in /etc/uci-defaults the next boot would run and DELETE it.
# - etc/config holds conffiles, which the package manager keeps on upgrade and so must we: the
# live file holds the admin's "Save to router".
set --
for _d in "$D"/root/*/; do set -- "$@" "$(basename "$_d")"; done
tar -C "$D/root" --mode=u=rwX,go=rX --exclude=etc/uci-defaults --exclude=etc/config -cf - "$@" |
ssh "$R" "tar -C / -xf -"
# conffiles: install only when ABSENT, which is exactly what the package manager does with them.
for _f in "$D"/root/etc/config/*; do
[ -f "$_f" ] || continue
_b=$(basename "$_f")
scp -q "$_f" "$R":"/tmp/.fs-conf-$_b"
ssh "$R" "[ -f /etc/config/$_b ] || { mv /tmp/.fs-conf-$_b /etc/config/$_b; \
echo ' installed /etc/config/$_b (was absent)'; }; rm -f /tmp/.fs-conf-$_b"
done
# No rpcd reload here — same race as the Makefile's postinst note (Package/luci-theme-
# footstrap/postinst): this copies files onto a router where the theme registers no rpcd object,
# rpcd re-reads acl.d/*.json at LOGIN (not only on reload), and a developer's root session already
# carries read='*' write='*', so the theme's ACL group is irrelevant to it regardless.
ssh "$R" "
# Sweep every pre-consolidation name, $N-top included: a stale mediaurlbase on its media dir renders
# a theme LuCI no longer lists. rm -rf, not rm -f (it refuses directories and this block has no
# set -e); every path is a literal built from \$N.
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
"
# 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"
# ---- last: drop LuCI's menu cache and bust the browser cache ----
#
# LuCI's index cache is rebuilt from the files and the registration above. head.ut versions assets
# with `?v={{ pkgs_update_time }}`, the mtime of the PACKAGE DATABASE. An install moves it, scp does
# not, so a dev deploy keeps the URL while the bytes change and a browser may serve a stylesheet
# several deploys old. Touching the file is what an install would do.
ssh "$R" 'rm -f /tmp/luci-indexcache*;
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.3 KiB

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 -1 100 100" fill="#00b5e2">
<!-- The OpenWrt mark, and it stays OpenWrt's on purpose: the tab icon says which DEVICE this
is, not which theme is painting it. The mark carries no flat cyan tile: a tile fights every
browser's tab strip, so the background is transparent and the icon sits in whatever the
browser draws.
Colours are the mark's own: cyan arcs (the root's fill, stated once), dark-blue ring (its
own fill). 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"/>
<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"/>
<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"/>
<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: 291 B

@@ -0,0 +1,15 @@
{
"name": "LuCI",
"start_url": "/cgi-bin/luci/",
"display": "standalone",
"background_color": "#f6f8fa",
"theme_color": "#f6f8fa",
"icons": [
{
"src": "app-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}
@@ -0,0 +1,635 @@
'use strict';
'require baseclass';
'require ui';
'require fs-prefs as prefs';
'require fs-axes as axes';
'require fs-router as router';
'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 and
* fs-axes.js hold the axes and fs-version.js the version string. `view/footstrap/appearance.js`
* calls `renderStandalone()`, the whole surface this file ships.
*
* 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 readout. Two questions no stored value answers: what colour a role is right now (the
* palette's own while the axis is off) and what contrast it lands at. */
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. Every declaration is !important: an app's CSS can style a
* plain <div>. */
_probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
* would report a stale answer as a fresh one */
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps the
* space it was authored in, so `oklch(0.54 0.19 300)` parses as three numbers in the wrong units
* (measured: #010078, graded "Too faint to read"). Painting one pixel makes the engine convert. */
let _cx = null;
function rasterCtx() {
if (!_cx) {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true });
}
return _cx;
}
function parseColor(s) {
const cx = rasterCtx();
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = String(s || '');
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
/* translucent: composite over nothing is meaningless for a readout */
return d[3] === 255 ? [ d[0], d[1], d[2] ] : null;
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
* derives the ink over a fill, which is a different question). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
});
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
}
function contrastRatio(fgExpr, bgExpr) {
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
if (!fg || !bg) return null;
const a = luminance(fg), b = luminance(bg);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => 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". */
function colorControl(onPick, label, o) {
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;
let typed = false;
/* 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; typed = false; }
/* 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. */
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. An untouched field returns first, so a
* bare Tab-through never pins the shown palette colour on any of the 9 axes. */
const commit = () => {
if (!typed) return;
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
/* re-read rather than a cached copy: the page can change what the axis holds behind this
* control (a preset, Reset to built-in), which a snapshot taken at build time would miss */
else reflect(o.read());
};
field.addEventListener('input', () => { typed = true; });
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' }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(o.read());
return wrap;
}
/* Every stamp that moves a colour lands on :root, including head.ut's OS listener and an app
* rewriting data-darkmode, neither of which calls back here. */
let _watch = null;
function unwatchColours() {
if (_watch) _watch.disconnect();
_watch = null;
}
router.onNavigate(unwatchColours);
function watchColours(refresh) {
unwatchColours();
_watch = new MutationObserver(refresh);
_watch.observe(document.documentElement, {
attributes: true, attributeFilter: [ 'data-darkmode', 'data-palette', 'data-wallpaper' ]
});
}
/* Build the whole form; renderStandalone() hands it to the page. Everything applies immediately and
* there is nothing to save: every axis is this browser's, in localStorage. Only "Save to router"
* writes anything, pushing the current look to the router for other browsers. */
function build() {
/* 24.10.0-24.10.2 have no ui.RangeSlider and a package from the feed still installs there (opkg does
* not version luci-base); one readable line beats the raw "not a constructor" TypeError. */
if (!ui.RangeSlider)
return E('p', { 'class': 'alert-message warning' }, [ _('Footstrap needs OpenWrt 24.10.3 or newer.', 'footstrap') ]);
/* 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());
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
* `.cbi-value-field`, which base/30-forms.css and theme/60-inputs.css already lay out. `make` is
* handed the caption because each control needs it again as the aria-label of its inner
* input/select (a stock widget's wrapper div cannot carry a name). `extra` carries rows with more
* than a control, `opts.cls` marks the rows CSS singles 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');
/* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`, already dressed by
* base/30-forms.css and theme/60-inputs.css (`ui.RangeSlider` reached openwrt-24.10 in 24.10.3). Listen
* for `widget-change`, the seam UIElement publishes, not an event on the inner element.
* RangeSlider also emits `widget-update` while the handle moves, which resizes the page under the
* drag; the appliers are idempotent, so wiring both costs nothing. */
const selectCtl = (current, choices, apply, label) => {
/* an array of [value, label] pairs when a key is integer-like: Object.keys hoists it ahead of
* the authored order ('2020' rendered above Footstrap, the default); `sort` pins the order */
const pairs = Array.isArray(choices) ? choices : Object.entries(choices);
const w = new ui.Select(String(current), Object.fromEntries(pairs), { widget: 'select', sort: pairs.map((p) => p[0]) });
const node = w.render();
node.querySelector('select, input').setAttribute('aria-label', label);
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
const sliderCtl = (axis, apply, label, opts) => {
const o = opts || {};
const w = new ui.RangeSlider(String(axis.current()), {
min: axis.min, max: axis.max, step: o.step || 1
});
const node = w.render();
node.querySelector('select, input').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
* page is drawn in, so applying mid-drag slides the handle from under the pointer and one small
* drag runs to the maximum. A keyboard user still applies at once (an arrow key fires `change`). */
if (o.live !== false) node.addEventListener('widget-update', push);
node.addEventListener('widget-change', push);
return node;
};
const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => {
const ctl = colorControl(bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast
});
colourCtls.push(ctl);
return ctl;
}, opts);
/* Every label here carries the 'footstrap' context: LuCI serves one merged catalogue and the first
* archive holding the hash wins, so a bare msgid is a global name any luci-app can win. */
/* ---- 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(prefs.applyMode), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), [
[ 'footstrap', 'Footstrap' ],
[ 'hicontrast', 'Hi-Contrast' ],
[ 'bootstrap', 'Bootstrap' ],
/* names another package, luci-theme-openwrt-2020, whose colourway this is */
[ '2020', 'OpenWrt 2020' ],
[ 'forum', 'Forum' ]
], bump(axes.palette.apply), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(axes.density.current(), {
compact: _('Compact', 'footstrap'),
normal: _('Normal', 'footstrap'),
large: _('Large', 'footstrap')
}, bump(axes.density.apply), label)),
/* the content column's own cap, separate from Density. The slider starts at the 1280px every
* page was designed against and only widens, which also keeps --fs-content-min (500px, what
* the sidebar-to-bar fold is measured against) out of reach by construction. */
group(_('Content width', 'footstrap'),
(label) => sliderCtl(axes.contentWidth, bump(axes.contentWidth.apply), label, { step: 40, live: false })),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(axes.radius, bump(axes.radius.apply), label)),
/* always built and hidden by CSS in the top layout (:root[data-layout="top"] .fs-ap-submenus):
* the page is built once, so a currentLayout() branch would freeze it 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 = [
colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', {
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 has no chroma of
* ours to scale); CSS hides it otherwise. Not named "Density": the string is also the
* aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(axes.tintStrength, bump(repaint(axes.tintStrength.apply)), label, {
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
/* Accent recolours the accented controls (buttons, toggles, sliders, focus rings), not the
* canvas; any #rrggbb is taken. Four roles, one shape: the role's colour read as text on a
* card, the use that fails first (as a fill it carries derived ink). The rows are data
* because a string literal repeated eight times is not mangled by the minifier. */
...[
[ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ]
].map(([ label, axis, ink ]) =>
colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
];
/* The surfaces: the sheet the UI is drawn on. Body text is read on each, so each reports
* --fs-text against itself; no ink is derived, since moving --fs-text would recolour what is
* measured against. The hairline takes the 3:1 UI-component threshold (a border is a shape) and
* the readout states the number and leaves the call to the admin. */
const surfaces = [
...[
[ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ],
[ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
[ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, axis, probe, fg, bg, where, kind ]) =>
colourGroup(label, axis, probe, { fg, bg, label: where, kind }))
];
/* ---- section 3: the wallpaper and the rows each value brings ----
* The rows a value brings are siblings of the Wallpaper row: nested in its `.cbi-value-field`
* they started 216px right of every other control (measured on the router). They are hidden as
* a group, as stock pages do with a dependent field. The select is the per-browser switch that
* keeps the Save button honest; Choose/Remove only swap the picture and never touch the axis. */
const REMOVE = _('Remove', 'footstrap');
const wallpaper = (() => {
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
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 ]);
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 ]);
/* Dim is an ordinary saved axis, so it is bump()-ed or the Save button misreports its status */
const dimLabel = _('Dim', 'footstrap');
const scaleLabel = _('Scale', 'footstrap');
const strengthLabel = _('Strength', 'footstrap');
const inkLabel = _('Colours', 'footstrap');
/* Colours decides whether the file's own palette is kept or replaced by the theme's: a mask
* uses the alpha only, right for line art and wrong for artwork with its own colours. */
const patRows = [
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(axes.patternSize, bump(axes.patternSize.apply), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength, bump(axes.patternStrength.apply), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
}, bump(axes.patternInk.apply), lbl))
];
const fileRows = [
group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(axes.photoDim, bump(axes.photoDim.apply), lbl, { step: 5 }))
];
function reflect(tok) {
if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; }
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
}
function reflectPattern(tok) {
if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
}
/* `hidden` on the row, which base/10-reset.css restates with !important (the UA's bare
* `[hidden]` loses to `.cbi-value`'s display). Hidden, not removed: each row holds a live
* control that a rebuild would freeze to its construction-time state. */
function togglePanel(v) {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(axes.loginBg.current());
reflectPattern(axes.pattern.current());
togglePanel(axes.wallpaper.current());
const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); };
/* Both uploads share one wiring (pick, upload, report, remove) and never move the Wallpaper
* dropdown, which the user may change mid-upload. The input is cleared on every change so
* re-picking the same file fires `change`; the label is restored in `finally`, or a failed
* upload leaves "Uploading…" standing. */
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(u.reflect)
.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
});
wireUploader({
choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
label: chooseLabel, reflect: reflect,
upload: assets.uploadLoginBg, drop: assets.removeLoginBg
});
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => selectCtl(axes.wallpaper.current(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
}, setWallpaper, label));
return [ wallRow ].concat(patRows, fileRows);
})();
/* ---- section 4: the router default and the version ----
* Save writes the current look to /etc/config/footstrap over the scoped uci ACL. It does not
* change this browser (localStorage keeps overriding), so it only shows on a fresh browser. */
/* each label is said twice in build(); hoisted, a msgid is paid once (24 B x2 -> 30 B) */
const SAVE_TO_ROUTER = _('Save to router', 'footstrap');
const RESET_TO_ROUTER = _('Reset to router', 'footstrap');
const RESET_TO_BUILTIN = _('Reset to built-in', '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: "Reset to router" clears them
* and lets every axis fall back to the router, "Reset to built-in" writes the theme's defaults
* explicitly. Neither touches /etc/config/footstrap. "Default" is not a label: it named the
* router's look in "Save as default" and the theme's in "Reset to default", three names for two
* states (forum topic 251930, post 92). */
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]);
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ RESET_TO_BUILTIN ]);
/* Save's only visible failure surface: the rpc rejecting (expired session 403, missing ACL, ubus
* down). A deleted config is not caught: commit no-ops and returns 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. A browser that
* refuses storage keeps its choices in fs-prefs's in-memory map, so the comparison stays honest;
* the note waits while a failed Save's error shows, or the .finally below would overwrite it. */
function refreshSave() {
if (prefs.storageBroken() && saveErr.hidden) {
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving to the router still works and applies to every browser.', 'footstrap');
saveErr.hidden = false;
}
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;
saveErr.title = ''; /* a stale rpc error would tooltip the storage note (measured: 'ubus code 6' over it) */
axes.saveAsDefault()
/* 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 to the router. 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. */
let armed = null;
function disarm() {
if (!armed) return;
armed.btn.textContent = armed.label;
armed.btn.classList.remove('fs-ap-armed');
armed = null;
}
function twoClick(btn, label, run) {
btn.addEventListener('click', () => {
if (armed && armed.btn === btn) {
disarm();
run();
location.reload();
return;
}
disarm();
armed = { btn: btn, label: label };
btn.textContent = _('Confirm reset', 'footstrap');
btn.classList.add('fs-ap-armed');
});
}
twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved);
twoClick(resetBtn, RESET_TO_BUILTIN, 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() ]);
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 theme's translations are their own packages, and nothing in a package manager can read `uci
* luci.main.lang` to fetch the right one. */
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
]));
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ E('h2', {}, [ title ]) ])
].concat(rows));
/* Folded groups: nine colour fields and an uploader are the widest rows and rarely touched, so
* each group is a closed-by-default disclosure that applies and disables nothing. */
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
}, [
title,
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();
if (open) refreshColours();
});
paint();
return E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ E('h2', {}, [ btn ]) ]), body
]);
}
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') ])
]);
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('h3', {}, [ _('Surfaces', 'footstrap') ]) ]) ], surfaces),
'fs-ui-colours'),
foldable(_('Background', 'footstrap'), wallpaper, 'fs-ui-background'),
section(_('Saving', 'footstrap'), defaults)
]);
Promise.resolve().then(refreshColours);
watchColours(refreshColours);
return page;
}
return baseclass.extend({
/* the dispatched page's whole render(), called by view/footstrap/appearance.js */
renderStandalone: () => Promise.resolve().then(build),
});
@@ -0,0 +1,291 @@
'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: the pattern and login-photo uploads,
* the SVG sanitizer, the rollback. A module of its own: only the Appearance page needs these ~4 KB. */
/* Hoisted: a string literal is not mangled, so each repeat is paid on flash; msgid stays literal
* for update-po.sh. */
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: a translator places both %d in one sentence instead of agreeing
* plurals. */
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');
/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
* Fixed path matched by the rpcd ACL, under /etc so an upgrade keeps it. Not the security boundary: a
* `curl` to cgi-upload skips every check below; a direct open is stopped by the CGI's
* `Content-Security-Policy: default-src 'none'; sandbox`. */
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 `_sanitizeSvg` removes, judged on the parsed document, not the text. Painted as an image nothing
* loads; these rules cover a direct open of the file:
* - elements: only the SVG namespace stays (`<h:frame src="javascript:…">` ran same-origin), minus
* script, foreignObject, a `<style>` with an element or off-document ref, an animation whose
* attributeName is a URL attribute (SMIL retargets it at run time)
* - href, xlink:href, ping: a `#fragment`, or a raster `data:` on an `<image>`; else removed
* - on* and `xml:base` removed; `style` and un-namespaced attributes by `_externalStyleRef` */
const PAT_BAD_TAGS = [ 'script', 'foreignobject' ];
const PAT_ANIM_TAGS = [ 'animate', 'set', 'animatetransform', 'animatemotion', 'animatecolor' ];
const PAT_URL_ATTR = /(?:^|:)(?:href|ping)$/;
const PAT_RASTER_DATA = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp)[;,]/i;
const SVG_NS = 'http://www.w3.org/2000/svg';
/* CSS Syntax §4.3.7: `\` + 1-6 hex digits (+ one whitespace) is a code point, so `u\72l(` is
* `url(`; decode first. */
function _cssUnescape(v) {
/* §3.3 folds CR LF to LF first, so one whitespace ends `\72<CR><LF>`; unfolded,
* `u\72<CR><LF>l(` hid a `url(`. */
v = v.replace(/\r\n?|\f/g, '\n');
/* U+FFFD for zero, a surrogate or > U+10FFFF: fromCodePoint throws on `\FFFFFF`. */
return v.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?|\\([^\r\n\f])/g, (m, hex, lit) => {
if (hex === undefined) return lit;
const cp = parseInt(hex, 16);
return String.fromCodePoint(cp === 0 || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF) ? 0xFFFD : cp);
});
}
/* True if CSS text (a declaration or a whole <style>) reaches off this document. `url(` is matched
* everywhere. Whitespace is CSS's — space, TAB, LF, CR, FF; `\s` also takes NBSP, which CSS keeps in the
* URL (a fetch of /%C2%A0). `url(`/`expression(` allow space before `(`; `image (` is no function. */
function _externalStyleRef(v) {
v = _cssUnescape(v);
if ((/@import\b/i).test(v) || (/expression[ \t\n\r\f]*\(/i).test(v) || (/(?:image-set|cross-fade|image|src)\(/i).test(v)) return true;
const urlRe = /url[ \t\n\r\f]*\([ \t\n\r\f]*(['"]?)[ \t\n\r\f]*([^) \t\n\r\f'"]*)/gi;
let m;
while ((m = urlRe.exec(v)) !== null)
if (m[2].charAt(0) !== '#') return true;
return false;
}
/* A `style` minus its off-document declarations: one Inkscape export carried 278 style attributes,
* none external. */
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 judges and cleans, so two traversals cannot disagree. `text` is the original when nothing was
* removed: an XMLSerializer round-trip rewrites namespaces. */
function _sanitizeSvg(text) {
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
const root = doc.documentElement;
/* An SVG is its root's namespace, not its spelling: `<svg xmlns=xhtml>` is an executing XHTML
* document. */
if (!root || doc.querySelector('parsererror') ||
root.localName.toLowerCase() !== 'svg' || root.namespaceURI !== SVG_NS)
return { error: MSG_NOT_SVG };
let elements = 0, refs = 0;
/* A PI can attach an XSLT stylesheet whose output this walk never sees (executes on Firefox). */
/* An external-DTD DOCTYPE is left: no engine resolves it, and the CSP answers a direct open. */
for (const n of [ ...doc.childNodes ])
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
/* Snapshot first: removing a node detaches its subtree; isConnected skips it. */
const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
for (const el of els) {
if (el !== root && !el.isConnected) continue;
/* localName, never nodeName: the prefix hid `s:script` from a name list (executing on 3
* engines). */
const tag = el.localName.toLowerCase();
/* A browser reads a <style> from its direct text children and steps over an element:
* `@imp<x>z</x>ort` is `@import` to it and `@impzort` to textContent: an element removes it,
* else textContent is those children. */
if (el.namespaceURI !== SVG_NS || PAT_BAD_TAGS.indexOf(tag) >= 0 ||
(PAT_ANIM_TAGS.indexOf(tag) >= 0 &&
PAT_URL_ATTR.test(String(el.getAttribute('attributeName') || '').trim().toLowerCase())) ||
(tag === 'style' && (el.children.length || _externalStyleRef(String(el.textContent || ''))))) {
el.remove();
elements++;
continue;
}
/* Snapshot: removing while indexing the live NamedNodeMap shifts the indexes. */
for (const a of Array.from(el.attributes)) {
const n = a.name.toLowerCase();
const raw = a.value;
const v = raw.trim();
/* A real handler is `on` + letters (`only_selected` is not); a prefixed `s:onload`
* binds nothing. */
if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; }
/* `xml:base` moves what every relative reference below it resolves against, even `#g`;
* removed on every element. */
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
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 (PAT_URL_ATTR.test(n)) {
/* Allowlist, case-sensitive as XML. Strip as the URL parser does: every TAB/LF/CR, then
* leading spaces (XML 1.0 admits no other C0 control); trim() also takes NBSP and U+FEFF,
* which the parser keeps. */
const u = raw.replace(/[\t\n\r]/g, '').replace(/^ +/, '');
if (u.charAt(0) !== '#' && !(el.localName === 'image' && PAT_RASTER_DATA.test(u)))
{ el.removeAttribute(a.name); refs++; }
} else if (n.indexOf(':') < 0 && _externalStyleRef(v)) { el.removeAttribute(a.name); refs++; }
}
}
if (!(elements + refs)) return { text }; /* untouched: hand back the original bytes */
if (root.children.length === 0) return { error: MSG_NOTHING_LEFT };
return { text: new XMLSerializer().serializeToString(doc), elements, refs };
}
/* ---- login/page background upload: router-side, and not an axis ----
* One router-wide image shown pre-login: absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault()
* so it never moves Save. Only its cache-bust token lives in uci -> window.__fsSD -> head.ut. */
const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
const BG_MAX_SIDE = 1920; /* longest side: 1080p covers the screens LuCI is administered from */
const BG_QUALITY = 0.9;
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding */
/* No `reject: true`: it would make "already gone" and "refused" the same Error. */
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
/* "Not found" counts as done; any other refusal must surface: the file stays fetchable via /www. */
const UBUS_NOT_FOUND = 4;
function _removeServed(path) {
return _fileRemoveStatus(path).then((res) => {
if (res === 0 || res === UBUS_NOT_FOUND) return;
return Promise.reject(new Error(
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(res)));
});
}
/* Re-encode to a bounded JPEG: only decoded pixels survive (no EXIF). A throw inside onload would
* leave the promise pending. */
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 could not be recorded would stay served via /www with Remove hidden: roll it back. */
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: `prepare` the bytes, POST, take the md5 as cache-bust token, write it to uci, paint. Bytes
* land before the token, so a later failure rolls back. */
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);
/* cgi-io writes 0600 and uhttpd 403s that; filemode makes it chmod 644 itself, inside its ACL check */
fd.append('filemode', '644');
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] }; a failed
* chmod leaves the file in place, so only that message rolls back */
if (reply && reply.message === 'Failed to chmod target file')
return _rollbackUpload(o.path, new Error(MSG_UPLOAD_FAILED));
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));
/* uci gets the token only: uploading is not making every device paint it */
return axes.saveSettings({ [o.field]: tok })
.catch((e) => _rollbackUpload(o.path, e))
/* an upload never touches the Wallpaper axis: the dropdown alone sets it */
.then(() => { o.apply(tok); return tok; });
});
/* Remove: delete the file, blank the token (the scoped ACL grants set/commit only), clear the
* url(). */
const remove = () => _removeServed(o.path)
.then(() => axes.saveSettings({ [o.field]: '' }))
.then(() => { o.apply(''); });
return { upload, remove };
}
/* The tile: no canvas step (it would return a raster); the parsed check stands in. */
const PATTERN = assetAxis({
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern',
apply: (tok) => axes.pattern.apply(tok),
prepare: (file) => {
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')));
/* same message: what reaches the router is the bytes that were checked */
const readErr = () => Promise.reject(new Error(_('That file could not be read.', 'footstrap')));
return file.text().catch(readErr).then((text) => {
const cleaned = _sanitizeSvg(text);
if (cleaned.error) return Promise.reject(new Error(cleaned.error));
/* Told once, whether or not the upload succeeds; `prepare()` has no channel back past
* its Blob. */
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 L.ui.uploadFile's endpoint, authorised by the ACL for BG_PATH. */
const LOGIN_BG = assetAxis({
path: BG_PATH, filename: 'login-bg', field: 'login_bg',
apply: (tok) => axes.loginBg.apply(tok),
prepare: (file) => {
if (!(/^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,
});
@@ -0,0 +1,341 @@
'use strict';
'require baseclass';
'require rpc';
'require fs-prefs as prefs';
'require fs-fit as fit';
/* fs-axes — the Appearance axes and the Save-as-default machinery.
*
* Split out of fs-prefs.js because only `fs-appearance` (the form) and `fs-assets` (the uploads)
* need it, so the router fetches it on the Appearance page and nowhere else: 5.9 KB off what every
* page downloads. fs-prefs keeps what the chrome applies live: the localStorage wrappers and sd(),
* dark mode (guardDarkStamp defends it on every page), layout, rail and auto-collapse.
*
* head.ut's pre-paint 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. */
/* 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 localStorage key is hyphenated
* and the uci option is not */
const sdKey = key.slice(3).replace(/-/g, '_');
const ok = (v) => (values.indexOf(v) >= 0);
const def = () => (ok(prefs.sd(sdKey)) ? prefs.sd(sdKey) : dflt);
return {
def,
current() {
const s = prefs.lsGet(key);
if (ok(s)) return s;
if (s === null) return def();
return dflt; /* a stray value reads as the built-in default */
},
apply(val) {
const root = document.documentElement;
const v = ok(val) ? val : dflt;
/* stored explicitly (including the default), so it overrides a router default */
prefs.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 "two-valued" is what most of these
* axes are. */
function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off);
}
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 an older config) */
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;
}
/* Black or white ink for a '#rrggbb' fill: OKLab L above .62 reads black, else white (black clears
* 4.5:1 on #ffcc00, white on #0033aa). Computed here, not in CSS. */
function inkFor(hex) {
const [ lr, lg, lb ] = [ 1, 3, 5 ].map((i) => {
const u = parseInt(hex.slice(i, i + 2), 16) / 255;
return (u <= 0.04045) ? (u / 12.92) : (((u + 0.055) / 1.055) ** 2.4);
});
const f = (x, y, z) => Math.cbrt((x * lr) + (y * lg) + (z * lb));
return (((0.21045 * f(0.41222, 0.53633, 0.05145)) + (0.79362 * f(0.2119, 0.6807, 0.1074)) - (0.00407 * f(0.0883, 0.28172, 0.62998))) > 0.62) ? '#000' : '#fff';
}
function colorAxis(key, attr, hueProp, colorProp, inkProp) {
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);
if (inkProp) root.style.removeProperty(inkProp);
} else if (typeof v === 'number') {
root.style.removeProperty(colorProp);
if (inkProp) root.style.removeProperty(inkProp);
/* 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);
if (inkProp) root.style.setProperty(inkProp, inkFor(v));
root.setAttribute(attr, 'hex');
}
}
};
}
/* A numeric slider axis that sets an inline custom property and no attribute: it validates to
* [min,max], stores the choice explicitly and removes the property at the default. */
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, min, max,
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 (styles/03-palettes.css). Not the enumAxis shape. */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* the non-default values; 'footstrap' = bare :root */
const PALETTE = listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
/* Wallpaper: off (bare canvas), pattern (the admin-uploaded SVG, tiled and recoloured) or file
* (the admin-uploaded photo). */
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
const WALLPAPER = listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
/* Density is a pure token axis; it re-runs the fit because Compact makes more fit and Large less. */
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
* while canvas, cards and status colours stay put. */
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent', '--fs-on-accent');
/* The surface axes (cards, chrome, inset controls, hairlines) are their own factory, not four more
* colorAxis instances: there is no hue mode, as rotating a near-white card keeps it near-white. */
function surfaceAxis(key, sdKey, prop) {
const norm = (v) => {
const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
return FS_HEX_RE.test(s) ? s : 0;
};
const def = () => norm(prefs.sd(sdKey));
return {
def,
current() {
const raw = prefs.lsGet(key);
return (raw !== null) ? norm(raw) : def();
},
apply(val) {
const v = norm(val);
prefs.lsSet(key, String(v));
if (v) document.documentElement.style.setProperty(prop, v);
else document.documentElement.style.removeProperty(prop);
}
};
}
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
/* The three status colours are colorAxis pointed at --fs-good / --fs-warn / --fs-danger, separate
* because each derived tint is a color-mix() of its own role. */
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good', '--fs-on-good');
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn', '--fs-on-warn');
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger', '--fs-on-danger');
/* Rounding: the propAxis instance, --fs-radius-base in px. */
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
/* Content width: how far the reader lets the column grow past --fs-content-max's own 1280px; the
* range is bounded in 02-tokens.css. It re-runs the fit. */
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());
/* Tint strength: a multiplier on the tint chroma, 100% the designed strength and 200% the cap. 0
* is not "no tint". */
const FS_TSTR_DEFAULT = 100;
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
/* Photo dim: the scrim opacity over the file photo (0–100%); how strongly this browser dims the
* shared photo reaches the router through Save-as-default. */
const FS_PDIM_DEFAULT = 74;
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
/* The pattern's live knobs bite only while the wallpaper is 'pattern'. */
const FS_PSIZE_DEFAULT = 440;
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
const FS_PSTR_DEFAULT = 20;
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
const PINK = enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
/* `reject: true` is load-bearing: without it rpc.js hands the ubus status back as the resolved
* value, so a per-config ACL refusal (6, permission denied) runs every `.then()` as a success. */
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 });
const saveSettings = (values) => _uciSet('footstrap', 'settings', values).then(() => _uciCommit('footstrap'));
/* The one list of axes. Its keys are the uci option names Save-as-default writes (and partials/head.ut
* stamps back into window.__fsSD); `key` is the localStorage key, `ax` the axis object. */
const AXES = {
layout: { key: 'fs-layout', ax: { current: prefs.currentLayout, def: () => prefs.sd('layout') || 'top', apply: prefs.applyLayout }, builtin: 'top' },
darkmode: { key: 'fs-darkmode', ax: { current: prefs.currentMode, def: prefs.modeDefault, apply: prefs.applyMode }, builtin: 'auto' },
palette: { key: 'fs-palette', ax: PALETTE, builtin: 'footstrap' },
wallpaper: { key: 'fs-wallpaper', ax: WALLPAPER, builtin: 'off' },
tint: { key: 'fs-tint', ax: TINT, builtin: 0 },
accent: { key: 'fs-accent', ax: ACCENT, builtin: 0 },
good: { key: 'fs-good', ax: GOOD, builtin: 0 },
warn: { key: 'fs-warn', ax: WARN, builtin: 0 },
danger: { key: 'fs-danger', ax: DANGER, builtin: 0 },
card: { key: 'fs-card', ax: CARD, builtin: 0 },
control: { key: 'fs-control', ax: CONTROL, builtin: 0 },
bar: { key: 'fs-bar', ax: BAR, builtin: 0 },
line: { key: 'fs-line', ax: LINE, builtin: 0 },
rounding: { key: 'fs-radius', ax: RADIUS, builtin: FS_RADIUS_DEFAULT },
autocollapse: { key: 'fs-menu-autocollapse', ax: {
current: () => (prefs.currentAutoCollapse() ? 'on' : 'off'),
def: () => (prefs.autoCollapseDefault() ? 'on' : 'off'),
apply: prefs.applyAutoCollapse }, builtin: 'off' },
tint_strength: { key: 'fs-tint-strength', ax: TSTR, builtin: FS_TSTR_DEFAULT },
density: { key: 'fs-density', ax: DENSITY, builtin: 'normal' },
photo_dim: { key: 'fs-photo-dim', ax: PDIM, builtin: FS_PDIM_DEFAULT },
pattern_size: { key: 'fs-pattern-size', ax: PSIZE, builtin: FS_PSIZE_DEFAULT },
pattern_strength: { key: 'fs-pattern-strength', ax: PSTR, builtin: FS_PSTR_DEFAULT },
pattern_ink: { key: 'fs-pattern-ink', ax: PINK, builtin: 'theme' },
content_width: { key: 'fs-content-width', ax: CWIDTH, builtin: FS_CWIDTH_DEFAULT }
};
const AXIS_KEYS = Object.values(AXES).map((a) => a.key);
const snapshotAxes = () => Object.fromEntries(Object.entries(AXES).map(([ k, a ]) => [ k, String(a.ax.current()) ]));
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
* form, so the Appearance page can grey the Save button when this browser already matches. */
const _resolvedDefault = () => Object.fromEntries(Object.entries(AXES).map(([ k, a ]) => [ k, String(a.ax.def()) ]));
let _savedDefault = _resolvedDefault();
function matchesSavedDefault() {
const cur = snapshotAxes();
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
}
/* No axis reaches /etc/config/footstrap except through Save-as-default: an axis that wrote through
* on change re-pointed the router-wide default for every other device. */
function saveAsDefault() {
const snap = snapshotAxes();
return saveSettings(snap)
.then(() => { _savedDefault = snap; });
}
/* The two resets differ in what is underneath: resetToSaved() clears the keys, so every axis falls
* back to the router default where one is set and the built-in where not. */
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. */
function resetToBuiltin() {
Object.values(AXES).forEach((a) => a.ax.apply(a.builtin));
}
/* The two uploaded wallpapers, browser side: what the router last saved, its URL, and how to paint
* it. The upload itself is fs-assets.js. */
/* the cache-bust token charset, an md5/sha hex string; head.ut's ucode sanitiser keeps its own
* copy, running before this module */
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
/* The pattern and the login-bg photo are the same shape twice: a router-saved cache-bust token,
* read back through prefs.sd(), that sets or clears one url() custom property. */
function tokenAsset(sdKey, serve, prop) {
const current = () => {
const t = prefs.sd(sdKey);
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
};
const url = (tok) => serve + '?v=' + tok;
const apply = (tok) => {
const root = document.documentElement;
if (tok) root.style.setProperty(prop, 'url("' + url(tok) + '")');
else root.style.removeProperty(prop);
prefs.setSD(sdKey, tok || '');
};
return { current, url, apply };
}
/* Not a /www symlink, unlike LOGIN_BG: an SVG served as a plain static file is a same-origin
* document that runs script opened directly. */
const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '--fs-pattern-url');
/* Plain /www symlink, extension-less, so uhttpd serves it as octet-stream (the uci-default says why). */
const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url');
return baseclass.extend({
palette: PALETTE,
wallpaper: WALLPAPER,
density: DENSITY,
tint: TINT,
accent: ACCENT,
good: GOOD,
warn: WARN,
danger: DANGER,
card: CARD,
control: CONTROL,
bar: BAR,
line: LINE,
radius: RADIUS,
tintStrength: TSTR,
photoDim: PDIM,
patternSize: PSIZE,
patternStrength: PSTR,
patternInk: PINK,
contentWidth: CWIDTH,
pattern: PATTERN,
loginBg: LOGIN_BG,
tokenOk,
matchesSavedDefault, saveSettings, saveAsDefault, resetToSaved, resetToBuiltin
});
@@ -0,0 +1,660 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
'require fs-widgets as widgets';
/* The chrome around the content: the main menu, the mode menu, the section tabs, the rail toggle
* and the measurements deciding how much room each gets. menu-footstrap-common.js calls wireMenu()
* and start(); nothing else here needs wiring from outside. Spec: docs/chrome.md */
/* section tabs -> #tabmenu (horizontal) */
function renderTabMenu(node, url, level) {
/* #tabmenu is emitted whenever this module loads (header.ut, !blank_page) */
const container = document.querySelector('#tabmenu');
const ul = E('ul', { 'class': 'tabs' });
const children = ui.menu.getChildren(node);
let activeNode = null;
children.forEach((child) => {
const isActive = (L.env.dispatchpath[3 + (level || 0)] === child.name);
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s %s'.format(child.name, isActive ? 'active' : '') }, [
E('a', { 'href': L.url(url, child.name), 'aria-current': isActive ? 'page' : null }, [ _(child.title) ])
]));
if (isActive)
activeNode = child;
});
if (ul.children.length === 0)
return;
container.appendChild(ul);
container.style.display = '';
if (activeNode)
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
}
/* ---- tab-strip auto-fit ----
* A strip can carry ~11 pills (luci-app-justclash) that overflow one row: rather than wrap, two
* density classes (fs-dense1, fs-dense2) shrink it. */
function stripFitsOneRow(ul) {
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 || first.offsetTop === last.offsetTop;
}
function fitTabStrips() {
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;
}
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 yields to the bar when what is left for the content is too narrow. A viewport
* breakpoint cannot say that: the cut depends on the slice (224 px expanded, 68 px rail). */
let _probe = null;
/* The probe is a plain <div> in the shared document (no chrome mark; the fence does not spare it),
* so a third-party app's CSS can style it and every declaration is !important. */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
_probe.id = 'fs-chrome-geom-probe';
_probe.setAttribute('aria-hidden', 'true');
/* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
_probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
'pointer-events:none!important;height:0!important;box-sizing:content-box!important;' +
'min-width:0!important;max-width:none!important;border:0!important;' +
'padding:0!important;margin:0!important;';
document.body.appendChild(_probe);
}
_probe.style.setProperty('width', 'var(' + token + ')', 'important');
const v = parseFloat(getComputedStyle(_probe).width);
return Number.isFinite(v) ? v : dflt;
}
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc;
* keyed on the density, the one thing that changes these widths at runtime. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
const CLASS_IND_COMPACT = 'fs-ind-compact';
let _geom = null, _geomKey = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
if (_geom && _geomKey === density) {
_geom.contentPad = _shellPad ?? GEOM_DFLT.contentPad;
return _geom;
}
_geomKey = density;
const g = {
contentMin: resolveLen('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: resolveLen('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: resolveLen('--fs-rail-w', GEOM_DFLT.railW)
};
/* Plausibility, one comparison: the rail is the sidebar collapsed, so 0 < railW < sidebarW holds
* by construction; a hijacked probe reports a foreign width and fails it. */
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
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);
_geom.contentPad = _shellPad ?? GEOM_DFLT.contentPad;
return _geom;
}
/* The window's width and the column's gutter are read here (from a fitter or from
* `contentWidth()`'s staleness check) and nowhere else; the gutter is measured where it is
* applied. */
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;
/* memoised on what moves the gutter: the width (a media query re-pads the column below 767px) and
* the density */
const key = (document.documentElement.getAttribute('data-density') || '') + '|' + _shellOuter +
'|' + (document.body ? document.body.getAttribute('data-page') || '' : '');
if (_padAt === key) 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;
}
}
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);
return Math.max(0, state.outerW - cut - g.contentPad);
}
function fitShell() {
const root = document.documentElement;
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,
* toggleAttribute on a present attribute queues none, and fitShell runs on every mutation batch */
root.toggleAttribute('data-narrow', content < g.contentMin);
}
function fitChrome() {
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. */
const topBar = prefs.isTopLayout();
/* The bar may not change height while it is being measured, in either direction: with the three
* classes off and fs-dense1/2 stripped, the bar's own box answers any height its content needs, so
* a min-height pin holds it for the pass (230 -> 123 px inside one pass, 107 px of growth). */
const pinned = Math.round(bar.getBoundingClientRect().height);
if (pinned > 0) { bar.style.minHeight = pinned + 'px'; bar.style.height = pinned + 'px'; }
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), so it is
* measured, not decided by a `max-width: 1199px` breakpoint. */
if (topBar && !stripFitsOneRow(menu)) {
/* first step before stacking: collapse the poll pill (~90px) to an icon square and re-measure */
bar.classList.add(CLASS_IND_COMPACT);
fitTabStrips();
if (!stripFitsOneRow(menu)) {
bar.classList.add('fs-bar-stack');
fitTabStrips();
}
}
if (topBar || document.documentElement.hasAttribute('data-narrow'))
fitCluster(bar, menu);
if (pinned > 0) bar.style.minHeight = bar.style.height = '';
publishBarHeight(bar);
}
/* ---- how tall the bar actually is, for whoever sticks underneath it ----
* A data table's header row sticks (theme/30-tables.css) and must clear the sticky bar;
* `--fs-bar-h` is only the bar's designed height, so the real one is measured here. */
let _barH = 0;
function publishBarHeight(bar) {
const root = document.documentElement;
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 three siblings (indicators, Search, Log out), which flexbox wraps one at a time:
* on a narrow bar Log out alone drops to a second row. */
function fitCluster(bar, menu) {
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 offsetTop: align-items:center over children of different
* heights makes it differ even on one row. The menu is excluded, as on a row of its own it is not
* competing. */
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;
}
/* Null prototype: `ICONS[key]` is keyed by a menu node name a third-party package picks, and on a
* plain literal a node called `constructor` or `__proto__` resolves out of Object.prototype. */
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);
}
/* Is a section's panel a popup (flyout / bar dropdown) rather than an unfolded accordion? `.open`
* means two things: in the expanded sidebar an unfolded accordion section (several may be open); in
* the rail or the bar a popup panel (exactly one open). Reads the stylesheet's own input,
* `data-narrow`, stamped by fitShell() before the menu renders; never a viewport query. */
function flyoutMode() {
return prefs.currentRail() || prefs.isTopLayout() ||
document.documentElement.hasAttribute('data-narrow');
}
/* The trigger — a bare <a>. setOpen() below keeps `.open` and aria-expanded in step. */
const TRIGGER = ':scope > a';
const OPEN_LI = '#topmenu > li.open';
/* ---- disclosure primitives ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree. */
function setOpen(li, on) {
li.classList.toggle('open', on);
li.querySelector(TRIGGER)?.setAttribute('aria-expanded', on ? 'true' : 'false');
}
/* ---- dropdown edge-clamp (the bar, at every width) ----
* A bar panel hangs off its own item (theme/20-shell.css), so an item near the inline-end edge
* overflows the viewport: the panel is nudged back inside. */
/* 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`. */
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;
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
li._fsClampRaf = window.requestAnimationFrame(() => {
li._fsClampRaf = 0;
menu.style.insetInlineStart = ''; /* back to the CSS anchor before measuring */
const r = menu.getBoundingClientRect();
if (!r.width) return; /* still hidden — nothing to place */
/* RTL anchors at right:0, so only the left edge can leave (a 220 px panel on a 390 px viewport
* sits at -130 px); both edges use the logical property. */
const rtl = window.getComputedStyle(menu).direction === 'rtl';
const over = rtl ? EDGE_GAP - r.left : r.right - (window.innerWidth - EDGE_GAP);
if (over > 0)
menu.style.insetInlineStart = -Math.min(over,
rtl ? window.innerWidth - EDGE_GAP - r.right : r.left - EDGE_GAP) + 'px';
});
}
function clearClamps() {
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.insetInlineStart = ''; });
}
function closeFlyouts() {
document.querySelectorAll(OPEN_LI).forEach((o) => setOpen(o, false));
}
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 an opened section would otherwise refold on every tab switch. */
const OPEN_KEY = 'fs-menu-open';
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(node, url, level, parentActive = true) {
const ul = level ? E('ul', {}) : document.querySelector('#topmenu');
const children = ui.menu.getChildren(node);
/* #topmenu is emitted whenever this module loads (header.ut, !blank_page) */
if (children.length === 0)
return E([]);
const idx = (level || 0) + 1;
/* read once per render: the click and pointerenter handlers below re-read at event time */
const flyout = flyoutMode(), auto = prefs.currentAutoCollapse();
children.forEach((child, i) => {
if (!level && child.name === 'logout')
return;
const isActive = parentActive && L.env.dispatchpath[idx] === child.name;
/* only sections carry a panel; a leaf's own children are never rendered */
const submenu = level ? E([]) : renderMainMenu(child, url + '/' + child.name, 1, isActive);
const hasSub = !!submenu.firstElementChild;
const keepOpen = hasSub && !flyout && !auto;
const startOpen = hasSub && !flyout &&
(isActive || (keepOpen && _openSections.has(child.name)));
if (keepOpen && startOpen && !_openSections.has(child.name)) {
_openSections.add(child.name);
saveOpenSections();
}
const chevron = hasSub
? widgets.svgIcon('<path d="M9 6l6 6-6 6"/>', 'fs-chevron')
: '';
/* `active` only paints the item; aria-current is what a screen reader reads, and belongs on the
* leaf alone. */
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);
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' : '',
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": site navigation must not take on the menubar pattern's arrow-key semantics. */
const subId = 'fs-sub-' + i;
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');
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.wireActivate(link, () => link.click());
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is authoritative;
* guarded on pointerType, since a touch tap fires pointerenter ('touch') first */
li.addEventListener('pointerenter', (ev) => {
if (ev.pointerType === 'mouse' && flyoutMode())
closeFlyouts();
clampDropdown(li);
});
}
ul.appendChild(li);
});
return ul;
}
/* modes -> #modemenu; drives renderMainMenu for the active mode */
function renderModeMenu(node) {
/* #modemenu is emitted whenever this module loads (header.ut, !blank_page) */
const ul = document.querySelector('#modemenu');
const children = ui.menu.getChildren(node);
children.forEach((child, index) => {
const isActive = L.env.requestpath.length
? child.name === L.env.requestpath[0]
: index === 0;
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
if (isActive)
renderMainMenu(child, child.name);
});
if (children.length <= 1)
ul.classList.add('single');
if (ul.children.length > 1)
ul.style.display = '';
}
function renderChrome() {
const root = tree.tree();
const modemenu = document.querySelector('#modemenu');
const topmenu = document.querySelector('#topmenu');
const tabmenu = document.querySelector('#tabmenu');
modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single');
topmenu.innerHTML = '';
tabmenu.innerHTML = ''; tabmenu.style.display = 'none';
renderModeMenu(root);
const segs = L.env.dispatchpath.slice(0, 3);
const node = segs.length === 3 && tree.nodeForSegs(segs);
if (node)
renderTabMenu(node, segs.join('/'));
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. */
function wireRail() {
/* #fs-rail-toggle is emitted whenever this module loads (header.ut, !blank_page) */
const btn = document.getElementById('fs-rail-toggle');
function sync() {
const on = prefs.currentRail();
btn.setAttribute('aria-expanded', on ? 'false' : 'true');
const label = on ? _('Expand menu') : _('Collapse menu');
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
btn.addEventListener('click', () => {
prefs.applyRail(!prefs.currentRail());
sync();
fit.schedule();
});
sync();
}
/* An indicator pill carries its meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
* 68px wide, so the pill would wrap onto three lines: CSS squares the pill and draws the count
* written here. */
const IND_DOT = '•';
/* A clickable `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare span: no
* role, name or tabindex, so Tab skips it and a screen reader announces text with no role. */
function wireIndicatorKeyboard(el) {
if (el.dataset.fsWired) return;
el.dataset.fsWired = '1';
widgets.wireActivate(el, () => el.click());
}
function wireIndicatorCounts() {
/* #indicators is emitted whenever this module loads (header.ut, !blank_page) */
const box = document.getElementById('indicators');
function stamp() {
box.querySelectorAll('[data-indicator]').forEach((el) => {
const txt = el.textContent || '';
const m = txt.match(/(\d+)\s*$/);
widgets.syncAttr(el, 'data-fs-badge', m ? m[1] : IND_DOT);
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
widgets.syncAttr(el, 'title', txt);
/* Only a clickable indicator becomes a control: `ui.showIndicator` sets `data-clickable` only when
* it was handed a handler (luci-base ui.js). */
if (el.hasAttribute('data-clickable')) {
widgets.syncAttr(el, 'role', 'button');
widgets.syncAttr(el, 'tabindex', '0');
widgets.syncAttr(el, 'aria-label', txt.trim() || null);
wireIndicatorKeyboard(el);
}
});
}
/* ui.showIndicator sets the label's data on an update and appends the span on the session's first
* change, so childList, subtree and characterData matter; attributes are not observed, so our own
* writes do not loop. */
new MutationObserver(() => {
stamp();
fitChrome();
})
.observe(box, { childList: true, subtree: true, characterData: true });
stamp();
}
function wireMenu() {
/* click-outside and Escape close an open flyout, gated on flyoutMode(): outside it `.open` means
* an unfolded accordion, which must not fold on a click elsewhere (WCAG 2.2 SC 1.4.13). */
document.addEventListener('click', (ev) => {
if (flyoutMode() && !ev.target.closest?.('#topmenu > li.has-sub'))
closeFlyouts();
});
document.addEventListener('keydown', (ev) => {
/* the search palette preventDefaults the Escape it handles; without this guard the same keypress
* also folds a keyboard-opened flyout */
if (ev.defaultPrevented || ev.key !== 'Escape' || !flyoutMode()) return;
const open = document.querySelector(OPEN_LI);
if (!open) return;
const trigger = open.querySelector(TRIGGER);
closeFlyouts();
trigger?.focus();
});
/* Entering flyout mode folds everything, or a section left open as an accordion reappears as a
* popup stuck on screen; leaving it restores the accordion. Watch the attribute, not the rail
* button. */
const modeChanged = () => {
clearClamps();
flyoutMode() ? closeFlyouts() : restoreAccordion();
};
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it): narrowing the
* window must also run the transition handler. */
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 the attribute
* observer and the resize clamp-clear cover every transition. */
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame (resize fires
* dozens of times a second while dragging) */
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 ours. */
document.addEventListener('fs-autocollapse', (ev) => {
if (ev.detail?.on) {
_openSections.clear();
saveOpenSections();
}
if (!flyoutMode()) restoreAccordion();
});
}
/* The one call the theme's init() makes once the menu tree is loaded; the bar's fit joins fs-fit
* (re-run on every #view resize and mutation) before the first measure. */
function start() {
fit.add(fitChrome);
renderChrome();
wireRail();
wireIndicatorCounts();
}
return baseclass.extend({
renderChrome,
wireMenu,
start,
/* 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 needs it. */
contentWidth() {
/* `_shellOuter` is refreshed only by measureShell(), and fitChrome() steps aside for the whole
* SCROLL_IDLE window (400ms, fs-fit.js) while fit.scrolling() answers yes; a resize that moves the
* offset first would read a stale one. */
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()
});
},
});
@@ -0,0 +1,488 @@
'use strict';
'require baseclass';
/* 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. */
/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
* without it left every data table invisible. */
function armGate() {
if (!fittersEnabled()) return;
document.documentElement.dataset.fsFit = '1';
}
const _fitters = [];
let _ro = null, _mo = null;
function runAll(list, what) {
for (const fit of list) {
try { fit(); }
catch (e) { console.error('fs-fit: a ' + what + ' threw', e); }
}
}
/* dev switch */
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. */
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. */
function holdFloor(records) {
if (scrolling() || !fittersEnabled()) return;
const host = document.getElementById('view');
if (!host) return; /* the login page has no view */
const boxes = new Set();
host.querySelectorAll(SHRINKS).forEach((el) => {
let box = el, cs = window.getComputedStyle(el);
while (box !== host && cs.display.startsWith('table')) {
box = box.parentElement;
cs = window.getComputedStyle(box);
}
if (box !== host) boxes.add(box);
});
host.querySelectorAll(FLOORED).forEach((box) => boxes.add(box));
let dirty = [...boxes];
if (records && records.length) {
const targets = new Set();
for (const r of records) if (r.target) targets.add(r.target);
dirty = dirty.filter((box) => {
for (const t of targets) if (box.contains(t) || t.contains(box)) return true;
return false;
});
}
if (!dirty.length) return;
const page = scroller() || 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. */
const hs = dirty.map((box) => 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. */
const landed = scrollTop();
if (landed >= at) return;
if (page.scrollHeight >= tall) writeOffset(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. */
const SCROLL_IDLE = 400;
let _deferred = false;
function deferMeasurement() { _deferred = true; }
let _movingUntil = 0;
let _lastOffset = null;
let _sampling = false;
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. */
let _clampedTo = null;
function sawClamp() {
if (scrollTop() === _clampedTo) return true;
_clampedTo = null;
return false;
}
function writeOffset(value) {
const sc = scroller();
if (sc) sc.scrollTop = value; else window.scrollTo(0, value);
_ownWrite = scrollTop();
}
/* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the
* stylesheet decides it. */
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 (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. */
/* Only a box wholly above the viewport moves the reader when it gives its floor up. */
const floorsAbove = [..._deferredFloors].filter((b) => b.isConnected && b.getBoundingClientRect().bottom <= 0)
.map((b) => [ b, parseFloat(b.style.minHeight) || 0 ]);
_deferredFloors.clear();
const offsetBefore = scrollTop();
holdFloor();
let shrink = 0;
for (const [ b, h ] of floorsAbove) shrink += h - (parseFloat(b.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();
}
/* Read on the next frame, never in the scroll event: a layout forced while WebKit dispatches one
* skips its anchoring adjustment and leaves the scroller marked as queued, so the engine anchors
* nothing for the rest of the document's life (ScrollAnchoringController::
* adjustScrollPositionForAnchoring returns before it clears the mark). Any sticky or fixed box —
* `.th`, `#modal_overlay`, the bar — leaves layout dirty there, so one `scrollY` read is enough. */
const noteScroll = frame(noteMotion);
(function watchMotion() {
const opts = { passive: true, capture: true };
window.addEventListener('scroll', noteScroll, 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 frame(fn) {
let pending = false;
return () => {
if (pending) return;
pending = true;
requestAnimationFrame(() => { pending = false; fn(); });
};
}
const schedule = frame(run);
/* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only
* steps rewrote 1054 class attributes. */
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 (!_ro) _ro = new ResizeObserver(onResize);
_ro.observe(el);
}
/* ---- scroll anchoring, where the engine has none ----
* Safari and iOS before 27 have no `overflow-anchor`, and a tick's growth above the reader moves
* them by all of it. Whether the engine anchors is asked of the platform, never of a browser name;
* where it does, the observer leaves the reader to it. Elsewhere the drift is computed from a
* reference element, never from the offset. */
const ENGINE_ANCHORS = (() => {
try { return CSS.supports('overflow-anchor', 'auto'); }
catch (e) { return true; }
})();
let _rest = null;
let _restAt = null, _restPage = null;
/* Every floored box a tick refilled while the reader scrolled: one tick refills several, and keeping
* one lost the lease table's 172px. */
const _deferredFloors = new Set();
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. */
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('[data-fs-chrome]');
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) => {
for (const cand of document.elementsFromPoint(x, yy))
if (cand !== host && host.contains(cand)) return cand;
return 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.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 === host) keep = null;
return { el, top: el.getBoundingClientRect().top,
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
}
let _anchorPending = null;
function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; }
catch (e) { return true; }
}
/* Floors `holdFloor()` could not clear while the reader scrolled, cleared once they let go: written
* in the frame the floors came off, so no frame is painted with the reader moved. The offset as
* laid out after the clear tells whether the engine already gave it back. */
function settleDeferredFloor(offsetBefore, shrink) {
if (!anchorEnabled() || Date.now() < _userUntil) return;
const seen = scrollTop();
const gap = offsetBefore - shrink - seen;
/* 16: a 32-36 row lease table rounds by 8-12.25px at 390 wide */
if (Math.abs(gap) <= 16) return; /* the engine already gave it back */
/* no cap at the viewport: four leases stacked at 390px give up 1032px, all of it above */
writeOffset(seen + gap);
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
_restAt = scrollTop();
}
function scheduleAnchor(ref) {
if (!ref) return;
if (!anchorEnabled()) return;
if (_anchorPending) return;
_anchorPending = ref;
requestAnimationFrame(() => {
const pending = _anchorPending;
_anchorPending = null;
const wrote = applyAnchor(pending);
rememberRest(wrote);
});
}
function applyAnchor(ref) {
/* Not into a moving page — unless the motion is the clamp this correction is for, which held a
* correct -60px correction unwritten. */
if (scrolling() && !sawClamp()) return;
const at = scrollTop();
if (ref.by != null) {
if (ref.by < 1) return;
writeOffset(at + ref.by);
return true;
}
if (at <= 0) return;
if (!ref.el.isConnected) return;
const drift = ref.el.getBoundingClientRect().top - ref.top;
if (Math.abs(drift) < 1) return; /* nothing needed correcting here */
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return;
writeOffset(at + 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 ref = ENGINE_ANCHORS ? null : anchorFor();
const wasScrolling = scrolling();
/* every floored box a record sits in, since `dom.content()` refills a node inside the pinned box
* on 1 of 12 nodes, and one batch refills several: only the first lost the lease table's.
* Only a box already floored before run(): one run() floors now has nothing to settle later. */
if (wasScrolling)
for (const m of records) {
const b = m.type === 'childList' && m.target.closest(FLOORED);
if (b && (parseFloat(b.style.minHeight) || 0) > 0) _deferredFloors.add(b);
}
run(records);
if (!wasScrolling) _deferredFloors.clear();
/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
* out and written over a correct restoreScroll(). */
let gone = 0, came = 0;
for (const m of records)
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
if (gone && came) {
forgetRest();
return;
}
scheduleAnchor(ref);
});
for (const host of hosts) {
_mo.observe(host, { childList: true, subtree: true });
watch(host);
}
const _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. 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. */
const _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) {
_fitters.push(fit);
observeContent();
try { fit(); }
catch (e) { console.error('fs-fit: a fitter threw on registration', e); }
},
scrolling,
deferMeasurement,
forgetRest,
armGate,
schedule,
frame,
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,133 @@
'use strict';
'require baseclass';
/* The menu tree as a routing table: path <-> node, with LuCI's alias/firstchild resolution. Pure
* lookup, no DOM. */
/* 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'] */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && !pathname.startsWith(base))
return null;
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
return rest.length ? rest.split('/') : [];
}
/* walk the (scrubbed, ACL-filtered) menu tree to the node for a path */
function nodeForSegs(segs) {
let node = _tree;
for (let i = 0; i < segs.length; i++) {
node = node && node.children && node.children[segs[i]];
if (!node) return null;
}
return node;
}
/* ---- alias / firstchild resolution ----
* 7 of the 27 menu links are redirects (4 `alias`, 3 `firstchild`); the client resolves them as the
* dispatcher does. */
/* 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). */
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. */
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 ----
* See apply_tree_acls() (dispatcher.uc:442). */
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. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
if (node.action.type === 'view')
return 'view.' + String(node.action.path).replace(/\//g, '.');
if (node.action.type === 'template' && node.action.path === 'admin_status/index')
return 'view.status.index';
return null;
}
/* The node the current full load landed on, i.e. what L.env.dispatchpath points at. */
function currentNode() {
return nodeForSegs(L.env.dispatchpath || []);
}
return baseclass.extend({
setTree,
tree: () => _tree,
segsFromPath,
nodeForSegs,
currentNode,
resolveSegs,
readonlyForSegs,
viewClassFor
});
@@ -0,0 +1,320 @@
'use strict';
'require baseclass';
'require dom';
'require network';
'require fs-fit as fit';
'require fs-widgets as widgets';
/* Overview layout and meters: re-arranges the stock System / Memory / Storage sections into a grid,
* marks the meters up (aria, and data-fs-level warn/danger at the FS_METER_* thresholds), turns
* card headings into disclosure buttons and draws progressively through a wrapped poll_status.
* Content and data stay luci-mod-status's. */
/* the data-page value this module acts on; the loader's PAGE_MODULES map must name the same page */
const PAGE = 'admin-status-overview';
const onPage = () => document.body.getAttribute('data-page') === PAGE;
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
/* 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. */
const FS_METER_WARN = 80;
const FS_METER_DANGER = 92;
/* Row labels this theme has seen misread, matched like `ROLES`: `_(msgid)` against
* luci-mod-status's own msgids, no msgctxt. */
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
/* The meter's name, if the markup already states one — never invented: a `.cbi-value` row's own
* label or a key/value table row's first cell. */
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`: `'X / Y (Z%)'` or a bare `'97%'`, both end-anchored so
* a stray "%" earlier in a localised reading is ignored. */
function parseMeterPercent(title) {
if (title == null) return null;
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title);
return m ? parseInt(m[1], 10) : null;
}
/* The attribute work for one `.cbi-progressbar`: Overview's 20_memory.js, 25_storage.js and
* 30_network.js declare and call their own local `progressbar()`, so the theme's
* window.progressbar never stamps them. */
function annotateMeter(pg) {
const title = pg.getAttribute('title');
const pc = parseMeterPercent(title);
if (pc == null) return;
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
widgets.syncAttr(pg, 'role', 'progressbar');
widgets.syncAttr(pg, 'aria-valuemin', '0');
widgets.syncAttr(pg, 'aria-valuemax', '100');
widgets.syncAttr(pg, 'aria-valuenow', String(level));
widgets.syncAttr(pg, 'aria-valuetext', title);
const label = findProgressbarLabel(pg);
const name = label ? label.textContent.trim() : '';
widgets.syncAttr(pg, 'aria-label', label ? name : null);
/* Polarity: an unrecognised bar keeps the plain fill-based rule, on purpose: a wrong red is worse
* than a missing colour. */
let dataLevel = null;
if (!FS_METER_NEUTRAL.has(name)) {
const v = FS_METER_INVERTED.has(name) ? 100 - level : level;
dataLevel = v >= FS_METER_DANGER ? 'danger' : (v >= FS_METER_WARN ? 'warn' : null);
}
widgets.syncAttr(pg, 'data-fs-level', dataLevel);
}
function annotateMeters(root) {
root.querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
}
function headerEl(sec) {
return sec.querySelector('.cbi-title h3, :scope > h3');
}
function sectionTitle(sec) {
const h = headerEl(sec);
if (!h) return '';
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 ----
* On 25.12 Status -> Overview carries 14 `[data-clickable]` bare <span>s (the topbar poll pill plus
* one Hide/Show pill per card), clickable by mouse only. */
function pillExpanded(label) {
return label.getAttribute('data-style') !== 'active';
}
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 it wired
* touches only aria-expanded. */
function wireDisclosure(sec) {
const h = headerEl(sec);
const label = h && h.querySelector('.label[data-indicator="poll-status"]');
if (!h || !label) return; /* not this card's shape — left alone rather than guessed at */
const panel = panelFor(h);
if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);
widgets.syncAttr(h, 'role', 'button');
widgets.syncAttr(h, 'tabindex', '0');
widgets.syncAttr(h, 'aria-controls', panel ? panel.id : null);
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
widgets.syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
h.dataset.fsWired = '1';
h.addEventListener('click', (ev) => {
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
});
widgets.wireActivate(h, () => label.click());
}
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
* "-", the poll pill its only content, 72px tall on the live Overview. */
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: the stylesheet ellipses it at one line,
* and the name cannot be guessed from the rest of the card. */
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() {
if (!onPage()) {
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, so the reading and colour those bars need are stamped here. */
annotateMeters(view);
tidyCards(view);
/* Fast path: the poll lands here on every tick, and the stock poll never rebuilds the .cbi-section
* wrappers, so the grid survives. Deliberately not a disconnect(). */
if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
return;
const found = {};
view.querySelectorAll(':scope > .cbi-section').forEach((sec) => {
const r = ROLES[sectionTitle(sec)];
if (r && !found[r]) found[r] = sec;
});
/* wait until all three stock sections exist */
if (!(found.sys && found.mem && found.sto)) return;
/* already wrapped? (first tick after a rebuild re-finds the existing grid) */
if (found.sys.parentElement && found.sys.parentElement.classList.contains('fs-ovl')) {
_wrapEl = found.sys.parentElement;
return;
}
const wrap = document.createElement('div');
wrap.className = 'fs-ovl';
found.sys.parentNode.insertBefore(wrap, found.sys);
found.sys.classList.add('fs-ovl-sys'); wrap.appendChild(found.sys);
found.mem.classList.add('fs-ovl-mem'); wrap.appendChild(found.mem);
found.sto.classList.add('fs-ovl-sto'); wrap.appendChild(found.sto);
_wrapEl = wrap;
}
/* Stock sections render async and repaint every poll, so watch #maincontent and re-run arrange(),
* coalesced. #maincontent outlives every SPA swap, so one observer is attached once. */
let _observer = null;
function stopWatch() {
if (_observer) _observer.disconnect();
_observer = null;
_wrapEl = null; /* the grid belongs to the #view we are leaving */
}
function watch() {
const view = document.getElementById('view');
/* `#maincontent`, not `#view`: a client navigation builds a fresh `#view` before it is in the
* document and swaps it in afterwards, so an observer on it would watch a detached node. */
const root = document.getElementById('maincontent');
arrange();
if (_observer || !view)
return;
_observer = new MutationObserver(fit.frame(arrange));
_observer.observe(root, { childList: true, subtree: true });
}
/* Called by the loader on every arrival at the overview, full load or SPA navigation;
* patchOverview() and watch() are idempotent. */
function wire() {
if (!onPage()) {
stopWatch();
return;
}
patchOverview();
watch();
}
/* ---- progressive paint ----
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over every
* include's load(): #view stays empty until the slowest is in (182 ms, most sections). */
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;
let _inflightFor = null;
function pollProgressive(includes, containers, first_load) {
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);
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, so a rejection would surface as an unhandled one; `run`
* rejects for one ordinary reason: flushCache() on an expired session. */
_inflight.catch(() => {});
/* first load resolves now, so index.render() returns its tree 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 wire(), not at module eval, which would pull the whole status view in on
* every page. */
function patchOverview() {
/* `window.L`, never the bare `L` this factory was handed: index.js loads its own includes with the
* `L` require() passes the loaded module (the two-L trap). */
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: a stock
* include calls them bare from its own render(), so they must exist before the view class does. */
return baseclass.extend({
/* called by menu-footstrap-common's loader on every data-page stamp that maps here */
wire,
});
@@ -0,0 +1,163 @@
'use strict';
'require baseclass';
'require fs-fit as fit';
/* The live appliers every page needs (dark mode, layout, auto-collapse, rail) and the localStorage
* wrappers under them. The other axes, the snapshot and the resets live in fs-axes.js. */
/* A browser can refuse storage (blocked cookies, a partitioned WebView, a full quota) and then
* reads or writes throw: every access is wrapped, and an unreadable preference is a default, not
* an error. */
const _mem = new Map();
let _lsBroken = false;
function storageBroken() { return _lsBroken; }
function lsGet(k) { if (_mem.has(k)) return _mem.get(k); try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); _mem.delete(k); } catch (e) { _lsBroken = true; _mem.set(k, String(v)); } }
function lsDel(k) { _mem.delete(k); try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } }
/* A stored JSON array, or [] — the shape of the two remembered lists (the search palette's recent
* paths, the menu's open sections). */
function lsGetArr(k) {
try {
const a = JSON.parse(lsGet(k) || '[]');
return Array.isArray(a) ? a : [];
} catch (e) { return []; }
}
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
function recentPaths() {
return lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string').slice(0, RECENT_MAX);
}
/* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the
* effective default when this browser has no localStorage value */
function sd(k) { return (window.__fsSD || {})[k]; }
function setSD(field, val) { (window.__fsSD = window.__fsSD || {})[field] = val; }
function modeDefault() {
const d = sd('darkmode');
return (d === 'dark' || d === 'light') ? d : 'auto';
}
function currentMode() {
const s = lsGet('fs-darkmode');
if (s === 'true') return 'dark';
if (s === 'false') return 'light';
if (s === null) return modeDefault();
return 'auto';
}
/* Dark mode is announced in three dialects because apps sniff for it: `data-theme="dark"`
* (luci-app-justclash keys 21 rules off it), Bootstrap's `data-bs-theme` and `data-darkmode`. */
function stampDark(root, dark) {
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)');
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 */
if (val === 'auto') lsSet('fs-darkmode', 'auto');
else lsSet('fs-darkmode', val === 'dark' ? 'true' : 'false');
stampDark(root, intendedDark());
}
/* Third parties write the dialects too: luci-app-openclash stamps `data-darkmode="true"` onto
* :root from seven templates, so the guard re-asserts the stamp. */
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);
};
check();
new MutationObserver(check).observe(root, {
attributes: true,
attributeFilter: [ 'data-darkmode', 'data-theme', 'data-bs-theme' ]
});
}
/* `<meta name="theme-color">` colours a mobile address bar and an installed PWA's title bar;
* head.ut ships the default palette's page colour and this tracks the live one. */
function paintThemeColor() {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta || !document.body) return;
const bg = getComputedStyle(document.body).backgroundColor;
/* a transparent body means the sheet has not applied yet; keep the server's value */
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') meta.setAttribute('content', bg);
}
function watchThemeColor() {
paintThemeColor();
new MutationObserver(fit.frame(paintThemeColor)).observe(document.documentElement,
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
}
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 */
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. */
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 the open ones are folded. */
document.dispatchEvent(new CustomEvent('fs-autocollapse', { detail: { on } }));
}
/* The sidebar rail's collapsed flag, flipped by fs-chrome.js: a transient chrome collapse, not an
* axis, so snapshotAxes() and resetToSaved() skip it. */
function applyRail(on) {
const root = document.documentElement;
if (on) { root.setAttribute('data-rail', 'true'); lsSet('fs-rail', 'true'); }
else { root.removeAttribute('data-rail'); lsDel('fs-rail'); }
}
function currentRail() {
return document.documentElement.getAttribute('data-rail') === 'true';
}
return baseclass.extend({
/* the storage wrappers and the router-default reader: fs-axes.js is built on these */
lsGet, lsSet, lsDel, lsGetArr, RECENT_KEY, RECENT_MAX, recentPaths, storageBroken, sd, setSD,
currentMode, applyMode, modeDefault, guardDarkStamp, watchThemeColor,
currentLayout, applyLayout, isTopLayout,
currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
currentRail, applyRail
});
@@ -0,0 +1,812 @@
'use strict';
'require baseclass';
'require ui';
'require rpc';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-sheets as sheets';
'require fs-fit as fit';
/* The SPA client router. Re-instantiates a `view` node in place instead of a full reload (54 of 74
* menu leaves on the dev router); anything else, a modified click or any error falls through to a
* normal navigation. A revisit runs `new v.constructor()` on the cached singleton. Path->node:
* fs-menutree.js; poisoned documents: fs-sheets.js. */
/* ---- stray-interval teardown ----
* A full load kills every window.setInterval the outgoing page set; SPA nav does not, so view-set
* ids are tracked and cleared per navigation. Hooked at module eval. */
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Map()));
const _si = window.setInterval, _ci = window.clearInterval;
window.setInterval = function (fn, ms, ...rest) {
const id = _si.apply(window, arguments);
_viewIntervals.set(id, { fn, ms, rest, live: id });
return id;
};
window.clearInterval = function (id) {
const spec = _viewIntervals.get(id);
_viewIntervals.delete(id);
if (spec) return (spec.live == null) ? undefined : _ci.call(window, spec.live);
return _ci.apply(window, arguments);
};
/* A hidden tab must not keep calling the router: view timers are disarmed on hide and re-armed on
* show. A paused timer stays in the registry, so the navigation sweep still sees it. */
let _pollWasActive = false;
document.addEventListener('visibilitychange', () => {
try {
if (document.hidden) {
/* remembered so a manual pause (the "Refreshing" indicator) is not undone on show */
_pollWasActive = !!(L.Poll && L.Poll.active());
if (_pollWasActive) L.Poll.stop();
for (const spec of _viewIntervals.values()) {
if (spec.live == null) continue;
_ci.call(window, spec.live);
spec.live = null;
}
}
else {
for (const spec of _viewIntervals.values()) {
if (spec.live != null) continue;
spec.live = _si.call(window, spec.fn, spec.ms, ...spec.rest);
}
/* never resume a poll the session can no longer answer (luci-base stopped it behind its modal) */
if (_pollWasActive && !_expired) L.Poll.start();
}
} catch (e) { console.error('footstrap: the hidden-tab pause/resume threw', e); }
});
function clearViewIntervals() {
/* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */
for (const id of [ ..._viewIntervals.keys() ]) window.clearInterval(id);
}
function hidePollIndicatorIfEmpty() {
if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return;
ui.hideIndicator('poll-status');
}
/* ---- uci cache teardown ----
* uci.load() skips packages in its document-scoped cache, so a view reading it as an existence
* check rendered "no config found" on the second SPA visit. Navigation drops the cache with
* unload() via `window.L.uci` (the two-L trap); never rejects. */
function flushUciCache() {
const uci = window.L.uci;
if (!uci || typeof uci.unload !== 'function') return null;
if (!uci.state || typeof uci.state.values !== 'object' || typeof uci.loaded !== 'object') {
if (!_uciCacheWarned) {
_uciCacheWarned = true;
console.error('footstrap: LuCI.uci keeps its cache somewhere this router does not know, so '
+ 'it is left alone. An app that reads uci.load()\'s return value as an existence check '
+ 'will report a missing config on the second SPA visit. fs-router.js needs updating for '
+ 'this luci-base.');
}
return null;
}
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
if (!names.length) return null;
uci.unload(names);
/* …and put back the three packages network.js reads but never reloads, or every consumer sees an
* empty config until F5. The refill costs one uci `get` (136 -> 159 ms median). */
if (!window.L.network) return null;
const refill = [ 'network', 'wireless', 'luci' ].filter((p) => names.indexOf(p) !== -1);
if (!refill.length) return null;
return uci.load(refill).catch((e) => {
console.error('footstrap: reloading uci ' + refill.join(', ') + ' after a navigation failed', e);
});
}
let _uciCacheWarned = false;
/* ---- a dead session ends the document ----
* An expired session gets luci-base's modal, which reloads into the login form; navigating on would
* dismiss it, so the router stops claiming navigations. Signals: a 403 with `X-LuCI-Login-Required:
* yes`, and a rejected `session.access` probe; `access: false` is an ACL answer. Interceptors must
* not throw. */
let _expired = false;
function markExpired() {
if (_expired) return;
_expired = true;
console.warn('footstrap: the LuCI session is gone — every navigation from here is a full load.');
}
function markAlive() {
if (!_expired) return;
_expired = false;
console.warn('footstrap: the LuCI session answers again — client navigation is back on.');
}
function watchSession() {
const req = window.L.Request;
if (req && typeof req.addInterceptor === 'function')
req.addInterceptor((res) => {
try {
if (res && res.status === 403 && res.headers &&
res.headers.get('X-LuCI-Login-Required') === 'yes')
markExpired();
}
catch (e) { /* see above: an interceptor that throws rejects the caller's request */ }
});
if (rpc && typeof rpc.addInterceptor === 'function')
rpc.addInterceptor((msg, r) => {
try {
if (!r || r.object !== 'session' || r.method !== 'access') return;
if (!msg || msg.jsonrpc !== '2.0') return;
if (msg.error && msg.error.code && msg.error.message) { markExpired(); return; }
if (Array.isArray(msg.result) && msg.result[1] && msg.result[1].access === true)
markAlive();
}
catch (e) { /* ditto */ }
});
}
/* ---- throw an element away the way luci-base does ----
* Only `dom.content()` deletes a `dom.registry` entry; a plain `remove()` keeps the element
* reachable (83 entries after lap one and after lap three). The node itself is never reaped, so it
* is first moved into a detached container. */
function discard(el) {
const bin = document.createElement('div');
bin.appendChild(el);
window.L.dom.content(bin, null);
}
/* ---- a page that parks nodes on <body> makes the next navigation a full load ----
* Some apps append nodes to document.body on every visit (one leaves 7). The recorder is
* header.ut's first <body> <script>; this file only reads it. */
function strayBodyNode(el) {
if (!el || el.nodeType !== 1) return false;
/* never rendered on their own, wherever a script parks them */
switch (el.nodeName) {
case 'SCRIPT': case 'STYLE': case 'LINK': case 'TEMPLATE': case 'NOSCRIPT': case 'META':
return false;
}
if (el.dataset && el.dataset.fsChrome !== undefined) return false;
if (el.id && el.id.indexOf('fs-') === 0) return false;
for (const c of el.classList) if (c.indexOf('fs-') === 0) return false;
if (el.id === 'modal_overlay') return false;
if (el.classList.contains('cbi-tooltip')) return false;
if (el.nodeName === 'A' && el.hasAttribute('download') && el.style.display === 'none') return false;
return true;
}
function bodyLittered() {
const adds = window.__fsBodyAdds;
if (!adds || !adds.length) return false;
let littered = false;
for (let i = adds.length - 1; i >= 0; i--) {
if (!document.body || adds[i].parentNode !== document.body) { adds.splice(i, 1); continue; }
if (strayBodyNode(adds[i])) littered = true;
}
return littered;
}
let _curPath = window.location.pathname;
let _curSearch = window.location.search;
let _navGen = 0;
let _shown = null;
/* ---- Back must restore the scroll of whichever element is the scroller ----
* The sidebar layout scrolls #maincontent, the top layout the document; the UA restores the
* document's offset before #view is swapped and the clamp takes it to 0. Offsets are not saved by
* replaceState (Safari allows 100 history writes per 30 s): each entry carries a session-prefixed
* id in history.state, the offsets live in a Map. */
const _scrollMem = new Map();
const _scrollSess = Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
let _histN = 0;
let _curId = null;
const SCROLL_MEM_MAX = 50;
let _pendingRestore = null;
function newEntryId() { return _scrollSess + ':' + (++_histN); }
function adoptEntry() {
const st = history.state;
if (st && st.fsid) { _curId = st.fsid; return; }
_curId = newEntryId();
try { history.replaceState(Object.assign({}, st, { fsid: _curId }), '', window.location.href); } catch (e) {}
}
/* must run before _curId moves on to the incoming entry */
function saveScroll() {
if (!_curId) return;
const sc = document.getElementById('maincontent');
_scrollMem.delete(_curId);
_scrollMem.set(_curId, { win: Math.round(window.scrollY) || 0, main: sc ? sc.scrollTop : 0 });
while (_scrollMem.size > SCROLL_MEM_MAX)
_scrollMem.delete(_scrollMem.keys().next().value);
}
/* Put the scrollers back once the incoming view is tall enough: restoring earlier is clamped to 0
* and reads as "worked". Polls by frame; a newer navigation cancels via the generation. */
function restoreScroll(pos, gen) {
const until = Date.now() + 5000;
/* The user outranks the saved position. A `scroll` is not intent on an axis `pos` does not carry,
* when it is our own write coming back, or when the engine settles (below). */
let cancelled = false, wroteWin = -1, wroteMain = -1;
const stop = () => { cancelled = true; off(); };
/* The engine settling is not input: commitStage() leaves the page shorter than the offset for a
* frame and the clamp fires a `scroll` before any write (overview <- package-manager: 2684
* restored, `y=0` 5 s early). */
const onScroll = (ev) => {
const t = ev.target;
const isWin = (t === document || t === document.documentElement || t === document.body);
if (isWin) {
if (!pos.win) return;
if (Math.round(window.scrollY) === wroteWin) return; /* our own write coming back */
const de = document.documentElement;
const ceiling = Math.max(0, de.scrollHeight - de.clientHeight);
if (ceiling < pos.win && Math.round(window.scrollY) === ceiling) return; /* the page settling */
}
else {
if (!pos.main) return;
if (t !== document.getElementById('maincontent')) return;
if (t.scrollTop === wroteMain) return; /* our own write coming back */
const ceiling = Math.max(0, t.scrollHeight - t.clientHeight);
if (ceiling < pos.main && t.scrollTop === ceiling) return; /* the page settling */
}
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
window.removeEventListener('wheel', stop, opts);
window.removeEventListener('touchstart', stop, opts);
window.removeEventListener('keydown', onKey, opts);
window.removeEventListener('scroll', onScroll, opts);
}
window.addEventListener('wheel', stop, opts);
window.addEventListener('touchstart', stop, opts);
window.addEventListener('keydown', onKey, opts);
window.addEventListener('scroll', onScroll, opts);
(function tick() {
if (cancelled) return;
if (gen !== _navGen || Date.now() > until) { off(); return; }
const de = document.documentElement;
const sc = document.getElementById('maincontent');
let pending = false;
if (pos.main) {
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) { wroteMain = pos.main; sc.scrollTop = pos.main; }
else pending = true;
}
if (pos.win) {
if (de.scrollHeight - de.clientHeight >= pos.win) { wroteWin = pos.win; window.scrollTo(0, pos.win); }
else pending = true;
}
if (pending) requestAnimationFrame(tick);
else off();
})();
}
/* ---- the host half of "<host> | <page>" is read once ----
* head.ut stamps it; re-deriving it from document.title would let a page that renames the tab
* become the host for every later page. */
let _titleHost = '';
function moduleUrl(className) {
const v = L.env.resource_version ? ('?v=' + L.env.resource_version) : '';
return (L.env.base_url || '') + '/' + className.replace(/\./g, '/') + '.js' + v;
}
/* ---- link prefetch: warm the module cache for a page the user is about to open ----
* A plain fetch(), not require(): a view's __init__ is its render. Transitive, which is most of the
* win: warming the view class alone leaves its `require` pragmas a round trip behind (418 -> 296 ms
* on a first visit at 120 ms RTT). The scan is not line-anchored: shipped files carry every pragma
* on one line. */
const PRAGMA_HEAD = 2000;
const PREFETCH_DEPTH = 3;
function pragmaDeps(src) {
const re = /(['"])require[ \t]+([^'"]+?)\1/g;
const head = src.slice(0, PRAGMA_HEAD);
const out = [];
let m;
while ((m = re.exec(head)))
out.push(m[2].split(/[ \t]+as[ \t]+/)[0]);
return out;
}
/* Only dotted class names are prefetched: a name with no dot is a virtual class with no file (a
* 404) or a flat library already loaded (a seven-page walk fetched 10 files, every one nested). */
const _seen = new Set();
const _prefetched = new Set();
const _warming = new Map();
const _committed = new Set();
function warmClass(name, depth, root) {
if (_prefetched.has(name)) return;
_prefetched.add(name);
if (name.indexOf('.') < 0 || _seen.has(name)) return;
const req = fetch(moduleUrl(name), { credentials: 'same-origin' });
const body = req.then((res) => (res.ok ? res.text() : '')).catch(() => '');
_warming.set(name, body.then(() => {}));
if (depth < PREFETCH_DEPTH)
body.then((src) => {
if (_committed.has(root)) return;
for (const d of pragmaDeps(src)) warmClass(d, depth + 1, root);
});
}
function prefetchSegs(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const res = tree.resolveSegs(segs);
const className = tree.viewClassFor(res && res.node);
if (className) warmClass(className, 0, className);
}
/* Capped: a wedged prefetch must never wedge a navigation. */
const WARM_WAIT_MS = 5000;
function warmedThen(className) {
_committed.add(className);
const body = _warming.get(className);
if (!body) return Promise.resolve();
let t = 0;
return Promise.race([ body, new Promise((r) => { t = window.setTimeout(r, WARM_WAIT_MS); }) ])
.finally(() => window.clearTimeout(t));
}
function seed() {
_shown = (L.env.dispatchpath || []).slice();
const here = tree.viewClassFor(tree.currentNode());
if (here)
_seen.add(here);
_titleHost = (document.title.split('|')[0] || '').trim(); /* before any view can rename the tab — see there */
adoptEntry();
const curPage = document.body ? (document.body.getAttribute('data-page') || '') : '';
/* The document's own first render is the first link in the chain: a LuCI view chain resolves
* `#view` at paint time and would paint into the newer stage (at 350 ms latency a click 150 ms
* after DOMContentLoaded ended on the new URL with the old page painted over it). */
const vp = document.getElementById('view');
if (vp) {
vp.setAttribute('data-page', curPage);
_inflight = renderedIn(vp).catch(() => {});
}
}
/* ---- the incoming page is rendered off screen and swapped in when ready ----
* Rendering into the live `#view` shows an empty page while module and data load (1800 ms on a
* first visit at 600 ms latency). The view writes into a hidden stage, and renders are serialized:
* a chain paints into whatever `#view` is first at paint time. */
const RENDER_TIMEOUT = 15000;
let _inflight = Promise.resolve();
/* A fresh `<div id="view">` in a hidden wrapper as the first child of `.fs-content`
* (getElementById returns the first match). Hidden but laid out (`visibility: hidden; height: 0;
* overflow: clip`, never `display: none`): views size themselves from the element they render
* into. */
function stageView(contentHost) {
const wrapper = document.createElement('div');
wrapper.className = 'fs-staging';
const view = document.createElement('div');
view.id = 'view';
wrapper.appendChild(view);
/* first in tree order, or getElementById() would keep answering with the live one */
contentHost.insertBefore(wrapper, contentHost.firstChild);
return { wrapper, view };
}
function renderedIn(view) {
const painted = () => view.querySelector(':scope > :not(.spinning):not(script)') !== null;
if (painted()) return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
finish(() => reject(new Error('the view did not render within ' + RENDER_TIMEOUT + ' ms')));
}, RENDER_TIMEOUT);
const mo = new MutationObserver(() => {
if (painted() || view.childElementCount === 0) finish(resolve);
});
function finish(settle) {
window.clearTimeout(timer);
mo.disconnect();
settle();
}
mo.observe(view, { childList: true });
});
}
function dropStage(stage) {
if (stage.wrapper.parentNode) discard(stage.wrapper);
}
/* Put the staged page on screen: move its children into the live `#view` through `dom.content()`,
* drop the wrapper. The nodes move, not the element: fs-fit's content observer is bound to the
* `#view` that existed at chrome init. No startViewTransition(): WebKit's capture held the main
* thread 2,143 ms vs 213 ms at 390px. */
function commitStage(stage, contentHost) {
sheets.scopeToCurrentPage();
sweepAround(contentHost, stage);
const live = liveView(contentHost, stage);
const page = stage.view.getAttribute('data-page');
if (page != null) {
live.setAttribute('data-page', page);
contentHost.setAttribute('data-page', page);
}
const nodes = Array.from(stage.view.childNodes);
window.L.dom.content(live, nodes);
dropStage(stage);
}
function liveView(contentHost, stage) {
for (const el of contentHost.querySelectorAll(':scope > #view'))
if (el !== stage.view) return el;
const v = document.createElement('div');
v.id = 'view';
contentHost.appendChild(v);
return v;
}
/* Clear what the outgoing page left beside #view: keep tabs, server notices, <noscript> and the
* stage. Only `stage.banners` go (what #maincontent held when staging began): the incoming view's
* own banner lands before the commit (nftables' "Legacy rules detected") and must survive. */
function sweepAround(contentHost, stage) {
Array.from(contentHost.children).forEach((c) => {
if (c.id !== 'view' && c.id !== 'tabmenu' && !c.classList.contains('fs-staging') &&
!c.classList.contains('alert-message') && c.nodeName !== 'NOSCRIPT')
discard(c);
});
stage.banners.forEach((b) => { if (b.parentNode) discard(b); });
}
/* ---- a slow navigation is a navigation ----
* A cold route looks like a click that did nothing. A hairline shows when the navigation outlives
* `PROGRESS_DELAY`; below it a bar flashes on every warm click. */
const PROGRESS_DELAY = 150;
let _progressPending = 0;
let _progressTimer = 0;
let _fadeTimer = 0;
function progressBar() {
let bar = document.getElementById('fs-nav-progress');
if (!bar) {
bar = document.createElement('div');
bar.id = 'fs-nav-progress';
bar.setAttribute('aria-hidden', 'true');
document.body.insertBefore(bar, document.body.firstChild);
}
return bar;
}
function progressStart() {
_progressPending++;
window.clearTimeout(_progressTimer);
_progressTimer = window.setTimeout(() => {
const bar = progressBar();
if (bar.dataset.state === 'done') { delete bar.dataset.state; getComputedStyle(bar).transform; }
bar.dataset.state = 'active';
}, PROGRESS_DELAY);
}
function progressEnd() {
if (--_progressPending > 0) return;
_progressPending = 0;
window.clearTimeout(_progressTimer);
const bar = progressBar();
if (bar.dataset.state !== 'active') return;
bar.dataset.state = 'done';
window.clearTimeout(_fadeTimer);
_fadeTimer = window.setTimeout(() => {
if (bar.dataset.state === 'done') delete bar.dataset.state;
}, 300);
}
function navigate(pathname, push, kbd) {
const segs = tree.segsFromPath(pathname);
if (!segs) return false;
if (sheets.documentPoisoned()) return false;
if (bodyLittered()) return false;
/* an extension's lodash/underscore replaced window._: LuCI's own `_('x').format()` then throws on every view; a full load restores it */
if (typeof _ !== 'function' || typeof _('') !== 'string') return false;
if (_expired) return false;
/* `segs` is what the user clicked, `rsegs` the leaf it resolves to (they differ for an
* alias/firstchild link); a full load keeps URL and pathinfo as requested but the rest resolved */
const res = tree.resolveSegs(segs);
const node = res && res.node;
const className = tree.viewClassFor(node);
if (!className)
return false;
/* A page whose stylesheet only the server can emit (a menu.d node's `"css"`) is not ours to swap
* into: a click would show it without the app's CSS. Decline, as the poisoned-document bail does. */
if (typeof node.css === 'string' && node.css !== '' && !sheets.documentCarries(node.css))
return false;
const rsegs = res.segs;
/* declined before the first write below, or the refusal would still supersede the navigation in
* flight */
const contentHost = document.querySelector('.fs-content');
if (!contentHost) return false;
/* from here on the navigation is committed */
const gen = ++_navGen;
const restoreTo = _pendingRestore;
_pendingRestore = null;
_curPath = pathname; /* what is on screen from now on — read by the popstate handler */
_curSearch = '';
/* Run every registered navigation callback with the resolved segments (before L.env is
* re-pointed). A throwing registrant is logged and isolated. */
for (const fn of _navCbs) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
ui.hideModal();
L.env.requestpath = rsegs.slice();
L.env.dispatchpath = rsegs.slice();
L.env.pathinfo = '/' + segs.join('/');
/* `readonly` is load-bearing: luci.js's hasViewPermission() is `!env.nodespec.readonly`; it comes
* from the whole path (readonlyForSegs()) */
L.env.nodespec = { satisfied: true, action: node.action, title: node.title,
depends: node.depends, readonly: tree.readonlyForSegs(rsegs) };
/* re-navigating to the page on screen replaces its entry: a duplicate makes Back do nothing */
if (push) {
const same = pathname === window.location.pathname;
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsid: _curId }, '', pathname);
}
const title = node.title ? _(node.title) : '';
document.title = title ? (_titleHost + ' | ' + title) : _titleHost;
const tmain = document.querySelector('.fs-title-main');
if (tmain) tmain.textContent = title || _titleHost;
chrome.renderChrome();
/* A full load starts at the top and so must the swap (the write is at commitStage).
* `fit.forgetRest()` runs for a click and a Back alike: a mutation the outgoing page's poller
* makes during staging must not be read against the old rest. scrollRestoration stays 'auto'. */
fit.forgetRest();
/* ---- what a full load does for a keyboard/screen-reader user ----
* renderChrome() has emptied #topmenu, so the <a> activated with Enter is gone and focus falls
* to <body>. A keyboard activation (ev.detail === 0) moves focus to the skip link; a pointer
* activation or a popstate replay keeps the wrapper focus. preventScroll: the scroll is still
* the outgoing page's. */
const skip = kbd ? document.querySelector('.fs-skip') : null;
const main = skip || document.getElementById('maincontent');
if (main) main.focus({ preventScroll: true });
const live = document.getElementById('fs-nav-status');
if (live) live.textContent = title;
/* Require through `window.L`, not the bare `L` a factory is handed (the two-L trap: "L.itemlist is
* not a function"). On a first visit the require is the render, so only a revisit gets a singleton
* whose __init__ already ran: `_seen` is read before the require resolves. */
const RT = window.L;
const cached = _seen.has(className);
/* Wait for an in-flight prefetch of this class: two requests for one URL do not coalesce. `_seen`
* is marked after the wait, or a superseded navigation would make the next one render twice. */
const previous = _inflight;
let release;
_inflight = new Promise((r) => { release = r; });
progressStart();
Promise.all([ warmedThen(className), previous.catch(() => {}) ]).then(() => {
if (gen !== _navGen) return null;
/* ---- teardown, now that the previous render is finished ----
* Drop the outgoing view's pollers and re-arm the poll loop: LuCI fires an entry only when
* `tick % interval == 0`, so the outgoing tick makes the incoming poller wait up to a full
* `pollinterval` (Wireless drew its station list at 4950 ms against ~360 ms on a full
* load). stop()+start() is upstream's own sequence. */
L.Poll.queue.length = 0;
L.Poll.stop();
clearViewIntervals();
L.Poll.start();
const uciWarm = flushUciCache();
/* Keep <body data-page> in sync with the route: chrome modules read it as the route'S identity,
* never as a CSS scope. Page-scoped CSS keys off the `#view`/`.fs-content` stamps below: keyed off
* body, 33 rules of the Overview sheet matched the outgoing page for the whole staging window
* (1407 ms), growing the document 211px and moving the reader 140px. */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and hand the new page to fs-sheets, which darkens foreign sheets of other pages so an invasive
* one can stay in the document. Enabling the incoming page's sheets is what the staged render
* needs; disabling the outgoing page's runs at the swap (commitStage), or it would strip an app's
* CSS off content still being read. */
sheets.scopeToCurrentPage(rsegs, _shown);
const mainHost = document.getElementById('maincontent');
const outgoingBanners = mainHost
? Array.from(mainHost.children).filter((c) => c.classList.contains('alert-message')) : [];
const stage = stageView(contentHost);
stage.banners = outgoingBanners;
stage.view.setAttribute('data-page', rsegs.join('-'));
const painted = renderedIn(stage.view);
_seen.add(className);
if (!cached) sheets.attributeTo(rsegs, gen);
return Promise.resolve(uciWarm)
.then(() => RT.require(className))
.finally(() => { if (!cached) sheets.attributeTo(null, gen); })
.then((view) => {
if (!(view instanceof RT.view))
throw new TypeError('Loaded class ' + className + ' is not a view');
if (cached) new view.constructor();
return painted;
})
.then(() => {
if (gen !== _navGen) { dropStage(stage); return; }
commitStage(stage, contentHost);
_shown = rsegs.slice();
const sc = document.getElementById('maincontent');
const at = restoreTo || {};
if (push || !at.win) window.scrollTo(0, 0);
if (sc && (push || !at.main)) sc.scrollTo(0, 0);
/* now, and only now, is there one height to read: the incoming page's */
if (at.win || at.main) restoreScroll(restoreTo, gen);
})
.catch((e) => { dropStage(stage); throw e; });
}).catch((e) => {
console.error('footstrap: SPA nav to ' + className + ' failed, falling back to a full load', e);
if (gen === _navGen) window.location = pathname;
}).then(() => {
progressEnd();
release();
});
return true;
}
/* The same-origin nav URL an event's link points at, or null when it is not ours (new-tab target,
* download, bare #hash, cross-origin, ?query / #hash). navigate() pushes only the pathname, so a
* link with ?query or #hash must full-load. */
function linkUrlFrom(ev) {
const a = ev.target.closest?.('a[href]');
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download'))
return null;
const raw = a.getAttribute('href');
if (!raw || raw.charAt(0) === '#') return null;
let url;
try { url = new URL(a.href, window.location.href); } catch (e) { return null; }
return (url.origin === window.location.origin && !url.search && !url.hash) ? url : null;
}
function prefetchFrom(ev) {
const url = linkUrlFrom(ev);
if (url) prefetchSegs(tree.segsFromPath(url.pathname));
}
/* ---- a document the router could not have rendered is not one it may navigate away from ----
* A `call`, `cbi` or `function` node, and any `template` other than the Overview, may carry inline
* scripts and timers the interval hook never saw, so the first click away is a full load. A
* wildcard URL resolves to nothing; refusing to wire there would turn the router off on some of the
* most-used pages. */
function bootDocumentIsOurs() {
const node = tree.currentNode();
if (!node || !node.action)
return true; /* unknown to the tree: a wildcard page, where the router is right to run */
return tree.viewClassFor(node) != null;
}
/* ---- the boot contract: the luci-base surfaces this router calls ----
* These were never an API (`L.Poll` is a deprecated alias). Existence is checked once at boot, and
* a missing name turns the router off: the page is the server-dispatched MPA and the console names
* the gone surface. */
const CONTRACT_FNS = [
'L.require',
'L.dom.content',
'L.Poll.start/stop',
'L.Request.addInterceptor',
'rpc.addInterceptor',
'ui.hideModal',
'ui.hideIndicator'
];
function hasFns(path) {
const seg = path.split('.');
const leaf = seg.pop();
let node = { L: window.L, ui: ui, rpc: rpc }[seg.shift()];
for (const k of seg) node = node[k];
return leaf.split('/').every((n) => typeof node[n] === 'function');
}
/* the two surfaces that are not functions */
const CONTRACT_REST = [
/* the L.env keys navigate() re-points, plus the base_url moduleUrl() reads */
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
const env = window.L.env;
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
.every((k) => k in env);
} ],
[ 'L.Poll.queue', () => Array.isArray(window.L.Poll.queue) ]
];
function contractBreaks() {
const gone = (probe) => {
try { return !probe(); }
catch (e) { return true; }
};
return CONTRACT_FNS.filter((path) => gone(() => hasFns(path)))
.concat(CONTRACT_REST.filter(([ , probe ]) => gone(probe)).map(([ name ]) => name));
}
function wireRouter() {
const broken = contractBreaks();
if (broken.length) {
/* the URL, not a repository path: this package also ships inside openwrt/luci, where no `docs/`
* directory exists */
console.error('footstrap: this luci-base has no ' + broken.join(', ') +
' — the client router stays off and every link is a full page load, which is what the ' +
'theme did before it existed. Please report this line: ' +
'https://github.com/VizzleTF/luci-theme-footstrap/blob/main/docs/spa-router.md');
return;
}
if (!bootDocumentIsOurs())
return;
document.addEventListener('click', (ev) => {
if (ev.defaultPrevented || ev.button !== 0 ||
ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)
return;
const url = linkUrlFrom(ev);
if (!url) return;
saveScroll();
if (navigate(url.pathname, true, ev.detail === 0))
ev.preventDefault();
}, false);
document.addEventListener('pointerover', prefetchFrom, { passive: true });
document.addEventListener('focusin', prefetchFrom, { passive: true });
document.addEventListener('pointerdown', prefetchFrom, { passive: true });
window.addEventListener('popstate', () => {
const loc = window.location;
/* A fragment change is not a navigation: Chrome fires `popstate` for a same-document fragment nav,
* and re-running navigate() would wipe the state the click set. A query counts only when it is not
* the one the page booted with. The entry is adopted (its state is null). */
if (loc.pathname === _curPath && (!loc.search || loc.search === _curSearch)) {
adoptEntry();
return;
}
/* an entry carrying a query belongs to a full load: replaying it as a bare path would drop the
* query */
if (loc.search) {
loc.reload();
return;
}
saveScroll();
adoptEntry();
/* handed to navigate(), not started here: while the incoming page renders off screen the outgoing
* one satisfies the scroller height (restored at 386, clamped to 197) */
_pendingRestore = _scrollMem.get(_curId) || null;
if (!navigate(window.location.pathname, false)) {
_pendingRestore = null;
window.location.reload();
}
});
}
/* ---- the poll-status pill must not outlive the poll it reports on ----
* LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop` and never hides it. The
* queue empties through navigate()'s teardown and `L.Poll.remove()`, both reaching `L.Poll.stop()`.
* The hide runs a microtask later, after luci.js's own `poll-stop` listener. */
document.addEventListener('poll-stop', () => {
queueMicrotask(hidePollIndicatorIfEmpty);
});
watchSession();
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
return baseclass.extend({
seed,
wire: wireRouter,
onNavigate,
prefetchSegs
});
@@ -0,0 +1,297 @@
'use strict';
'require baseclass';
'require fs-menutree as tree';
'require fs-prefs as prefs';
'require fs-router as router';
/* Find a page by typing its name instead of knowing which section owns it: a loaded router carries
* ~200 menu nodes across 11 sections. */
/* ---- the index ---- */
/* How deep below the mode the walk goes (`depth` starts at 1 on a node that already has two
* segments). */
const MAX_DEPTH = 4;
let _index = null;
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 (header.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(),
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. */
function index() {
if (!_index) _index = buildIndex();
return _index;
}
/* ---- matching ---- */
/* Every whitespace-separated token must hit something: a second word means and. Deliberately not a
* fuzzy subsequence match. */
const HIT_NONE = 99;
function tokenScore(e, tok) {
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
if (e.t.includes(tok)) return 1; /* somewhere in the title */
if (e.n.includes(tok)) return 2; /* the English path segment */
if (e.p.includes(tok)) return 3; /* an ancestor's title */
return HIT_NONE;
}
function search(q, limit) {
const toks = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!toks.length) return [];
const hits = [];
for (const e of index()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
if (s === HIT_NONE) { sum = HIT_NONE; break; }
sum += s;
}
if (sum !== HIT_NONE)
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: shallower page first */
}
hits.sort((a, b) => a.score - b.score);
return hits.slice(0, limit).map((h) => h.e);
}
/* ---- recently visited ---- */
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view. Only the path is stored. */
/* The list is written by menu-footstrap-common.js, which is on every page; this module is loaded
* only on open. */
function recentEntries() {
const byPath = new Map(index().map((e) => [ e.path, e ]));
return prefs.recentPaths().map((k) => byPath.get(k)).filter(Boolean);
}
/* ---- 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. */
let _built = null;
function build() {
const SEARCH_PAGES = _('Search pages', 'footstrap');
const btn = document.getElementById('fs-search-btn');
if (!btn) return null;
const ID_SEARCH_LIST = 'fs-search-list';
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,
'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.appendChild(btn.querySelector('svg').cloneNode(true));
const box = E('div', { 'class': 'fs-search-box' }, [
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
note,
list
]);
/* data-fs-chrome marks a zone-1 root: 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 */
const ov = E('div', {
'id': 'fs-search-ov',
'class': 'fs-search-ov',
'data-fs-chrome': '',
'role': 'dialog',
'aria-modal': 'true',
'aria-label': SEARCH_PAGES,
'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 each keystroke. */
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. */
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(' › ') ]) : ''
]);
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
});
note.textContent = opts.length
? (q ? '' : _('Recently visited', 'footstrap'))
: (q ? _('No pages found', 'footstrap') : _('Start typing to find a page', 'footstrap'));
note.hidden = !note.textContent;
setActive(0);
}
function open() {
if (!ov.hidden) return;
ov.hidden = false;
btn.setAttribute(ATTR_EXPANDED, 'true');
input.value = '';
render('');
input.focus();
}
function close(returnFocus = true) {
if (ov.hidden) return;
ov.hidden = true;
btn.setAttribute(ATTR_EXPANDED, 'false');
if (returnFocus) btn.focus();
}
input.addEventListener('input', () => render(input.value.trim()));
/* keys are handled on the overlay, not the input: a click on the scrim moves focus to the
* overlay itself, where Escape must still close */
ov.addEventListener('keydown', (ev) => {
switch (ev.key) {
case 'Escape':
ev.preventDefault();
close();
return;
case 'ArrowDown':
ev.preventDefault(); setActive(at + 1); return;
case 'ArrowUp':
ev.preventDefault(); setActive(at - 1); return;
case 'Home':
if (!input.value) { ev.preventDefault(); setActive(0); }
return;
case 'End':
if (!input.value) { ev.preventDefault(); setActive(opts.length - 1); }
return;
case 'Enter':
if (at < 0 || !opts[at]) return;
ev.preventDefault();
/* a synthetic click carries detail 0, which the router reads as a keyboard
* activation, so focus lands where a keyboard navigation puts it */
opts[at].click();
return;
case 'Tab':
/* aria-modal="true" promises Tab cannot walk out into the page behind, and the input is the
* dialog's only tabbable element. */
ev.preventDefault();
input.focus();
return;
}
});
/* a click on the scrim — anywhere outside the box — closes */
ov.addEventListener('click', (ev) => { if (ev.target === ov) close(); });
/* Back and Forward are navigations no listener above can see, so without this an open palette
* rides along onto the previous page. */
router.onNavigate(() => close(false));
return { open, toggle: () => (ov.hidden ? open() : close()) };
}
/* the one entry point: built on the first gesture, null when the page has no #fs-search-btn */
function palette() {
if (!_built) _built = build();
return _built;
}
return baseclass.extend({
open: () => palette()?.open(),
toggle: () => palette()?.toggle()
});
@@ -0,0 +1,599 @@
'use strict';
'require baseclass';
'require ui';
'require dom';
'require fs-fit as fit';
'require fs-chrome as chrome';
'require fs-widgets as widgets';
/* Three jobs; 2, 3 and the value resync register with fs-fit (measuring and scheduling stay there):
* 1. <select> enhancement: a styled cbi-dropdown beside each plain LuCI <select>, values mirrored
* both ways. It keeps its own observer on body (childList and `disabled`) so it also catches
* selects added later by client CBI, and schedules the scan through fit.frame.
* 2. data-table tagging and a11y roles: `.fs-dt`, adopted markup, data-title captions, ARIA roles.
* 3. table fit decisions: the card/drop/shred ladder, scrollers, the action-column pin.
* The native <select> must remain frameEl.firstChild, because ui.Select.getValue() returns
* `this.node.firstChild.value`; the widget goes after it and shares frameEl's lifecycle. */
function readChoices(sel) {
const choices = {};
for (const o of sel.options) choices[o.value] = o.textContent;
return choices;
}
function choicesKey(sel) {
return Array.from(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
}
function teardown(sel) {
if (sel._fsAbort) sel._fsAbort.abort();
sel._fsNode?.remove();
delete sel.dataset.fsSelect;
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
sel.removeAttribute('aria-hidden');
sel.style.display = '';
}
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 (`sel.value = x`, what ui.Select.setValue() does on every
* dependency pass) sets no content attribute and adds no node, so no MutationRecord wakes
* relevant() and the widget would show the old label while Save reads the new value. So this runs
* from the fitter once per content mutation batch: the cheap half of resync(), a value compare per
* enhanced select. */
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 */
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: [...new Set(Array.from(sel.options, (o) => o.value))],
optional: Object.prototype.hasOwnProperty.call(choices, '')
});
} catch (e) {
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';
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
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 is `display: none`. Focus only. */
if (title)
title.addEventListener('click', () => node.focus(), { signal: ac.signal });
sel.setAttribute('aria-hidden', 'true');
sel._fsDd = dd;
sel._fsNode = node;
sel._fsKey = choicesKey(sel);
sel._fsAbort = ac;
/* after the select: it must stay frameEl.firstChild for ui.Select to read its value on save */
sel.after(node);
/* The dropdown re-dispatches its own bubbling `widget-change` on every pick, open/close and
* setValue(), reaching form.js while `sel.value` still holds the old value (fs-appearance's
* selectCtl applied 3x per pick). The form is to hear only ui.Select's own `widget-change`, which
* the `change` we dispatch on `sel` below produces. */
node.addEventListener('widget-change', (ev) => ev.stopPropagation(), { signal: ac.signal });
/* 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 });
sel.addEventListener('change', () => {
if (syncing) return;
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}, { signal: ac.signal });
/* A failing validator lands on the hidden <select> (class, data-tooltip, data-tooltip-style), and
* ui.js finds a tooltip through the hovered element's ancestors, so a display:none select never
* shows it: mirror the two attributes onto the widget. The class is not copied: ui.updateTabs
* counts every '.cbi-input-invalid', and a copy counts the field twice. */
const mirrorError = () => [ 'data-tooltip', 'data-tooltip-style' ]
.forEach((a) => widgets.syncAttr(node, a, sel.getAttribute(a)));
mirrorError();
sel.addEventListener('validation-failure', mirrorError, { signal: ac.signal });
sel.addEventListener('validation-success', mirrorError, { signal: ac.signal });
}
/* Tag standalone data tables so the stacking rules key off a static `.fs-dt`, not a live
* `:has(.tr.table-titles)` re-evaluated on every mutation of these polled tables. Not a
* .cbi-section-table: config forms keep their own layout. Polling swaps rows inside a table we
* already claimed, so both passes below are additive. */
const ROOTS = [ '#view', '#modal_overlay' ];
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
/* every call passes liveRoots(); folded in here rather than threaded as a parameter */
const inRoots = (sel) => liveRoots().map((r) => `${r} ${sel}`).join(', ');
const foreignTables = () => inRoots(':is(.table, table):not(.cbi-section-table)');
/* The fourth header markup, only a foreign table produces: `<table><tr><th>…` with no `<thead>`
* and none of LuCI's class names. "Every cell in the first row is a `<th>`" is the test. */
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 headOf(t) {
return t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
}
const titleRow = (head) =>
(head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
function tagDataTables() {
document.querySelectorAll(foreignTables()).forEach((t) => {
const head = headOf(t);
if (!head) return;
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 card is written one level down (`.table.fs-stacked .tr`, `.td[data-title]::before`). */
function adoptMarkup(t, head) {
/* decided once at claim time: asked afresh it would answer "already adopted" the moment we
* adopted, so polled bare rows would never be taken */
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');
for (const c of titleRow(head).children) c.classList.add('th');
if (!t.rows) return;
for (const row of t.rows) {
if (head.contains(row)) 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 prints
* `attr(data-title)` above each value (theme/30-tables.css); LuCI's builders fill it and a foreign
* table does not. */
function labelCells(t, head) {
const rows = t.querySelectorAll('.tr, tbody tr');
const titles = [ ...titleRow(head).children ].map((c) => (c.textContent || '').trim());
if (!titles.some(Boolean)) return;
for (const row of rows) {
if (row === head) continue;
const cells = row.children;
if (cells.length && cells[0].hasAttribute('data-title')) continue;
/* column cursor, not the cell index: a cell spanning N columns takes N header slots */
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 sets `display` on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` in over
* twenty places, and any `display` other than table/table-row/table-cell drops the implicit role
* HTML-AAM derives (measured on Overview's key/value cards at 1440 and 390px). So the role is
* written unconditionally, the whole chain every pass: a `cell` with no `row` above it reads worse
* than no roles. */
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' ],
];
function roleTables() {
[ ...TABLE_ROLE_CLASSES, ...TABLE_ROLE_TAGS ].forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel)).forEach((el) => widgets.syncAttr(el, 'role', role));
});
}
/* ---- card-stack a data table that no longer fits ----
* Measuring, scheduling and the observers are fs-fit.js; this file supplies the decision. Measured,
* not @container thresholds: a table of unknowable width works. A config table (.cbi-section-table)
* keeps its @container (960) and is not measured: a widget bakes in the width of the layout it was
* laid out in (firewall zone table: 1747px against a real 1190px). */
const stackables = () => inRoots('.table.fs-dt');
/* "Too cramped to be a table any more": a design judgement, the one threshold taken on trust.
* Stock LuCI cards at a 600px viewport; below 767px .fs-content pads 16px a side, so 568px of
* room. */
const CRAMPED = 568;
/* ---- the remedy ladder: cheapest first, re-measured at every rung ----
* theme/30-tables.css gives a data table `overflow-wrap: break-word`, so a column's min-content is
* an honest floor (`WPA2-PSK/CCMP` measured 144px against a real 93px with `anywhere`). Ladder: 1
* it fits, write nothing; 2 drop the columns the view marked `hide-xs`/`hide-sm`; 3 shred the
* widest breakable column; 4 card it. The second measurement is the guard: one base64 key breaking
* makes the table fit, every column over its share breaking changes nothing. */
function markBreakColumn(t, rows, col) {
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);
}
}
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 ----
* Measuring strips the table's marks first (fs-fit rule 1), laying the page out with a full-width
* table. The poll replaces these tables, so each tick repeated that and at iPhone width threw the
* reader 612px out and back, twice per tick. So the answer is kept on the slot (the section frame)
* and holds while column count (shapeOf) and room (roomFor) hold. */
const _slots = new WeakMap();
function shapeOf(t) {
const head = headOf(t);
return String(head ? titleRow(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 !== -1) markBreakColumn(t, t.querySelectorAll('.tr'), d.breakCol);
}
/* A first cell with no break opportunity (no space, no hyphen: "/dev/mtdblock6") is marked so a carded half-cell can
* widen to it: CSS cannot tell a token from a phrase, and `anywhere` cut it mid-word at 94-106px.
* textContent reads no layout; the class is a function of the text, so a poll's identical copy gets
* the same answer, and an unchanged mark is not rewritten (no mutation for the observers). */
function markTokenCells(t) {
t.querySelectorAll('.tr > .td:first-child').forEach((c) => {
const txt = c.textContent.trim();
const on = !!txt && !(/[\s\u002d\u00ad\u2010-\u2014]/).test(txt);
if (c.classList.contains('fs-td-token') !== on) c.classList.toggle('fs-td-token', on);
});
}
function fitTables() {
let cw;
document.querySelectorAll(stackables()).forEach((t) => {
markTokenCells(t);
if (fit.scrolling() && t.classList.contains('fs-fitted')) {
if (t._fsBreakCol >= 0) markBreakColumn(t, t.querySelectorAll('.tr'), t._fsBreakCol);
return;
}
const home = t.closest('.cbi-section, .cbi-section-node, #view, #modal_overlay');
const slots = _slots.get(home) || new Map();
const key = t.id ? '#' + t.id : 'at' + Array.from(home.querySelectorAll('.table')).indexOf(t);
const known = slots.get(key);
if (fit.scrolling()) {
if (!known && cw === undefined) cw = chrome.contentWidth();
applyDecision(t, known || { stack: cw < CRAMPED, drop: false, breakCol: -1 });
fit.deferMeasurement();
return;
}
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);
if (known) applyDecision(t, known);
const room = Math.round(fit.roomFor(t));
if (!(room > 0)) {
t.classList.add('fs-fitted');
return;
}
if (t.scrollWidth === 0) {
if (!t.classList.contains('fs-fitted')) {
t.classList.add('fs-fitted');
fit.schedule();
}
return;
}
/* the slot's answer holds for the same shape in the same room, unless the table outgrew it (a poll
* put longer values in: 1000px in an 810px column). A remedied table cannot be asked the other
* direction (every remedy fits by construction), so one whose row count went down is re-decided. */
const rows = t.querySelectorAll('.tr').length;
const remedied = !!(known && (known.stack || known.drop || known.breakCol !== -1));
const shrank = remedied && rows < known.rows;
if (known && known.room === room && known.shape === shape && !shrank && !fit.overflows(t))
return;
t.classList.remove('fs-stacked', 'fs-drop-xs');
if (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');
if (fit.overflows(t) && !breakWidestColumn(t)) stack = true;
}
t.classList.add('fs-fitted');
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');
}
slots.set(key, {
stack,
drop: t.classList.contains('fs-drop-xs'),
breakCol: (t._fsBreakCol === undefined) ? -1 : t._fsBreakCol,
room,
shape,
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, so a card is unlabelled values; comparison across
* rows is its purpose (WCAG 2.2 SC 1.4.10 excepts two-dimensional scrolling). But `overflow-x:
* auto` computes `overflow-y` to `auto` too, clipping every popup inside, so a header-less table
* that holds controls stacks instead: Network -> Diagnostics needs 338px in 320px. */
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)');
const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]';
/* ---- making a scroll box reachable, and handing the markup back as it was found ----
* Firefox makes scrollers focusable, Chrome since 132 only when they hold no focusable child,
* WebKit not at all, so the markup says it: a tab stop (SC 2.1.1) and a name (SC 4.1.2). A role is
* written only where there is none to lose (`<div class="table">` takes `group`; a real `<table>`
* keeps its own). What is removed is what was written, remembered per element. */
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() {
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');
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);
/* a table of controls stacks; a table of values scrolls and keeps its shape */
if (over && t.querySelector(HOLDS_CONTROLS)) {
t.classList.add('fs-rowstack');
if (was) unreach(t);
return;
}
if (over) {
t.classList.add('fs-xscroll');
if (!was) 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) pins the widest `td.cbi-section-actions > div`
* as inline width/min-width and its resize handler never drops the pin, so the next measurement can
* only grow: firewall/zones pins 634px (scrolls 256px sideways) against 192px fresh. Drop the pin
* when the room changes, not the mode: 768px and 800px are both carded. */
function unpinActionColumn() {
/* a layout read here lands mid-flick once per poll tick — see fitTables() */
if (fit.scrolling()) { fit.deferMeasurement(); return; }
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table'))) {
if (!t.querySelector('.cbi-section-actions')) continue;
/* claim upstream's resize hook: stabilizeActionColumnWidth attaches a `resize` listener once per
* table element (this expando) and never removes it, which leaks under SPA navigation (1 to 31
* listeners over 120 navigations, ~11.8 KB each). Set first, it is never attached. */
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');
});
}
}
function relevant(mutations) {
for (const m of mutations) if (m.target.tagName === 'SELECT') return true;
/* Additions only: a select going away costs nothing to notice. */
return fit.touches(mutations, 'select.cbi-input-select');
}
/* ---- type-ahead: jump to an option by typing its first letters ----
* A native <select> gives this free (picking a country out of 248 entries); enhance() hides it and
* ui.Dropdown.handleKeydown does only Esc/Enter/Space/arrows. One document-level listener for every
* .cbi-dropdown: only while open, printable keys without modifiers, buffer reset after a pause, a
* repeated letter cycles, label first then value. Only highlights (setFocus); 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');
if (!ul) return [];
return [...ul.children].filter((li) =>
li.tagName === 'LI' &&
!li.querySelector('input:not([type="hidden"]):not([type="checkbox"])') &&
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'));
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) {
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);
const inst = dom.findClassInstance(sb);
if (inst) inst.setFocus(sb, hit, true);
ev.preventDefault();
ev.stopPropagation();
});
}
return baseclass.extend({
__init__() {
wireTypeahead();
const scan = () => {
document.querySelectorAll('select.cbi-input-select:not([data-fs-select])').forEach(enhance);
document.querySelectorAll('select.cbi-input-select[data-fs-select="1"]').forEach(resync);
};
scan();
fit.armGate();
fit.add(tagDataTables);
fit.add(roleTables);
fit.add(fitTables);
fit.add(fitScrollables);
fit.add(unpinActionColumn);
fit.add(resyncValues);
const scanSoon = fit.frame(scan);
new MutationObserver((mutations) => {
if (relevant(mutations)) scanSoon();
}).observe(document.body, {
childList: true, subtree: true,
/* a `disabled` flip mutates no childList, so watch it for resync()/enhance() */
attributes: true, attributeFilter: [ 'disabled' ]
});
}
});
@@ -0,0 +1,455 @@
'use strict';
'require baseclass';
/* A view's injected CSS is never deleted; a poisoned document is left to a real load. A view's
* <style> outlives its page under SPA nav (filemanager's unlayered `display: none !important` hides
* Save/Reset on every later page). Deleting is one-way: a library importing CSS at module eval never
* re-runs (ACE's 14 KB sheet came back as a black rectangle). A sheet reaching into the widget
* universe the theme styles spends the document: invasiveSheet() is the test (universe read from
* cascade.css, 0.3 ms per nav) and the router falls back to a real load. Exempt: `[data-fs-shell]`
* and anything in `#view`. */
let _themeNames = null;
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;
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;
}
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) {
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;
}
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);
}
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;
}
/* 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`)? Such a part can reach neither another page nor
* our chrome. */
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. */
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;
}
/* true when this sheet can repaint a page that is not its own; an unreadable one (loading, 404,
* cross-origin) is invasive by default, so unknown CSS takes the slow path, never the broken one */
function invasiveSheet(el, universe) {
const sheet = el.sheet;
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) {
for (const p of selectorParts(r.selectorText)) {
if (pinnedToApp(p, names)) continue;
if (inertDeclarations(r, props)) continue;
invasive = true;
return;
}
}
if (r.cssRules) walk(r.cssRules);
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;
}
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
/* Does this sheet outlive the page it arrived with? One inside the view tree dies with the swap. */
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? Whole path, never a suffix: a suffix match would let a third party's `"css": "custom.css"`
* pass on luci-app-adblock's own. No in-tree node sets `css`. */
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 on the next stamp. One we
* could not attribute (a pending @import, insertRule()) still spends the document. */
function documentPoisoned() {
const names = themeNames();
return Array.from(document.querySelectorAll(VIEW_SHEETS)).some(
(el) => outlivesPage(el)
&& (!names || (!_owner.has(el) && !_silenced.has(el) && invasiveSheet(el, names))));
}
/* ---- the fence: the chrome is ours, so make a foreign rule unable to match it ----
* Re-hosting settles specificity but not `!important` (the pure-CSS answer is ~550 flags).
* Appending `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's subject
* matches everything except us at zero specificity, so the app's rules keep their weight against
* each other. */
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
function fenceSelector(part) {
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.some((p) => !pinnedToApp(p, names))) {
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);
if (r.styleSheet) {
try { fenceRules(r.styleSheet.cssRules, names); } catch (e) { /* unfenced */ }
}
}
}
function fenceImported(styleEl, names) {
const fence = () => {
let ready;
try { ready = !!styleEl.sheet.cssRules[0].styleSheet.cssRules; } catch (e) { ready = false; }
if (ready) fenceRules(styleEl.sheet.cssRules, names);
return ready;
};
if (fence()) return;
styleEl.addEventListener('load', () => {
if (!fence()) {
console.error('footstrap: the re-hosted @import loaded but could not be read — the sheet '
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
}
}, { once: true });
}
const serializeRules = (rules) => Array.from(rules, (r) => r.cssText).join('\n');
/* ---- a <style>'s textContent is not its sheet ----
* Wrapping re-sets textContent, which re-parses, and whatever the parse does not reproduce is
* deleted (a <style> filled by insertRule() has empty text over live rules): textIsSheet()
* decides. */
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; }
}
const _silenced = new WeakSet();
/* Take a re-hosted <link> out of the cascade: `el.disabled = true` alone does not;
* `el.sheet.disabled` decides whether the CSS paints, and a still-loading <link> has no .sheet
* yet. */
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 spending the document for each is paid by ordinary
* pages (filemanager lands two <style>s, ssclash four more). Removal on the way out is wrong
* (L.require caches, so a revisit renders unstyled); disabling is reversible. Owner =
* `body[data-page]` when re-hosted. */
const _owner = new WeakMap();
const APP_DEPTH = 3;
let _curKey = null;
function appKey(segs) {
return (segs || []).slice(0, APP_DEPTH).join('/');
}
/* Who a sheet injected right now belongs to, when that is not the page the chrome shows: click a
* page whose module injects CSS, click away before it lands, and the <style> appears under the
* superseding page's data-page. The router names the owner during such a require; of two
* overlapping cold requires the newer wins. */
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;
return appKey(L.env.dispatchpath);
}
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; `keep` is the page still on screen, whose sheets stay lit during staging. */
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);
});
}
function claimOwner(el) {
const key = ownerKey();
_owner.set(el, key);
if (outlivesPage(el)) setEnabled(el, key === currentKey());
}
/* Re-host an invasive sheet into the existing `theme` layer so it still renders its page: an
* unlayered declaration beats every layered one, so an app's `* { margin: 0 }` would own the
* chrome. Measured: unlayered = chrome broken, app OK; `@layer theme` = both OK; a layer before
* theme = app broken. Never delete the sheet: an app's "already imported?" check must find it. */
function rehostIntoThemeLayer(el, universe) {
if (el.dataset.fsLayered) return;
if (el.tagName === 'LINK') {
/* A link the app marked alternate or disabled paints nothing and is left unmarked and watched:
* enabling it adds no node, so only an attribute observer sees it. */
if ((/\balternate\b/i).test(el.rel) || el.hasAttribute('disabled')) {
enableWatch().observe(el, { attributes: true, attributeFilter: [ 'disabled', 'rel' ] });
return;
}
const s = document.createElement('style');
s.dataset.fsLayered = '1';
s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme)'
+ (el.media ? ' ' + el.media : '') + ';';
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);
return;
}
const rules = el.sheet && el.sheet.cssRules;
if (!rules || !rules.length) return;
/* Handled, and never twice: fenceRules() is not idempotent, since an already-fenced selector reads
* as unpinned again. */
el.dataset.fsLayered = '1';
/* Wrap only if the text still is the sheet; otherwise the sheet stays unlayered rather than lose
* rules, and is still fenced below (pure CSSOM, the half that answers `!important`). */
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 */ }
claimOwner(el); /* a <style> is re-hosted in place, so it paints itself */
}
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;
/* `insertRule()` produces no mutation record, so an empty <style> filled later is never looked at
* again: a sheet with no rules yet is not an innocent one. */
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 ----
* `luci-app-podkop` injects 4 KB from render() with no guard and `luci-app-mosdns` re-appends three
* CodeMirror <link>s, so every SPA re-visit adds a copy parsed forever. The first copy keeps the
* rules and any app handle. Re-hosting runs before this, or "keep the first" would keep the raw
* copy and strand the shim. A duplicate needs the same owner. */
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 the textContent may not be the sheet: insertRule-built <style>s all have an
* empty one, and keying on the text would remove the second. */
const rules = el.sheet && el.sheet.cssRules;
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;
/* owner first: a page key never holds '\n', so the first one always ends the owner part */
const k = (_owner.get(el) || '') + '\n' + key;
if (seen.has(k)) el.remove();
else seen.add(k);
});
}
/* ---- the layer order is a document-wide fact, and a sheet inserted first can rewrite it ----
* `@layer tokens, base, theme, page;` holds only while cascade.css is the first sheet to name a
* layer: Ace inserts its <style> first in <head>, and an earlier sheet naming `theme` makes it the
* weakest layer. Inserting a new sheet re-runs the ordering while moving one does not (measured),
* so the order is re-declared from a fresh <style> placed first. */
const LAYER_ORDER = '@layer tokens, base, theme, page;';
let _layerStmt = null;
function reassertLayerOrder() {
const head = document.head;
if (!head) return;
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;
if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER;
/* before the first sheet, not into <head>: a document_start script (an extension) appends its
* <style> to <html> ahead of <head>, and Firefox then laid Overview out 2125px short at 390px */
const first = document.querySelector('style, link[rel~="stylesheet"]');
first.parentNode.insertBefore(_layerStmt, first);
}
function sweepViewSheets() {
rehostInvasiveSheets();
dedupeViewSheets();
reassertLayerOrder();
}
let _enableWatch = null;
function enableWatch() {
return _enableWatch || (_enableWatch = new MutationObserver(sweepViewSheets));
}
function watchViewSheets() {
sweepViewSheets();
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;
sweepViewSheets();
return;
}
});
mo.observe(document.head, { childList: true });
/* …and <body>: a <style> appended there after chrome init is seen by nothing otherwise. */
mo.observe(document.body, { childList: true });
}
return baseclass.extend({
attributeTo,
documentCarries,
documentPoisoned,
scopeToCurrentPage,
watchViewSheets,
});
@@ -0,0 +1,14 @@
'use strict';
'require baseclass';
/* Must stay in this file: Makefile, stage.sh and dev-sync.sh stamp it by name. */
const FS_VERSION = '0.0.0-dev';
/* By shape: the minifier folds `!== '0.0.0-dev'`; a dev deploy stamps `git describe`, so only
* `-dev$` is unstamped. */
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
return baseclass.extend({
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -0,0 +1,35 @@
'use strict';
'require baseclass';
/* UI primitives shared by the chrome modules. */
/* aria-hidden: each icon sits beside its own label. Only `.fs-ico` and `.fs-chevron` are stroked,
* by theme/20-shell.css. */
function svgIcon(body, cls) {
return '<svg class="' + (cls || 'fs-ico') + '" aria-hidden="true" viewBox="0 0 24 24">' + body + '</svg>';
}
/* Idempotent: a poll tick that changes nothing fires no mutation record. null removes the attribute. */
function syncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* Enter/Space activation for an element with no native key handling; once per press, not per key
* repeat (~30/s). */
function wireActivate(el, activate) {
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
if (!ev.repeat) activate();
});
}
return baseclass.extend({
svgIcon,
syncAttr,
wireActivate
});
@@ -0,0 +1,159 @@
'use strict';
'require baseclass';
'require ui';
'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';
/* Chrome bootstrap: load the menu tree once, hand it to the parts that need it and wire them in
* order; it renders nothing itself. */
/* Page modules: `fs-overview` adds to a stock page rather than owning a route, so it is required
* only there: a `require` pragma would fetch it on every admin page. A map from `data-page` to
* module name drives the lazy load. */
const PAGE_MODULES = {
'admin-status-overview': 'fs-overview'
};
function wirePageModules() {
/* through window.L, never the factory's `L`: the factory's `L` has no ui helpers, and require() caches the first requirer's binding */
const RT = window.L;
const load = () => {
const name = PAGE_MODULES[document.body.getAttribute('data-page') || ''];
if (!name) return;
RT.require(name).then((m) => m.wire()).catch((e) => {
console.error('footstrap: ' + name + ' did not load', e);
});
};
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: it is fetched on the first gesture. */
const RECENT_WARM = 5;
/* A key is a menu path — what the router can navigate to and what warmRecent() prefetches. */
function remember(key) {
if (!key) return;
prefs.lsSet(prefs.RECENT_KEY, JSON.stringify([ key ].concat(prefs.recentPaths().filter((p) => p !== key)).slice(0, prefs.RECENT_MAX)));
}
/* ---- warm the pages this admin actually uses ----
* The router's per-link prefetch needs a hover, tap or focus first, so the recently visited pages
* are warmed ahead. */
function warmRecent() {
if (navigator.connection?.saveData) return;
const here = (L.env.dispatchpath || []).join('/');
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
const paths = prefs.recentPaths().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');
const RT = window.L;
const rememberSegs = (segs) => remember((segs || []).join('/'));
rememberSegs(L.env.dispatchpath);
router.onNavigate(rememberSegs);
warmRecent();
/* One fetch, on the first gesture; `require` is a singleton, so every later gesture reaches the
* same palette. This is the only owner of the button. */
let pending = false;
const gesture = (act) => {
if (pending) return;
pending = true;
RT.require('fs-search').then((m) => { pending = false; m[act](); },
(e) => { pending = false; console.error('footstrap: fs-search did not load', e); });
};
btn.addEventListener('click', () => gesture('toggle'));
/* `/` must not steal a keystroke from someone typing into a field, a contenteditable, or a
* .cbi-dropdown (fs-select's typeahead) */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
ev.preventDefault(); gesture('toggle'); return;
}
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
ev.preventDefault(); gesture('open');
});
}
/* The three template globals Status -> Overview needs (progressbar, renderBox, renderBadge), which
* stock includes call bare and an SPA arrival never defines. */
function ensureOverviewHelpers() {
/* Adapted copies of LuCI's admin_status/index.ut (window.L, an array child for E(), the title
string hoisted into `reading`); keep the shape, `var`s included, so a diff against upstream
stays readable when it changes. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn),
reading = '%s / %s (%d%%)'.format(fv, fm, pc);
if (pg) {
pg.firstElementChild.style.width = pc + '%';
pg.setAttribute('title', reading);
}
};
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))
]);
};
}
ensureOverviewHelpers();
return baseclass.extend({
init() {
/* First, and outside the promise: a third-party sheet that outranks the chrome is already painting
* (fs-sheets). */
sheets.watchViewSheets();
prefs.guardDarkStamp(); /* same, for a third party stamping :root */
prefs.watchThemeColor(); /* the mobile address bar, from the live page colour */
chrome.wireMenu(); /* flyout dismissal listeners: no wait on the menu fetch either */
ui.menu.load().then((menu) => {
tree.setTree(menu);
/* the view this full load already rendered — see fs-router's seed() */
router.seed();
chrome.start();
wireSearch();
/* 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();
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
* page together — so the .catch fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
});
@@ -0,0 +1,10 @@
'use strict';
'require baseclass';
'require menu-footstrap-common as common';
/* footer.ut's entry: menu-footstrap-common wires the chrome, fs-chrome renders it. */
return baseclass.extend({
__init__() {
common.init();
}
});
@@ -0,0 +1,13 @@
'use strict';
'require view';
'require fs-appearance as appearance';
/* The Footstrap page, dispatched by menu.d; `.cbi-section` draws the card. */
return view.extend({
render: () => appearance.renderStandalone().then((form) => E('div', { 'class': 'cbi-section' }, [ form ])),
/* null: a set handler makes View.addFooter() draw Save & Apply. */
handleSave: null,
handleSaveApply: null,
handleReset: null
});
+31
View File
@@ -0,0 +1,31 @@
# Single source of the openwrt/luci commit whose i18n-scan.pl update-po.sh downloads and runs by
# perl to decide whether the translation catalogue is complete, and of its checksum. Sourced as
# shell (`. luci-upstream.pin`) by update-po.sh, build-apk.sh and tools/build-usign.sh.
# Off a moving `master` the check would be whatever upstream pushed last.
# To bump: change LUCI_PIN, re-fetch the file at the new commit, update its sha256.
LUCI_PIN=5cf20c5741dc34eea0faaf7324ef3751b05fa1f3
I18N_SCAN_SHA256=a1823d1fdd942f31a105e1e53b401e146bc10527f231e016930b1a5123ef21d3
# No ucode pin, deliberately: the `e2e` job renders every installed template with the router's own
# ucode on both release lines (tools/e2e.mjs), so nothing is pinned, built or stubbed here.
# usign, OpenWrt's ed25519 signer: owfeed signs each release package with it, and build-apk.sh
# builds this copy (via tools/build-usign.sh, not run in CI) to check the SDK it downloads. This is
# the commit the router's own /usr/bin/usign is built from (`usign-2025.10.03~c4c72b1b`), so signer
# and field verifier are the same code; `cc *.c` over six sources plus base64.c is the whole build.
USIGN_PIN=c4c72b1b07945ee192361dc751291a7c98d6adcd
# OpenWrt's usign RELEASE keys and the keyring commit they come from; build-apk.sh checks the SDK
# with them. The SDK is the only input that ends up inside the installed package, and the
# `sha256sums` beside the tarball is no verification: same host, same directory, unsigned
# (docs/conventions.md). `sha256sums.sig` is, verified with a key pinned from a different host
# (github.com/openwrt/keyring): the router's usign says "signature OK", and one flipped byte gives
# "verification failed". 24.10 and 25.12 do not share a key (a release key vs the unattended-build
# key). The key id is the keyring file name and the keynum usign embeds in the signature; on
# "verification failed" read that keynum first, since a key rotation looks exactly like an attack.
# The sha256 holds the content.
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
+163
View File
@@ -0,0 +1,163 @@
#!/bin/sh
# Rename the PRIVATE `--fs-*` custom properties in a built cascade.css to short names, then join its rules.
# ./mangle-tokens.sh <cascade.css> <rewrite-dir>...
#
# Run from the Makefile and tools/stage.sh over a BUILD tree only, never the checkout. The names are
# 14% of the sheet: 18283 of 134210 bytes (measured 2026-10-14), wire bytes too: uhttpd serves /www
# uncompressed.
# The same map rewrites the .js and .ut under each <rewrite-dir> in place, BEFORE `strip-build.sh
# templates` and the JS minifier.
# Safe because (each checked):
# * `--fs-*` is the private tier; the export tier `--*-color-*` is untouched, and no installed
# luci-app reads a `--fs-` name.
# * every far-side `--fs-` is a WHOLE string literal (83 sites, measured 2026-10-14); a composed
# `'--fs-' + role` would silently ask for a name that no longer exists.
# * the scan reads the whole identifier (`--fs-space-2` prefixes `--fs-space-2-5`); short names
# `--a`…`--Z`, `--aa`… cannot collide (shortest existing property is 5 chars).
# Cost: the shipped sheet is unreadable on a router, hence a package step, not build-css.sh.
set -eu
CSS="${1:-}"
[ -n "$CSS" ] && [ -f "$CSS" ] && [ $# -ge 2 ] || { echo "usage: mangle-tokens.sh <cascade.css> <rewrite-dir>..." >&2; exit 2; }
shift
# NEVER the checkout: a target under this script's own directory is the source tree, and an
# in-place rename there destroys it (a bad argument split once rewrote eight modules and a template).
SELF_DIR=$(cd "$(dirname "$0")" && pwd -P)
for d in "$@"; do
abs=$(cd "$d" 2>/dev/null && pwd -P) || { echo "mangle-tokens: $d is not a directory" >&2; exit 1; }
case "$abs/" in
"$SELF_DIR"/*) echo "mangle-tokens: $d is inside the source tree ($SELF_DIR) — refusing, this rewrites in place" >&2; exit 1 ;;
esac
done
MAP="$CSS.map.$$"
UNSORTED="$CSS.unsorted.$$"
SORTED="$CSS.sorted.$$"
LIST="$CSS.list.$$"
CUR=""
trap 'rm -f "$MAP" "$MAP.ord" "$UNSORTED" "$SORTED" "$CSS.tmp.$$" "$CSS.one.$$" "$LIST" ${CUR:+"$CUR"}' EXIT
trap 'exit 130' INT TERM
# ---- pass 1: count every manglable --fs- name, string-aware, write unsorted ----
#
# Paths travel through ENVIRON, not `-v` or `var=value`: those run awk's escape processing, so a
# literal `\t` in a buildbot-supplied path becomes a tab and names no file. ENVIRON is verbatim.
UNSORTFILE="$UNSORTED" awk '
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
# read the identifier starting at i (which points at the first "-" of "--")
function ident(s, i, j, n) {
n = length(s); j = i
while (j <= n && isname(substr(s, j, 1))) j++
return substr(s, i, j - i)
}
BEGIN {
UNSORTFILE = ENVIRON["UNSORTFILE"]
q = ""
}
{ css = css $0 "\n" }
END {
n = length(css); i = 1
while (i <= n) {
c = substr(css, i, 1)
if (q != "") { if (c == "\\") { i += 2; continue } ; if (c == q) q = ""; i++; continue }
if (c == "\"" || c == "'"'"'") { q = c; i++; continue }
if (c == "-" && substr(css, i, 5) == "--fs-") {
id = ident(css, i)
if (!(id in cnt)) order[++ncnt] = id
cnt[id]++
i += length(id); continue
}
i++
}
for (x = 1; x <= ncnt; x++) print cnt[order[x]], order[x] > UNSORTFILE
}
' "$CSS"
# sort runs in the SHELL: a path concatenated into an awk pipe string lets a `"` break the quoting.
sort -k1,1nr -k2,2 "$UNSORTED" > "$SORTED" || { echo "mangle-tokens: sort failed" >&2; exit 1; }
# ---- pass 2: short-name alphabet from the sorted order (hottest name shortest), then rewrite ----
# SORTFILE/MAPFILE/OUTFILE via ENVIRON too (see pass 1).
SORTFILE="$SORTED" MAPFILE="$MAP" OUTFILE="$CSS.tmp.$$" awk '
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
function ident(s, i, j, n) {
n = length(s); j = i
while (j <= n && isname(substr(s, j, 1))) j++
return substr(s, i, j - i)
}
BEGIN {
SORTFILE = ENVIRON["SORTFILE"]; MAPFILE = ENVIRON["MAPFILE"]; OUTFILE = ENVIRON["OUTFILE"]
x = 0
while ((getline sline < SORTFILE) > 0) { split(sline, sf, " "); order[++x] = sf[2]; cnt[sf[2]] = sf[1] }
ncnt = x
A = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
nsh = 0
for (a = 1; a <= 52; a++) short[++nsh] = "--" substr(A, a, 1)
for (a = 1; a <= 52; a++) for (b = 1; b <= 52; b++) short[++nsh] = "--" substr(A, a, 1) substr(A, b, 1)
if (ncnt > nsh) { print "mangle-tokens: more names than short forms" > "/dev/stderr"; exit 1 }
q = ""
}
{ css = css $0 "\n" }
END {
n = length(css)
for (x = 1; x <= ncnt; x++) {
map[order[x]] = short[x]
# a mangled name must not already exist in the sheet
if (index(css, short[x] ":") || index(css, "var(" short[x] ")")) {
print "mangle-tokens: " short[x] " already used in the sheet" > "/dev/stderr"; exit 1
}
print order[x] " -> " short[x] " x" cnt[order[x]] > MAPFILE
}
q = ""; i = 1; out = ""
while (i <= n) {
c = substr(css, i, 1)
if (q != "") { if (c == "\\") { out = out substr(css, i, 2); i += 2; continue }
if (c == q) q = ""; out = out c; i++; continue }
if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue }
if (c == "-" && substr(css, i, 5) == "--fs-") {
id = ident(css, i)
out = out ((id in map) ? map[id] : id)
i += length(id); continue
}
out = out c; i++
}
printf "%s", out > OUTFILE
print "mangle-tokens: " ncnt " names mangled, " (length(css) - length(out)) " bytes saved" > "/dev/stderr"
}
' "$CSS"
before=$(wc -c < "$CSS")
# most of the sheet's newlines (1009 of 1021, measured 2026-10-14) follow a rule's `}`: dead bytes on
# every cold page. A /*! banner keeps its lines.
awk '
inb || /\/\*!/ { inb = !/\*\//; last = $0; print; next }
{ printf "%s%s", $0, (/}$/ ? "" : "\n"); last = $0 }
END { if (last ~ /}$/) printf "\n" }
' "$CSS.tmp.$$" > "$CSS.one.$$" || { echo "mangle-tokens: awk failed on the join — refusing" >&2; exit 1; }
mv "$CSS.one.$$" "$CSS"
after=$(wc -c < "$CSS")
echo "mangle-tokens: $before -> $after bytes (-$((before - after)))"
# ---- the far side of the seam, renamed with the same map ----
[ -s "$MAP" ] || { echo "mangle-tokens: the map is empty — refusing" >&2; exit 1; }
# 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
# a list file, not `for f in $(find …)`: that splits a path on whitespace (strip-build.sh does the same)
for d in "$@"; do find "$d" -type f \( -name '*.js' -o -name '*.ut' \); done > "$LIST"
while IFS= read -r f; do
CUR="$f.tmp$$"
# ENVIRON, not `-v`: a `\t` in the map path reads 0 lines and the JS stays unrenamed (see pass 1)
MAPF="$MAP.ord" awk '
BEGIN { MAPF = ENVIRON["MAPF"]; 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" > "$CUR" || { echo "mangle-tokens: awk failed on $f — refusing" >&2; exit 1; }
mv "$CUR" "$f"
CUR=""
touched=$((touched + 1))
done < "$LIST"
rm -f "$MAP.ord"
echo "mangle-tokens: seam renamed in $touched file(s)"
+808
View File
@@ -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:326
msgctxt "footstrap"
msgid "Accent"
msgstr "Acento"
#: htdocs/luci-static/resources/fs-appearance.js:385
msgctxt "footstrap"
msgid "As in file"
msgstr "Como en el archivo"
#: ucode/template/themes/footstrap/sysauth.ut:69
msgid "Authorization Required"
msgstr "Se requiere autorización"
#: htdocs/luci-static/resources/fs-appearance.js:267
msgctxt "footstrap"
msgid "Auto"
msgstr "Auto"
#: htdocs/luci-static/resources/fs-appearance.js:302
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr "Colapso automático"
#: htdocs/luci-static/resources/fs-appearance.js:622
msgctxt "footstrap"
msgid "Background"
msgstr "Fondo"
#: htdocs/luci-static/resources/fs-appearance.js:120
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr "Apenas visible %s"
#: htdocs/luci-static/resources/fs-appearance.js:343
msgctxt "footstrap"
msgid "Borders"
msgstr "Bordes"
#: htdocs/luci-static/resources/fs-overview.js:25
msgid "Buffered"
msgstr "En búfer"
#: htdocs/luci-static/resources/fs-overview.js:25
msgid "Cached"
msgstr "En caché"
#: htdocs/luci-static/resources/fs-appearance.js:340
msgctxt "footstrap"
msgid "Cards"
msgstr "Tarjetas"
#: htdocs/luci-static/resources/fs-appearance.js:365
msgctxt "footstrap"
msgid "Choose SVG"
msgstr "Elegir SVG"
#: htdocs/luci-static/resources/fs-appearance.js:358
msgctxt "footstrap"
msgid "Choose image"
msgstr "Elegir imagen"
#: htdocs/luci-static/resources/fs-assets.js:18
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:119
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Bien visible %s"
#: htdocs/luci-static/resources/fs-chrome.js:516
#: ucode/template/themes/footstrap/header.ut:117
msgid "Collapse menu"
msgstr "Contraer menú"
#: htdocs/luci-static/resources/fs-appearance.js:373
#: htdocs/luci-static/resources/fs-appearance.js:619
msgctxt "footstrap"
msgid "Colours"
msgstr "Colores"
#: htdocs/luci-static/resources/fs-appearance.js:282
msgctxt "footstrap"
msgid "Compact"
msgstr "Compacto"
#: htdocs/luci-static/resources/fs-appearance.js:529
msgctxt "footstrap"
msgid "Confirm reset"
msgstr "Confirmar restablecimiento"
#: htdocs/luci-static/resources/fs-appearance.js:290
msgctxt "footstrap"
msgid "Content width"
msgstr "Ancho del contenido"
#: htdocs/luci-static/resources/fs-appearance.js:128
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:341
msgctxt "footstrap"
msgid "Controls"
msgstr "Controles"
#: htdocs/luci-static/resources/fs-assets.js:14
msgctxt "footstrap"
msgid "Could not process the image."
msgstr "No se pudo procesar la imagen."
#: htdocs/luci-static/resources/fs-appearance.js:503
msgctxt "footstrap"
msgid "Could not save to the router. Reload the page and try again."
msgstr ""
"No se pudo guardar en el router. Recargue la página e inténtelo de nuevo."
#: htdocs/luci-static/resources/fs-appearance.js:329
msgctxt "footstrap"
msgid "Danger"
msgstr "Peligro"
#: htdocs/luci-static/resources/fs-appearance.js:269
msgctxt "footstrap"
msgid "Dark"
msgstr "Oscuro"
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Density"
msgstr "Densidad"
#: htdocs/luci-static/resources/fs-appearance.js:370
msgctxt "footstrap"
msgid "Dim"
msgstr "Oscurecimiento"
#: htdocs/luci-static/resources/fs-appearance.js:122
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Se lee bien %s"
#: htdocs/luci-static/resources/fs-chrome.js:516
msgid "Expand menu"
msgstr "Expandir menú"
#: htdocs/luci-static/resources/fs-appearance.js:389
#: htdocs/luci-static/resources/fs-appearance.js:456
msgctxt "footstrap"
msgid "File"
msgstr "Archivo"
# Product name, kept identical on purpose: 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:184
msgctxt "footstrap"
msgid "Footstrap needs OpenWrt 24.10.3 or newer."
msgstr "Footstrap requiere OpenWrt 24.10.3 o posterior."
#: htdocs/luci-static/resources/fs-appearance.js:613
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/header.ut:205
msgid "Go to firmware upgrade..."
msgstr "Ir a la actualización del firmware..."
#: ucode/template/themes/footstrap/header.ut:196
msgid "Go to password configuration..."
msgstr "Ir a la configuración de contraseñas..."
#: htdocs/luci-static/resources/fs-appearance.js:327
msgctxt "footstrap"
msgid "Good"
msgstr "Correcto"
#: htdocs/luci-static/resources/fs-appearance.js:124
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:618
msgctxt "footstrap"
msgid "Interface"
msgstr "Interfaz"
#: ucode/template/themes/footstrap/sysauth.ut:53
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/header.ut:211
msgid "JavaScript required!"
msgstr "¡Se requiere JavaScript!"
#: htdocs/luci-static/resources/fs-appearance.js:301
msgctxt "footstrap"
msgid "Keep open"
msgstr "Mantener abierto"
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "Large"
msgstr "Grande"
#: htdocs/luci-static/resources/fs-appearance.js:261
#: htdocs/luci-static/resources/fs-appearance.js:567
msgctxt "footstrap"
msgid "Layout"
msgstr "Diseño"
#: htdocs/luci-static/resources/fs-appearance.js:268
msgctxt "footstrap"
msgid "Light"
msgstr "Claro"
#: ucode/template/themes/footstrap/sysauth.ut:129
msgid "Log in"
msgstr "Iniciar sesión"
#: ucode/template/themes/footstrap/header.ut:151
#: ucode/template/themes/footstrap/header.ut:158
msgid "Log out"
msgstr "Cerrar sesión"
#: ucode/template/themes/footstrap/footer.ut:24
msgid "Lua compatibility mode active"
msgstr "Modo de compatibilidad Lua activo"
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "Memory"
msgstr "Memoria"
#: ucode/template/themes/footstrap/header.ut:99
#: ucode/template/themes/footstrap/header.ut:125
msgid "Menu"
msgstr "Menú"
#: ucode/template/themes/footstrap/header.ut:203
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:221
msgctxt "footstrap"
msgid "No pages found"
msgstr "No se encontraron páginas"
#: ucode/template/themes/footstrap/header.ut:193
msgid "No password set!"
msgstr "¡No se ha establecido ninguna contraseña!"
#: htdocs/luci-static/resources/fs-appearance.js:283
msgctxt "footstrap"
msgid "Normal"
msgstr "Normal"
#: htdocs/luci-static/resources/fs-appearance.js:454
msgctxt "footstrap"
msgid "Off"
msgstr "Apagado"
#: htdocs/luci-static/resources/fs-search.js:149
msgctxt "footstrap"
msgid "Pages"
msgstr "Páginas"
#: htdocs/luci-static/resources/fs-appearance.js:93
#: htdocs/luci-static/resources/fs-appearance.js:272
msgctxt "footstrap"
msgid "Palette"
msgstr "Paleta"
#: ucode/template/themes/footstrap/sysauth.ut:78
msgid "Password"
msgstr "Contraseña"
#: htdocs/luci-static/resources/fs-appearance.js:378
#: htdocs/luci-static/resources/fs-appearance.js:455
msgctxt "footstrap"
msgid "Pattern"
msgstr "Patrón"
#: htdocs/luci-static/resources/fs-assets.js:15
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr "Elija un archivo SVG."
#: htdocs/luci-static/resources/fs-assets.js:278
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr "Por favor, seleccione un archivo de imagen."
#: htdocs/luci-static/resources/fs-search.js:220
msgctxt "footstrap"
msgid "Recently visited"
msgstr "Visitado recientemente"
#: htdocs/luci-static/resources/fs-appearance.js:353
msgctxt "footstrap"
msgid "Remove"
msgstr "Eliminar"
#: htdocs/luci-static/resources/fs-appearance.js:468
msgctxt "footstrap"
msgid "Reset to built-in"
msgstr "Restablecer al integrado"
#: htdocs/luci-static/resources/fs-appearance.js:551
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:467
msgctxt "footstrap"
msgid "Reset to router"
msgstr "Restablecer al del router"
#: htdocs/luci-static/resources/fs-appearance.js:549
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:293
msgctxt "footstrap"
msgid "Rounding"
msgstr "Redondeo"
#: htdocs/luci-static/resources/fs-appearance.js:466
msgctxt "footstrap"
msgid "Save to router"
msgstr "Guardar en el router"
#: htdocs/luci-static/resources/fs-appearance.js:547
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:557
msgctxt "footstrap"
msgid "Saved look"
msgstr "Aspecto guardado"
#: htdocs/luci-static/resources/fs-appearance.js:492
msgctxt "footstrap"
msgid "Saved to router"
msgstr "Guardado en el router"
#: htdocs/luci-static/resources/fs-appearance.js:623
msgctxt "footstrap"
msgid "Saving"
msgstr "Guardado"
#: htdocs/luci-static/resources/fs-appearance.js:371
msgctxt "footstrap"
msgid "Scale"
msgstr "Escala"
#: ucode/template/themes/footstrap/header.ut:142
#: ucode/template/themes/footstrap/header.ut:144
msgid "Search"
msgstr "Buscar"
#: htdocs/luci-static/resources/fs-search.js:130
msgctxt "footstrap"
msgid "Search pages"
msgstr "Buscar páginas"
#: htdocs/luci-static/resources/fs-search.js:144
msgctxt "footstrap"
msgid "Search pages…"
msgstr "Buscar páginas…"
#: htdocs/luci-static/resources/fs-appearance.js:262
msgctxt "footstrap"
msgid "Sidebar"
msgstr "Barra lateral"
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr "Barra lateral y superior"
#: ucode/template/themes/footstrap/header.ut:88
msgid "Skip to content"
msgstr "Saltar al contenido"
#: htdocs/luci-static/resources/fs-search.js:221
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:16
msgid "Storage"
msgstr "Almacenamiento"
#: htdocs/luci-static/resources/fs-appearance.js:372
msgctxt "footstrap"
msgid "Strength"
msgstr "Intensidad"
#: htdocs/luci-static/resources/fs-appearance.js:299
msgctxt "footstrap"
msgid "Submenus"
msgstr "Submenús"
#: htdocs/luci-static/resources/fs-appearance.js:620
msgctxt "footstrap"
msgid "Surfaces"
msgstr "Superficies"
#: htdocs/luci-static/resources/fs-overview.js:24
msgid "Swap free"
msgstr "Intercambio libre"
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "System"
msgstr "Sistema"
#: ucode/template/themes/footstrap/header.ut:202
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:420
msgid "Table"
msgstr "Tabla"
#: htdocs/luci-static/resources/fs-assets.js:19
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:259
msgctxt "footstrap"
msgid "That file could not be read."
msgstr "No se ha podido leer el archivo."
#: htdocs/luci-static/resources/fs-assets.js:193
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:13
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:257
msgctxt "footstrap"
msgid "That file is too large."
msgstr "Ese archivo es demasiado grande."
#: htdocs/luci-static/resources/fs-assets.js:280
msgctxt "footstrap"
msgid "That image is too large."
msgstr "Esa imagen es demasiado grande."
#: htdocs/luci-static/resources/fs-assets.js:169
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:266
#: htdocs/luci-static/resources/fs-appearance.js:384
msgctxt "footstrap"
msgid "Theme"
msgstr "Tema"
#: ucode/template/themes/footstrap/header.ut:194
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:487
msgctxt "footstrap"
msgid ""
"This browser is not storing preferences (site data is blocked), so a change "
"here lasts until you reload. Saving to the router 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 en el "
"router sigue funcionando y se aplica a todos los navegadores."
#: htdocs/luci-static/resources/fs-appearance.js:569
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:309
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr "Tinte (identificación del enrutador)"
#: htdocs/luci-static/resources/fs-appearance.js:316
msgctxt "footstrap"
msgid "Tint strength"
msgstr "Intensidad del tinte"
#: htdocs/luci-static/resources/fs-appearance.js:125
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr "Demasiado tenue para leerse %s"
#: htdocs/luci-static/resources/fs-appearance.js:263
msgctxt "footstrap"
msgid "Top"
msgstr "Arriba"
#: htdocs/luci-static/resources/fs-overview.js:24
msgid "Total Available"
msgstr "Total disponible"
#: htdocs/luci-static/resources/fs-assets.js:12
msgctxt "footstrap"
msgid "Upload failed."
msgstr "Error al cargar el archivo."
#: htdocs/luci-static/resources/fs-appearance.js:428
msgctxt "footstrap"
msgid "Uploading…"
msgstr "Cargando…"
#: ucode/template/themes/footstrap/sysauth.ut:72
msgid "Username"
msgstr "Nombre de usuario"
#: htdocs/luci-static/resources/fs-appearance.js:453
msgctxt "footstrap"
msgid "Wallpaper"
msgstr "Fondo de pantalla"
#: htdocs/luci-static/resources/fs-appearance.js:328
msgctxt "footstrap"
msgid "Warning"
msgstr "Advertencia"
#: ucode/template/themes/footstrap/header.ut:212
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:342
msgctxt "footstrap"
msgid "in the sidebar"
msgstr "en la barra lateral"
#: htdocs/luci-static/resources/fs-appearance.js:212
msgctxt "footstrap"
msgid "on a card"
msgstr "en una tarjeta"
#: htdocs/luci-static/resources/fs-appearance.js:341
msgctxt "footstrap"
msgid "on a control"
msgstr "en un control"
#: htdocs/luci-static/resources/fs-appearance.js:310
msgctxt "footstrap"
msgid "on the canvas"
msgstr "en el lienzo"
#: htdocs/luci-static/resources/fs-assets.js:203
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."
#~ msgid "Please enter your username and password."
#~ msgstr "Por favor, ingrese su nombre de usuario y contraseña."
#~ 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"
+798
View File
@@ -0,0 +1,798 @@
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:326
msgctxt "footstrap"
msgid "Accent"
msgstr "Акцент"
#: htdocs/luci-static/resources/fs-appearance.js:385
msgctxt "footstrap"
msgid "As in file"
msgstr "Как в файле"
#: ucode/template/themes/footstrap/sysauth.ut:69
msgid "Authorization Required"
msgstr "Требуется авторизация"
#: htdocs/luci-static/resources/fs-appearance.js:267
msgctxt "footstrap"
msgid "Auto"
msgstr "Авто"
#: htdocs/luci-static/resources/fs-appearance.js:302
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr "Сворачивать"
#: htdocs/luci-static/resources/fs-appearance.js:622
msgctxt "footstrap"
msgid "Background"
msgstr "Фон"
#: htdocs/luci-static/resources/fs-appearance.js:120
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr "Едва заметна %s"
#: htdocs/luci-static/resources/fs-appearance.js:343
msgctxt "footstrap"
msgid "Borders"
msgstr "Границы"
#: htdocs/luci-static/resources/fs-overview.js:25
msgid "Buffered"
msgstr "Буферизовано"
#: htdocs/luci-static/resources/fs-overview.js:25
msgid "Cached"
msgstr "Кэшировано"
#: htdocs/luci-static/resources/fs-appearance.js:340
msgctxt "footstrap"
msgid "Cards"
msgstr "Карточки"
#: htdocs/luci-static/resources/fs-appearance.js:365
msgctxt "footstrap"
msgid "Choose SVG"
msgstr "Выбрать SVG"
#: htdocs/luci-static/resources/fs-appearance.js:358
msgctxt "footstrap"
msgid "Choose image"
msgstr "Выбрать изображение"
#: htdocs/luci-static/resources/fs-assets.js:18
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:119
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Хорошо видна %s"
#: htdocs/luci-static/resources/fs-chrome.js:516
#: ucode/template/themes/footstrap/header.ut:117
msgid "Collapse menu"
msgstr "Свернуть меню"
#: htdocs/luci-static/resources/fs-appearance.js:373
#: htdocs/luci-static/resources/fs-appearance.js:619
msgctxt "footstrap"
msgid "Colours"
msgstr "Цвета"
#: htdocs/luci-static/resources/fs-appearance.js:282
msgctxt "footstrap"
msgid "Compact"
msgstr "Компактно"
#: htdocs/luci-static/resources/fs-appearance.js:529
msgctxt "footstrap"
msgid "Confirm reset"
msgstr "Подтвердить сброс"
#: htdocs/luci-static/resources/fs-appearance.js:290
msgctxt "footstrap"
msgid "Content width"
msgstr "Ширина контента"
#: htdocs/luci-static/resources/fs-appearance.js:128
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:341
msgctxt "footstrap"
msgid "Controls"
msgstr "Элементы форм"
#: htdocs/luci-static/resources/fs-assets.js:14
msgctxt "footstrap"
msgid "Could not process the image."
msgstr "Не удалось обработать изображение."
#: htdocs/luci-static/resources/fs-appearance.js:503
msgctxt "footstrap"
msgid "Could not save to the router. Reload the page and try again."
msgstr "Не удалось сохранить на роутер. Обновите страницу и повторите."
#: htdocs/luci-static/resources/fs-appearance.js:329
msgctxt "footstrap"
msgid "Danger"
msgstr "Опасность"
#: htdocs/luci-static/resources/fs-appearance.js:269
msgctxt "footstrap"
msgid "Dark"
msgstr "Тёмная"
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Density"
msgstr "Плотность"
#: htdocs/luci-static/resources/fs-appearance.js:370
msgctxt "footstrap"
msgid "Dim"
msgstr "Затемнение"
#: htdocs/luci-static/resources/fs-appearance.js:122
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Хорошо читается %s"
#: htdocs/luci-static/resources/fs-chrome.js:516
msgid "Expand menu"
msgstr "Развернуть меню"
#: htdocs/luci-static/resources/fs-appearance.js:389
#: htdocs/luci-static/resources/fs-appearance.js:456
msgctxt "footstrap"
msgid "File"
msgstr "Файл"
# Product name, kept identical on purpose: 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:184
msgctxt "footstrap"
msgid "Footstrap needs OpenWrt 24.10.3 or newer."
msgstr "Footstrap требует OpenWrt 24.10.3 или новее."
#: htdocs/luci-static/resources/fs-appearance.js:613
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/header.ut:205
msgid "Go to firmware upgrade..."
msgstr "Перейти к обновлению прошивки..."
#: ucode/template/themes/footstrap/header.ut:196
msgid "Go to password configuration..."
msgstr "Перейти к настройке пароля..."
#: htdocs/luci-static/resources/fs-appearance.js:327
msgctxt "footstrap"
msgid "Good"
msgstr "Норма"
#: htdocs/luci-static/resources/fs-appearance.js:124
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr "Плохо читается %s — только крупный текст"
#: htdocs/luci-static/resources/fs-appearance.js:618
msgctxt "footstrap"
msgid "Interface"
msgstr "Интерфейс"
#: ucode/template/themes/footstrap/sysauth.ut:53
msgid "Invalid username and/or password! Please try again."
msgstr "Неверное имя пользователя и/или пароль! Попробуйте ещё раз."
#: ucode/template/themes/footstrap/header.ut:211
msgid "JavaScript required!"
msgstr "Требуется JavaScript!"
#: htdocs/luci-static/resources/fs-appearance.js:301
msgctxt "footstrap"
msgid "Keep open"
msgstr "Не сворачивать"
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "Large"
msgstr "Крупно"
#: htdocs/luci-static/resources/fs-appearance.js:261
#: htdocs/luci-static/resources/fs-appearance.js:567
msgctxt "footstrap"
msgid "Layout"
msgstr "Раскладка"
#: htdocs/luci-static/resources/fs-appearance.js:268
msgctxt "footstrap"
msgid "Light"
msgstr "Светлая"
#: ucode/template/themes/footstrap/sysauth.ut:129
msgid "Log in"
msgstr "Войти"
#: ucode/template/themes/footstrap/header.ut:151
#: ucode/template/themes/footstrap/header.ut:158
msgid "Log out"
msgstr "Выйти"
#: ucode/template/themes/footstrap/footer.ut:24
msgid "Lua compatibility mode active"
msgstr "Включён режим совместимости с Lua"
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "Memory"
msgstr "Память"
#: ucode/template/themes/footstrap/header.ut:99
#: ucode/template/themes/footstrap/header.ut:125
msgid "Menu"
msgstr "Меню"
#: ucode/template/themes/footstrap/header.ut:203
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:221
msgctxt "footstrap"
msgid "No pages found"
msgstr "Страницы не найдены"
#: ucode/template/themes/footstrap/header.ut:193
msgid "No password set!"
msgstr "Пароль не установлен!"
#: htdocs/luci-static/resources/fs-appearance.js:283
msgctxt "footstrap"
msgid "Normal"
msgstr "Обычно"
#: htdocs/luci-static/resources/fs-appearance.js:454
msgctxt "footstrap"
msgid "Off"
msgstr "Выкл."
#: htdocs/luci-static/resources/fs-search.js:149
msgctxt "footstrap"
msgid "Pages"
msgstr "Страницы"
#: htdocs/luci-static/resources/fs-appearance.js:93
#: htdocs/luci-static/resources/fs-appearance.js:272
msgctxt "footstrap"
msgid "Palette"
msgstr "Палитра"
#: ucode/template/themes/footstrap/sysauth.ut:78
msgid "Password"
msgstr "Пароль"
#: htdocs/luci-static/resources/fs-appearance.js:378
#: htdocs/luci-static/resources/fs-appearance.js:455
msgctxt "footstrap"
msgid "Pattern"
msgstr "Узор"
#: htdocs/luci-static/resources/fs-assets.js:15
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr "Выберите файл SVG."
#: htdocs/luci-static/resources/fs-assets.js:278
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr "Выберите файл изображения."
#: htdocs/luci-static/resources/fs-search.js:220
msgctxt "footstrap"
msgid "Recently visited"
msgstr "Недавние"
#: htdocs/luci-static/resources/fs-appearance.js:353
msgctxt "footstrap"
msgid "Remove"
msgstr "Убрать"
#: htdocs/luci-static/resources/fs-appearance.js:468
msgctxt "footstrap"
msgid "Reset to built-in"
msgstr "Сбросить к встроенному"
#: htdocs/luci-static/resources/fs-appearance.js:551
msgctxt "footstrap"
msgid "Reset to built-in — go back to the look the theme ships with."
msgstr "«Сбросить к встроенному» — вернуть вид, с которым поставляется тема."
#: htdocs/luci-static/resources/fs-appearance.js:467
msgctxt "footstrap"
msgid "Reset to router"
msgstr "Сбросить к виду роутера"
#: htdocs/luci-static/resources/fs-appearance.js:549
msgctxt "footstrap"
msgid "Reset to router — drop this browser's changes and follow the router."
msgstr ""
"«Сбросить к виду роутера» — отменить правки этого браузера и следовать "
"роутеру."
#: htdocs/luci-static/resources/fs-appearance.js:293
msgctxt "footstrap"
msgid "Rounding"
msgstr "Скругление"
#: htdocs/luci-static/resources/fs-appearance.js:466
msgctxt "footstrap"
msgid "Save to router"
msgstr "Сохранить на роутер"
#: htdocs/luci-static/resources/fs-appearance.js:547
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:557
msgctxt "footstrap"
msgid "Saved look"
msgstr "Сохранённый вид"
#: htdocs/luci-static/resources/fs-appearance.js:492
msgctxt "footstrap"
msgid "Saved to router"
msgstr "Сохранено на роутере"
#: htdocs/luci-static/resources/fs-appearance.js:623
msgctxt "footstrap"
msgid "Saving"
msgstr "Сохранение"
#: htdocs/luci-static/resources/fs-appearance.js:371
msgctxt "footstrap"
msgid "Scale"
msgstr "Масштаб"
#: ucode/template/themes/footstrap/header.ut:142
#: ucode/template/themes/footstrap/header.ut:144
msgid "Search"
msgstr "Поиск"
#: htdocs/luci-static/resources/fs-search.js:130
msgctxt "footstrap"
msgid "Search pages"
msgstr "Поиск по страницам"
#: htdocs/luci-static/resources/fs-search.js:144
msgctxt "footstrap"
msgid "Search pages…"
msgstr "Поиск по страницам…"
#: htdocs/luci-static/resources/fs-appearance.js:262
msgctxt "footstrap"
msgid "Sidebar"
msgstr "Сбоку"
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr "Боковая панель и шапка"
#: ucode/template/themes/footstrap/header.ut:88
msgid "Skip to content"
msgstr "Перейти к содержимому"
#: htdocs/luci-static/resources/fs-search.js:221
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr "Начните вводить название страницы"
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "Storage"
msgstr "Накопитель"
#: htdocs/luci-static/resources/fs-appearance.js:372
msgctxt "footstrap"
msgid "Strength"
msgstr "Насыщенность"
#: htdocs/luci-static/resources/fs-appearance.js:299
msgctxt "footstrap"
msgid "Submenus"
msgstr "Подменю"
#: htdocs/luci-static/resources/fs-appearance.js:620
msgctxt "footstrap"
msgid "Surfaces"
msgstr "Поверхности"
#: htdocs/luci-static/resources/fs-overview.js:24
msgid "Swap free"
msgstr "Свободно в подкачке"
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "System"
msgstr "Система"
#: ucode/template/themes/footstrap/header.ut:202
msgid "System running in recovery (initramfs) mode."
msgstr "Система работает в режиме восстановления (initramfs)."
#: htdocs/luci-static/resources/fs-select.js:420
msgid "Table"
msgstr "Таблица"
#: htdocs/luci-static/resources/fs-assets.js:19
msgctxt "footstrap"
msgid "That SVG had nothing left once the unsafe parts were removed."
msgstr "От этого SVG ничего не осталось после удаления опасных частей."
#: htdocs/luci-static/resources/fs-assets.js:259
msgctxt "footstrap"
msgid "That file could not be read."
msgstr "Не удалось прочитать файл."
#: htdocs/luci-static/resources/fs-assets.js:193
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr "Не удалось прочитать файл как изображение."
#: htdocs/luci-static/resources/fs-assets.js:13
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr "Этот файл — не изображение SVG."
#: htdocs/luci-static/resources/fs-assets.js:257
msgctxt "footstrap"
msgid "That file is too large."
msgstr "Файл слишком большой."
#: htdocs/luci-static/resources/fs-assets.js:280
msgctxt "footstrap"
msgid "That image is too large."
msgstr "Изображение слишком большое."
#: htdocs/luci-static/resources/fs-assets.js:169
msgctxt "footstrap"
msgid "The router refused to delete the file (ubus status %d)."
msgstr "Роутер отказался удалить файл (статус ubus %d)."
#: htdocs/luci-static/resources/fs-appearance.js:266
#: htdocs/luci-static/resources/fs-appearance.js:384
msgctxt "footstrap"
msgid "Theme"
msgstr "Тема"
#: ucode/template/themes/footstrap/header.ut:194
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:487
msgctxt "footstrap"
msgid ""
"This browser is not storing preferences (site data is blocked), so a change "
"here lasts until you reload. Saving to the router still works and applies to "
"every browser."
msgstr ""
"Этот браузер не хранит настройки (данные сайта заблокированы), поэтому "
"изменение здесь действует до перезагрузки страницы. «Сохранить на роутер» по-"
"прежнему работает и применяется ко всем браузерам."
#: htdocs/luci-static/resources/fs-appearance.js:569
msgctxt "footstrap"
msgid "This theme is not translated on this router yet."
msgstr "Тема ещё не переведена на этом роутере."
#: htdocs/luci-static/resources/fs-appearance.js:309
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr "Оттенок (опознание роутера)"
#: htdocs/luci-static/resources/fs-appearance.js:316
msgctxt "footstrap"
msgid "Tint strength"
msgstr "Насыщенность оттенка"
#: htdocs/luci-static/resources/fs-appearance.js:125
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr "Слишком блёкло, не читается %s"
#: htdocs/luci-static/resources/fs-appearance.js:263
msgctxt "footstrap"
msgid "Top"
msgstr "Сверху"
#: htdocs/luci-static/resources/fs-overview.js:24
msgid "Total Available"
msgstr "Свободно"
#: htdocs/luci-static/resources/fs-assets.js:12
msgctxt "footstrap"
msgid "Upload failed."
msgstr "Не удалось загрузить."
#: htdocs/luci-static/resources/fs-appearance.js:428
msgctxt "footstrap"
msgid "Uploading…"
msgstr "Загрузка…"
#: ucode/template/themes/footstrap/sysauth.ut:72
msgid "Username"
msgstr "Имя пользователя"
#: htdocs/luci-static/resources/fs-appearance.js:453
msgctxt "footstrap"
msgid "Wallpaper"
msgstr "Обои"
#: htdocs/luci-static/resources/fs-appearance.js:328
msgctxt "footstrap"
msgid "Warning"
msgstr "Предупреждение"
#: ucode/template/themes/footstrap/header.ut:212
msgid ""
"You must enable JavaScript in your browser or LuCI will not work properly."
msgstr ""
"Включите JavaScript в браузере, иначе LuCI не будет работать корректно."
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "in the sidebar"
msgstr "в боковой панели"
#: htdocs/luci-static/resources/fs-appearance.js:212
msgctxt "footstrap"
msgid "on a card"
msgstr "на карточке"
#: htdocs/luci-static/resources/fs-appearance.js:341
msgctxt "footstrap"
msgid "on a control"
msgstr "на элементе формы"
#: htdocs/luci-static/resources/fs-appearance.js:310
msgctxt "footstrap"
msgid "on the canvas"
msgstr "на холсте"
#: htdocs/luci-static/resources/fs-assets.js:203
msgctxt "footstrap"
msgid ""
"the uploaded file could not be removed either; it is still on the router."
msgstr "загруженный файл тоже не удалось удалить — он остался на роутере."
#~ msgid "Please enter your username and password."
#~ 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,584 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: htdocs/luci-static/resources/fs-appearance.js:326
msgctxt "footstrap"
msgid "Accent"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:385
msgctxt "footstrap"
msgid "As in file"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:69
msgid "Authorization Required"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:267
msgctxt "footstrap"
msgid "Auto"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:302
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:622
msgctxt "footstrap"
msgid "Background"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:120
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:343
msgctxt "footstrap"
msgid "Borders"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:25
msgid "Buffered"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:25
msgid "Cached"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:340
msgctxt "footstrap"
msgid "Cards"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:365
msgctxt "footstrap"
msgid "Choose SVG"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:358
msgctxt "footstrap"
msgid "Choose image"
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:18
msgctxt "footstrap"
msgid ""
"Cleaned before upload: removed %d unsafe element(s) and %d external "
"reference(s)."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:119
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:516
#: ucode/template/themes/footstrap/header.ut:117
msgid "Collapse menu"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:373
#: htdocs/luci-static/resources/fs-appearance.js:619
msgctxt "footstrap"
msgid "Colours"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:282
msgctxt "footstrap"
msgid "Compact"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:529
msgctxt "footstrap"
msgid "Confirm reset"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:290
msgctxt "footstrap"
msgid "Content width"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:128
msgctxt "footstrap"
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:341
msgctxt "footstrap"
msgid "Controls"
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:14
msgctxt "footstrap"
msgid "Could not process the image."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:503
msgctxt "footstrap"
msgid "Could not save to the router. Reload the page and try again."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:329
msgctxt "footstrap"
msgid "Danger"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:269
msgctxt "footstrap"
msgid "Dark"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Density"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:370
msgctxt "footstrap"
msgid "Dim"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:122
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:516
msgid "Expand menu"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:389
#: htdocs/luci-static/resources/fs-appearance.js:456
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:184
msgctxt "footstrap"
msgid "Footstrap needs OpenWrt 24.10.3 or newer."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:613
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/header.ut:205
msgid "Go to firmware upgrade..."
msgstr ""
#: ucode/template/themes/footstrap/header.ut:196
msgid "Go to password configuration..."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:327
msgctxt "footstrap"
msgid "Good"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:124
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:618
msgctxt "footstrap"
msgid "Interface"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:53
msgid "Invalid username and/or password! Please try again."
msgstr ""
#: ucode/template/themes/footstrap/header.ut:211
msgid "JavaScript required!"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:301
msgctxt "footstrap"
msgid "Keep open"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "Large"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:261
#: htdocs/luci-static/resources/fs-appearance.js:567
msgctxt "footstrap"
msgid "Layout"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:268
msgctxt "footstrap"
msgid "Light"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:129
msgid "Log in"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:151
#: ucode/template/themes/footstrap/header.ut:158
msgid "Log out"
msgstr ""
#: ucode/template/themes/footstrap/footer.ut:24
msgid "Lua compatibility mode active"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "Memory"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:99
#: ucode/template/themes/footstrap/header.ut:125
msgid "Menu"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:203
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:221
msgctxt "footstrap"
msgid "No pages found"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:193
msgid "No password set!"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:283
msgctxt "footstrap"
msgid "Normal"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:454
msgctxt "footstrap"
msgid "Off"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:149
msgctxt "footstrap"
msgid "Pages"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:93
#: htdocs/luci-static/resources/fs-appearance.js:272
msgctxt "footstrap"
msgid "Palette"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:78
msgid "Password"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:378
#: htdocs/luci-static/resources/fs-appearance.js:455
msgctxt "footstrap"
msgid "Pattern"
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:15
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:278
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:220
msgctxt "footstrap"
msgid "Recently visited"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:353
msgctxt "footstrap"
msgid "Remove"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:468
msgctxt "footstrap"
msgid "Reset to built-in"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:551
msgctxt "footstrap"
msgid "Reset to built-in — go back to the look the theme ships with."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:467
msgctxt "footstrap"
msgid "Reset to router"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:549
msgctxt "footstrap"
msgid "Reset to router — drop this browser's changes and follow the router."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:293
msgctxt "footstrap"
msgid "Rounding"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:466
msgctxt "footstrap"
msgid "Save to router"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:547
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:557
msgctxt "footstrap"
msgid "Saved look"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:492
msgctxt "footstrap"
msgid "Saved to router"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:623
msgctxt "footstrap"
msgid "Saving"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:371
msgctxt "footstrap"
msgid "Scale"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:142
#: ucode/template/themes/footstrap/header.ut:144
msgid "Search"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:130
msgctxt "footstrap"
msgid "Search pages"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:144
msgctxt "footstrap"
msgid "Search pages…"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:262
msgctxt "footstrap"
msgid "Sidebar"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:88
msgid "Skip to content"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:221
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "Storage"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:372
msgctxt "footstrap"
msgid "Strength"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:299
msgctxt "footstrap"
msgid "Submenus"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:620
msgctxt "footstrap"
msgid "Surfaces"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:24
msgid "Swap free"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:16
msgid "System"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:202
msgid "System running in recovery (initramfs) mode."
msgstr ""
#: htdocs/luci-static/resources/fs-select.js:420
msgid "Table"
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:19
msgctxt "footstrap"
msgid "That SVG had nothing left once the unsafe parts were removed."
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:259
msgctxt "footstrap"
msgid "That file could not be read."
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:193
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:13
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:257
msgctxt "footstrap"
msgid "That file is too large."
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:280
msgctxt "footstrap"
msgid "That image is too large."
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:169
msgctxt "footstrap"
msgid "The router refused to delete the file (ubus status %d)."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:266
#: htdocs/luci-static/resources/fs-appearance.js:384
msgctxt "footstrap"
msgid "Theme"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:194
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:487
msgctxt "footstrap"
msgid ""
"This browser is not storing preferences (site data is blocked), so a change "
"here lasts until you reload. Saving to the router still works and applies to "
"every browser."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:569
msgctxt "footstrap"
msgid "This theme is not translated on this router yet."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:309
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:316
msgctxt "footstrap"
msgid "Tint strength"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:125
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:263
msgctxt "footstrap"
msgid "Top"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:24
msgid "Total Available"
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:12
msgctxt "footstrap"
msgid "Upload failed."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:428
msgctxt "footstrap"
msgid "Uploading…"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:72
msgid "Username"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:453
msgctxt "footstrap"
msgid "Wallpaper"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:328
msgctxt "footstrap"
msgid "Warning"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:212
msgid ""
"You must enable JavaScript in your browser or LuCI will not work properly."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "in the sidebar"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:212
msgctxt "footstrap"
msgid "on a card"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:341
msgctxt "footstrap"
msgid "on a control"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:310
msgctxt "footstrap"
msgid "on the canvas"
msgstr ""
#: htdocs/luci-static/resources/fs-assets.js:203
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,42 @@
#!/bin/sh
# Registration plus the symlinks that expose the admin's uploads. Nothing migrates an older footstrap
# (OpenWrt expects a sysupgrade) except the stale pattern symlink removed below.
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
# set_opt never overwrites, and luci-base ships main.mediaurlbase, so on a stock router this line
# sets nothing on install or upgrade: the admin picks Footstrap in Language and Style. An upgrade
# must never move a router off its theme; on an upgrade the themes entry exists, so `changed` is 0
# (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 uploads live in /etc (kept by lib/upgrade/keep.d) behind symlinks.
# The pattern is NOT symlinked: an SVG opened through a same-origin URL runs as a script with the
# admin's session (OpenWrt forum 251930). cgi-bin/luci-theme-footstrap-pattern serves it under CSP
# 'none' + sandbox + nosniff, which uhttpd cannot attach to a static file.
# The login background stays a symlink: its extension-less name makes uhttpd answer
# application/octet-stream, so a direct open downloads instead of rendering. The canvas re-encode in
# fs-assets.js is compression, not a boundary.
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
# Remove a pre-0.14.11 install's stale pattern symlink (a reported security hole); `-L` only, never a
# real file.
[ -L /www/luci-static/footstrap/pattern.svg ] && rm -f /www/luci-static/footstrap/pattern.svg
exit 0
@@ -0,0 +1,7 @@
# Keep the admin's uploads across sysupgrade: login background, pattern, webfonts. They are written
# at runtime, so only a firmware sysupgrade needs them listed. Not conffiles: they live outside
# root/etc/config, and a conffiles entry the package does not ship is rejected.
/etc/footstrap/login-bg
/etc/footstrap/pattern.svg
# Whole directory: the faces without the generated fonts.css leave settings.fonts 404ing 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,37 @@
{
"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"
]
},
"uci": [
"footstrap"
],
"cgi-io": [
"upload"
],
"file": {
"/etc/footstrap/login-bg": [
"write",
"remove"
],
"/etc/footstrap/pattern.svg": [
"write",
"remove"
]
}
}
}
}
@@ -0,0 +1,30 @@
#!/bin/sh
# Serves the uploaded wallpaper pattern with headers a browser will not execute: opened directly as
# a /www symlink it was a same-origin document with an authenticated LuCI session in reach (OpenWrt
# forum 251930: a marker SVG read document.cookie, pre-login too). The theme's own use paints it via
# mask-image or background-image, which never run script, so only the direct URL is closed here.
#
# Unauthenticated on purpose: the pattern paints before login (head.ut pre-paint). Safe only because
# the script takes no input: FILE is a constant, never $QUERY_STRING or $PATH_INFO.
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: fs-axes.js appends `?v=<token>`, which changes with every upload. A static
# /www file had an ETag; this handler must not make the tile re-fetch on every page load, off flash
# with no gzip.
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
+303
View File
@@ -0,0 +1,303 @@
#!/bin/sh
# Strip build-time-only bytes from a BUILD TREE, one mode per kind of file:
# ./strip-build.sh templates|shell|assets <dir>
#
# templates `{# … #}`, whole-line `/* … */` and the indentation out of the .ut files
# shell whole-line `#` out of the shell under root/ (uci-defaults, the CGI, keep.d)
# assets the SVG favicon's comment and the whitespace in the manifest JSON
#
# Run from the Makefile over $(PKG_BUILD_DIR) and from tools/stage.sh over the staged payload, never
# over the checkout. sh and awk only: an OpenWrt buildbot has no node. Every rewrite is
# `cat > "$f"`, never `mv`, which would drop the +x of the CGI and uci-defaults; one trap removes
# `<file>.tmp<pid>`, which stage.sh would otherwise copy into /etc/uci-defaults, where the ROUTER
# executes it. Reasons per mode sit above each branch below; docs/package.md has the rest.
set -eu
usage() { echo "usage: strip-build.sh templates|shell|assets <dir>" >&2; exit 2; }
MODE="${1:-}"
DIR="${2:-}"
case "$MODE" in templates|shell|assets) ;; *) usage ;; esac
[ -n "$DIR" ] && [ -d "$DIR" ] || usage
LIST=$(mktemp)
CUR=""
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT
trap 'exit 130' INT TERM
# The list goes through a file: `for f in $(find …)` splits on whitespace.
case "$MODE" in
templates) find "$DIR" -type f -name '*.ut' ;;
assets) find "$DIR" -type f \( -name '*.svg' -o -name '*.json' \) ;;
shell) find "$DIR" -type f ;;
esac | sort > "$LIST"
# want <file>: 0 when this mode rewrites it, 1 when it does not; the one refusal is a heredoc (below).
want() {
case "$MODE" in
shell)
# ONLY a line whose first non-blank char is `#`, never the shebang; sh has no block comment, so
# `#` is data only in a string or heredoc: want() refuses the heredoc, strip() the string.
# Matched by shebang or keep.d, never by extension; the uci config and the rpcd ACL stay untouched.
case "$(head -c 2 "$1")" in
'#!') ;;
*) case "$1" in */keep.d/*) ;; *) return 1 ;; esac ;;
esac
# checked on rewritten files only: over the whole tree a `<<` in the ACL aborted the build
if grep -q '<<' "$1"; then
echo "strip-build: $1:$(grep -n '<<' "$1" | head -1 | cut -d: -f1): a heredoc — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1
fi ;;
# acl.d is excluded: rpcd skips a malformed ACL SILENTLY, so the grant would go to nobody, for ~200 B
assets) case "$1" in */rpcd/acl.d/*.json) return 1 ;; esac ;;
esac
}
# strip <file>: rewrite it through $CUR, refusing on a result that cannot be right.
strip() {
case "$MODE" in
templates)
# `{# … #}` is a template comment everywhere outside `{% … %}` (0 of them inside a code block,
# one closer each). `{#-` / `-#}` also eat the whitespace before/after, as ucode renders them:
# head.ut's `-#}` swallows the newline before <!DOCTYPE html>.
awk '
# slurp by hand: RS="^$" is a regex in gawk, mawk, busybox and one-true-awk since 2019; POSIX
# mode (gawk --posix: 2 records on "a^b") and older one-true-awk read "^", eating head.ut regexes
{ buf = buf $0 "\n" }
END {
s = buf; 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
}
' "$1" > "$CUR"
cat "$CUR" > "$1"
# `/* … */` that OWNS its lines (`/*` first, `*/` last): 18362 of 18362 comment bytes in the .ut
# files are whole-line, so eating live code needs a string spanning lines. Done per line, not
# on the slurp, so a string literal cannot be walked into; the rest is left and counted.
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-build: %d comment(s) not on lines of their own, left in place\n", LEFT | "cat 1>&2" }
' "$1" > "$CUR"
cat "$CUR" > "$1"
# Indentation and blank lines, -1,627 B of the 21,631 the comment passes leave: ucode eats the
# newline after a `%}`, so the line below one keeps its indentation; anywhere else every
# whitespace run keeps its newline, which HTML, ucode code and inline JS/CSS read the same.
# Line 1 stays whole.
# Refused: an odd backtick count (a template literal spanning lines), a trailing backslash (a
# continued string), a quote left open at a line end inside `{% %}` or `{{ }}` (its next line is
# string data; 0 of the 5 templates have one), <pre> and <textarea>.
awk '
function refuse(why) { print "strip-build: " FILENAME ":" NR ": " why > "/dev/stderr"; bad = 1 }
# One line of the ucode lexer; mode, q, cm and prev carry over. Strings, comments and regex
# literals (head.ut has one holding both quote marks) are skipped, so only a quote still open
# at the line end is reported. A regex is told from a division by the token before it: an opening
# character or an operator, or return, case, in, delete, else, the only words the ucode lexer
# follows with a regex. After any other word, a number, a string, `)`, `]` or `}` it is a division,
# which holds inside an expression only: a statement-initial regex there reads as a division, so
# a quote in it is refused (ucode runs that form as a no-op, a regex has no methods).
function afterkw(s, i) { return match(substr(s, 1, i - 1), /(^|[^A-Za-z0-9_$.])(return|case|in|delete|else)[ \t]*$/) }
function scan(s, i, j, n, c, d, cls) {
n = length(s)
for (i = 1; i <= n; i++) {
c = substr(s, i, 1); d = substr(s, i, 2)
if (!mode) {
if (d == "{%") { mode = 1; prev = ""; i++ }
else if (d == "{{") { mode = 2; prev = ""; i++ }
continue
}
if (cm) { if (d == "*/") { cm = 0; i++ } continue }
if (q) {
if (c == "\\") i++
else if (c == q) { q = ""; prev = c }
continue
}
if (d == "//") return
if (d == "/*") { cm = 1; i++; continue }
if (c == "\047" || c == "\"" || c == "`") { q = c; continue }
if ((mode == 1 && d == "%}") || (mode == 2 && d == "}}")) { mode = 0; i++; continue }
if (c == "/" && (prev == "" || index("(,=:[!&|?{;<>+-*%^~", prev) > 0 || afterkw(s, i))) {
cls = 0
for (j = i + 1; j <= n; j++) {
d = substr(s, j, 1)
if (d == "\\") j++
else if (d == "[") cls = 1
else if (d == "]") cls = 0
else if (d == "/" && !cls) break
}
i = j; prev = "x"; continue
}
if (c != " " && c != "\t") prev = c
}
if (q == "`") q = ""
else if (q != "" && s !~ /\\$/) refuse("a string in a {% %} or {{ }} block spans lines")
}
{
scan($0)
t = $0
if (gsub(/`/, "", t) % 2) refuse("a template literal spans lines")
if ($0 ~ /\\$/) refuse("a line ends in a backslash")
if (tolower($0) ~ /<(pre|textarea)([ \t>]|$)/) refuse("<pre> and <textarea> keep their indentation")
line = $0; keep = 1
if (NR > 1) {
if (!blk) { sub(/^[ \t]+/, "", line); keep = (line != "") }
else if (line ~ /^[ \t]+$/) line = ""
}
blk = ($0 ~ /%[}][ \t\r]*$/)
if (keep) print line
}
END { exit bad }
' "$1" > "$CUR" ;;
shell)
# q is the quote open at a line start: sh strings span lines, and a `#` line inside one is data.
# A `#` opens a comment only at a word start; `$#`, `${#x}` and `a#b` are not one.
awk '
function scan(s, i, n, c) {
n = length(s)
for (i = 1; i <= n; i++) {
c = substr(s, i, 1)
if (q == "\047") { if (c == q) q = ""; continue }
if (c == "\\") { i++; continue }
if (q == "\"") { if (c == q) q = ""; continue }
if (c == "\047" || c == "\"") { q = c; continue }
if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[ \t;&|()<>]/)) return
}
}
NR == 1 && /^#!/ { print; next }
/^[ \t]*#/ {
if (q != "") { print "strip-build: " FILENAME ":" NR ": a # line inside a quoted string" > "/dev/stderr"; bad = 1 }
next
}
{ scan($0); print }
END { exit bad }
' "$1" > "$CUR"
# never leave a file that lost its shebang
if [ "$(head -c 2 "$1")" = '#!' ] && [ "$(head -c 2 "$CUR")" != '#!' ]; then
echo "strip-build: $1 lost its shebang — refusing" >&2
exit 1
fi ;;
assets)
case "$1" in
*.svg)
# XML comments, then whitespace BETWEEN tags only: inside a tag `viewBox="-9 -1 100 100"` is
# data. 1,360 -> ~590 B. Slurp without RS tricks, which are not POSIX.
awk '
{ buf = buf $0 "\n" }
END {
$0 = buf
# comments first: they may span lines and may contain angle brackets
while (match($0, /<!--([^-]|-[^-]|--[^>])*-->/)) {
$0 = substr($0, 1, RSTART - 1) substr($0, RSTART + RLENGTH)
}
gsub(/>[ \t\r\n]+</, "><")
gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "")
printf "%s", $0
}
' "$1" > "$CUR"
# a truncated write must never ship: nothing valid here is under 100 bytes
if [ "$(wc -c < "$CUR")" -lt 100 ]; then
echo "strip-build: $1 came out implausibly small — refusing" >&2
exit 1
fi
# a surviving comment is the cheapest proof of a no-op on some other awk
if grep -q '<!--' "$CUR"; then
echo "strip-build: $1 still has an XML comment after stripping — refusing" >&2
exit 1
fi ;;
*)
# JSON: one line, structure only, every value byte kept; 366 -> ~310 B
awk '
{ buf = buf $0 "\n" }
END {
q = 0
out = ""
n = length(buf)
for (i = 1; i <= n; i++) {
c = substr(buf, i, 1)
if (q) {
out = out c
if (c == "\\") { out = out substr(buf, i + 1, 1); i++; continue }
if (c == "\"") q = 0
continue
}
if (c == "\"") { q = 1; out = out c; continue }
if (c == " " || c == "\t" || c == "\n" || c == "\r") continue
out = out c
}
printf "%s", out
}
' "$1" > "$CUR"
# a surviving newline means stripping did not happen
if [ "$(wc -l < "$CUR")" -gt 0 ]; then
echo "strip-build: $1 still has a newline after stripping — refusing" >&2
exit 1
fi ;;
esac ;;
esac
}
before=0
after=0
found=0
while IFS= read -r f; do
want "$f" || continue
found=$((found + 1))
b=$(wc -c < "$f")
CUR="$f.tmp$$"
strip "$f"
[ -s "$CUR" ] || { echo "strip-build: $f came out empty — refusing" >&2; exit 1; }
cat "$CUR" > "$f" # cat, not mv: keep the original mode (uci-defaults must stay +x)
rm -f "$CUR"
CUR=""
before=$((before + b))
after=$((after + $(wc -c < "$f")))
done < "$LIST"
case "$MODE" in
templates) [ "$found" -gt 0 ] || { echo "strip-build: no .ut under $DIR" >&2; exit 1; } ;;
shell) [ "$found" -gt 0 ] || { echo "strip-build: no shell file under $DIR" >&2; exit 1; } ;;
assets) [ "$found" -gt 0 ] || { echo "strip-build: no .svg or .json in $DIR" >&2; exit 1; } ;;
esac
case "$MODE" in
assets) echo "strip-build: assets: $found file(s)" ;;
*) echo "strip-build: $MODE: $found file(s), $before -> $after bytes (-$((before - after)))" ;;
esac
+16
View File
@@ -0,0 +1,16 @@
/*!
* luci-theme-footstrap
* Copyright 2026 footstrap contributors
* Licensed under the Apache License v2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Portions of styles/base are derived from luci-theme-bootstrap, whose notices
* the Apache License requires this file to carry for as long as that code ships:
* Copyright 2012 Nut & Bolt <david@nut-bolt.nl>
* Copyright 2011 Twitter, Inc
*/
/* Layer order, declared here and nowhere else. Directory-to-layer map, concatenation order and
* the hijack risk from a foreign sheet naming a layer first: docs/css.md "One directory per
* cascade layer" and "Layer order is fixed by the FIRST mention of a name". */
@layer tokens, base, theme, page;
+240
View File
@@ -0,0 +1,240 @@
@layer tokens {
/* PRIVATE --fs-* only; the EXPORT --*-color-* tier is derived elsewhere and unread here.
* docs/design-system.md "Two tiers, and the split is load-bearing". */
:root {
/* palette colours live in 03-palettes.css */
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30); /* pop surfaces: menus, dropdown lists, the select picker */
/* which of the three a control takes: docs/design-system.md "The derived ladder" */
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* opaque, not a tint (4.21:1 in dark) */
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
/* every separator is this one line, stated once rather than in 69 longhand declarations */
--fs-hairline: 1px solid var(--fs-border);
/* ring and border-colour transition together, or the ring arrives before the edge does */
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
/* the theme's one colour literal on purpose. docs/design-system.md "The derived ladder". */
--fs-scrim: rgba(0, 0, 0, .7);
/* Role tint ladder, derived so it follows the palette. docs/design-system.md "The derived
* ladder". */
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
--fs-accent-fill: color-mix(in srgb, var(--fs-accent) 18%, transparent);
--fs-good-fill: color-mix(in srgb, var(--fs-good) 18%, transparent);
--fs-warn-fill: color-mix(in srgb, var(--fs-warn) 18%, transparent);
--fs-danger-fill: color-mix(in srgb, var(--fs-danger) 18%, transparent);
--fs-accent-line: color-mix(in srgb, var(--fs-accent) 40%, transparent);
--fs-good-line: color-mix(in srgb, var(--fs-good) 40%, transparent);
--fs-danger-line: color-mix(in srgb, var(--fs-danger) 40%, transparent);
--fs-accent-line-hi: color-mix(in srgb, var(--fs-accent) 55%, transparent);
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
* tokens, not fetched". */
--fs-icon-refresh: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'><path d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/><path d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/><path fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/><path fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/></svg>");
/* a glyph inlined once, sized at two call sites — keep them in step by hand */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
--fs-hover-lift: brightness(.90);
/* four durations, no easing token. docs/design-system.md "Scales" ("Motion") */
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
--fs-dur-fill: .4s; /* a progress bar growing to its value */
--fs-content-max: 1280px; /* content column cap */
/* also read by the bar's centering calc and the notification-width calc */
--fs-content-pad: calc(28px * var(--fs-density-space));
/* tokens, not literals — fs-chrome.js's fitShell() reads them back. docs/design-system.md
* "Scales" ("Shell geometry"). */
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
--fs-content-min: 500px; /* below this a content column stops being readable */
/* opaque on purpose. docs/design-system.md "The derived ladder". */
--fs-bar-bg: var(--fs-panel);
/* drawn, not fetched. docs/design-system.md "Two glyphs drawn as tokens, not fetched". */
--fs-select-chevron:
linear-gradient(-45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%),
linear-gradient(45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%);
/* the bar's designed height; fs-chrome.js publishes the measured one as --fs-bar-live */
--fs-bar-h: calc(46px * var(--fs-density-box));
/* logo box, .fs-themerow controls, Log out and the poll pill share this size */
--fs-btn-size: calc(34px * var(--fs-density-box));
/* one base (Appearance -> Rounding); three radii scale from it */
--fs-radius-base: 12px;
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
--fs-radius-sm: calc(var(--fs-radius-base) * 2 / 3); /* chips, code, small insets, rail toggle (8 @12) */
--fs-radius-pill: 999px; /* pills, toggles, badges — always round */
/* .cbi-section's own gutter; theme/45-misc.css reads it back to undo it (bleed to the card edge) */
--fs-card-pad: calc(16px * var(--fs-density-space));
/* vertical rhythm between cards: .cbi-section, tab groups, alerts, action bars */
--fs-card-gap: calc(16px * var(--fs-density-space));
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* three multipliers, one knob each for text/air/boxes */
--fs-density-type: 1;
--fs-density-space: 1;
--fs-density-box: 1;
/* chrome-control floors, not a field's height (--fs-control-h below) */
--fs-ctl-h-sm: calc(30px * var(--fs-density-box)); /* dense/small: .btn.small, table-row buttons */
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
/* rounded in @supports below (round() is younger than the floor) */
--fs-type-2xs: calc(10px * var(--fs-density-type));
--fs-type-xs: calc(11px * var(--fs-density-type)); /* eyebrow, badges, tooltips, dense metadata */
--fs-type: calc(13px * var(--fs-density-type)); /* body, tables, fields, menu items — the base */
--fs-type-lg: calc(16px * var(--fs-density-type)); /* section titles (h3/legend), alert headings */
--fs-type-xl: calc(20px * var(--fs-density-type)); /* page titles */
--fs-type-2xl: calc(26px * var(--fs-density-type)); /* h1: error500 and third-party apps; the chrome title and the login h1 are .fs-sr */
/* iOS Safari zooms the page when a field under 16px takes focus and never zooms back; max() holds
* the floor at Compact, where --fs-type-lg is 14.4px. -field/-field-xs are the size typed text is
* set in: the body (or dense) size on desktop, the floor under 767px (@media below). */
--fs-type-floor: max(16px, var(--fs-type-lg));
--fs-type-field: var(--fs-type);
--fs-type-field-xs: var(--fs-type-xs); /* the table filter and the colour hex */
/* one spelling per weight — `700`/`bold` and `400`/`normal` were both in use. */
--fs-weight-normal: 400; /* "not bold" */
--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: it scales with the element's own font-size (13 -> 19.5px) */
--fs-leading-tight: 1.2; /* headings and single-line plates, where 1.5 looks loose */
/* one microlabel idiom, named once */
--fs-eyebrow-tracking: .06em;
--fs-eyebrow-weight: 700;
--fs-eyebrow-color: var(--fs-faint);
/* pick the nearer step; do not add one for a single rule */
--fs-space-0-5: calc(2px * var(--fs-density-space));
--fs-space-1: calc(4px * var(--fs-density-space));
--fs-space-1-5: calc(6px * var(--fs-density-space));
--fs-space-2: calc(8px * var(--fs-density-space));
--fs-space-2-5: calc(10px * var(--fs-density-space));
--fs-space-3: calc(12px * var(--fs-density-space));
--fs-space-3-5: calc(14px * var(--fs-density-space));
--fs-space-4: calc(16px * var(--fs-density-space));
--fs-space-5: calc(20px * var(--fs-density-space));
--fs-space-6: calc(24px * var(--fs-density-space));
--fs-space-7: calc(28px * var(--fs-density-space));
/* sparse on purpose above -7 — gutters and insets, not rhythm */
--fs-space-8: calc(32px * var(--fs-density-space));
--fs-space-10: calc(40px * var(--fs-density-space));
/* every single-line field's height, derived not measured */
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
/* ceiling for an elastic `.cbi-value-field` (flex: 1) — a COLUMN width, off the 4px scale */
--fs-field-max: 440px;
/* every z-index but the content's own stacking (wallpaper -1, sticky table heads 1-2) */
--fs-z-raise: 2;
--fs-z-sticky: 50;
--fs-z-flyout: 70;
--fs-z-header: 800;
--fs-z-popover: 850;
--fs-z-overlay: 900;
--fs-z-tooltip: 1000;
--fs-z-dropdown: 1100;
/* outbound only: styles/ never reads one (nothing checks it). docs/design-system.md
* "The export tier is a RAMP" */
--background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
--background-color-low: var(--fs-panel2);
--text-color-highest: var(--fs-text);
--text-color-high: var(--fs-text);
--text-color-medium: var(--fs-dim);
--text-color-low: var(--fs-faint);
/* borders carry no text, so -low may fade toward the surface — a hairline is meant to
* disappear into it */
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
--border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
--primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
--on-primary-color: var(--fs-on-accent);
--error-color-high: var(--fs-danger);
--error-color-medium: color-mix(in oklch, var(--fs-danger) 78%, var(--fs-dim));
--error-color-low: color-mix(in oklch, var(--fs-danger) 45%, var(--fs-dim));
--on-error-color: var(--fs-on-danger);
--success-color-high: var(--fs-good);
--success-color-medium: color-mix(in oklch, var(--fs-good) 78%, var(--fs-dim));
--success-color-low: color-mix(in oklch, var(--fs-good) 45%, var(--fs-dim));
--on-success-color: var(--fs-on-good);
--warn-color-high: var(--fs-warn);
--warn-color-medium: color-mix(in oklch, var(--fs-warn) 78%, var(--fs-dim));
--warn-color-low: color-mix(in oklch, var(--fs-warn) 45%, var(--fs-dim));
--on-warn-color: var(--fs-on-warn);
--fs-disabled-opacity: .6;
color-scheme: light;
}
/* round(…) tested where a length is expected, not by a calc() probe */
@supports (width: round(1px, 1px)) {
:root {
--fs-type-2xs: calc(round(10px * var(--fs-density-type), 1px));
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px));
--fs-type: calc(round(13px * var(--fs-density-type), 1px));
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px));
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px));
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px));
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
}
}
@media (max-width: 767px) {
:root { --fs-type-field: var(--fs-type-floor); --fs-type-field-xs: var(--fs-type-floor); }
}
/* bare :root is Normal; these multiply the LADDERS, not the rules */
:root[data-density="compact"] {
--fs-density-type: .9;
--fs-density-box: .85;
--fs-density-space: .65;
}
:root[data-density="large"] {
--fs-density-type: 1.15;
--fs-density-box: 1.15;
/* --fs-density-space stays at the bare :root default of 1 here — large density widens type
* and controls, not the space/gutter scale. */
}
/* No :root[data-content-width] block: fs-axes.js's propAxis writes --fs-content-max inline
* instead. docs/design-system.md "The appearance axes" ("Content width"). */
:root[data-darkmode="true"] {
/* palette colours are in 03-palettes.css; only mode-level tokens live here */
--fs-disabled-opacity: .45;
/* dark fill is light, so brightening (not darkening) moves it away from page AND ink */
--fs-hover-lift: brightness(1.15);
color-scheme: dark;
}
/* no component bridge (--*-hsl, --*-rgb): docs/conventions.md "Writing CSS" ("No colour
* literals"). The one surviving --*-rgb is luci-mod-network's own (base/95-luci.css), not ours. */
}
+270
View File
@@ -0,0 +1,270 @@
@layer tokens {
/* five colourways, one block per (palette x mode). docs/design-system.md "Palettes";
* adding one: "Adding a palette". */
/* --fs-bg washed by hue tint; --fs-bg-base dodges a self-reference cycle (invalid at
* computed-value time). docs/design-system.md "Tint and Accent". */
:root {
--fs-bg: var(--fs-bg-base);
/* derived from -base so the accent-hue axis has one copy to rotate */
--fs-accent: var(--fs-accent-base);
/* tint OF --fs-accent, mixed FROM it so it never goes stale. The formula lives on :root only;
* dark blocks re-state the percentage, since a (0,2,0) re-declaration would outrank the
* (0,1,0) twin in 04-nocolormix.css and leave the token invalid without color-mix. */
--fs-accent-soft-mix: 10%;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) var(--fs-accent-soft-mix), transparent);
/* surfaces + status: raw per palette, derived form is read everywhere else */
--fs-panel: var(--fs-panel-base);
--fs-panel2: var(--fs-panel2-base);
--fs-border: var(--fs-border-base);
--fs-good: var(--fs-good-base);
--fs-warn: var(--fs-warn-base);
--fs-danger: var(--fs-danger-base);
/* deliberately under AA — the "nothing here" ink.
* docs/design-system.md "The fourth ink is the one". */
--fs-placeholder-mix: 55%;
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
/* declared here too, so the sheet stands alone — an undefined var() invalidates the rule */
--fs-accent-h: 0;
--fs-tint-h: 0;
--fs-good-h: 0;
--fs-warn-h: 0;
--fs-danger-h: 0;
/* multiplier on the tint chroma below (Tint strength, not Density). docs/design-system.md
* "Tint and Accent". */
--fs-tint-strength: 1;
/* inert default; head.ut/fs-prefs.js stamp the real url() inline */
--fs-login-bg-url: none;
/* scrim over a File photo, mixed from --fs-bg-base so tint never leaks onto it */
--fs-photo-dim: 74%;
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
/* tiled pattern axes; .2 is where a hairline reads as a drawing, not texture (.15 is swallowed) */
--fs-pattern-url: none;
--fs-pattern-size: 440px;
--fs-pattern-strength: .2;
/* floor + two cos() terms, not flat chroma. docs/design-system.md "Tint and Accent". */
--fs-tint-c: calc((.016 + .012 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))) * var(--fs-tint-strength));
/* ---- footstrap — GitHub Primer (DEFAULT), LIGHT ---- */
--fs-bg-base: #f6f8fa;
--fs-panel-base: #fff;
--fs-panel2-base: #f6f8fa;
--fs-border-base: #d0d7de;
--fs-text: #1f2328;
/* raised past AA — GitHub-light greys read as 5.2/4.9:1 on the card (page descriptions,
* field titles), now 11.8/10.0:1 */
--fs-dim: #33383d;
--fs-faint: #3d4348;
--fs-accent-base: #0969da;
--fs-track: #eaeef2;
--fs-good-base: #197c36;
--fs-warn-base: #946300;
--fs-danger-base: #cf222e;
/* ink ON the fills above — white clears AA on all four: 5.19/5.28/5.19/5.36. --fs-panel-base
* and these four inks are the same #fff in every light palette below, so THIS is their
* only definition — hicontrast/bootstrap/2020/forum's light blocks inherit them, unset.
* docs/design-system.md "Adding a palette". */
--fs-on-accent: #fff;
--fs-on-good: #fff;
--fs-on-warn: #fff;
--fs-on-danger: #fff;
}
:root[data-darkmode="true"] {
--fs-tint-c: calc((.011 + .016 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))
- .004 * max(0, cos((var(--fs-tint-h) - 55) * 1deg))) * var(--fs-tint-strength));
/* per-mode, not per-palette — see the placeholder-mix note above */
--fs-placeholder-mix: 64%;
/* hicontrast dark re-states 14% */
--fs-accent-soft-mix: 15%;
/* 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-track: #373e47;
--fs-good-base: #5aad5d;
--fs-warn-base: #c79128;
--fs-danger-base: #ea7a74;
/* dark ink: fills are light; white fails all four (2.79 worst), #10151c (a shade of
* --fs-bg) clears them (6.55 worst) */
--fs-on-accent: #10151c;
--fs-on-good: #10151c;
--fs-on-warn: #10151c;
--fs-on-danger: #10151c;
}
/* relative colour is above the browser floor; below it the token is invalid at computed-value
* time and fills paint transparent, so the palette stays. Versions: docs/css.md "The browser floor". */
@supports (color: oklch(from red l c 40)) {
:root[data-tint="hue"] { --fs-bg: oklch(from var(--fs-bg-base) l var(--fs-tint-c) var(--fs-tint-h)); }
/* hue mode rotates via oklch(from…); hex mode, and its ink, is stamped inline by the
* appliers (inkFor() in fs-axes.js) */
: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)); }
}
/* ---- hicontrast — deeper, more saturated (opt-in); docs/design-system.md "Palettes" ---- */
:root[data-palette="hicontrast"] {
/* LIGHT — accents DARKER than the default's on purpose: 5.22/5.35/5.93/5.80 vs its 5.08. */
--fs-bg-base: #eef1f6;
--fs-panel2-base: #f6f8fb;
--fs-border-base: #e4e9f1;
--fs-text: #101725;
--fs-dim: #272f3d;
--fs-faint: #2e3745;
--fs-accent-base: #0b6fbd;
--fs-track: #eaeef4;
--fs-good-base: #0f7a52;
--fs-warn-base: #8a5a00;
--fs-danger-base: #c02b2b;
}
:root[data-palette="hicontrast"][data-darkmode="true"] {
/* DARK */
--fs-bg-base: #0a0d13;
--fs-panel-base: #111620;
--fs-panel2-base: #161c28;
--fs-border-base: #232b3a;
--fs-text: #e8edf5;
--fs-dim: #9ba6b8;
--fs-faint: #8f9aad;
--fs-accent-base: #3ba7ff;
--fs-accent-soft-mix: 14%;
--fs-track: #1b2230;
--fs-good-base: #43e0a0;
--fs-warn-base: #ffb454;
--fs-danger-base: #ff6b6b;
/* dark ink: white on these neons fails (2.57 worst), #0a0d13 (=--fs-bg-base) clears
* (7.01 worst) */
--fs-on-accent: #0a0d13;
--fs-on-good: #0a0d13;
--fs-on-warn: #0a0d13;
--fs-on-danger: #0a0d13;
}
/* ---- bootstrap — luci-theme-bootstrap's own colours (opt-in). docs/design-system.md
* "Palettes" ("bootstrap"). ---- */
:root[data-palette="bootstrap"] {
/* LIGHT — white ink on the fills below clears AA on all four: 5.81/5.54/5.40/5.70. */
--fs-bg-base: #fff;
--fs-panel2-base: #f5f5f5;
--fs-border-base: #ccc;
--fs-text: #232323;
--fs-dim: #383838;
--fs-faint: #424242;
/* --primary-color-medium; -high (#1976d2) measured 4.22:1 on --fs-panel2 */
--fs-accent-base: #1564c0;
--fs-track: #eee;
--fs-good-base: #007936; /* --success-color-low */
--fs-warn-base: #826706; /* #efbd0b darkened; 1.61:1 as text is not a rounding error */
--fs-danger-base: #d00000; /* --error-color-low */
}
:root[data-palette="bootstrap"][data-darkmode="true"] {
/* DARK */
--fs-bg-base: #222;
--fs-panel-base: #282828;
/* raised past bootstrap's own value: docs/design-system.md "Palettes" ("bootstrap") */
--fs-panel2-base: #303030;
--fs-border-base: #444;
--fs-text: #bfbfbf;
/* --fs-dim/--fs-faint construction, not bootstrap's own — see the panel2 note above */
--fs-dim: #a6a6a6;
--fs-faint: #9a9a9a;
--fs-accent-base: #53a4c2;
--fs-track: #333;
--fs-good-base: #1aaf7b;
--fs-warn-base: #aa9968;
--fs-danger-base: #dd807e;
/* dark ink: light fills, dark ink — matches bootstrap's own --on-*-color; #222 gives
* 5.65 on all four */
--fs-on-accent: #222;
--fs-on-good: #222;
--fs-on-warn: #222;
--fs-on-danger: #222;
}
/* ---- 2020 — the OpenWrt 2020 theme's colourway (opt-in), split into a pair.
* docs/design-system.md "Palettes" ("2020"). ---- */
:root[data-palette="2020"] {
/* LIGHT — white content, the CI cyan darkened until it can be read on it. */
--fs-bg-base: #fff;
--fs-panel2-base: #eef4f8;
--fs-border-base: #c3d2dd;
/* navy-tinted neutrals rather than grey: the ink belongs to the same scheme as the accent */
--fs-text: #10202b;
--fs-dim: #2c4150;
--fs-faint: #3d5566;
/* #00B5E2 held at hue, taken down until it reads: 5.89:1 on white against its own 2.09:1 */
--fs-accent-base: #036c92;
--fs-track: #e3ebf1;
--fs-good-base: #276e2a; /* #5CB85C darkened; 2.28:1 as text is not a rounding error */
--fs-warn-base: #8a5a00; /* #C80, same move */
--fs-danger-base: #c1121f; /* #C11 is 5.25:1 already — the one that stays where it was */
}
:root[data-palette="2020"][data-darkmode="true"] {
/* DARK — 2020's own scheme: the navy is the canvas and the cyan is nearly untouched */
--fs-bg-base: #002b49;
--fs-panel-base: #0a3a58;
--fs-panel2-base: #0e4362;
--fs-border-base: #1d5a7d;
--fs-text: #e6f1f7;
--fs-dim: #b8cfdd;
--fs-faint: #9dbccd;
/* the CI cyan, one step up so it clears AA on the lightest of the three surfaces too */
--fs-accent-base: #35c6ea;
--fs-track: #123c56;
--fs-good-base: #74cc74; /* #5CB85C lifted for the same reason */
--fs-warn-base: #eab346;
--fs-danger-base: #ff9d9d;
/* these fills are LIGHT, so the ink is the canvas navy itself */
--fs-on-accent: #002b49;
--fs-on-good: #002b49;
--fs-on-warn: #002b49;
--fs-on-danger: #002b49;
}
/* ---- forum — sampled from forum.openwrt.org's own getComputedStyle (opt-in).
* docs/design-system.md "Palettes" ("forum"). ---- */
:root[data-palette="forum"] {
/* LIGHT — the forum's own page, exactly: white canvas, near-black ink. white on the
* fills below: 5.19/5.14/5.17/5.17. */
--fs-bg-base: #fff;
--fs-panel2-base: #f0f0f0;
--fs-border-base: #ddd;
--fs-text: #222;
--fs-dim: #3d3d3d;
--fs-faint: #4a4a4a;
/* sampled #08c 3.89:1/4.49:1 (panel2 fails the 4.5:1 floor); held hue to 5.19:1/4.55:1 */
--fs-accent-base: #0073ac;
--fs-track: #eee;
/* sampled #090 3.78:1; #ffff4d (highlight, fills warn) 1.07:1 as text */
--fs-good-base: #008000;
--fs-warn-base: #717100;
--fs-danger-base: #c63c1b;
}
:root[data-palette="forum"][data-darkmode="true"] {
/* DARK — the forum's own dark scheme: #222 canvas, #ddd ink, both exactly as sampled */
--fs-bg-base: #222;
--fs-panel-base: #262626;
--fs-panel2-base: #2e2e2e;
--fs-border-base: #3a3a3a;
--fs-text: #ddd;
--fs-dim: #a3a3a3;
--fs-faint: #969696;
/* sampled #0f82af 3.66:1/3.12:1 on bg/panel2, under 4.5:1; held the hue to 5.86:1/5.00:1 */
--fs-accent-base: #13a8e3;
--fs-track: #333;
--fs-good-base: #1fb65a;
--fs-warn-base: #c99259;
--fs-danger-base: #eb8066;
/* dark ink: these fills are light, and #222 (= --fs-bg-base) gives 5.86/5.98/5.86/5.92 */
--fs-on-accent: #222;
--fs-on-good: #222;
--fs-on-warn: #222;
--fs-on-danger: #222;
/* overrides --fs-bar-bg: forum's own masthead navy, measured live */
--fs-bar-bg: #002b49;
}
}
@@ -0,0 +1,51 @@
@layer tokens {
/* Static twin for each of the 26 color-mix() tokens (28 declarations: the two focus rings read
* them), below Chrome 111 / Firefox 113 / Safari 16.2. Last in the tokens layer, to outrank
* 02-tokens.css and 03-palettes.css. A fallback declaration cannot do this job — a custom
* property fails at the point of USE, not of definition. A re-declaration of a twinned token
* outside :root (dark blocks, theme layer) outranks it: re-state the percentage, or wrap the
* block in the same @supports. Degradation rules: docs/css.md "The browser floor". */
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
:root {
--fs-accent-soft: transparent;
--fs-good-soft: transparent;
--fs-danger-soft: transparent;
--fs-accent-fill: transparent;
--fs-good-fill: transparent;
--fs-warn-fill: transparent;
--fs-danger-fill: transparent;
--fs-accent-line: var(--fs-accent);
--fs-good-line: var(--fs-good);
--fs-danger-line: var(--fs-danger);
--fs-accent-line-hi: var(--fs-accent);
--fs-good-line-hi: var(--fs-good);
--fs-danger-line-hi: var(--fs-danger);
/* -solo is already color-mix-free */
--fs-focus-ring: var(--fs-focus-ring-solo);
--fs-focus-ring-invalid: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-danger);
--background-color-medium: var(--fs-panel2);
/* --fs-photo-dim is a percentage this cannot apply; the fixed dialog scrim beats no scrim */
--fs-photo-scrim: var(--fs-scrim);
/* an unresolved token computes `color` to unset; near-black over a light field beats that */
--fs-placeholder: var(--fs-faint);
/* export tier — third-party apps read these, so they must resolve to something.
* -medium keeps the hue, -low degrades to the dim text colour it was mixed toward. */
--border-color-high: var(--fs-border);
--border-color-low: var(--fs-border);
--primary-color-medium: var(--fs-accent);
--primary-color-low: var(--fs-dim);
--error-color-medium: var(--fs-danger);
--error-color-low: var(--fs-dim);
--success-color-medium: var(--fs-good);
--success-color-low: var(--fs-dim);
--warn-color-medium: var(--fs-warn);
--warn-color-low: var(--fs-dim);
}
}
}
@@ -0,0 +1,98 @@
@layer base {
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
/* scroll-margin-top: anchor jump must clear the sticky bar (WCAG 2.2 SC 2.4.11). Its designed
* height, 46px, is the floor everywhere; the rule below raises it where the bar is measured. */
*, *::before, *::after {
scroll-margin-top: var(--fs-bar-h);
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
}
/* The document-scrolled bar outgrows 46px when the brand wraps or a row is added; the measured
* height (fs-chrome.js publishBarHeight) replaces it there. .fs-main only: overlays and the
* sidebar before fitShell keep 46px. */
:where(:root[data-layout="top"] .fs-main, :root[data-narrow] .fs-main) * {
scroll-margin-top: var(--fs-bar-live, var(--fs-bar-h));
}
abbr[title], acronym[title] {
border-bottom: 1px dotted;
cursor: help;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
/* Outranks every layer's `display`, id rules included: docs/css.md "Reset: [hidden] must
* outrank every layer, not patch one mistake". */
[hidden]:not([hidden="until-found"]) { display: none !important; }
html {
height: 100%;
}
/* :focus-visible: UA heuristic keeps the ring off a mouse click. */
a:focus-visible {
outline: 2px solid var(--fs-accent);
outline-offset: 2px;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -.5em;
}
sub {
bottom: -.25em;
}
/* font-size: button/label in base/30-forms.css; input/select/option/textarea in theme/60-inputs.css — absorbed */
button,
input,
select,
option {
vertical-align: baseline;
}
button,
input[type="button"],
input[type="reset"],
input[type="submit"] {
cursor: pointer;
appearance: button;
}
/* disabled-opacity fade: 30-forms.css, same elements + pointer-events/cursor */
input[type="search"] {
appearance: textfield; /* not content-box: contradicts border-box above, ~22px too wide */
}
input[type="search"]::-webkit-search-decoration {
appearance: none;
}
textarea {
overflow: auto;
vertical-align: top;
font-family: var(--fs-font-mono);
}
/* Unjoined (issue #7): bootstrap's joins square the trailing button's corner; this theme's
* buttons keep full radius. Password reveal stays joined (theme/60-inputs.css). */
.control-group {
display: inline-flex;
gap: var(--fs-space-2);
}
}
@@ -0,0 +1,144 @@
@layer base {
/* body/link box + the type scale a view inherits. background/padding/font-family:
* theme/20-shell.css — absorbed. */
body {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
line-height: var(--fs-leading); /* unitless: inherits as a FACTOR, not a clipping flat length */
color: var(--fs-text);
min-height: 100%;
display: flex;
flex-direction: column;
scrollbar-gutter: stable; /* no-reflow guarantee without a dead scrollbar on a short page */
}
a {
color: var(--fs-accent);
text-decoration: none;
}
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-block: 0 var(--fs-space-4);
margin-inline: var(--fs-space-6) 0;
}
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-bottom: var(--fs-hairline);
}
strong {
font-weight: var(--fs-weight-bold);
}
small { font-size: var(--fs-type) }
address {
display: block;
margin-bottom: var(--fs-space-4);
}
/* no shared padding: both members set their own below (code a chip's inset, pre a block's) */
code, pre {
font-family: var(--fs-font-mono);
font-size: var(--fs-type);
border-radius: var(--fs-radius-sm);
}
code {
/* panel2 (6.45:1): --fs-text on a border-level surface is 4.40:1 in dark, under AA */
background-color: var(--fs-panel2);
color: var(--fs-text);
padding: 1px var(--fs-space-1);
/* docs/css.md "Typography and widgets: an opaque token needs overflow-wrap: anywhere, a
* tooltip needs pre-wrap" — a WireGuard key measured 64px past the column at 320px. */
overflow-wrap: anywhere;
}
/* border-radius: the shared `code, pre` rule above already sets it — a second copy of the
* same value here restates, not overrides. */
pre {
background-color: var(--fs-panel2);
display: block;
padding: var(--fs-space-2);
margin: 0 0 var(--fs-space-4);
border: 1px solid color-mix(in srgb, var(--fs-border) 15%, transparent);
white-space: pre-wrap;
overflow-wrap: break-word;
}
}
@@ -0,0 +1,203 @@
@layer base {
/* Forms: input types, .cbi-value row layout, focus/disabled/readonly/invalid states. */
fieldset {
margin-bottom: var(--fs-space-2);
padding-top: var(--fs-space-2);
}
/* size + ink: theme/30-tables.css (legend) — absorbed */
fieldset legend {
display: block;
line-height: 1;
padding-top: var(--fs-space-5);
}
/* Stays in BASE: docs/css.md "Forms: control typography stays in base, on purpose". select is
* excluded, and an input's size is theme/60-inputs.css's --fs-type-field. */
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;
align-items: baseline;
}
.cbi-value-field {
margin-inline-start: var(--fs-space-5); /* logical: LuCI ships four RTL languages */
flex: 1;
min-width: 0; /* else a nested table or one long value pushes the row out of the card */
}
/* size + ink: theme/60-inputs.css (.cbi-value-title) — absorbed. Left: label column geometry. */
.cbi-value label.cbi-value-title {
padding-top: var(--fs-space-1); /* lands the label's first baseline on the field's */
flex: 0 0 180px; /* column WIDTH (longest option label), not on the 4px step scale */
text-align: end; /* towards the field — not base/95-luci.css's physical .left/.right/.center */
/* docs/css.md "Forms: the label column's min-width: 0 survives the responsive layer" —
* stated here, not in theme/90-responsive.css. */
min-width: 0;
}
.cbi-value > .cbi-section,
.cbi-value > .cbi-tblsection {
width: 100%;
}
label > input[type="checkbox"],
label > input[type="radio"] {
vertical-align: text-top;
}
label[for] {
cursor: pointer;
}
/* Shared field box (210px x control-height, #localtime widening, per-widget escapes):
* theme/60-inputs.css — absorbed whole, in-layer, or it loses to a theme rule by layer. */
.cbi-dynlist {
height: auto;
display: inline-flex;
flex-direction: column;
}
/* item paint: theme/60-inputs.css — absorbed. Left: drag-and-drop machinery (UIDynamicList). */
.cbi-dynlist > .item {
padding-block: var(--fs-space-1);
padding-inline: var(--fs-space-2) 2em;
position: relative;
pointer-events: auto;
overflow: hidden;
word-break: break-all;
cursor: move;
user-select: text;
}
/* plate paint: theme/60-inputs.css — absorbed. Left: pins the × to the chip. */
.cbi-dynlist > .item::after {
content: "×";
position: absolute;
align-items: center;
top: -1px;
inset-inline-end: -1px;
bottom: -1px;
font-weight: var(--fs-weight-bold);
pointer-events: auto;
}
/* drop indicator (.drag-over): theme/60-inputs.css — a base rule lost to the theme border shorthands by layer */
.cbi-dynlist > .item.dragging {
opacity: .5;
}
.cbi-dynlist > .item > span {
pointer-events: none;
}
.cbi-dynlist > .add-item {
display: flex;
}
.cbi-dynlist > .add-item > input,
.cbi-dynlist > .add-item > button {
/* flex: theme/60-inputs.css — absorbed */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cbi-value-field > .cbi-checkbox,
.cbi-value-field > div > .cbi-radio {
height: var(--fs-control-h); /* lines a toggle up with the field above it */
display: inline-flex;
align-items: center;
}
.cbi-radio {
cursor: pointer;
gap: var(--fs-space-1);
}
/* .cbi-select and the checkbox/radio drawing: theme/60-inputs.css — absorbed whole. Do not
* re-add an option rule here either: layer beats specificity, so theme's bare `option`
* outranks even `.cbi-select select option:checked`. */
/* --fs-placeholder, not --fs-dim: a placeholder carries the option's DEFAULT and must not read
* as a value the reader typed (03-palettes.css has the numbers). */
::placeholder {
color: var(--fs-placeholder);
}
/* Transitions and the focus/hover ring: the theme's one ring token
* (--fs-focus-ring/--fs-focus-ring-invalid). No generic glow here. */
input[disabled],
button[disabled],
select[disabled],
textarea[disabled],
.cbi-dropdown[disabled]:not(.btn):not(.cbi-button) {
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-block: 0 var(--fs-space-3);
padding-inline: var(--fs-space-3) 0;
}
.cbi-section-create {
display: inline-flex;
align-items: center;
gap: var(--fs-space-2); /* not a negative-margin pair — that trick predates flex gap, leaks */
}
.cbi-section-create > * {
flex: 1 1 auto;
}
/* the input's cell-filling width: theme/60-inputs.css — absorbed with the generic box */
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. .actions has no theme override, so
* it stays: some luci-app-* still emits it. */
.actions {
background: var(--fs-panel2);
margin-bottom: var(--fs-space-4);
padding: var(--fs-card-pad); /* a plate, so it takes the card's own gutter on every side */
border-top: var(--fs-hairline);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
text-align: end;
}
/* Physical, hand-mirrored: docs/css.md "Widgets: .actions .secondary-action still floats, and
* needs a hand RTL mirror". */
.actions .secondary-action {
float: right;
}
:root[dir="rtl"] .actions .secondary-action { float: left; }
.actions .secondary-action a,
.cbi-page-actions .secondary-action a {
line-height: var(--fs-control-h);
}
.actions .secondary-action a:hover,
.cbi-page-actions .secondary-action a:hover {
text-decoration: underline;
}
.cbi-page-actions > form {
display: inline;
margin: 0;
}
}
@@ -0,0 +1,61 @@
@layer base {
/* LuCI tables are div soup (.table/.tr/.th/.td), display roles assigned by hand. */
.tr { display: table-row; }
.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; }
.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; }
.table {
display: table;
width: 100%;
margin-bottom: var(--fs-space-4);
padding: 0;
font-size: var(--fs-type);
border-collapse: collapse;
position: relative;
}
/* Separator on the ROW (theme/30-tables.css), not the cell — else doubled and squares off
* the frame's rounded corner (issue #7). */
.table .th, .table .td {
display: table-cell;
vertical-align: middle;
padding: var(--fs-space-2) var(--fs-space-3);
text-align: start;
}
/* Containment default, not the data-table floor: docs/css.md "Tables: overflow-wrap follows
* the tier, not the file". First column is a label too; exempt once carded (.fs-stacked .td,
* theme/30-tables.css). */
.table .td { overflow-wrap: anywhere; }
.table .th,
.table .tr > .td:first-child { overflow-wrap: break-word; }
.table .tr:first-child .th {
padding-top: var(--fs-space-2);
font-weight: var(--fs-weight-bold);
vertical-align: top;
}
/* Out-of-flow, static position (never `bottom: 0`): docs/css.md "Tables: the empty-table
* placeholder must not pin to the table's bottom" (issue #36). */
.tr.placeholder {
height: calc(3em + var(--fs-space-4));
}
.tr.placeholder > .td {
position: absolute;
inset-inline: 0;
text-align: center;
line-height: 3em;
padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */
}
/* Physical: border-block-start/-end need a browser-floor support decision first. */
.tr.drag-over-above {
border-top: 2px solid var(--fs-accent);
}
.tr.drag-over-below {
border-bottom: 2px solid var(--fs-accent);
}
}
@@ -0,0 +1,125 @@
@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. */
.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 start-start only: the panel hangs off the tab above it and the two share that
* corner */
border-radius: var(--fs-radius); border-start-start-radius: 0;
}
/* 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,61 @@
@layer base {
/* the modal overlay ui.js shows for config diffs, uploads and login */
#modal_overlay {
position: fixed;
top: 0;
bottom: 0;
background: var(--fs-scrim);
z-index: var(--fs-z-overlay);
overflow: auto;
transition: opacity var(--fs-dur);
opacity: 0;
visibility: hidden;
}
.modal {
width: 90%;
margin: var(--fs-space-6) auto; /* clear of the sticky bar, and centred */
min-height: var(--fs-control-h);
max-width: 600px;
border-radius: var(--fs-radius-lg); /* a modal is a card — the large radius, like every panel */
background: var(--fs-panel);
border: var(--fs-hairline);
padding: var(--fs-card-pad);
min-width: 270px;
}
/* .modal > * / pre / textarea: theme/70-modal.css — absorbed with the heading ramp and the
* generic field box, both of which would out-layer a restatement here. */
.modal > .button-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.modal > .button-row > button:not(:last-of-type) {
margin-inline-end: var(--fs-space-2);
}
.modal > .button-row > button:first-of-type {
margin-inline-end: auto;
}
body.modal-overlay-active {
overflow: hidden;
height: 100svh; /* not vh (crops under URL bar) or dvh (re-lays out each frame it slides) */
}
body.modal-overlay-active #modal_overlay {
inset-inline: 0;
opacity: 1;
visibility: visible;
}
.btn .close, .alert-message .close {
font-family: var(--fs-font-sans);
}
/* Colour-word block absorbed, do not re-add: .btn.<word> theme/55-buttons.css,
* .alert-message/.cbi-tooltip.<word> theme/35-alerts.css. */
}
@@ -0,0 +1,28 @@
@layer base {
/* .btn/.cbi-button are interchangeable in LuCI markup. Paint (box, fill, radius) is
* theme/55-buttons.css's. */
.btn,
.cbi-button {
cursor: pointer;
font-size: var(--fs-type);
white-space: pre;
}
/* border-color: theme/60-inputs.css — absorbed. */
.cbi-input-invalid,
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li {
color: var(--fs-danger);
}
/* Not a clearfix — flex container, nothing floats: docs/css.md "Buttons: the flex clearfix,
* and why float is dead on .cbi-page-actions". */
.cbi-page-actions::after {
display: table;
content: "";
clear: both;
}
.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) {
margin-inline-end: var(--fs-space-1);
}
}
@@ -0,0 +1,118 @@
@layer base {
/* small widgets any luci-app-* can drop anywhere: tooltip, progress bar, zone badge, close
* cross, alert box, .label pill. Arrow: content, not paint — colour is theme/45-misc.css. */
.cbi-title-ref::after {
content: "➙";
}
.cbi-tooltip-container {
cursor: help;
}
.cbi-tooltip {
position: absolute;
z-index: var(--fs-z-tooltip);
/* docs/css.md "Widgets: the tooltip parks past the unreachable edge, and the close cross
* needs the same mirror" — a physical `left` grows a 10000px RTL scrollbar. */
inset-inline-start: -10000px;
border-radius: var(--fs-radius-sm);
background: var(--fs-panel);
/* pre-wrap, never pre: docs/css.md "Typography and widgets: an opaque token needs
* overflow-wrap: anywhere, a tooltip needs pre-wrap". */
white-space: pre-wrap;
padding: var(--fs-space-0-5) var(--fs-space-1);
opacity: 0;
transition: opacity var(--fs-dur-fade);
}
.cbi-tooltip-container:hover .cbi-tooltip:not(:empty) {
inset-inline-start: auto;
opacity: 1;
}
/* .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-block: -1.6em 0;
margin-inline: -5px 0;
pointer-events: none;
}
.zonebadge .cbi-tooltip > * {
margin: 1px;
z-index: var(--fs-z-raise);
position: relative;
}
.zone-forwards {
display: flex;
flex-wrap: nowrap;
}
.zone-forwards > * {
flex: 1 1 40%;
padding: 1px;
text-wrap: auto;
}
.zone-forwards > span {
flex-basis: 10%;
text-align: center;
}
.zone-forwards .zone-src,
.zone-forwards .zone-dest {
display: flex;
flex-direction: column;
}
.btn.disabled,
.btn[disabled] {
cursor: default;
opacity: var(--fs-disabled-opacity);
}
/* .btn.large/.btn.small, close cross colour/shadow/opacities: theme/55-buttons.css and
* theme/70-modal.css — absorbed. Left here: geometry only. */
/* Physical, hand-mirrored: docs/css.md "Widgets: the tooltip parks past the unreachable edge,
* and the close cross needs the same mirror" — appears in every modal and .alert-message. */
.close {
float: right;
font-size: var(--fs-type-xl);
font-weight: var(--fs-weight-bold);
line-height: 1;
}
:root[dir="rtl"] .close { float: left; }
.close:hover {
text-decoration: none;
}
/* alert paint: theme/35-alerts.css — absorbed. Left: geometry and the white-space reset. */
.alert-message {
position: relative;
margin-bottom: var(--fs-space-2);
white-space: unset;
}
.alert-message .close {
margin-top: 1px;
}
.alert-message button {
margin: var(--fs-space-1) 0;
}
/* .label pill and the indicator pill: theme/35-alerts.css / theme/20-shell.css — absorbed. */
header [data-indicator] {
white-space: nowrap;
text-shadow: none;
margin-block: var(--fs-space-1); margin-inline: var(--fs-space-1) 0;
}
}
@@ -0,0 +1,617 @@
@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 */
form.inline { display: inline; margin-bottom: 0; }
/* #syslog: absorbed into pages/70-syslog.css — in base it LOST to the theme layer's generic
* form-field box (a layer beats specificity), so the log rendered as a 210px column. */
/* :not(.placeholder): the empty-table row gets no hover cue (theme/30-tables.css spares it),
* but this rule paints the CELL and a placeholder row carrying no `.cbi-section-table-row`
* class escapes that guard — measured on UPnP (issue #15). */
.cbi-section-table .tr:not(.placeholder):hover .td,
.cbi-section-table .tr:not(.placeholder):hover .th,
.cbi-section-table .tr:not(.placeholder):hover::before {
background-color: var(--fs-panel2);
}
.cbi-section-table .tr.cbi-section-table-descr .th {
font-weight: var(--fs-weight-normal);
}
.cbi-section-table-titles {
font-weight: var(--fs-weight-bold);
}
/* GridSection name column, discriminated by markup not release: docs/css.md "base/95-luci.css:
* the GridSection name column, read from the markup, not the release" (issue #9). Column
* geometry lives in theme/30-tables.css, next to the real cells it lines up with. */
.cbi-section-table-titles.named[data-title]::before,
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before,
.cbi-section-table-row[data-title]:is(:not(:has(> .cbi-section-table-titles)))::before {
content: attr(data-title) " ";
display: table-cell;
vertical-align: middle;
}
.left { text-align: left !important; }
.right { text-align: right !important; }
.center { text-align: center !important; }
.top { vertical-align: top !important; }
.middle { vertical-align: middle !important; }
.bottom { vertical-align: bottom !important; }
table table td,
.cbi-value-field table td {
border: none;
}
/* cell-filling field widths (+ numeric-cell refinement): theme/30-tables.css —
* absorbed with the generic field box, which would out-layer them here */
.table.cbi-section-table .td.cbi-section-table-cell {
white-space: nowrap;
text-align: end;
}
.td.cbi-section-actions {
text-align: end;
vertical-align: middle;
width: 15%;
}
.td.cbi-section-actions > * {
display: flex;
}
.td.cbi-section-actions > :not(.cbi-dropdown) > *,
.td.cbi-section-actions > * > form > * {
flex: 1 1 4em;
margin: 0 var(--fs-space-1); /* WCAG 2.2 SC 2.5.8: 1px apart, targets sat inside each other */
}
.td.cbi-section-actions > * > form {
display: inline-flex;
margin: 0;
}
.cbi-rowstyle-2,
.tr.table-titles,
.tr.cbi-section-table-titles {
background: var(--fs-panel2);
}
th[data-sortable-row] { cursor: pointer; }
th[data-sort-direction="asc"]::after { content: "\a0\25b2"; }
th[data-sort-direction="desc"]::after { content: "\a0\25bc"; }
/* Indent and the ::before glyph's `inset-inline-start` are one relationship (both 1.25em),
* held in one local. */
.cbi-value-description {
--help-indent: 1.25em;
margin-block: var(--fs-space-1) 0;
margin-inline: var(--help-indent) 0;
position: relative;
}
.cbi-value-description:not(:empty)::before {
/* OURS, drawn as a stroke like --spinner-icon below and --fs-icon-refresh in 02-tokens.css
* (fill:none, round caps, 24 grid) — not the solid filled path luci-theme-bootstrap used. */
--help-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='9.4'/><path d='M8.9 9.4a3.2 3.2 0 1 1 3.9 3.6v1.4'/><path d='M12.8 17.6h-.02'/></svg>");
position: absolute;
inset-inline-start: calc(var(--help-indent) * -1);
content: "\a0";
display: inline-block;
width: 1em;
height: 1em;
margin-inline-end: var(--fs-space-1);
background: var(--fs-accent);
mask-image: var(--help-icon);
mask-size: cover;
-webkit-mask-image: var(--help-icon);
-webkit-mask-size: cover;
}
/* tint (6%-not-12% reasoning): theme/30-tables.css — absorbed */
.cbi-section-error {
border: 1px solid var(--fs-danger);
border-radius: var(--fs-radius);
padding: var(--fs-space-2);
margin-bottom: var(--fs-space-4);
}
.cbi-section-error ul { margin-block: 0; margin-inline: var(--fs-space-5) 0; }
.cbi-section-error ul li {
font-weight: var(--fs-weight-bold); /* colour: theme/35-alerts.css — absorbed */
}
.ifacebox {
border: var(--fs-hairline);
margin: 0 var(--fs-space-3);
text-align: center;
white-space: nowrap;
border-radius: var(--fs-radius-sm);
display: inline-flex;
flex-direction: column;
line-height: var(--fs-leading-tight); /* a plate of single-line labels, not prose */
min-width: 100px;
}
.ifacebox .ifacebox-head {
border-bottom: var(--fs-hairline);
/* inside the box's 1px border, so radius = the box's less that border */
border-radius: calc(var(--fs-radius-sm) - 1px) calc(var(--fs-radius-sm) - 1px) 0 0;
padding: var(--fs-space-0-5);
}
/* !important fights luci-mod-network's inline zone-colour style=: docs/css.md "base/95-luci.css:
* the dark-mode zone tint, and why it has no grey fallback". */
[data-darkmode="true"] .zonebadge[style],
[data-darkmode="true"] .ifacebox-head[style] {
background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important;
}
/* generalised once: docs/css.md "base/95-luci.css: the overflow-wrap fix generalised once, not
* restated per page". */
.ifacebox .ifacebox-body {
padding: var(--fs-space-1);
white-space: normal;
overflow-wrap: anywhere;
}
#cbi-network-interface .ifacebox img {
width: 24px;
height: 24px;
}
.ifacebadge {
display: inline-block;
border: var(--fs-hairline);
padding: var(--fs-space-0-5);
color: var(--fs-text);
border-radius: var(--fs-radius-sm);
cursor: default;
line-height: var(--fs-leading-tight);
}
.ifacebadge img {
width: 32px;
height: 32px;
vertical-align: middle;
}
.ifacebadge-active {
font-weight: var(--fs-weight-bold);
}
.network-status-table {
display: flex;
flex-wrap: wrap;
}
.network-status-table .ifacebox {
margin: var(--fs-space-2);
flex-grow: 1;
}
.network-status-table .ifacebox-body {
display: flex;
flex-direction: column;
height: 100%;
text-align: start;
}
/* same fix as .ifacebox-head above, same doc section (RU caption "Подключение IPv4
* (внешняя сеть)" measured 41.3px past #view at 320px/Large, 0px in English). */
.network-status-table .ifacebox-head {
white-space: normal;
overflow-wrap: anywhere;
}
.network-status-table .ifacebox-body > * {
margin: var(--fs-space-1);
}
.network-status-table .ifacebox-body > span {
flex: 10 10 auto;
height: 100%;
}
.network-status-table .ifacebox-body > div {
margin: -.125em;
display: flex;
flex-wrap: wrap;
}
#dsl_status_table .ifacebox-body span > strong {
display: inline-block;
min-width: 35%;
}
.ifacebadge.large,
.network-status-table .ifacebox-body .ifacebadge {
flex: 1;
padding: var(--fs-space-1);
min-width: 220px;
margin: var(--fs-space-1);
}
.network-status-table .ifacebox-body .ifacebadge {
display: flex;
}
.ifacebadge.large {
display: inline-flex;
}
.network-status-table .ifacebox-body .ifacebadge > span {
overflow: hidden;
text-overflow: ellipsis;
}
.ifacebadge > *,
.ifacebadge.large > * {
margin: 0 var(--fs-space-1);
}
.zonebadge {
padding: var(--fs-space-0-5);
border-radius: var(--fs-radius-sm);
display: inline-block;
white-space: nowrap;
color: var(--fs-text);
}
.zonebadge > em,
.zonebadge > strong {
margin: 0 var(--fs-space-1);
display: inline-block;
}
/* .zonebadge input width: theme/30-tables.css — absorbed with the generic box */
.zonebadge > .ifacebadge {
margin-inline-start: var(--fs-space-0-5);
}
.zonebadge-empty {
border: 1px dashed var(--fs-border); /* width/style only — colour + ink: theme/45-misc.css */
font-style: italic;
font-size: smaller;
}
div.cbi-value var,
.td.cbi-value-field var {
font-style: italic; /* colour: theme/60-inputs.css — absorbed */
}
div.cbi-value var[data-tooltip],
.td.cbi-value-field var[data-tooltip],
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container {
cursor: help;
border-bottom: 1px dotted var(--fs-accent);
}
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container .cbi-tooltip {
font-style: normal;
white-space: normal;
color: var(--fs-text);
}
#modal_overlay > .modal.uci-dialog,
#modal_overlay > .modal.cbi-modal {
max-width: 900px;
}
#modal_overlay > .modal.login > button.important {
/* Coverage vocabulary, not a live control: no luci-base call site passes ui.showModal a
* `login` class, and this theme's own login is a server-rendered form. Inherited from
* luci-theme-bootstrap so a foreign app emitting it still gets a themed result. */
font-size: var(--fs-type-lg);
margin-top: var(--fs-space-2);
width: 100%;
}
.uci-change-list {
line-height: var(--fs-leading); /* prose leading for a list of changed settings, not a hand 170% */
white-space: pre;
}
.uci-dialog div > del,
.uci-dialog div > ins,
.uci-dialog div > var {
margin-bottom: var(--fs-space-0-5);
border: var(--fs-hairline);
line-height: var(--fs-leading-tight); /* one row of single-line code takes the tight step */
overflow: hidden;
text-overflow: ellipsis;
padding: var(--fs-space-0-5);
position: relative;
background-color: var(--fs-panel2);
}
.uci-dialog div > ins {
background-color: var(--fs-good-fill);
border-color: var(--fs-good-line);
}
.uci-dialog div > del {
background-color: var(--fs-danger-fill);
border-color: var(--fs-danger-line);
}
.uci-dialog var > ins {
background-color: var(--fs-good-fill);
}
.uci-dialog var > del {
background-color: var(--fs-danger-fill);
}
.uci-change-list > var > * {
overflow: hidden;
text-overflow: ellipsis;
}
.uci-dialog del,
.uci-dialog ins,
.uci-dialog var {
text-decoration: none;
font-family: var(--fs-font-mono);
font-style: normal;
color: var(--fs-text);
display: block;
}
.uci-change-legend {
display: flex;
flex-wrap: wrap;
}
.uci-change-legend-label {
flex-basis: 150px;
margin: var(--fs-space-0-5);
display: flex;
align-items: center;
}
.uci-change-legend-label > ins,
.uci-change-legend-label > del,
.uci-change-legend-label > var {
margin-inline-end: var(--fs-space-1);
width: 16px;
height: 16px;
display: flex;
}
.uci-change-legend-label > * > * {
flex-basis: 100%;
padding: 1px;
}
#applyreboot-section {
line-height: 300%;
}
@keyframes flash {
0% { opacity: 1; }
50% { opacity: .5; }
100% { opacity: 1; }
}
.flash {
animation: flash var(--fs-dur-fade);
}
#view > div.spinning:first-child {
display: table;
margin: 15vh auto;
}
/* Rotate in CSS, never a SMIL <animateTransform> inside the data-URI: that runs in the SVG's
* own document, out of reach of the page's `prefers-reduced-motion`. In-flow glyph, not
* absolutely positioned: docs/css.md "base/95-luci.css: the spinner glyph sits in the flow, on
* purpose" (issues #15, #22). */
.spinning::before {
--spinner-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");
display: inline-block;
flex: 0 0 auto;
vertical-align: middle;
margin-inline-end: var(--fs-space-1);
width: var(--fs-space-5); /* one reader, so the size lives here rather than behind a token */
height: var(--fs-space-5);
content: " ";
background: var(--fs-text); /* palette ink, not CanvasText: a foreign `:root{color-scheme}` flips that against the theme */
mask: var(--spinner-icon) center/cover no-repeat;
-webkit-mask: var(--spinner-icon) center/cover no-repeat;
/* two deliberate exceptions to the motion scale (02-tokens.css): a LOOP, not a state change
* — 1s is a spinner period not one of the four durations, and `linear` avoids the stutter
* `ease` gives an infinite rotation at each turn's seam. */
animation: fs-spin 1s linear infinite;
}
/* A button's glyph is its label ink, so it cannot match the fill under it (white on Save's pale
* tint measures 1.22:1). A bare `.spinning` keeps the page ink above. */
:is(.btn, .cbi-button).spinning::before {
background: currentColor;
}
/* One user (.spinning::before above) — the rail's poll glyph is deliberately STATIC
* (theme/20-shell.css). Unscoped: keyframe names resolve document-wide, a luci-app-* may ride it. */
@keyframes fs-spin { to { transform: rotate(360deg); } }
[data-tab-title] {
height: 0;
opacity: 0;
overflow: hidden;
}
/* the revealed state of both collapsibles — tab pane and file browser — in one rule */
[data-tab-active="true"],
.cbi-filebrowser.open {
opacity: 1;
height: auto;
overflow: visible;
transition: opacity var(--fs-dur-fade);
}
/* min(210px, 100%) floor: docs/css.md "base/95-luci.css: the file browser's floor, and the row
* that is not text". */
.cbi-filebrowser {
min-width: min(210px, 100%);
max-width: 100%;
border: var(--fs-hairline); /* width/style only; radius: theme/60-inputs.css — absorbed */
display: flex;
flex-direction: column;
opacity: 0;
height: 0;
overflow: hidden;
}
/* padding: theme/60-inputs.css — absorbed. Zero side padding here let a 36px themed button
* touch this element's own clip box and lose its corners. */
.cbi-filebrowser > * {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
margin: .25em .25em 0 .25em;
white-space: nowrap;
border-bottom: var(--fs-hairline);
}
.cbi-filebrowser .cbi-button-positive {
margin-inline-end: .25em;
}
/* .right is not one line of text (upload strip + Create/Cancel), so it opts out of the
* nowrap+ellipsis assumption above: docs/css.md "base/95-luci.css: the file browser's floor,
* and the row that is not text". */
.cbi-filebrowser > .right {
white-space: normal;
overflow: visible;
}
.cbi-filebrowser > div {
border-bottom: none;
}
.cbi-filebrowser > ul > li {
display: flex;
}
/* row hover surface + flex sizing: theme/60-inputs.css — absorbed (this file's old
* name:10/actions:3 split fitted base's ~20px buttons, not the theme's 36px ones). */
.cbi-filebrowser > ul > li > div:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.cbi-filebrowser > ul > li > div:last-child {
text-align: end;
}
.cbi-filebrowser > ul > li > div:last-child > button {
padding: .125em .25em;
}
.cbi-filebrowser .upload {
display: flex;
flex-wrap: wrap;
margin: 0 -.125em .25em -.125em;
padding: 0 0 .125em 0;
border-bottom: var(--fs-hairline);
}
.cbi-filebrowser .upload > * {
margin: .125em;
flex: 1;
}
.cbi-filebrowser .upload > .btn {
flex-basis: 60px;
}
.cbi-filebrowser .upload > div {
flex: 10;
min-width: 150px;
}
/* the upload input's width: theme/60-inputs.css — absorbed with the generic box */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
/* `fade-in` stays although nothing here rides it: an upstream animation NAME a luci-app-* may
* ride (coverage contract, docs/conventions.md). The theme's own `.fade-in` CLASS rule is gone
* on purpose — luci-mod-status re-marks it on every poll tick, replaying anything hung there.
* `fs-fade` is one-shot instead, played once by the rail flyout and the search overlay.
* docs/css.md "Misc: fade-in animates nothing, on purpose". */
@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%;
}
/* PNGs on a white ground, inverted for dark mode: docs/css.md "base/95-luci.css: Statistics
* graphs and Realtime SVGs are art drawn for a white ground" — 180deg is exact for the three
* primaries, not tuned further. */
[data-darkmode="true"] :where(#view)[data-page="admin-statistics-graphs"] [data-plugin] img {
filter: invert(100%) hue-rotate(180deg);
}
/* Realtime SVGs carry inline style= for a black ground; only an author !important beats it:
* same doc section. */
#view div[style] > svg {
background-color: var(--fs-panel);
}
#view div[style] > svg line[style] {
stroke: var(--fs-text) !important;
}
/* child combinator, not descendant: reaches only the axis labels, not Channel Analysis's
* per-BSSID curve labels nested inside a <g> — same doc section. */
#view div[style] > svg > text[style] {
fill: var(--fs-text) !important;
text-shadow: none !important;
font-weight: var(--fs-weight-bold);
}
/* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label), the chevron in
* theme/45-misc.css, the 16px pill in pages/20-overview.css — absorbed. */
}
@@ -0,0 +1,55 @@
@layer page {
/* LOGIN — themes/footstrap/sysauth.ut's own template, centred card, no chrome. Scoped by
* `.fs-login`, a class the template puts on the form: replaced
* `form:has(> .cbi-map input[name="luci_username"])`, 49 chars × 17 rules (~0.8 KB) matching
* nothing else. Never turn this into a JS-revealed modal: the view runs pre-session, RPCs answer
* "Access denied", render() never runs. docs/css.md "Login: a definite width, not max-width". */
.fs-login {
/* DEFINITE width, never max-width: a direct flex item of <body> collapses to min-content
* (~131px) under max-width alone — docs/css.md above. */
width: min(400px, calc(100% - 32px));
margin: 7vh auto var(--fs-space-10);
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
/* the token, not a literal 45%-black 60px halo: that ignored the palette. */
padding: var(--fs-space-6); box-shadow: var(--fs-shadow-pop);
}
/* router hostname as the card title, at the 20px page-title step; not the eyebrow tokens: those
* force uppercase and a hostname is a name (case tells two similar ones apart). docs/css.md above. */
.fs-login-host {
margin: 0 0 var(--fs-space-6); text-align: center;
font-size: var(--fs-type-xl); font-weight: var(--fs-weight-bold); letter-spacing: -.01em;
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;
}
.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;
}
.fs-login .alert-message { margin: 0 0 var(--fs-space-3-5); border-radius: var(--fs-radius); }
/* column, not block: an auth plugin's second action (Reset) glues to Login under margin:0 */
.fs-login .cbi-page-actions {
display: flex; flex-direction: column; gap: var(--fs-space-2-5);
background: none; border: 0;
padding: 0; margin: var(--fs-space-1-5) 0 0; text-align: start;
}
.fs-login .cbi-page-actions .btn,
.fs-login .cbi-page-actions input[type="submit"] {
width: 100%; 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,251 @@
@layer page {
/* Every !important here fights an inline style= from luci-mod-status's 29_ports.js — page is
* the last layer, so a flag fighting only another footstrap rule is not kept. docs/css.md
* "Overview: the port-tile reskin". */
/* decoration only, glyph aria-hidden — docs/css.md "Overview: the disclosure pill, the grid,
* and the duplicate heading" */
.cbi-title .label[data-indicator="poll-status"] {
font-size: 0;
width: 16px;
height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 0;
color: var(--fs-faint);
}
/* two selectors: wrapper differs by release (25.12 .cbi-title>h3, 24.10 bare h3); matches 0 of
* 14 h3 on live 24.10.8 — not dead there, that release has no hide/show control */
:is(.cbi-title h3, .cbi-section > h3)[role="button"] {
cursor: pointer;
-webkit-user-select: none; /* every shipped Safari/iOS reads only the prefixed form */
user-select: none; /* rapid header clicks read as a double-click and select the card */
border-radius: var(--fs-radius-sm);
}
:is(.cbi-title h3, .cbi-section > h3)[role="button"]:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
/* every page-scoped selector below anchors on :where(#view)[data-page="…"], not bare #view —
* docs/css.md ":where(#view), not bare #view". */
/* drops what hideEmptyCard() marked (a stock include with nothing to show) and the stock <h2> duplicate of the chrome's title, the latter via .fs-content not #view */
:where(#view)[data-page="admin-status-overview"] .cbi-section.fs-ovl-empty,
.fs-content[data-page="admin-status-overview"] h2[name="content"] { display: none; }
.fs-ovl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--fs-space-4);
margin-bottom: var(--fs-card-gap); /* match stock .cbi-section spacing below the grid */
}
/* min-width:0 so an item shrinks to its TRACK, not its content's min-width — docs/css.md above */
.fs-ovl > .fs-ovl-sys,
.fs-ovl > .fs-ovl-mem,
.fs-ovl > .fs-ovl-sto { margin: 0; min-width: 0; }
.fs-ovl > .fs-ovl-sys { grid-column: 1; grid-row: 1 / 3; }
.fs-ovl > .fs-ovl-mem { grid-column: 2; grid-row: 1; }
.fs-ovl > .fs-ovl-sto { grid-column: 2; grid-row: 2; }
@container fs-view (max-width: 800px) {
.fs-ovl { grid-template-columns: 1fr; }
.fs-ovl > .fs-ovl-sys,
.fs-ovl > .fs-ovl-mem,
.fs-ovl > .fs-ovl-sto { grid-column: 1; grid-row: auto; }
}
/* Port status reskin. Scoped to :has(img[src*="/port_"]) — do not unscope, Network/Wireless
* reuse the same .ifacebox classes. Child order: 1 name-head, 2 speed, 3 zone bar, 4 traffic. */
:where(#view)[data-page="admin-status-overview"] div:has(> .ifacebox img[src*="/port_"]) {
/* 8px not 10: an 11-port switch in a 1190px column fits 11 cards only at 8px gap */
gap: var(--fs-space-2);
grid-template-columns: repeat(auto-fit, minmax(calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2), 1fr)) !important;
margin-bottom: 0 !important;
}
/* flex-wrap, not a grid+@container threshold (issue #7) — docs/css.md above */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) {
margin: 0 !important;
min-width: 0 !important;
/* caps a 2-port device growing into two half-page cards; fights stock's inline max-width */
max-width: 200px !important;
/* ImmortalWrt 25.12 sets inline margin/width instead of stock's min/max pair */
width: auto !important;
/* same fork centres the grid CONTAINER; *-self wins over parent *-items, no flag needed */
justify-self: stretch;
align-self: stretch;
text-align: start;
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
padding: var(--fs-space-1-5) var(--fs-space-3);
/* no container-type: broke poll-replaced cards before containment resolved (bisected) */
display: flex;
/* NOT redundant: base/95-luci.css sets .ifacebox inline-flex column */
flex-direction: row;
flex-wrap: wrap;
/* row-gap 0: every row carries its own spacing already (zone bar margins, name line box) */
column-gap: var(--fs-space-2); row-gap: 0;
align-items: baseline;
}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) img { display: none; }
/* two stock border-bottoms stack around the colour line; drop both */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) .ifacebox-head { border-bottom: 0; border-radius: 0; }
/* zone-colour bar (WAN red/LAN green, issue #13); order places it between name and traffic.
* No overflow/border-radius: both clip the absolutely-positioned .cbi-tooltip */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:nth-child(3) {
order: 1;
flex: 0 0 100%;
width: 100%;
margin: var(--fs-space-0-5) 0 var(--fs-space-1);
}
/* no status dot: speed + zone bar report carrier */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
flex: 0 0 auto;
width: 100%;
/* one line (nowrap from .ifacebox), ellipsised: without min-width: 0 a long name sets every tile's min width */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
line-height: var(--fs-leading);
font-size: var(--fs-type); color: var(--fs-text);
}
/* no link (*_down.svg): dim to match the dim speed and faded zone bar */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-head:first-child { color: var(--fs-dim); }
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) {
order: 2; /* below the zone-colour line (child 3, order 1) */
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-good); text-align: start;
/* margin-left:auto forces its own row in Chrome's line-break count; flex-grow instead */
flex: 1 1 auto; white-space: normal; overflow-wrap: break-word;
}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) br { display: none; }
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-body:nth-child(2) { color: var(--fs-dim); }
/* traffic: tx over rx, as stock's <br> leaves them — CSS cannot reorder br-separated text.
* Size flag fights the inline font-size:80%. */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) {
order: 2; /* shares the speed's row, below the zone line */
text-align: end;
/* grows to anchor text-align:end, never shrinks (nowrap figures) */
flex: 1 0 100%;
}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container {
font-family: var(--fs-font-mono);
/* widest thing a card must fit whole, sets the card's floor; fights inline font-size:80% */
font-size: var(--fs-type-2xs) !important;
color: var(--fs-dim);
line-height: var(--fs-leading-tight);
white-space: nowrap;
/* fights the element's own inline text-align:left */
text-align: end !important;
}
/* 25.12+ PoE line: inline color:#000 is 1.65:1 on the dark tile (#2d333b); beats it, needs the flag */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container > span[style*="color:#000"] { color: var(--fs-text) !important; }
/* meter rows — a key/value-shaped .table row with a .cbi-progressbar (same shape test as
* :218 below and 30-tables.css's key/value rule); a data table's own bar stays untouched.
* docs/css.md above. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))):has(.cbi-progressbar) { display: block; }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--fs-space-1) var(--fs-space-2-5);
/* halved from 8/10: the outer padding duplicated the gap. Bottom stays larger for the bar. */
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
border: 0;
position: relative;
}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td {
display: block;
/* beats base's .td[width="33%"] with no flag: that sets a width ATTRIBUTE, a later layer wins */
width: auto;
padding: 0;
border: 0;
}
/* label — undoes key/value nowrap: a mount point is one unbreakable line past the card.
* padding-inline-end is the gutter for the out-of-flow value (::after over the bar's edge). */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
grid-column: 1; grid-row: 1;
align-self: center;
min-width: 0;
white-space: normal;
padding-inline-end: min(230px, 50%);
font-size: var(--fs-type);
}
/* value rides the label's own line at desktop (::after floats over the row's top edge) —
* opt out of the bare-meter default reserve. <=560px below restores it (own line, no room). */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: 0;
}
/* narrow card: the value takes its own line, the reserve above cannot fit it — docs/css.md
* above has the 218px/190px measurement. */
@container fs-view (max-width: 560px) {
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
padding-inline-end: 0;
}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:nth-child(2) {
grid-column: 1 / -1; grid-row: 2;
}
/* per-row dividers dropped, keyed on table SHAPE (no .th, no 3rd .td — same test as
* 30-tables.css's key/value rule, docs/css.md "Tables: the key/value discriminator") not on the
* three named cards. attendedsysupgrade's 11_upgrades.js (a stock header row) keeps its
* dividers under this test. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
/* own rule, chosen instead of :is(): .cbi-value carries no :has(), and a selector list is not
* forgiving below the browser floor */
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
* first column already resolved to. Data tables excluded by the same shape test above. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td:first-child {
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
/* .active fill (theme/45-misc.css) makes three saturated bars at once here; "Up" said in
* colour+text on --fs-panel2 instead. Network -> Interfaces keeps the fill (tells boxes apart
* there), so this is scoped to Overview only. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox .ifacebox-head.active {
background: var(--fs-panel2);
color: var(--fs-good);
font-weight: var(--fs-weight);
}
/* L.itemlist wraps every caption in <strong> at 700 weight — the boldest text on the card,
* inverted like the key/value rule above; the value keeps --fs-text with no rule of its own */
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox-body strong {
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
/* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's
* bars something at full strength. Overview only — on Software the value sits beside its own
* field label. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .cbi-progressbar::after {
font-size: var(--fs-type);
color: var(--fs-text);
}
/* row height in DATA tables (DHCP leases, station list, attendedsysupgrade's 11_upgrades.js):
* theme/30-tables.css pads for a page that IS a table, here it's a card read at a glance — 8
* leases measured 740px against a 440px System card. Positive test is the complement of the
* key/value shape above (:has(.th) or a 3rd .td), not .tr.table-titles — a stock header .th
* emitted through initFromMarkup never carries that class. One step down the space ladder,
* only here. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .td,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .th {
padding-block: var(--fs-space-1-5);
}
}
@@ -0,0 +1,39 @@
@layer page {
/* Package list and disk-space bar. :where(#view): docs/css.md ":where(#view), not bare
* #view". Full rationale for every rule below: docs/css.md "Software: the disk-space bar and
* the carded package row". */
:where(#view)[data-page="admin-system-package-manager"] .controls > div:first-child { position: relative; display: block; flex: 1 1 100%; width: 100%; margin: 0 0 var(--fs-space-1-5); }
#packages:not(.fs-stacked) .tr > .td:first-child { white-space: nowrap; }
/* the id beats theme/30-tables.css's header-hiding rule in card mode — load-bearing. */
#packages.fs-stacked .tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr) {
display: block; padding: var(--fs-space-3) var(--fs-space-4);
}
#packages.fs-stacked .td {
display: flex; align-items: baseline; justify-content: flex-end;
gap: 0; padding: var(--fs-space-1) 0; border: 0; text-align: end;
flex: none;
}
#packages.fs-stacked .td[data-title]::before {
content: attr(data-title); flex: 0 0 auto; text-align: start; margin-bottom: 0;
margin-inline-end: auto; padding-inline-end: var(--fs-space-3-5);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color); white-space: nowrap;
}
/* column 4 = Description, matched by index — a `[data-title]` value is a translated string. */
#packages.fs-stacked .td:nth-child(4) { display: block; text-align: start; }
#packages.fs-stacked .td:nth-child(4)::before { display: block; margin-bottom: var(--fs-space-1); }
#packages.fs-stacked .td.cbi-section-actions { margin-top: var(--fs-space-1-5); }
#packages.fs-stacked .td.cbi-section-actions::before,
#packages.fs-stacked .td:not([data-title])::before { content: none; }
:where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar) {
display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim);
}
/* value rides the "Disk space:" label's line at both widths — one unconditional opt-out of
* the bare-meter reserve; theme/90-responsive.css's phone rule only adds margin-bottom. */
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
margin-block-start: 0;
}
}
@@ -0,0 +1,14 @@
@layer page {
/* :where(#view): docs/css.md ":where(#view), not bare #view". Rest: docs/css.md "SSH-keys: the
* card wrapper and the wide dynlist". */
:where(#view)[data-page="admin-system-admin-sshkeys"] > div {
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
padding: var(--fs-space-4);
}
:where(#view)[data-page="admin-system-admin-sshkeys"] .cbi-dynlist {
max-width: none;
}
}
@@ -0,0 +1,18 @@
@layer page {
/* DHCP leases nowrap by default; `.lases` is upstream's 24.10 spelling (issue #7), do not drop.
* docs/css.md "Leases: nowrap counted from the end". */
#view table.fs-dt:is(.leases, .lases, .leases6):not(.fs-stacked) .td { white-space: nowrap; }
/* opaque columns re-permitted to wrap; counted from the END, split on column COUNT — docs/css.md
* above has both matrices and why :where() costs nothing. */
#view table.fs-dt:is(.leases, .lases):has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
.td:is(:nth-last-child(7), :nth-last-child(4)),
#view table.fs-dt.leases6:has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
.td:is(:nth-last-child(6), :nth-last-child(5), :nth-last-child(4)),
#view table.fs-dt:is(.leases, .lases):not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
.td:nth-last-child(5),
#view table.fs-dt.leases6:not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
.td:is(:nth-last-child(5), :nth-last-child(4), :nth-last-child(3)) {
white-space: normal;
}
}
@@ -0,0 +1,40 @@
@layer page {
/* Filter bar, scoped by SHAPE not `data-page` (a 3rd-party LogreadBox has neither). Row gap
* only — LuCI's inline margin stays as horizontal spacing. docs/css.md "Syslog filter bar". */
#content_syslog > div:has(> label) {
display: flex;
flex-wrap: wrap;
align-items: center;
row-gap: var(--fs-space-2);
}
#content_syslog > div:has(> label) > label { white-space: nowrap; }
#content_syslog > div:has(> label) > :is(select, input) {
/* max-width clamps the theme's 210px select (3px overflow measured at 360px). */
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
}
/* disjoint tiers, not base+override — neither out-specifies the other, docs/css.md above */
@container fs-view (width > 560px) {
#content_syslog > div:has(> label) > input:not([type]) { flex: 1 1 12rem; }
}
@container fs-view (width <= 560px) {
#content_syslog > div:has(> label) > :is(select, input:not([type="checkbox"])) {
flex-basis: 100%;
}
}
/* width beats theme/60-inputs.css's 210px textarea by layer order alone */
#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,90 @@
@layer page {
/* The axis controls are page content, not chrome (fs-appearance.js): docs/css.md "Appearance: from popover to page, and what stayed CSS". */
.fs-ap { display: flex; flex-direction: column; gap: var(--fs-space-5); }
.fs-ap-note p:first-child { margin-top: 0; }
.fs-ap-note p:last-child { margin-bottom: 0; }
/* heading row: on a folded group the WHOLE row is the disclosure button inside the h2, full
* width so the target is the heading, not a chevron nobody can hit on a phone. `font: inherit`
* hands the button the heading's size, weight and leading, which a button resets to the UA's. */
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; font: inherit;
}
button.fs-ap-fold:hover { 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 glyph itself is in theme/45-misc.css, shared with the overview card toggle */
.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);
}
button.fs-ap-fold:hover .fs-ap-chev { color: var(--fs-accent); }
.fs-ap-sub { margin-top: var(--fs-space-4); }
.fs-ap-head :is(h2, h3) {
margin: 0;
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); color: var(--fs-text);
}
.fs-ap-section + .fs-ap-section { padding-top: var(--fs-space-4); border-top: var(--fs-hairline); }
/* rows are stock .cbi-value with no layout of their own — base/30-forms.css and
* theme/60-inputs.css already draw every other LuCI form this way. docs/css.md above. */
:root[data-layout="top"] .fs-ap-submenus { display: none; }
.fs-ap-actrow, .fs-ap-bgrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
.fs-ap-actgap { flex: 0 0 var(--fs-space-4); }
.fs-ap-actrow .btn, .fs-ap-bgrow .btn { padding: var(--fs-space-2) var(--fs-space-2-5); }
/* primed Reset (2nd click confirms) reads as the destructive action it is */
.fs-ap-armed { color: var(--fs-on-danger); background: var(--fs-danger); border-color: transparent; }
.fs-ap-err { margin-top: var(--fs-space-2); font-size: var(--fs-type-xs); line-height: 1.35; color: var(--fs-danger); }
.fs-colorctl { display: flex; flex-direction: column; gap: var(--fs-space-1-5); max-width: var(--fs-field-max); }
.fs-color-row { display: flex; align-items: center; gap: var(--fs-space-2); }
/* native swatch strips the browser's own inset well to the frame's edge (both engines) */
input[type="color"].fs-color-swatch {
flex: 0 0 auto; inline-size: 34px; block-size: 34px; padding: 0; cursor: pointer;
background: none; border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
}
input[type="color"].fs-color-swatch::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"].fs-color-swatch::-webkit-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
input[type="color"].fs-color-swatch::-moz-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
input[type="text"].fs-color-hex {
flex: 1 1 auto; min-inline-size: 0;
font-family: var(--fs-font-mono); font-size: var(--fs-type-field-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); }
/* live contrast readout, not a validation — docs/css.md above */
.fs-color-contrast { font-variant-numeric: tabular-nums; }
.fs-contrast-aa { color: var(--fs-good); }
.fs-contrast-aa-large { color: var(--fs-warn); }
.fs-contrast-low { color: var(--fs-danger); }
.fs-ap-bgprev {
display: block; width: 100%; max-width: var(--fs-field-max); height: 84px; object-fit: cover;
border: var(--fs-hairline); border-radius: var(--fs-radius);
margin-top: var(--fs-space-2);
}
/* File wallpaper: only Dim applies, Tint colours the canvas a photo already covers; strength is a multiplier on a chroma we choose, meaningless off hue mode */
:root[data-wallpaper="file"] .fs-ap-tint,
:root:not([data-tint="hue"]) .fs-ap-tintstr { display: none; }
.fs-ap-footer {
display: flex; flex-direction: column; gap: var(--fs-space-2);
margin-top: var(--fs-space-0-5); padding-top: var(--fs-space-2-5); border-top: var(--fs-hairline);
}
.fs-ap-verrow { display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-2-5); }
.fs-ap-version { position: relative; font-size: var(--fs-type-xs); color: var(--fs-dim); font-variant-numeric: tabular-nums; text-decoration: none; }
.fs-ap-version:hover { color: var(--fs-accent); text-decoration: underline; }
/* invisible overlay grows an 84x17 link past the WCAG 2.2 SC 2.5.8 24px floor, to 88x25,
* without moving the text (out of flow, same fix as .cbi-checkbox in theme/60-inputs.css) */
.fs-ap-version::before { content: ""; position: absolute; inset: -4px -2px; }
.fs-ap-i18n {
flex-wrap: wrap;
font-size: var(--fs-type-xs);
color: var(--fs-dim);
}
.fs-ap-i18n code { font-size: inherit; }
}
@@ -0,0 +1,107 @@
@layer theme {
/* Pins inherited-only props on [data-fs-chrome], closing what the fence cannot: inheritance
* from html/body. ROOT-only selector and text-align: start (never left) are load-bearing —
* third-party-apps.md, "Rule 7. The chrome is fenced off, in three places that must agree". */
:where([data-fs-chrome]:not([data-fs-chrome] *)) {
font-style: normal;
font-variant: normal;
letter-spacing: normal;
word-spacing: normal;
text-transform: none;
text-indent: 0;
text-align: start;
cursor: auto;
}
/* Brand/logo/wordmark/logout/indicators: one look for every layout — a layout file sets only
* PLACEMENT (flex, order, rail collapse), never look. */
.fs-brand {
display: flex; align-items: center; gap: var(--fs-space-2-5);
color: var(--fs-text); font-weight: var(--fs-weight-bold);
}
.fs-logo {
width: var(--fs-btn-size); height: var(--fs-btn-size); border-radius: var(--fs-radius);
border: var(--fs-hairline); /* glyph is baked-colour (header.ut), no currentColor use */
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto;
}
.fs-logo svg { width: 20px; height: 20px; }
.fs-logo-ring { fill: #002b49; } /* flips with mode like the favicon SVG; cyan arcs stay baked */
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
/* First tab stop — never display:none/visibility:hidden, that drops it from the tab order. */
.fs-skip {
position: fixed; top: 8px; inset-inline-start: 8px; z-index: var(--fs-z-dropdown);
padding: var(--fs-space-2-5) var(--fs-space-4); border-radius: var(--fs-radius);
background: var(--fs-accent); color: var(--fs-on-accent);
font-weight: var(--fs-weight-bold); font-size: var(--fs-type); text-decoration: none;
box-shadow: var(--fs-shadow-pop);
}
/* clip-path, not a -68px translate: the translate still bled ~16px of drop shadow into the
* viewport (--fs-shadow-pop is offset 12px/blur 34px); clip-path clips the shadow too. */
.fs-skip:not(:focus), .fs-sr {
clip-path: inset(50%);
width: 1px; height: 1px;
padding: 0; overflow: hidden; white-space: nowrap;
}
.fs-skip:focus {
outline: 2px solid var(--fs-panel); outline-offset: 2px;
}
/* Clipped, not display:none/visibility:hidden — both take the <h1> out of the a11y tree too. */
.fs-sr { position: absolute; margin: -1px; }
/* tabindex="-1" on <main> makes Safari/Chrome move focus here rather than just scroll to it. */
#maincontent:focus { outline: none; }
/* box and base colour: 20-shell.css (shared with .fs-themerow) */
.fs-logout:hover { color: var(--fs-danger); }
/* fitTabStrips (fs-chrome.js) adds .fs-dense1/2 when the horizontal menu row would wrap, and
* strips them in the vertical sidebar — no media guard needed. */
.fs-sidebar ul.nav.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
.fs-sidebar ul.nav.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-1-5); }
/* One markup (`ul.nav > li > a`) for both layouts, so these three run once. */
ul.nav { position: relative; margin: 0; list-style: none; }
ul.nav > li { display: block; }
ul.nav a { line-height: 19px; text-decoration: none; }
/* No emitter here, stays styled under the coverage contract — conventions.md, "Coverage is a
* contract". */
.dropdown-menu li { display: block; position: relative; }
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; } /* denser than ul.nav a above */
.fs-sidebar ul.nav > li > a:hover,
.fs-sidebar ul.nav ul a:hover,
.fs-themerow:hover {
background: var(--fs-panel2); color: var(--fs-text); opacity: 1;
}
.fs-sidebar ul.nav > li > a.active,
.fs-sidebar ul.nav ul a.active,
.fs-themerow[aria-expanded="true"],
.fs-modemenu li.active a {
background: var(--fs-accent-soft); color: var(--fs-accent);
}
/* Plate on the PAGE only; the open ANCESTOR keeps the ink alone — issue #37 (two identical
* plates stacked on System -> System otherwise). chrome.md, "fs-chrome.js". */
.fs-sidebar ul.nav > li.active > a:not(.active) { color: var(--fs-accent); }
/* --fs-focus-ring-solo: none of these flips a border on focus, so the ring alone must clear
* 3:1 — the tinted ring reads only 1.15-1.29:1 (two-ring contract, 02-tokens.css). */
.fs-railtoggle:focus-visible,
.fs-themerow:focus-visible,
.fs-logout:focus-visible,
.fs-sidebar ul.nav a:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring-solo);
}
/* accent-color reaches a foreign app's native checkbox/radio/progress — the theme's own are
* appearance:none and never see it. */
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
}
@@ -0,0 +1,43 @@
@layer theme {
/* .fs-pattern: the masked wallpaper element. Why a dedicated element, why z-index: -1 needs a
* transparent body — design-system.md, "Wallpaper rendering: why the canvas moves to :root". */
.fs-pattern { display: none; }
:root[data-wallpaper="pattern"] { background-color: var(--fs-bg); }
/* transparent for both wallpapers: an opaque --fs-bg here would cover the shell's one background layer
* (the photo on the file wallpaper, the .fs-pattern element on the pattern one). */
:root[data-wallpaper="pattern"] body,
:root:is([data-wallpaper="pattern"], [data-wallpaper="file"]) .fs-main { background-color: transparent; }
:root:is([data-wallpaper="pattern"], [data-wallpaper="file"]) footer.fs-footer { background: transparent; }
/* --fs-pattern-url is `none` until an upload exists (head.ut/fs-prefs.js) — the axis picked
* with no file is a bare canvas, not a broken tile. */
:root[data-wallpaper="pattern"] .fs-pattern {
display: block;
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: var(--fs-pattern-strength);
}
/* Theme ink (default): file's own colours discarded, only alpha used through the mask —
* design-system.md, "The appearance axes". */
:root[data-wallpaper="pattern"]:not([data-pattern-ink="original"]) .fs-pattern {
background-color: var(--fs-text);
-webkit-mask-image: var(--fs-pattern-url);
mask-image: var(--fs-pattern-url);
-webkit-mask-repeat: repeat;
mask-repeat: repeat;
-webkit-mask-size: var(--fs-pattern-size) auto;
mask-size: var(--fs-pattern-size) auto;
}
/* original: the file's own palette, plain tiled background, no mask. */
:root[data-wallpaper="pattern"][data-pattern-ink="original"] .fs-pattern {
background-image: var(--fs-pattern-url);
background-repeat: repeat;
background-size: var(--fs-pattern-size) auto;
}
/* No wash layer behind #view: cards paint their own opaque --fs-panel, so nothing needs
* dimming globally. */
}
@@ -0,0 +1,11 @@
@layer theme {
/* The `file` wallpaper: scrim + photo on one element — design-system.md,
* "Wallpaper rendering: why the canvas moves to :root". */
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) {
background-image: linear-gradient(var(--fs-photo-scrim), var(--fs-photo-scrim)), var(--fs-login-bg-url);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed; /* one calm backdrop the page scrolls under, not a sliding strip */
}
}
@@ -0,0 +1,560 @@
@layer theme {
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
body {
background-color: var(--fs-bg);
font-family: var(--fs-font-sans);
}
/* One stroke for every chrome icon (header.ut, svgIcon()): 5 attributes per <svg> were 300 B of each admin page */
.fs-ico, .fs-chevron { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
/* placement only — the look lives in 10-chrome.css */
.fs-brand { flex: 1 1 auto; min-width: 0; }
/* Hostname handling, two rules for two failures — chrome.md, "Hostname handling". */
.fs-wordmark {
overflow-wrap: anywhere; min-width: 0;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
}
/* collapse the sidebar to an icon rail. The toggle only EXISTS in the vertical sidebar,
* so its `display` is declared there, not here, or it would just be shadowed. */
.fs-railtoggle {
align-items: center; justify-content: center;
width: calc(28px * var(--fs-density-box)); height: calc(28px * var(--fs-density-box));
padding: 0; flex: 0 0 auto;
border: 1px solid transparent; border-radius: var(--fs-radius-sm);
background: transparent; color: var(--fs-faint); cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
.fs-railtoggle:hover { color: var(--fs-text); background: var(--fs-panel2); border-color: var(--fs-border); }
.fs-railtoggle .fs-ico { width: 16px; height: 16px; transition: rotate var(--fs-dur-move); }
/* base `ul` gives a 24px inline-start margin and a disc; padding is already 0 (reset) */
.fs-modemenu { list-style: none; margin: 0; }
.fs-sidebar ul.nav > li > a .fs-label { flex: 1 1 auto; }
/* collapse chevron for sections with children */
.fs-chevron {
width: 14px; height: 14px; flex: 0 0 auto;
color: var(--fs-dim);
transition: rotate var(--fs-dur-move);
}
/* `rotate`, not `transform: rotate()`: individual transform properties compose across rules,
* so the RTL mirror below sets `scale` without either rule restating the other's value. */
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
/* The two chevrons are DIRECTION, not decoration, so with the document flipped (head.ut
* stamps dir) they must be mirrored explicitly — logical properties flip the box sides
* (margin, padding, inset, radius), but a glyph needs the transform. */
:root[dir="rtl"] .fs-chevron,
:root[dir="rtl"] .fs-railtoggle .fs-ico { scale: -1 1; }
.fs-sidebar ul.nav > li.has-sub > a { cursor: pointer; }
/* .fs-logout shares the .fs-themerow button rule below (display, colour, box); only its
* LAYOUT (square button vs full-width row) differs per layout block. */
/* The bar is the default chrome and the only copy of it, unguarded — chrome.md, "The bar:
* fit steps, scroll model, a rail that must match its guard". The vertical sidebar below is
* the single guarded override, winning on SPECIFICITY, never on source order. */
.fs-shell {
display: flex; flex-direction: column;
min-height: 100svh; width: 100%; /* svh not dvh — chrome.md, same heading */
}
.fs-sidebar {
display: flex;
width: 100%; height: auto;
position: sticky; top: 0; z-index: var(--fs-z-sticky);
flex-direction: row; flex-wrap: wrap; align-items: center;
gap: var(--fs-space-2-5); padding: var(--fs-space-2-5) var(--fs-space-4);
background: var(--fs-bar-bg);
border-inline-end: 0; border-bottom: var(--fs-hairline);
/* the submenu popups anchor to this bar and must escape it */
overflow: visible;
}
/* brand may shrink (min-width: 0) but is capped at 30ch, tracking the text not the viewport;
* the extra line grows the bar's own min-height (50-toplayout.css). */
.fs-brandrow { flex: 0 1 auto; order: 1; min-width: 0; max-width: min(100%, 30ch); padding: 0; }
/* the bar has no section caption, no column to collapse, and its height follows its
* content — so the flex filler has nothing to fill */
.fs-navlabel, .fs-spacer, .fs-railtoggle, .fs-modemenu { display: none; }
/* the mode strip opts in at 2+ modes; empty (before renderModeMenu in fs-chrome.js runs: no gap, no brand
* shift) or `.single` stays hidden by the line above. */
.fs-modemenu:not(:empty):not(.single) { display: flex; }
/* 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 {
display: flex;
order: 3; flex: 1 1 100%;
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
}
/* anchors the dropdown to its OWN item, not the bar — issue #19, chrome.md, "A bar dropdown
* anchors to its own <li>". A phone never shows it: `hover: none` disables the rule. */
.fs-sidebar ul.nav > li.has-sub { position: relative; }
.fs-sidebar ul.nav > li > a {
display: flex; align-items: center;
padding: var(--fs-space-1-5) var(--fs-space-3); border-radius: var(--fs-radius);
color: var(--fs-dim);
font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
}
/* a bar item is its label: no icon, no chevron */
.fs-sidebar ul.nav > li > a .fs-ico,
.fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
/* popup card under its own item, sized to content and capped to the viewport; clampDropdown
* (fs-chrome.js) nudges one back inside near the inline-end edge, every bar state. */
.fs-sidebar ul.nav ul {
display: none; list-style: none;
position: absolute; inset-inline: 0 auto; top: calc(100% + 8px); z-index: var(--fs-z-flyout);
width: max-content; min-width: 200px; max-width: calc(100vw - 32px);
margin: 0; padding: var(--fs-space-2);
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
}
/* an invisible bridge across the 8px gap, so travelling from the item down into the panel
* does not drop the hover and close it. Unguarded: every state that can open a panel on
* hover needs it, not just one layout. */
.fs-sidebar ul.nav > li.has-sub > ul::before {
content: ""; position: absolute; inset-inline: 0; top: -10px; height: 10px;
}
/* hover only on a real pointer: a tap leaves :hover stuck on mobile browsers, which
* would block tap-to-close */
@media (hover: hover) {
.fs-sidebar ul.nav > li.has-sub:hover > ul { display: block; }
}
.fs-sidebar ul.nav > li.has-sub.open > ul { display: block; }
/* a submenu link is the same link in every layout, the accordion included. */
.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 Search 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; align-items: center; gap: var(--fs-space-1-5);
order: 2; margin-inline-start: auto; padding: 0;
}
/* the square icon button both far-edge controls are; .fs-themerow adds only what a <button> needs */
.fs-themerow, .fs-logout {
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto; order: 2;
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: var(--fs-hairline); border-radius: var(--fs-radius);
background: transparent; color: var(--fs-dim);
}
.fs-themerow {
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 */
font: inherit; font-weight: var(--fs-weight); font-size: var(--fs-type); line-height: 1.1;
text-align: start; cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
/* .fs-ind-compact then .fs-bar-actrow, both by fitCluster (fs-chrome.js) — chrome.md, "Right
* cluster: two fit steps". max-width releases with the flex basis, or the 30ch cap leaves
* the brand short of the row it was just given. */
.fs-sidebar.fs-bar-actrow .fs-brandrow { flex: 1 1 100%; max-width: 100%; }
/* the poll pill: label off, masked glyph on. Static green arrows, grey when the poll is paused —
* the same --fs-icon-refresh as the rail, and the same reason it does not spin (a permanently
* spinning glyph makes an idle router look busy, and a moving target cannot be clicked). */
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"] {
width: var(--fs-btn-size); padding: 0; font-size: 0; justify-content: center; position: relative;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
background: var(--fs-good);
-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;
}
.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 {
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
}
/* 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; }
/* on a square button the BORDER carries the state — the fill and the ink come from the
* shared hover/active groups in 10-chrome.css */
.fs-themerow:hover,
.fs-themerow[aria-expanded="true"] { border-color: var(--fs-accent); }
.fs-logout:hover { border-color: var(--fs-danger); }
.fs-themerow .fs-label, .fs-logout .fs-label { display: none; }
.fs-themerow .fs-ico,
.fs-logout .fs-ico { width: 18px; height: 18px; flex: 0 0 auto; }
.fs-sidebar ul.nav .fs-ico { flex: 0 0 auto; }
/* The vertical sidebar: the single override, guarded POSITIVELY (never `:not([data-layout=
* …])`); everything the bar declared above must be answered here. The `data-narrow`
* measurement and the 521px JS-disabled floor: chrome.md, "`data-narrow`". */
@media (min-width: 521px) {
/* Static, never sticky (issue #7) — chrome.md, "A static sidebar, never position:
* sticky". Confined here; the top layout and the phone bar keep window scrolling. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
flex-direction: row; height: 100svh; overflow: hidden;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
height: 100svh; overflow-y: auto;
/* Firefox-only overflow leak past the static sidebar (issue #12) — chrome.md,
* "contain: paint … is the fix for issue #12". */
contain: paint;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
width: var(--fs-sidebar-w); height: 100svh;
flex: 0 0 auto;
position: static;
flex-direction: column; flex-wrap: nowrap; align-items: normal;
gap: normal; padding: var(--fs-space-5) var(--fs-space-3-5);
background: var(--fs-panel);
border-inline-end: var(--fs-hairline); border-bottom: 0;
box-shadow: none;
/* `overflow: auto`, not overflow-y alone: the bar sets `overflow: visible` for its
* popups, and a lone overflow-y here would leave overflow-x visible — which the
* spec forces back to auto anyway. Say both. */
overflow: auto;
z-index: auto;
}
/* the column is already 224px wide — that IS the cap, so the bar's 30ch one must go
* or the brand is capped twice, at the wrong value */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-brandrow {
display: flex; align-items: center; gap: var(--fs-space-1-5);
flex: 0 0 auto; order: 0; min-width: auto; max-width: none; padding-block: 0 var(--fs-space-5); padding-inline: var(--fs-space-2) var(--fs-space-1);
}
: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; }
/* spacing only: whether the strip shows is the bar's :not(:empty):not(.single) rule above,
* and the rail's hide below outranks it (0,5,0 vs 0,3,0). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu { 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 { position: relative; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
gap: var(--fs-space-3);
padding: var(--fs-space-2-5) var(--fs-space-3);
/* Bold is the root level's own signal (lightening children reads as hierarchy instead); only the accordion needs it. */
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,
: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-block: 0; margin-inline: var(--fs-space-7) 0; padding: 0;
background: none;
border: 0; border-radius: 0; box-shadow: none;
overflow: hidden;
}
/* the bar's hover bridge does not follow: static here, its containing block would fall
* back to the <li> and swallow clicks above it. The rail brings its own (::after). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
/* An open section is a GROUP: an indent alone does not say where it ends, and after ten
* children the next root category read as an eleventh (issue #37). No :not([data-rail])
* guard needed — the rail's own rule below restates these at higher specificity. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul {
display: flex;
margin-block: var(--fs-space-1) var(--fs-space-3);
margin-inline: var(--fs-space-7) 0;
padding-inline-start: var(--fs-space-1);
border-inline-start: var(--fs-hairline);
}
/* Search 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;
/* no background here: at (0,4,0) it would out-rank the (0,2,0) hover and open plates in 10-chrome.css */
}
: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); }
: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 Search */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators {
order: 0; margin-inline-start: 0;
padding: 0 var(--fs-space-1-5) var(--fs-space-1);
}
/* In the column the indicator is one of a stack of ROWS, not a row of buttons —
* Search and Log out below it run the sidebar's full width, so a pill at its own
* content width read as a stray chip. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
flex: 1 1 100%;
justify-content: center;
}
/* 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 at its icon's inline-end. Same block as the vertical sidebar, so the two guards
* cannot drift — chrome.md, "The rail's guard must be the vertical sidebar's, exactly". */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar {
width: var(--fs-rail-w); padding: var(--fs-space-5) var(--fs-space-2-5);
/* the default `overflow-y: auto` computes overflow-x to `auto` as well,
* which would clip the flyouts; the rail is short enough not to scroll */
overflow: visible;
/* no z-index/sticky: static, like the vertical sidebar (issue #7) — its flyouts
* still cover the content on their own --fs-z-flyout in the root stacking context. */
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brandrow { flex-direction: column; gap: var(--fs-space-2-5); padding: 0 0 var(--fs-space-5); }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brand { flex: 0 0 auto; justify-content: center; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-railtoggle .fs-ico { rotate: 180deg; }
/* only the rail's own labels collapse — a `.fs-label` also exists on every
* submenu link, and those live inside the flyout where they must stay */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-wordmark,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-navlabel,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-modemenu,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout > .fs-label,
/* a rail item has no accordion — its children fly out sideways — so the chevron says
* nothing and crowds the icon. Selector kept this long because the vertical override
* turns the chevron back on at (0,6,3), so a plainer form here would only be (0,6,0). */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout {
justify-content: center; gap: 0; padding: var(--fs-space-2-5) 0;
}
/* leaf sections have no flyout — show their name as a tooltip bubble */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a::after {
content: attr(data-label);
position: absolute; inset-inline-start: 100%; top: 50%; transform: translateY(-50%);
margin-inline-start: var(--fs-space-2-5); z-index: var(--fs-z-flyout);
padding: var(--fs-space-1-5) var(--fs-space-2-5); border-radius: var(--fs-radius-sm);
background: var(--fs-panel); border: var(--fs-hairline);
box-shadow: var(--fs-shadow-pop);
color: var(--fs-text); font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a:hover::after { opacity: 1; }
/* the flyout: opened by hover on a mouse, or by .open which the menu JS toggles on tap
* (exclusively — fs-chrome.js flyoutMode). Hover gated on a real pointer: mobile
* browsers leave :hover stuck on the tapped element, which would block tap-to-close. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub > ul {
display: none;
position: absolute; inset-inline-start: 100%; top: -8px; margin-block: 0; margin-inline: var(--fs-space-2-5) 0;
min-width: 196px; padding: var(--fs-space-2); z-index: var(--fs-z-flyout);
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
animation: fs-fade var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
@media (hover: hover) {
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub:hover > ul { display: flex; }
}
/* The flyout's heading reuses the bar's hover-bridge pseudo-element (issue #22) —
* chrome.md, "A flyout's heading reuses the bar's hover-bridge pseudo-element". */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
content: attr(data-title);
position: static; inset: auto; height: auto;
padding: var(--fs-space-1) var(--fs-space-3) var(--fs-space-2);
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
}
/* bridge the 10px gap so the pointer can travel icon -> flyout without a hover drop */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub::after {
content: ""; position: absolute; inset-inline-start: 100%; top: 0;
width: 12px; height: 100%;
}
/* "Refreshing" -> a green masked refresh glyph. Needs the FULL guard, not [data-rail]
* alone: data-rail stamps pre-paint, data-narrow lands later from an async module, so a
* narrow phone would paint a bare glyph in the phone bar until the modules land.
* --fs-ctl-h-sm follows the Density axis, one step under the bar's pill. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
border: 0; box-shadow: none; background: var(--fs-good);
-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;
/* Static, deliberately: this is a poll running quietly forever, not something being
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
}
/* paused: the same glyph gone quiet — without it the rail kept saying GREEN while the
* poll was stopped, the one thing the indicator exists to report. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
background: var(--fs-dim);
}
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
* collapsed-rail indicator badges its count". `.fs-sidebar` is deliberately absent:
* #indicators is an id. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
/* min-width, not width: the count is unbounded (a big config can queue three digits)
* and a fixed box would clip it. */
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
/* .fs-ind-compact above paints the same badge but cannot SHARE this
* rule: it applies at every width, this one must stay inside the 521px floor. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
}
}
/* ---------- 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-content { padding: var(--fs-space-5) var(--fs-content-pad); flex: 1 1 auto; width: 100%; max-width: var(--fs-content-max); margin: 0 auto; }
/* fs-router.js builds the incoming page in a second #view here, then moves its children into
* the live one, so the visible page is never emptied while the next one loads. `visibility:
* hidden` (not display: none) keeps the box so a self-sizing view sees no zero width;
* `height: 0` stops the page moving; `overflow: clip` (not hidden) skips a second scrollport. */
.fs-staging { visibility: hidden; height: 0; overflow: clip; }
/* Slow-navigation hairline, shown once a nav outlives 150ms (fs-router.js). Fixed, and a
* child of <body>, not .fs-main (a column flex container shrinks its 2px to zero) or
* .fs-shell (contain: paint would re-base and clip a fixed descendant). transform/opacity
* only, both composited; the one style flush is on activation out of 'done', no layout. */
#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"] {
transform: scaleX(1);
transition: transform 120ms ease-out, opacity 300ms linear 100ms;
}
@media (prefers-reduced-motion: reduce) {
#fs-nav-progress[data-state="active"],
#fs-nav-progress[data-state="done"] { transition: none; transform: scaleX(1); }
}
/* ---------- footer ----------
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright, so a
* third-party view emitting its own <footer> stays themed too. */
footer {
padding: var(--fs-space-3-5) var(--fs-content-pad); border-top: var(--fs-hairline);
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
/* centre the flex LINE (base declares no justify-content) so text-align has something
* to centre rather than a <span> already shrunk to its content */
justify-content: center;
text-align: center;
/* target-size floor for the two links this sentence carries (WCAG 2.2 SC 2.5.8) —
* chrome.md, "A footer link pair needs 26px". */
line-height: max(26px, calc(var(--fs-type) * 2));
}
footer a { color: var(--fs-dim); }
footer a:hover { color: var(--fs-accent); }
/* Notifications — ui.addNotification prepends .alert-message as the first child of
* #maincontent (.fs-main). Order it above the content and give it card spacing. */
.fs-main > .alert-message {
order: 1; margin: var(--fs-space-4) auto 0;
width: calc(100% - var(--fs-content-pad) * 2);
max-width: calc(var(--fs-content-max) - var(--fs-content-pad) * 2);
}
.fs-main > .fs-content { order: 2; }
.fs-main > .fs-footer { order: 3; }
/* phone tier: content gutter, alert-message width and wrap — merged, none override each
* other (distinct selectors), must stay after the base rules above they narrow */
@media (max-width: 767px) {
.fs-content { padding: var(--fs-space-4); }
/* width: auto, not the base width above: that calc subtracts the DESKTOP gutter while
* .fs-content's own gutter is narrower here — auto plus the side margins tracks the
* content column exactly at any width in this tier. */
.fs-main > .alert-message { margin: var(--fs-space-3) var(--fs-space-4) 0; width: auto; }
/* a top-level alert's action buttons were nowrap, so Dismiss was shoved past the panel
* and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main for #view's too. */
.fs-main .alert-message { flex-wrap: wrap; }
.fs-main .alert-message > * { min-width: 0; margin-inline-start: 0; }
/* the action row is a nested flex that stayed nowrap and clipped its first label */
.fs-main .alert-message div { flex-wrap: wrap; min-width: 0; }
.fs-main .alert-message .btn,
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
}
/* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word
* overflowed a 320px button). */
.fs-main .alert-message .btn,
.fs-main .alert-message button { overflow-wrap: anywhere; }
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
#indicators [data-indicator] {
/* wears the row's own --fs-btn-size and --fs-radius, not a pill, or it reads as a
* different kind of object beside Search and Log out */
display: inline-flex; align-items: center;
min-height: var(--fs-btn-size);
text-transform: none;
font-weight: var(--fs-weight); font-size: var(--fs-type);
/* the bar's control surface; ui.js emits a bare `<span data-indicator=…>`, no class to key on */
background: transparent;
border: var(--fs-hairline);
color: var(--fs-dim);
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
}
#indicators [data-indicator][data-style="active"] { color: var(--fs-good); }
/* nothing else gives a clickable indicator a pointer cursor — base's rule carries geometry
* only, and this theme emits no <header> at all — so "Refreshing" showed a text caret. */
#indicators [data-indicator][data-clickable] { cursor: pointer; }
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so it
* takes the control ring the buttons beside it take (55-buttons.css). :focus-visible, not
* :focus: a pointer press on the pill should not draw a ring. */
#indicators [data-indicator][data-clickable]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
}
@@ -0,0 +1,77 @@
@layer theme {
/* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above
* the right edge instead of stuffed inside a fat pill. Default reserves that value's own line
* in flow (bootstrap's margin:1.4em model) — an abspos value with zero reserve spilled 5-6px
* UP across the divider of the row above, off the overview page (key/value .table, no header
* row). Contexts that put the value BESIDE the bar or on a neighbour's line opt out with
* margin-block-start: 0. */
.cbi-progressbar {
position: relative;
height: 10px;
background: var(--fs-track);
border: 0;
border-radius: var(--fs-radius-pill);
overflow: visible;
min-width: 120px;
margin: 0;
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
.cbi-progressbar > div {
height: 100%;
background: var(--fs-accent);
border-radius: var(--fs-radius-pill);
transition: width var(--fs-dur-fill);
}
/* Colour carries the reading too: data-fs-level set from JS (80/92% thresholds) every poll
* tick. Solid tokens, no color-mix, nothing owed to 04-nocolormix.css. */
.cbi-progressbar[data-fs-level="warn"] > div {
background: var(--fs-warn);
}
.cbi-progressbar[data-fs-level="danger"] > div {
background: var(--fs-danger);
}
.cbi-progressbar::after {
content: attr(title);
position: absolute; inset-inline: auto 0; bottom: calc(100% + var(--fs-space-1)); top: auto;
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-weight: var(--fs-weight-normal);
color: var(--fs-dim); white-space: nowrap; text-shadow: none;
}
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
* meter column, sharing one geometry via --fs-meter-value. One rule per shape, chosen instead
* of :is() — the table half needs :has(), and in a plain list a non-parsing engine drops the
* whole rule, form row included. */
.cbi-value-field > .cbi-progressbar {
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
--fs-meter-value: calc(var(--fs-space-10) * 2);
}
.cbi-value-field > .cbi-progressbar::after {
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
--fs-meter-value: var(--fs-space-10);
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
}
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
* the row). Back over the bar with a one-line top margin (was 6-9px overlap).
* `:has(.tr.table-titles)` stays for SPECIFICITY, matching the beside-rule's (0,6,0). */
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar {
margin-inline-end: 0;
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar::after {
inset-inline: auto 0;
bottom: calc(100% + var(--fs-space-1));
top: auto;
transform: none;
}
}
@@ -0,0 +1,439 @@
@layer theme {
/* ---------- panels / cards (cbi) ---------- */
.cbi-section, .cbi-map > .cbi-section-node, .panel {
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
}
.cbi-section { padding: var(--fs-card-pad); margin-bottom: var(--fs-card-gap); }
/* bare <legend> only; ink from the heading ramp. No line-height — base already sets 1. */
legend { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
/* overview (luci-mod-status) DOM:
* .cbi-section > .cbi-title>h3 + div > table.table (.tr/.td) */
/* section title + collapse control */
.cbi-section > .cbi-title { margin-bottom: var(--fs-space-3); }
.cbi-section > .cbi-title h3,
.cbi-section > h3:first-child,
.cbi-section > legend {
margin: 0; padding: 0; border: 0;
}
/* bare title (no wrapper) sat flush below — same 12px; excludes .cbi-title h3, already gapped */
.cbi-section > h3:first-child,
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
/* a heading FOLLOWING a table had no air; separates more from what precedes than introduces */
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
/* [id]/.fs-dt = data table; no id = key/value, no frame/clip; nested tables too */
.cbi-section .table:not([id]):not(.fs-dt),
.cbi-section-node .table:not([id]):not(.fs-dt),
.cbi-modal .table, .table .table, .cbi-value .table {
border: 0; border-radius: 0; overflow: visible; background: none;
}
.cbi-section .table { margin: 0; width: 100%; }
/* moved from base with the generic field box (theme/60-inputs.css) */
.table.cbi-section-table input[type="password"],
.table.cbi-section-table input[type="text"],
.table.cbi-section-table textarea,
.table.cbi-section-table select,
.table.cbi-section-table .cbi-select {
width: 100%;
min-width: auto;
}
.table.cbi-section-table .td.cbi-section-table-cell select,
.table.cbi-section-table .td.cbi-section-table-cell .cbi-select { width: inherit; }
/* filter placeholder sliced mid-glyph on a long title (issue #12); ellipsis changes the
* failure instead. No !important — the inline 30px flag width still stands. */
.table.cbi-section-table .cbi-section-filter {
padding: var(--fs-space-1) var(--fs-space-1-5);
font-size: var(--fs-type-field-xs);
text-overflow: ellipsis;
}
/* width:100% cannot shrink below min-content; the inverse rule further down restores the
* floor on a data table — read that block before touching either. */
.cbi-section .table .td,
.cbi-section .table .th {
padding-block: var(--fs-space-2-5);
padding-inline: 0 var(--fs-space-4);
border: 0; border-bottom: var(--fs-hairline);
line-height: 1.45; vertical-align: middle;
text-align: start;
}
/* hugs the far edge, beating text-align:start above (form.TableSection's own action cells;
* hand-built ones stay left) */
.cbi-section .table .td.cbi-section-table-cell.cbi-section-actions { text-align: end; }
.cbi-section .table .tr:last-child .td { border-bottom: 0; }
/* case is the app's: uppercase broke LuCI's own casing (issue #39); weight/size/ink alone
* still read as a heading. */
.cbi-section .table .th {
color: var(--fs-eyebrow-color); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs);
letter-spacing: var(--fs-eyebrow-tracking);
}
/* --- DATA tables ([id] or JS-tagged .fs-dt) --- `:is()` at ONE specificity: split, a table
* known only by the tag (Status -> Routing) lost to key/value's default. The padding, frame, corner
* and placeholder rules key on `:is(table, div).fs-dt` (0,1,1) as v0.14.14's `table.fs-dt`, plus
* the bare div: `.table.fs-dt .td` (0,3,0) beat an app's `.table td:nth-child(1)` (0,2,1),
* filemanager padding-left 5 -> 12px (docs/css.md). The rules that were `.table.fs-dt` stay so. */
.cbi-section .table:is([id], .fs-dt) .td,
.cbi-section .table:is([id], .fs-dt) .th,
:is(table, div).fs-dt .td, :is(table, div).fs-dt .th { padding: var(--fs-space-2-5) var(--fs-space-3); }
.cbi-section .table:is([id], .fs-dt) .td { font-family: var(--fs-font-mono); font-size: var(--fs-type); color: var(--fs-text); }
/* the min-content FLOOR belongs here: break-word (unlike base's -anywhere) does not lower
* min-content, so an overflow stays visible to fit.overflows(). :not(.fs-stacked) is the
* fitter's own measuring state. */
.table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; }
/* the ONE column the fitter allows to shred rather than card the table (fs-select.js);
* (0,5,0) beats the floor above on specificity, not source order */
.table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; }
/* processes.js's inline style= cannot be outranked by a layer — !important is the sanctioned
* fight against it. A CARD IS NOT EXEMPT: an unlayered app rule breaks a token that would fit. */
.table.fs-dt .td,
.table.fs-dt .td :not(button):not(.btn):not(input):not(select):not(textarea) {
word-break: normal !important;
}
/* Whether a table cards, breaks one column or scrolls is MEASURED (fs-select.js), at any width. */
/* sticks each .th, not the row — a row's fill does not travel with a sticky cell here. 0 in
* the sidebar (.fs-main scrolls itself), --fs-bar-live where the document does. */
:where(:root[data-layout="sidebar"]:not([data-narrow]) #view, #modal_overlay)
.table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
position: sticky; top: 0; z-index: 1;
background: var(--fs-panel2);
}
:where(:root[data-layout="top"] #view, :root[data-narrow] #view)
.table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
position: sticky; top: var(--fs-bar-live, var(--fs-bar-h)); z-index: 1;
background: var(--fs-panel2);
}
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
* (18 jumps/1500 frames measured). */
.table.fs-dt { overflow-anchor: none; }
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
.cbi-section .table:is([id], .fs-dt) .tr.table-titles .th { border-bottom-color: var(--fs-border); }
/* contour (border-collapse:separate, or the radius is silently dropped); #view too —
* Startup renders .table.fs-dt with no section */
.cbi-section .table[id], #view .table[id],
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, :is(table, div).fs-dt {
border-collapse: separate; border-spacing: 0;
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
/* EXPLICIT: the generic .table rule below still says overflow:hidden, clipped otherwise */
overflow: visible;
}
/* frame is overflow:visible, so nothing clips the corner cells — round the four OUTER ones.
* `:is(.tr.table-titles, .thead)` covers both header shapes; without .thead a real <thead>
* kept square top corners. */
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child,
#view .table[id] :is(.tr.table-titles, .thead) .th:first-child,
:is(table, div).fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-start-start-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,
:is(table, div).fs-dt :is(.tr.table-titles, .thead) .th:last-child { border-start-end-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:first-child,
#view .table[id] .tr:last-child .td:first-child,
:is(table, div).fs-dt .tr:last-child .td:first-child { border-end-start-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:last-child,
#view .table[id] .tr:last-child .td:last-child,
:is(table, div).fs-dt .tr:last-child .td:last-child { border-end-end-radius: var(--fs-radius-lg); }
/* empty table: L.ui.Table adds .tr.placeholder; spanning it is base's job, framed flavour left here */
.cbi-section .table[id] .tr.placeholder .td,
#view .table[id] .tr.placeholder .td,
:is(table, div).fs-dt .tr.placeholder .td {
padding-block: var(--fs-space-2); /* this layer's own cell padding made the plate 4px tall */
text-align: start;
color: var(--fs-dim); font-family: var(--fs-font-sans);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
}
/* --- KEY/VALUE: "no id" is not enough — GridSection also renders no-id .cbi-section-table,
* excluded along with .fs-dt and the legend's inline table-layout. Shape, not a class, says
* data table: any .th or a 3rd .td rules a table out — a real key/value row is exactly 2 .td
* and no .th. docs/css.md, "Tables: the key/value discriminator". */
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child {
color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:not(:first-child) {
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
}
/* below ~560px the 40%/nowrap column starves the value — stacked below (560, not the config
* table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */
@container fs-content (max-width: 560px) {
/* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr {
display: block;
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
border: 0;
border-bottom: var(--fs-hairline);
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr:last-child {
border-bottom: 0;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child:first-child {
display: block; width: auto; white-space: normal;
padding: 0; border: 0;
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
text-transform: uppercase;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
display: block; width: auto; text-align: start;
padding: 0; border: 0;
}
}
/* --- the realtime graph legend: the one no-id .table with inline table-layout:fixed, six
* equal columns overflowing on a phone — folded into label/value pairs. */
@media (max-width: 767px) {
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .tr {
display: flex; flex-wrap: wrap;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .td {
flex: 1 1 50%; min-width: 0; width: auto;
}
}
/* remove double card when a section nests a table-only include */
.cbi-section .cbi-section { border: 0; box-shadow: none; padding: 0; margin: 0; background: transparent; }
/* generic links/labels inside cards; the MAC badge keeps base's flat 4px, square against the card corners (issue #5.4) */
.cbi-section .ifacebox, .cbi-section .zonebadge,
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
.zonebadge input { width: 6em; } /* moved from base with the generic field box */
/* head rounds WITH the box (issue #7); max() since Rounding goes to 0, an invalid subtraction */
.cbi-section .ifacebox > .ifacebox-head {
--fs-head-radius: max(0px, calc(var(--fs-radius) - 1px));
border-radius: var(--fs-head-radius) var(--fs-head-radius) 0 0;
}
/* inactive tab panes: base's height:0/overflow:hidden still drew empty padding/border bars
* (issue #10). visibility not display:none — stock JS reads a graph's width once in a rAF. */
[data-tab-title]:not([data-tab-active="true"]) {
visibility: hidden;
position: relative;
height: 0;
overflow: hidden;
padding: 0;
margin: 0;
border: 0;
}
/* 6%, not 12%: bold --fs-danger on a tint OF --fs-danger eats its own contrast (12% measured
* 4.4:1); at 6% every palette clears 4.5 (4.87/4.95/5.28/6.09) */
.cbi-section-error { border-color: var(--fs-danger); background: color-mix(in srgb, var(--fs-danger) 6%, var(--fs-panel)); color: var(--fs-text); }
.cbi-section-error ul li { color: var(--fs-danger); }
/* denser data tables (dhcp static leases, section-tables) */
.cbi-section-table .th, .cbi-section-table .td,
/* the GridSection name CELL on 24.10, a ::before from data-title where form.js has no real
* cell — named here too or the column lines up with nothing */
.table.cbi-section-table .th, .table.cbi-section-table .td,
.table.cbi-section-table .tr::before { padding-block: var(--fs-space-2); padding-inline: 0 var(--fs-space-3); }
/* zero inline-start padding is right only INSIDE a card; a self-framed table glued its header text
* to its own corner without this (issue #7) */
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .th,
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .td { padding: var(--fs-space-2-5) var(--fs-space-3); }
.cbi-section-table .tr.cbi-section-table-titles .th,
.cbi-section-table .tr.table-titles .th { font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); line-height: 1.25; }
/* standalone data tables -> rounded card; inside a CBI section the section already IS the
* card, so the frame is stripped there (above) */
.table {
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
overflow: hidden;
background: var(--fs-panel);
}
/* separators on the CELLS: the frame needs border-collapse:separate, a row's own border
* paints nothing. :not(:last-child) keeps a trailing one off the rounded corner. */
.table .tr.table-titles > .th,
.table .tr.cbi-section-table-titles > .th,
.table .tr.cbi-section-table-titles::before,
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td,
.table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); }
/* per-CELL border, so a foreign `position: relative; top:` (dashboard) drags one cell's
* border out of line — `top` AND `bottom` cancels either without touching `relative` itself */
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
/* descendant combinator so a real <table>'s inserted <tbody> is covered too */
.table .tr.table-titles, .table .tr.cbi-section-table-titles {
background: transparent; /* drop the base panel2 header bar — match the dashboard's faint-label + underline look */
}
.table .tr.table-titles .th, .table .tr.cbi-section-table-titles .th,
.table .tr.cbi-section-table-titles::before {
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking);
background: transparent;
}
/* row NAME reads the same on both releases (real cell / ::before) — a LABEL, sans face */
.table.cbi-section-table .td.cbi-section-table-titles,
.table.cbi-section-table .tr.cbi-section-table-row::before {
font-family: var(--fs-font-sans); font-weight: var(--fs-weight-bold); color: var(--fs-text);
}
.table .tr:not(.table-titles):not(.cbi-section-table-titles) {
transition: background var(--fs-dur);
}
/* row hover ONLY on real data/config tables (an untagged plain .table lit its whole block,
* issue #5.3; a placeholder row lit up too, issue #5.2/#15) */
.table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); }
/* .fs-dt: fs-select.js on a header-row #view .table that is NOT .cbi-section-table —
* replacing a live :has() selector re-evaluated on every mutation. */
/* One card-stack copy, no @container: fs-select.js already measures OVERFLOW and sets
* .fs-stacked. The config table keeps its own @container — its widgets bake in a width. */
/* a poll REPLACES a data table with a fresh, unmarked copy that briefly lays out full-width,
* throwing the reader 612px out and back — waits OUTSIDE the layout until fs-select.js
* answers, guarded on :root[data-fs-fit]. */
:root[data-fs-fit] #view .table.fs-dt:not(.fs-fitted),
:root[data-fs-fit] #modal_overlay .table.fs-dt:not(.fs-fitted) { display: none; }
.table.fs-stacked { display: block; width: 100%; }
/* .fs-drop-xs is the same drop one rung earlier, on an overflowed table before carding */
.table.fs-stacked .td.hide-sm, .table.fs-stacked .th.hide-sm,
.table.fs-stacked .td.hide-xs, .table.fs-stacked .th.hide-xs,
.table.fs-drop-xs .td.hide-sm, .table.fs-drop-xs .th.hide-sm,
.table.fs-drop-xs .td.hide-xs, .table.fs-drop-xs .th.hide-xs,
.table.fs-stacked .tr.table-titles,
.table.fs-stacked .thead,
.table.fs-stacked .tr.cbi-section-table-titles,
.table.fs-stacked .tr.cbi-section-table-descr { display: none; }
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
* 1-line neighbour's (52px) */
/* a <tfoot> with no .tr IS the row. Two rules, chosen instead of :is(): a browser without :has() drops a whole list,
* losing display:flex on every card. Keep both in step by hand. */
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
.table.fs-stacked tfoot:not(:has(> .tr)),
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
.table.fs-stacked .td {
display: block; min-width: 0; width: auto;
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
overflow-wrap: anywhere; white-space: normal;
}
/* the column label, lifted above its value. A copy sits in theme/65-dropdown.css: the config
* table needs it under its own @container guard, which cannot share a block with a class. Keep
* the two identical by hand. */
.table.fs-stacked .td[data-title]::before {
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);
}
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */
.table.fs-stacked .td.cbi-section-actions::before,
.table.fs-stacked .td:not([data-title])::before,
.table.fs-stacked .td[data-title=""]::before,
.table.fs-stacked tfoot .td[data-title]::before,
.table.fs-stacked .tfoot .td[data-title]::before { content: none; }
/* row buttons are not a label/value pair — full width below the pairs. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-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);
}
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
* the tail button. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) {
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > .cbi-dropdown {
flex: 0 1 content; width: 0; min-width: 0;
}
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
* it does not scroll to an over-wide child */
.table.fs-stacked .td > *,
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
/* a <tfoot> is still a table-footer-group in a display:block table, drawing beside the cards
* instead of under them; `:has(> .tr)` tells the two shapes upstream writes it in apart. */
.table.fs-stacked tfoot:has(> .tr),
.table.fs-stacked .tfoot:has(> .tr) { display: block; }
.table.fs-stacked tfoot .td:empty,
.table.fs-stacked .tfoot .td:empty { display: none; }
/* --- DATA table flavour (L.ui.Table, tagged .fs-dt) --- the frame stays overflow:visible so a
* ComboButton's open list can leave the card; zebra and hover paint on the .tr, so the two outer
* rows carry the frame's inner radius (its radius less the 1px border) instead of a clip. */
.table.fs-dt.fs-stacked :is(.tr:first-child, :is(.tr.table-titles, .thead) + .tr) {
border-start-start-radius: calc(var(--fs-radius-lg) - 1px);
border-start-end-radius: calc(var(--fs-radius-lg) - 1px);
}
.table.fs-dt.fs-stacked .tr:last-child {
border-end-start-radius: calc(var(--fs-radius-lg) - 1px);
border-end-end-radius: calc(var(--fs-radius-lg) - 1px);
}
/* :not(.placeholder) — the empty-state plate is out of flow; its row only reserves height,
* and padding subtracted from that left text hanging below the frame (Wireless) */
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
/* half the row makes the card; col-N cells are excluded by the second :not(), not outranked. Third :not() loses on purpose to the meter below. */
.table.fs-stacked .td:not(.cbi-section-actions):not([class*="col-"]):not(:has(.cbi-progressbar)) {
flex: 1 1 50% !important;
}
/* a first cell that is one token (fs-select.js): "/dev/mtdblock6" is 110px, the half-split left
* 94-106px at 360-390 and `anywhere` cut it mid-word. fit-content of one token is the token,
* capped at the row, so a 64-char hash still breaks inside the card; a phrase keeps its half. */
.table.fs-stacked .tr > .td.fs-td-token:first-child:not(.cbi-section-actions):not([class*="col-"]) { min-width: fit-content; }
/* a meter takes the whole line — its own title prints over the cell's caption otherwise */
.table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; }
/* column weights (1-10): a share of a CARDED row, unguarded — `flex` only applies once a
* `.td` becomes a flex item, so one copy serves every tier and is inert elsewhere. */
.table .td.col-1, .table .th.col-1 { flex: 1 1 30px; }
.table .td.col-2, .table .th.col-2 { flex: 2 2 60px; }
.table .td.col-3, .table .th.col-3 { flex: 3 3 90px; }
.table .td.col-4, .table .th.col-4 { flex: 4 4 120px; }
.table .td.col-5, .table .th.col-5 { flex: 5 5 150px; }
.table .td.col-6, .table .th.col-6 { flex: 6 6 180px; }
.table .td.col-7, .table .th.col-7 { flex: 7 7 210px; }
.table .td.col-8, .table .th.col-8 { flex: 8 8 240px; }
.table .td.col-9, .table .th.col-9 { flex: 9 9 270px; }
.table .td.col-10, .table .th.col-10 { flex: 10 10 300px; }
/* general table cell padding — compact but readable (Startup/Processes/etc) */
.table .td { padding-top: var(--fs-space-2); padding-bottom: var(--fs-space-2); }
/* last resort: no header row, never carded (no data-title to print). .fs-xscroll: same,
* MEASURED to overflow; refused to a table holding a widget (clips a popup inside). */
#view table:not(.table):not(.cbi-section-table):not(.fs-dt),
#modal_overlay table:not(.table):not(.cbi-section-table):not(.fs-dt),
#view .table.fs-xscroll, #modal_overlay .table.fs-xscroll {
display: block;
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain; /* the gesture stops here instead of dragging the page */
scrollbar-width: thin; /* the scrollbar IS the affordance; a shadow trick shows through zebra rows */
}
/* a header-less table holding a control stacks instead of scrolling — a scroll container
* would clip the popup inside; nothing here creates one, so a dropdown still escapes. */
.table.fs-rowstack,
.table.fs-rowstack .tr,
.table.fs-rowstack .td,
.table.fs-rowstack .th { display: block; width: auto; }
/* the cells were table-cells a moment ago (side padding); as blocks they need the vertical
* rhythm instead, or the controls sit flush against each other */
.table.fs-rowstack .td { padding-inline: 0; padding-block: var(--fs-space-1); }
/* pins the row's NAME while scrolling — its own background (a row's fill does not travel
* with a sticky cell) and a hairline so it reads as an edge, not text over text. */
#view .table.fs-xscroll .tr > :first-child,
#modal_overlay .table.fs-xscroll .tr > :first-child,
#view .table.fs-xscroll tr > :first-child,
#modal_overlay .table.fs-xscroll tr > :first-child {
position: sticky; inset-inline-start: 0; z-index: 2;
background: var(--fs-panel);
border-inline-end: var(--fs-hairline);
}
/* a keyboard user tabs to the scroll box (fs-select.js gives it tabindex+name) — outline, not
* box-shadow, so forced-colors keeps it */
#view .table.fs-xscroll:focus-visible,
#modal_overlay .table.fs-xscroll:focus-visible {
outline: 2px solid var(--fs-accent);
outline-offset: 2px;
}
}
@@ -0,0 +1,128 @@
@layer theme {
/* alert panel: a left accent stripe on a panel card; variants recolour the frame */
.alert-message {
padding: var(--fs-space-3-5) var(--fs-space-4);
border-radius: var(--fs-radius-lg);
border: var(--fs-hairline);
/* logical: the stripe marks the READING start, swapping sides in RTL (LuCI ships ar/fa/he) */
border-inline-start: 3px solid var(--fs-accent);
/* --fs-panel stays the background-COLOUR; the tint is a background-IMAGE layer over it,
* not a replacement — a `-fill` background alone is transparent to the wallpaper. */
background-color: var(--fs-panel);
background-image: linear-gradient(var(--fs-alert-tint, transparent), var(--fs-alert-tint, transparent));
color: var(--fs-text);
gap: var(--fs-space-3);
}
/* status lives in stripe+surface, ring stays neutral. :not(.btn) is load-bearing: a confirm
* control is a `<label class="btn alert-message danger">`, and without the guard the tint
* out-ranked `.btn.danger`, fading its fill. */
.alert-message.warning:not(.btn) {
border-color: var(--fs-border); border-inline-start-color: var(--fs-warn);
--fs-alert-tint: var(--fs-warn-fill);
}
.alert-message.error:not(.btn), .alert-message.danger:not(.btn) {
border-color: var(--fs-border); border-inline-start-color: var(--fs-danger);
--fs-alert-tint: var(--fs-danger-fill);
}
.alert-message.success:not(.btn) {
border-inline-start-color: var(--fs-good);
--fs-alert-tint: var(--fs-good-fill);
}
/* .notice groups HERE, not with .label.notice's accent: ui.js emits `notice spinning` for an
* IN-PROGRESS banner, so it reads as informational rather than success. */
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
border-inline-start-color: var(--fs-accent);
--fs-alert-tint: var(--fs-accent-fill);
}
/* spinner ::before is inline-block for its usual inline hosts, but displayStatus() builds a
* BLOCK host, opening its own line. GRID, not flex: the caller decides child count. */
.alert-message.spinning {
display: grid;
grid-template-columns: auto 1fr;
align-items: center; /* centres the glyph against the FIRST row, not pinned to its top */
/* the column gap is the glyph's own spacing, stated rather than inherited from the row `gap` */
column-gap: var(--fs-space-2);
}
/* grid item now, so its inline margin would double the column gap; row 1 keeps it beside the
* FIRST child however many follow */
.alert-message.spinning::before { grid-row: 1; margin-inline-end: 0; }
.alert-message.spinning > * { grid-column: 2; }
/* h2 is what the theme's own notices emit; h4 covers a third-party alert, h5/h6 joined so an
* alert heading does not fall back to base's look past h4. */
.alert-message :is(h2, h3, h4, h5, h6) { margin: 0 0 var(--fs-space-1); font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
/* moved from base with the heading ramp: line-height:inherit beat the ramp's leading
* in-layer, left behind it loses by layer (21 -> 16.8px) */
.alert-message h4,
.alert-message h5,
.alert-message pre,
.alert-message ul,
.alert-message li,
.alert-message p {
border: none;
line-height: inherit;
background: transparent;
padding: 0;
}
/* --fs-text, not --fs-dim: the paragraph IS the message — dim measured 4.06:1 on the tint */
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
/* the body elements an alert may carry, absorbed from base/90-widgets.css (same values) */
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
/* inner table (firmware Current/Available) flattened — no nested-card look, row dividers only */
.alert-message .table { margin: var(--fs-space-2-5) 0; background: transparent; border: 0; box-shadow: none; border-radius: 0; }
/* --fs-text, not --fs-eyebrow-color: the muted ink does not survive the status tint
* (3.35-3.36:1 against AA's 4.5). */
.alert-message .table .th,
/* title-row arms (0,5,0) outrank 30-tables.css's `.table .tr.table-titles .th` (0,4,0) */
.alert-message .table .tr.table-titles .th,
.alert-message .table .tr.cbi-section-table-titles .th { color: var(--fs-text); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); }
/* column gap --fs-space-3 like every other cell; row density --fs-space-2, a dense readout */
.alert-message .table .th, .alert-message .table .td { padding-block: var(--fs-space-2); padding-inline: 0 var(--fs-space-3); background: transparent; border-color: color-mix(in srgb, var(--fs-border) 60%, transparent); }
.alert-message .table .td { color: var(--fs-text); }
/* validation tooltip: fill stays SOLID, unlike .label below — floats over arbitrary content,
* so a border alone cannot carry the status. Shadow replaces base's raw 2px halo (1.08-1.86:1). */
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
/* base reveals it on :hover alone; no tabindex in anything LuCI ships today, but a third
* party's own container may add one */
.cbi-tooltip-container:focus-within .cbi-tooltip:not(:empty) { inset-inline-start: auto; opacity: 1; }
.cbi-tooltip.info { background: var(--fs-accent); border-color: var(--fs-accent); color: var(--fs-on-accent); }
.cbi-tooltip.success { background: var(--fs-good); border-color: var(--fs-good); color: var(--fs-on-good); }
.cbi-tooltip.error { background: var(--fs-danger); border-color: var(--fs-danger); color: var(--fs-on-danger); }
.cbi-tooltip.notice { background: var(--fs-panel2); border-color: var(--fs-border); color: var(--fs-text); }
/* .label: core barely uses it, any luci-app-* may. .important is bootstrap's accent name. */
.label {
display: inline-block; padding: var(--fs-space-1) var(--fs-space-2); border-radius: var(--fs-radius-pill);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight); line-height: 1.4;
background: var(--fs-panel2); border: var(--fs-hairline);
color: var(--fs-dim); text-shadow: none; text-transform: none;
white-space: nowrap; margin-block: .125em; margin-inline: .4em 0; /* carried over from the absorbed base rule */
}
/* no background: a translucent tint under same-colour text eats contrast (3.4-4.4:1);
* inherited opaque --fs-panel2 guarantees >=4.5. */
.label.important, .label.notice { border-color: var(--fs-accent); color: var(--fs-accent); }
.label.warning { border-color: var(--fs-warn); color: var(--fs-warn); }
.label.success { border-color: var(--fs-good); color: var(--fs-good); }
.label.danger, .label.error { border-color: var(--fs-danger); color: var(--fs-danger); }
a.label:link, a.label:visited { color: var(--fs-dim); }
a.label:hover { text-decoration: none; color: var(--fs-text); }
/* section-title toggles are .label pills, clickable — SINGLE owner */
.cbi-title .label {
padding: var(--fs-space-1) var(--fs-space-3);
border: var(--fs-hairline);
color: var(--fs-dim); cursor: pointer;
letter-spacing: 0;
margin: 0; min-width: 0; box-shadow: none;
}
.cbi-title .label:hover { color: var(--fs-text); border-color: var(--fs-dim); opacity: 1; }
/* toggles section visibility — interactive, so keyboard focus must show */
.cbi-title .label:focus-visible {
outline: none; border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring);
}
/* opaque surface (--fs-panel2 inherited): accent text on --fs-accent-soft measured 4.21:1
* (theme/65-dropdown.css) — the border carries the accent instead */
.cbi-title .label.notice { opacity: 1; color: var(--fs-accent); border-color: var(--fs-accent); }
}
@@ -0,0 +1,113 @@
@layer theme {
/* one component, three LuCI selectors, no markup difference worth honouring: #tabmenu ul.tabs
* (page tabs), .tabs (view tabs), .cbi-tabmenu (CBI form tabs, the mode switcher included).
* Active marker: .active on ul.tabs, .cbi-tab on every .cbi-tabmenu flavour. */
#tabmenu { margin-bottom: 0; }
/* the card */
#tabmenu ul.tabs,
.tabs,
.cbi-tabmenu {
display: flex;
flex-wrap: wrap;
gap: var(--fs-space-1);
list-style: none;
margin: 0 0 var(--fs-card-gap);
padding: var(--fs-space-2);
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
/* zeroes the folder-tab baseline gradient (1px x 29px) instead of restating it, so a
* third-party gradient the strip carries is sized away too */
background-size: 0 0;
}
/* a strip nested in .cbi-section -> soft inset row, no frame (a card inside a card reads as a bug) */
.cbi-section .tabs,
.cbi-section .cbi-tabmenu:not(.mode):not(.map) {
background: var(--fs-panel2);
padding: var(--fs-space-1-5);
box-shadow: none;
}
#tabmenu ul.tabs > li,
.tabs > li,
.cbi-tabmenu > li {
height: auto;
margin: 0; padding: 0;
border: 0; background: none;
border-radius: var(--fs-radius);
color: var(--fs-dim);
}
/* page tabs and the mode switcher never carry a sentence-length label; ul.cbi-tabmenu.mode > li
* wins specificity over .cbi-tabmenu > li below, so mode pills stay none regardless of order */
#tabmenu ul.tabs > li,
ul.cbi-tabmenu.mode > li,
.tabs > li {
max-width: none;
}
/* CBI form tabs only, not the mode switcher: a long label overflowed its column by 28.6px at
* 320px/Large, EN measured 0 */
.cbi-tabmenu > li {
max-width: 100%;
}
#tabmenu ul.tabs > 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,
.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,
.tabs > li.active,
.cbi-tabmenu > li.cbi-tab {
color: var(--fs-on-accent);
}
#tabmenu ul.tabs > li.active > a,
.tabs > li.active > a,
.cbi-tabmenu > li.cbi-tab > a {
background: var(--fs-accent);
color: var(--fs-on-accent);
font-weight: var(--fs-weight-bold);
}
/* one ring for both states, on --fs-focus-ring-solo: base's outline is invisible on the active
* pill, whose background is the same accent. box-shadow, not outline, because the strip clips
* and a shadow is not */
#tabmenu ul.tabs > li > a:focus-visible,
.tabs > li > a:focus-visible,
.cbi-tabmenu > li > a:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring-solo);
}
/* fitTabStrips (fs-chrome.js) escalates dense1 -> dense2 on wrap; for tabs both are the same
* pill padding (gap and font-size already equal the base), so one list serves both */
#tabmenu ul.tabs:is(.fs-dense1, .fs-dense2) > li > a,
.tabs:is(.fs-dense1, .fs-dense2) > li > a,
.cbi-tabmenu:is(.fs-dense1, .fs-dense2) > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
/* 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,136 @@
@layer theme {
/* realtime graphs size their canvas from #view, clipping 34px off the newest samples — docs/css.md "Misc: the realtime graph bleed" */
#view .cbi-section > div[style]:has(> svg) {
width: auto !important;
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
}
/* same box's frame, painted BLACK inline — only colour moves; wider than the bleed above, channel_analysis.js's box must not be bled but is just as black */
#view div[style]:has(> svg) {
border-color: var(--fs-border) !important;
}
/* WCAG 1.4.1: --fs-accent on --fs-text measures 1.4:1 in dark, needs a non-colour cue in
* prose. Nav links/buttons/pills are identified by position and shape instead. */
p a, dd a,
.cbi-value-description a,
.alert-message a:not(.btn):not(.cbi-button) {
text-decoration: underline;
}
/* a label must set sans, not merely be excluded — excluded still inherits mono (227 elements/
* 20 KB of bold mono for nothing); exempt in pre/code/#syslog/textarea, monospaced content.
* `.ifacebadge` is a badge (chrome, not a value) in a mono cell; wins on specificity (0,2,0)
* over theme/60-inputs.css's (0,1,0) mono rule, not source order */
:is(strong, b):not(pre *, code *, #syslog *, textarea *),
.ifacebadge,
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
code, pre { font-weight: var(--fs-weight-normal); }
.cbi-title-ref { color: var(--fs-accent); }
.close { color: var(--fs-text); text-shadow: none; opacity: .6; }
.close:hover { opacity: 1; }
.ifacebox { background: var(--fs-panel); border-color: var(--fs-border); }
.ifacebox .ifacebox-head { background: var(--fs-panel2); color: var(--fs-text); }
/* lives in THIS layer, next to the head it overrides: the same rule in base was dead, a layer beats specificity */
.ifacebox .ifacebox-head.active { background: var(--fs-accent); color: var(--fs-on-accent); }
.ifacebadge { background: var(--fs-panel2); border-color: var(--fs-border); }
.ifacebadge-active { border-color: var(--fs-accent); }
.zonebadge-empty { border-color: var(--fs-border); color: var(--fs-dim); }
/* @container PLUMBING only, card rules live with the tables. Named: an unnamed query binds to the nearest container-type ancestor, silently re-targeted by a new one */
.cbi-map { container-type: inline-size; container-name: fs-content; }
#view { container-type: inline-size; container-name: fs-view fs-content; }
/* ui.js appends #modal_overlay outside #view, so the names above must repeat here or no dialog query resolves.
* box-shadow merged in from theme/70-modal.css: base's elevation = --fs-bg's hex in footstrap
* light, a glow not a shadow; others take --fs-shadow-pop */
.modal { container-type: inline-size; container-name: fs-view fs-content; box-shadow: var(--fs-shadow-pop); }
/* pager (« prev · text · next ») — generic, not page-scoped */
.pager.center { display: flex; align-items: center; justify-content: center; gap: var(--fs-space-2-5); padding: var(--fs-space-3-5) 0; }
.pager .text { flex: 0 0 auto; font-size: var(--fs-type); color: var(--fs-dim); }
/* absorbed from base; stock LuCI only renders h2/h3, the rest is third-party coverage.
* `legend` excluded: theme/30-tables.css owns it. */
h1, h2, h3, h4, h5, h6 {
font-weight: var(--fs-weight-bold);
color: var(--fs-text);
/* 1.5 is prose leading; a heading is a label and looks slack at it */
line-height: var(--fs-leading-tight);
}
h1 { margin-bottom: var(--fs-space-4); font-size: var(--fs-type-2xl); }
h2 { font-size: var(--fs-type-xl); }
h3 { font-size: var(--fs-type-lg); }
h4, h5 { font-size: var(--fs-type); }
h6 { font-size: var(--fs-type-xs); color: var(--fs-dim); text-transform: uppercase; }
/* heading card: h2 + .cbi-map-descr get a .cbi-section card's frame, so the page reads as a
* stack of panels rather than loose text over the background */
.cbi-map > h2,
#view > h2 {
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
margin: 0 0 var(--fs-card-gap);
padding: var(--fs-space-4);
}
/* split the card across the two siblings — no wrapper element exists to hold both */
.cbi-map > h2:has(+ .cbi-map-descr),
#view > h2:has(+ .cbi-map-descr) {
border-bottom: 0;
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
padding-bottom: var(--fs-space-1);
margin-bottom: 0;
box-shadow: none;
}
.cbi-map > h2 + .cbi-map-descr,
#view > h2 + .cbi-map-descr {
background: var(--fs-panel);
border: var(--fs-hairline);
border-top: 0;
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
margin: 0 0 var(--fs-card-gap);
padding: 0 var(--fs-space-4) var(--fs-space-4);
color: var(--fs-dim);
font-size: var(--fs-type);
line-height: var(--fs-leading);
}
/* .right holding Add/Save directly under a field had no gap: pinned the button to the textarea */
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
margin-top: var(--fs-space-2-5);
}
/* button row belonging to the PAGE TITLE, not a section — same 16px as .cbi-section, half above */
.cbi-title-buttons { margin-block: var(--fs-space-2) var(--fs-card-gap); }
/* Delete button luci-base renders for every NAMED section sat flush against what follows — one class, not one app */
.cbi-section-remove { margin-bottom: var(--fs-space-2); }
/* a view may hand the map a bare widget with no `.cbi-section`; exclusions are the children luci-base already spaces itself */
.cbi-map > *:not(.cbi-section):not(.cbi-map-descr):not(.cbi-page-actions):not(h2):not(legend):not(.cbi-tabmenu):not(.cbi-map-tabbed):not(:empty) {
margin-bottom: var(--fs-card-gap);
}
/* the disclosure chevron, one rule for both hosts: the overview card toggle (a stock element, keyed by
* attribute) and the Appearance fold (ours, keyed by class), so the two cannot drift.
* pages/20-overview.css sizes the pill around it. */
.cbi-title .label[data-indicator="poll-status"]::after,
.fs-ap-chev::after {
content: "";
width: 7px;
height: 7px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 1px;
transform: rotate(45deg); /* closed: chevron down */
margin-top: -2px; /* optical centring of the down chevron */
transition: transform var(--fs-dur-move);
}
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after,
button.fs-ap-fold[aria-expanded="true"] .fs-ap-chev::after {
transform: rotate(-135deg); /* open: chevron up */
margin-top: var(--fs-space-0-5);
}
}
@@ -0,0 +1,32 @@
@layer theme {
/* a DELTA on top of the bar's unguarded base (20-shell.css), every width — no @media floor,
* fitChrome measures instead — docs/css.md "Toplayout: measured, not media-queried". */
:root[data-layout="top"] .fs-sidebar {
/* min-height not height: a long hostname wraps the capped brand onto a second line (20-shell.css) */
min-height: var(--fs-bar-h);
gap: var(--fs-space-3);
}
/* the stacked bar (.fs-bar-stack, set by fitChrome in fs-chrome.js, not a media query) IS the
* base bar, so the delta below skips it instead of restating the base values */
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) {
/* nowrap or flexbox wraps the BAR itself instead of the menu's own pills — the signal fitChrome measures */
flex-wrap: nowrap;
/* aligns the bar to .fs-content so the brand/menu line up with the headings below */
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
}
/* flex:1 opens the gap pushing the right cluster to the column's edge */
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) ul.nav {
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
}
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) #indicators,
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) .fs-themerow,
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) .fs-logout { order: 3; margin-inline-start: 0; }
/* dropdown anchor/bridge/clamp live unguarded in 20-shell.css, not here (issue #19) */
/* placement only: the strip's own show/hide (empty, single) lives in 20-shell.css */
:root[data-layout="top"] .fs-modemenu { order: 4; flex: 1 1 100%; gap: var(--fs-space-1); }
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
}
@@ -0,0 +1,250 @@
@layer theme {
/* buttons straight in a field (Enable/Disable, Start/Restart/Stop) only get whitespace
* spacing inline-block, collapsing rows on wrap — wrapping flex with a real gap instead */
.cbi-value-field:has(> .cbi-button) {
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
}
/* native input types listed on purpose: LuCI also emits <input type=submit|reset|button>
* (sysauth, luci-compat cbi/*.htm), mostly with .cbi-button; luci-app-splash emits bare ones.
* Base only gives those width/height: auto — coverage is a contract (docs/gallery.html) */
.cbi-button, .btn,
input[type="button"], input[type="submit"], input[type="reset"] {
display: inline-flex; align-items: center; justify-content: center;
/* auto, stated: theme/60-inputs.css's generic input box says 210x32px, a base reset would lose to it by layer */
width: auto; height: auto;
min-height: var(--fs-ctl-h);
padding: var(--fs-space-2) var(--fs-space-3-5);
font-family: var(--fs-font-sans); font-weight: var(--fs-weight); line-height: 1.1;
color: var(--fs-text);
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
box-shadow: none; text-shadow: none;
/* border-COLOR not the shorthand, which also animates width — a layout animation on every hover */
transition: var(--fs-field-transition);
}
/* bare `button` rides along: harmless on a border:0 box, meaningful once an app gives it one */
.cbi-button:hover, .btn:hover, button:hover,
input[type="button"]:hover, input[type="submit"]:hover, input[type="reset"]:hover { border-color: var(--fs-dim); }
/* keyboard focus only: box-shadow:none above kills any stray glow, plain mouse :focus shows
* nothing on purpose — the pressed cue below already answers a click */
.cbi-button:focus-visible, .btn:focus-visible,
input[type="button"]:focus-visible, input[type="submit"]:focus-visible, input[type="reset"]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
/* solid variants sit ON --fs-accent/-good/-danger, an accent-tinted ring is invisible against
* them — ring in the surface colour with a solid accent halo (--fs-focus-ring-solo) instead */
.cbi-button-positive:focus-visible, .cbi-button-action:focus-visible,
.cbi-button-negative:focus-visible, .cbi-button-remove:focus-visible,
.cbi-button-apply:focus-visible, .cbi-button-save:focus-visible,
.btn.primary:focus-visible, .btn.danger:focus-visible,
.btn.error:focus-visible, .btn.success:focus-visible, .btn.info:focus-visible {
outline: none;
border-color: transparent;
box-shadow: var(--fs-focus-ring-solo);
}
/* the sole copy of the pressed inset shadow (base's identical one was absorbed) */
.cbi-button:active, .btn:active, .btn.active,
input[type="button"]:active, input[type="submit"]:active, input[type="reset"]:active {
box-shadow: inset 0 2px 4px color-mix(in srgb, var(--fs-border) 40%, transparent);
}
/* three fills, each shared by `.cbi-button-*` (LuCI's own views) and `.btn.<colour-word>` (third-party
* apps), or a recolour lands on one half and not the other.
* The `.important` twins are needed: at 0,2,0 they beat the neutral `input[type=button]` paint (0,1,1)
* for dockerman's `<input type=button class="cbi-button-action important">`. */
.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);
}
/* the lift is for the fills above plus the page-action Save & Apply: brightness deepens a solid
* fill and washes out an outline or a tint, so neutral Apply/Save keep their hairline.
* `.td .cbi-button-apply` is the one outline that does lift. */
.cbi-button-positive:hover, .btn.success:hover,
.cbi-button-action:hover, .btn.primary:hover, .btn.info:hover,
.cbi-button-negative:hover, .cbi-button-remove:hover, .btn.danger:hover, .btn.error:hover,
.cbi-page-actions .cbi-button-apply:hover, .td .cbi-button-apply:hover {
filter: var(--fs-hover-lift); border-color: transparent;
}
.cbi-button-reset, .cbi-button-link { background: transparent; border-color: var(--fs-border); color: var(--fs-dim); }
/* .btn owns size words in full now: the plain rule above re-sets padding in a later
* layer, so a size word carrying only font-size (base's old copy) got default padding */
.btn.large { font-size: var(--fs-type-lg); min-height: var(--fs-ctl-h-lg); padding: var(--fs-space-2) var(--fs-space-4); }
.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); }
/* size only: ui.changes puts .alert-message on the apply/rollback modal itself, so a paint here
* (0,2,0, after the fills) turned 'Apply unchecked' into the same panel2 grey as 'Cancel' */
.alert-message .btn, .alert-message .cbi-button {
border-radius: var(--fs-radius); padding: var(--fs-space-2) var(--fs-space-3);
}
.alert-message .btn:not(.cbi-button-positive, .cbi-button-action, .cbi-button-negative, .cbi-button-remove,
.primary, .info, .success, .danger, .error):hover { border-color: var(--fs-accent); color: var(--fs-accent); }
/* Save/Apply footer: base renders a tall half-empty plate with bottom-only radius, made a compact rounded bar */
.cbi-page-actions {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: var(--fs-space-2) 0;
margin: 0 0 var(--fs-card-gap);
text-align: end;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--fs-space-2);
/* WRAP the bar rather than let items shrink into it: a no-wrap row put the whole squeeze on
* the Save & Apply split control's caption (measured up to 119px cut at 320px/Large) instead
* of sharing it — docs/css.md "Buttons: the page-action bar wraps, not shrinks" has the
* before/after numbers. */
flex-wrap: wrap;
}
.cbi-page-actions .cbi-button,
.cbi-page-actions .btn {
min-height: var(--fs-ctl-h);
padding: 0 var(--fs-space-5);
border-radius: var(--fs-radius);
font-weight: var(--fs-weight);
vertical-align: middle;
}
/* Reset takes Save's same ladder step (-soft/-fill) off the DANGER role, since it discards
* unsaved changes; label is --fs-text for the same over-tint reason as Save's below. */
.cbi-page-actions .cbi-button-reset {
background: var(--fs-danger-soft);
border: 1px solid var(--fs-danger-line);
color: var(--fs-text);
}
.cbi-page-actions .cbi-button-reset:hover {
background: var(--fs-danger-fill);
border-color: var(--fs-danger-line-hi);
}
/* no `.spinning` padding ladder here, and it must not come back — base/95-luci.css puts the
* glyph IN THE FLOW instead, reserving its own space (the ladder lost twice: issues #15, #22) */
input[type="file"]::file-selector-button {
margin-inline-end: var(--fs-space-3); padding: var(--fs-space-2) var(--fs-space-3-5);
font-family: var(--fs-font-sans); font-size: var(--fs-type); font-weight: var(--fs-weight);
color: var(--fs-text); background: var(--fs-panel2);
border: var(--fs-hairline); border-radius: var(--fs-radius-sm); cursor: pointer;
}
input[type="file"]::file-selector-button:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
/* row-action buttons (Edit/Delete/Start-Stop): neutral outline, variants colour the ones that
* carry meaning. Neutral :hover comes FIRST — after it, it would grey the coloured variants
* back out on hover at equal specificity. 32px clears WCAG 2.5.8's 24px target/spacing rule;
* 28px did not (adjacent 24px circles overlapped). */
.cbi-section-actions .cbi-button, .cbi-section-actions .btn,
.cbi-section-table .cbi-button {
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); font-size: var(--fs-type);
/* flex-basis: auto, not a definite basis: with one, the automatic minimum resolves to 0 in
* every row and flex-grow divides by count instead of by need — docs/css.md "Buttons:
* `flex-basis: auto`, not the definite basis base gave every row action" has the
* regression this fixed and the measured widths before/after. */
flex-basis: auto;
}
/* a ComboButton is a widget: its open list is a child of the box, and its label ellipsizes in its own <li> */
:is(.cbi-section-actions .cbi-button, .cbi-section-actions .btn, .cbi-section-table .cbi-button):not(.cbi-dropdown) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.td .cbi-button, .cbi-section-actions .cbi-button, .cbi-section-table .cbi-button {
background: transparent;
border: var(--fs-hairline); color: var(--fs-dim);
}
.td .cbi-button:hover { border-color: var(--fs-dim); color: var(--fs-text); }
.td .cbi-button-action, .td .cbi-button-edit, .td .cbi-button-apply,
.cbi-section-actions .cbi-button-edit, .cbi-section-actions .cbi-button-action {
color: var(--fs-accent); border-color: var(--fs-accent-line); background: transparent;
}
/* hover ink is --fs-text, not the role colour: text of colour C on a tint of C eats its own
* contrast (Save: accent on accent-soft = 4.25:1, AA fail) — role reads from fill + border */
.td .cbi-button-action:hover, .td .cbi-button-edit:hover, .td .cbi-button-apply:hover,
.cbi-section-actions .cbi-button-action:hover {
background: var(--fs-accent-soft); color: var(--fs-text);
border-color: var(--fs-accent-line-hi);
}
/* an enabled/positive toggle in a table cell (System -> Startup) rendered blue text on a green fill = unreadable; green outline instead */
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
}
/* without this border-colour the positive toggle read less responsive than its hover neighbours (which brighten to -line-hi) */
.td .cbi-button-positive:hover, .cbi-section-actions .cbi-button-positive:hover {
background: var(--fs-good-soft); color: var(--fs-text);
border-color: var(--fs-good-line-hi);
}
.td .cbi-button-negative, .td .cbi-button-remove,
.cbi-section-actions .cbi-button-remove, .cbi-section-actions .cbi-button-negative {
color: var(--fs-danger); border-color: var(--fs-danger-line); background: transparent;
}
.td .cbi-button-negative:hover, .td .cbi-button-remove:hover,
.cbi-section-actions .cbi-button-negative:hover {
background: var(--fs-danger-soft); color: var(--fs-text);
border-color: var(--fs-danger-line-hi);
}
/* Save & Apply is THE primary action, Save demoted so only one reads primary */
.cbi-page-actions .cbi-button-apply,
.cbi-page-actions .cbi-dropdown .cbi-button-apply {
background: var(--fs-accent);
border: 1px solid transparent;
color: var(--fs-on-accent);
text-shadow: none;
}
/* a TINT at rest, not a bare outline, or Save reads disabled beside the solid Save & Apply;
* -soft/-fill off the role ladder. Label --fs-text, not --fs-accent: accent on accent-soft
* measures 4.25:1, an AA failure (docs/design-system.md). */
.cbi-page-actions .cbi-button-save,
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
background: var(--fs-accent-soft);
border: 1px solid var(--fs-accent-line);
color: var(--fs-text);
text-shadow: none;
}
.cbi-page-actions .cbi-button-save:hover {
background: var(--fs-accent-fill);
border-color: var(--fs-accent-line-hi);
}
/* buttons that are part of a compound field widget */
.cbi-dynlist > .add-item > button,
.cbi-dynlist > .add-item > .cbi-button-add {
/* min-width was a bare 38px, the number --fs-ctl-h already states — a restated value is the drift this ladder exists to prevent */
flex: 0 0 auto; width: auto; min-width: var(--fs-ctl-h);
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); margin: 0;
border: 0; border-inline-start: var(--fs-hairline);
border-radius: 0;
background: transparent; color: var(--fs-dim);
}
.cbi-dynlist > .add-item > button:hover,
.cbi-dynlist > .add-item > .cbi-button-add:hover { color: var(--fs-accent); }
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral {
border: 0; border-radius: 0; margin: 0;
background: transparent; color: var(--fs-dim);
padding: 0 var(--fs-space-3); min-height: var(--fs-ctl-h);
border-inline-start: var(--fs-hairline);
}
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral:hover { color: var(--fs-accent); }
/* SQUARE, ON THE LADDER: the only buttons once set in bare pixels (40x34, 34 being a height in
* no scale) and opted out of the Density axis; square because the glyph is an arrow, not a label */
.pager .btn.prev, .pager .btn.next {
flex: 0 0 auto; width: var(--fs-ctl-h); min-width: var(--fs-ctl-h); height: var(--fs-ctl-h); padding: 0;
border: var(--fs-hairline);
}
.pager .btn.prev:hover, .pager .btn.next:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
}
@@ -0,0 +1,532 @@
@layer theme {
/* baseline, not center: a taller field (help sentence under the control) would push a
* centered label off the control's first line */
.cbi-value { justify-content: space-between; gap: var(--fs-space-4); padding: var(--fs-space-2) 0; margin: 0; border-bottom: var(--fs-hairline); }
/* stacked controls need a gap, a raw <br> collapses it; password-reveal excluded, must stay seamless */
.cbi-value-field > .control-group { margin-top: var(--fs-space-2); }
.cbi-value-field > .control-group:not(:has(.cbi-input-password)) {
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center;
}
/* hairline separates a row from the NEXT one, so it drops whenever none follows, not only at the section's end */
.cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; }
/* .cbi-checkbox has no text baseline (synthesised at its bottom edge, floats ~5px above the label) — centred, except with a help sentence under it (see the baseline note above) */
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
align-items: center;
}
/* ONLY the transition here, shared by every field box below (`textarea` included); typography stays in base/30-forms.css. border-COLOR not the shorthand, which also animates width */
input, button, textarea {
transition: var(--fs-field-transition);
}
.cbi-value-title {
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
/* stated not inherited: this is TEXT, the control rule hands labels line-height: normal (measured 18px -> normal without this) */
line-height: var(--fs-leading);
}
/* mono is for VALUES: buttons, the help sentence and `<strong>` LABELS excluded — a label
* fed to mono also goes bold, 227 elements/20 KB of face for nothing. theme/45-misc.css's
* label rule must ASSIGN sans; excluding `<strong>` here alone still inherits mono */
.cbi-value-field,
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
/* not the generic input's fixed 210px (above): mono + padded --fs-space-3, 210px clipped a URL mid-domain (issue #5); elastic, capped to the dynlist's ceiling */
.cbi-value-field input[type="text"],
.cbi-value-field input[type="url"],
.cbi-value-field input[type="search"] { width: 100%; max-width: var(--fs-field-max); }
/* base assumes ONE title+field pair; stack full-width on a SECOND title (not a second field — a runtime-appended field would re-stack under the user's finger, measured: a button moved 31px) */
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-title,
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-field {
flex: 1 1 100%;
margin-inline-start: 0;
text-align: start;
}
.td > input[type="text"],
.td > input[type="password"],
.td > select,
.td > .cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-dynlist > .add-item > .cbi-dropdown,
.cbi-section-create > * > input { width: 100%; }
/* --- live components that bypassed the theme --- */
/* a CBI row is <div class="cbi-value"> on a form, <td class="cbi-value-field"> in a table — same <var>, theme had claimed only the table half */
div.cbi-value var, .td.cbi-value-field var { color: var(--fs-accent); }
.td.cbi-value-field var.cbi-tooltip-container { border-bottom-color: var(--fs-accent); }
.cbi-filebrowser { border-color: var(--fs-border); background: var(--fs-panel); border-radius: var(--fs-radius-lg); }
.cbi-filebrowser > ul > li:hover { background: var(--fs-panel2); }
.cbi-filebrowser .upload { border-top-color: var(--fs-border); }
/* base sizes a row by proportion for its own ~20px buttons; this theme's 36px button (3 per
* row) does not fit at 23% and `overflow: hidden` clips the corners — size actions to content,
* let the name ellipsize, wrap when that is not enough */
.cbi-filebrowser > * { border-bottom-color: var(--fs-border); padding: var(--fs-space-1-5) var(--fs-space-2); }
.cbi-filebrowser > ul { display: flex; flex-direction: column; gap: var(--fs-space-1); }
.cbi-filebrowser > ul > li { align-items: center; flex-wrap: wrap; gap: var(--fs-space-2); }
.cbi-filebrowser > ul > li > div:first-child { flex: 1 1 auto; min-width: 0; }
/* `0 1 auto`: may wrap but not shrink below min-content, or it sticks out (measured 23px past the box at 320px before min-width: 0) */
.cbi-filebrowser > ul > li > div:last-child {
flex: 0 1 auto; min-width: 0;
display: flex; flex-wrap: wrap; gap: var(--fs-space-2); justify-content: flex-end;
}
.cbi-filebrowser > ul > li > div:last-child > button { margin: 0; }
.cbi-filebrowser .upload > div > input { width: 100%; }
/* Form widgets — toggles, file input. Applies to every CBI form. */
/* the shared checkbox rule below gives the input a -4px margin (16px box in a 24px hit area, Normal
* density), so inside a .btn label the mark sat flush against the text; .5em restores the gap.
* The toggle switch is unaffected: its own margin:0 wins on a different ancestor */
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
/* checkbox -> toggle switch, drawn on the INPUT — docs/css.md "Inputs: the toggle switch,
* drawn on the input" has the forum regression this replaced. density-scaled local tokens. */
/* overflow-wrap: anywhere for the one caption word too long even at full width (pseudo-loc: a
* 14-letter Russian compound grown to 20 pushed the row 24.6px past the field at 320px/Large) */
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
/* label[for] stays in the DOM but draws nothing: out of flow, out of the flex item count, so gap opens only on the switch input + an optional caption */
.cbi-checkbox > label[for] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
.cbi-checkbox > input[type="checkbox"] {
--sw-w: calc(40px * var(--fs-density-box));
--sw-h: calc(22px * var(--fs-density-box));
--sw-knob: calc(16px * var(--fs-density-box));
--sw-pad: calc(2px * var(--fs-density-box));
--sw-travel: calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px);
/* appearance/--bd-color/--fg-color come from the shared input[type=checkbox] rule below;
* flex-shrink: 0 keeps the pill at its width beside a caption (docs/css.md "Inputs: the toggle switch, drawn on the input, not the label") */
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);
}
/* hit-target overlay to 44x30 (WCAG 2.5.8 floor 24px, pill is 22px); inset stays literal, not
* density-scaled — a floor that shrinks with Density is not one */
.cbi-checkbox > input[type="checkbox"]::before { inset: -4px -2px; }
.cbi-checkbox > input[type="checkbox"]::after {
top: var(--sw-pad); inset-inline-start: var(--sw-pad);
width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%;
background: var(--fs-dim);
/* transform not `left`: left re-lays out the row every frame, translateX is composited */
transform: translateX(0);
transition: transform var(--fs-dur-move), background var(--fs-dur);
}
.cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
/* derived so it cannot drift from the sizes above; -2px is the pill's two 1px borders (the knob sits against the padding box, so it has 2px less room — travel is 18px not 20) */
.cbi-checkbox > input[type="checkbox"]:checked::after {
transform: translateX(var(--sw-travel));
background: var(--fs-on-accent);
}
/* the knob starts at the inline-start edge, so RTL travels the other way (translateX is physical) */
:root[dir="rtl"] .cbi-checkbox > input[type="checkbox"]:checked::after { transform: translateX(calc(var(--sw-travel) * -1)); }
/* the input IS the switch, ring goes here; -solo: checked switch already spends its border channel on accent, only the tinted shadow shows */
.cbi-checkbox > input[type="checkbox"]:focus-visible {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring-solo);
}
.cbi-checkbox > input[type="checkbox"]:disabled { cursor: not-allowed; } /* opacity comes from base/30-forms.css input[disabled] */
/* raw checkbox/radio, absorbed from base: undoes the generic input box below. 16px box on the
* grid, mark inset FROM it. `.cbi-checkbox > input` also matches the switch's own input
* (unscoped, it wants this rule's appearance:none) — selectors that WOULD paint the switch
* exclude it individually below instead. */
input[type="checkbox"],
input[type="radio"] {
--bd-color: var(--fs-border);
--fg-color: var(--fs-text);
/* box tier not space tier: --fs-space-4 gave 16px at Normal but 10.4px at Compact (smaller than any control) and never grew at Large */
--box: calc(16px * var(--fs-density-box));
--mark-inset: 3px;
/* WCAG 2.5.8's 24px spacing floor holds only while the theme owns the layout; ELEMENT is
* 24px (drawing stays 16px, centred), max() so it never shrinks under --box at Large */
--hit: max(24px, var(--box));
appearance: none;
width: var(--hit);
height: var(--hit);
/* negative margin pulls the extra hit area back out of flow (without it, rows measured 42px -> 51px) */
margin: calc((var(--box) - var(--hit)) / 2);
color: var(--fg-color);
position: relative;
display: inline-block;
cursor: pointer;
background: none;
border: none;
/* padding/border-radius not reset: land on an appearance-none box whose pseudo-elements are absolute, invisible either way */
}
input[type="checkbox"]::before,
input[type="checkbox"]::after,
input[type="radio"]::before,
input[type="radio"]::after {
position: absolute;
content: "";
}
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
input[type="radio"]::before {
/* inset:0; margin:auto centres a fixed box the same in RTL as LTR; a translate pair would not */
inset: 0;
margin: auto;
width: var(--box);
height: var(--box);
background: linear-gradient(var(--fs-panel), var(--fs-panel2));
border: 1px solid var(--bd-color);
transition: var(--fs-field-transition);
}
input[type="checkbox"]::before { border-radius: 2px; }
input[type="radio"],
input[type="radio"]::before {
border-radius: 50%;
}
/* keyboard/click focus ring on the DRAWN box, the theme's one ring token */
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
input[type="radio"]::after {
/* mark inset from the DRAWING, which is centred in the target, not from the target's own edge */
inset: 0;
margin: auto;
width: calc(var(--box) - var(--mark-inset) * 2);
height: calc(var(--box) - var(--mark-inset) * 2);
}
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
input[type="radio"]:checked::after {
/* OURS: stroked path (this theme's icon language), replacing a fill byte-identical to luci-theme-bootstrap's; mask keys on alpha so a stroke masks like a fill did */
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");
-webkit-mask: var(--checkmark-icon) center/cover no-repeat;
mask: var(--checkmark-icon) center/cover no-repeat;
background: var(--fg-color);
}
input[type="radio"]:checked:after {
/* radio dot filled, not ringed — a ring reads as "not selected" */
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6.5' fill='%23000'/></svg>");
}
input[type="checkbox"].cbi-input-invalid,
input[type="radio"].cbi-input-invalid {
--bd-color: var(--fs-danger);
--fg-color: var(--fs-danger);
}
/* generic field box, absorbed from base; bare input keeps the historical 210px fallback for types the list below does not name */
input {
display: inline-block;
width: 210px;
height: var(--fs-control-h);
padding: var(--fs-space-1) var(--fs-space-2);
background: var(--fs-panel);
color: var(--fs-text);
font-size: var(--fs-type-field);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
}
/* base's old #localtime{width:300px} media rule not moved here: the elastic field width above already out-layers it (measured 440px, not 300) */
input[type="file"] {
color: var(--fs-dim); font-size: var(--fs-type);
padding: initial;
border: initial;
line-height: initial;
box-shadow: none;
/* no !important: width:auto out-specifies the generic input{width:210px} alone */
width: auto;
}
/* native <select> chevron + theme; the chevron is --fs-select-chevron (two gradient bands), the RTL rule below mirrors its position */
select, select.cbi-input-select, .cbi-value-field select, .cbi-select:not(select) {
/* unprefixed only, floor clears it everywhere — docs/css.md "The browser floor" */
appearance: none;
background-color: var(--fs-panel2);
background-image: var(--fs-select-chevron);
/* right inset is the scale step, not a literal 12; the .cbi-select shell shares this rule */
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-block: var(--fs-space-2); padding-inline: var(--fs-space-3) var(--fs-space-8);
border: var(--fs-hairline); border-radius: var(--fs-radius);
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type-field);
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
width: 210px;
display: inline-block;
transition: var(--fs-field-transition);
}
/* background-position has no logical keyword, so it is mirrored by hand; the two bands swap offsets
* to keep the chevron's left-leaning half on the left. The padding above is logical and follows. */
:root[dir="rtl"] :is(select, .cbi-select:not(select)) {
background-position: left calc(var(--fs-space-3) + 5px) center, left calc(var(--fs-space-3) - 1px) center;
}
/* height:inherit so the multiple/listbox variant does not take the closed control's one-line height */
select[multiple] { height: inherit; }
option { font-size: 100%; line-height: 2em; }
select option { background: var(--fs-panel); color: var(--fs-text); }
/* the OS renders the native list, so `select option` above is mostly ignored; Chromium's
* base-select renders it in the DOM instead, themeable like .cbi-dropdown; @supports-gated,
* scoped to .cbi-value-field so a third-party app's raw <select> stays native */
@supports (appearance: base-select) {
.cbi-value-field select { appearance: base-select; }
.cbi-value-field select::picker-icon { display: none; }
::picker(select) {
appearance: base-select;
background: var(--fs-panel); border: var(--fs-hairline);
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
padding: var(--fs-space-1-5); margin-top: var(--fs-space-1-5);
}
.cbi-value-field select option {
padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm); font-size: var(--fs-type);
/* background:transparent undoes `select option`'s --fs-panel so the row's own hover shows */
background: transparent; min-height: 0;
}
.cbi-value-field select option::checkmark { display: none; }
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
.cbi-value-field select option:checked {
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
}
}
/* .cbi-select — legacy luci-compat shell around a native <select>; it takes the themed select rule above and
* keeps only its box. :not(select): the class also appears on a bare <select>, which cannot draw the ::before chevron */
.cbi-select:not(select) {
/* height not min-height: the child's height:100% falls back to content height against an auto-height parent (measured collapse 30px -> 15px in a 38px shell) */
height: var(--fs-ctl-h);
padding: 0;
position: relative;
/* the shell draws no text; the shared rule's field size belongs to its select child */
font-size: var(--fs-type);
}
/* base/30-forms.css only dims the old ▾ plate; with the plate gone the disabled state had no visual, so the shell carries it now */
.cbi-select[disabled]:not(select) {
opacity: var(--fs-disabled-opacity);
pointer-events: none;
}
/* invisible, filling the shell, clearing the plate by the same --fs-space-8 inset the themed select uses */
.cbi-select select,
.cbi-select select:focus {
appearance: none;
outline: none;
border: none;
background: transparent;
box-shadow: none;
height: 100%;
min-height: 0;
width: 100%;
padding-block: 0; padding-inline: var(--fs-space-3) var(--fs-space-8);
font-size: var(--fs-type-field);
line-height: normal;
cursor: pointer;
}
/* :where() keeps this at 0,1,0: a bare :not() would be 0,2,0 and beat `.alert-message p` (0,1,1).
* A button keeps its own label ink while it spins; dim on a solid fill measured 1.00-2.24:1. */
.spinning:where(:not(.btn, .cbi-button)), .cbi-value em, em.spinning { color: var(--fs-dim); }
/* type list must be EXHAUSTIVE: an omission falls back to the generic `input` box above, beside a themed one, not to "unstyled" */
.cbi-input-text, .cbi-input-password, .cbi-input-textarea,
.cbi-section-create-name, .cbi-value-field input.create-item-input,
input[type="text"], input[type="password"], input[type="number"],
input[type="email"], input[type="url"], input[type="search"],
input[type="tel"], input[type="date"], input[type="time"],
input[type="datetime-local"], input[type="month"], input[type="week"],
input[type="color"],
textarea {
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
color: var(--fs-text);
padding: var(--fs-space-2) var(--fs-space-3);
min-height: var(--fs-ctl-h);
box-shadow: none;
}
/* max-width:100% beside the fixed 210px: a textarea's intrinsic width comes from `cols` (default 20), which put two textareas 19px outside their card at 300px before this */
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type-field); }
/* a square native scrollbar is not clipped by a rounded corner; self-rounded thumb + transparent track fixes it. no scrollbar-width/-color: either switches Chromium to the square standard bar */
textarea::-webkit-scrollbar { width: 16px; height: 16px; }
textarea::-webkit-scrollbar-track { background: transparent; margin: var(--fs-space-4) 0 var(--fs-space-2-5); }
textarea::-webkit-scrollbar-thumb {
background: var(--fs-dim); border-radius: var(--fs-radius-pill);
border: 5px solid transparent; background-clip: padding-box;
}
textarea::-webkit-scrollbar-thumb:hover { background: var(--fs-text); }
textarea::-webkit-scrollbar-corner { background: transparent; }
/* accent arc traces the frame's own radius (two borders shrunk to 16px - radius); mirrored by
* hand below, ::-webkit-resizer ignores logical properties */
textarea::-webkit-resizer {
background: transparent;
border-right: 2px solid var(--fs-accent);
border-bottom: 2px solid var(--fs-accent);
border-bottom-right-radius: var(--fs-radius);
margin: calc(16px - var(--fs-radius)) 0 0 calc(16px - var(--fs-radius));
}
:root[dir="rtl"] textarea::-webkit-resizer {
border-right: 0; border-left: 2px solid var(--fs-accent);
border-bottom-right-radius: 0; border-bottom-left-radius: var(--fs-radius);
margin: calc(16px - var(--fs-radius)) calc(16px - var(--fs-radius)) 0 0;
}
.cbi-input-text:hover, .cbi-input-password:hover, .cbi-input-textarea:hover,
input[type="text"]:hover, input[type="password"]:hover, input[type="number"]:hover,
input[type="email"]:hover, input[type="url"]:hover, input[type="search"]:hover,
textarea:hover { border-color: var(--fs-dim); }
/* fallback ring for untyped inputs; excludes the toggle switch, whose :focus-visible-only ring must not double up */
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
.cbi-section-create-name:focus,
input:focus:not(:where(.cbi-checkbox > input)),
input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus,
input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus,
input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus,
textarea:focus,
select:focus,
.cbi-dynlist > .item:focus {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
outline: none;
}
/* same ring, shared by 4 selectors that cannot join the list above; :is() around :has() keeps the list forgiving — unwrapped, a :has() compound sharing a list fails the WHOLE rule below the floor */
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before,
.cbi-select.focus,
.cbi-dynlist > .add-item:focus-within,
.control-group:is(:has(> .cbi-input-password)):focus-within {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
/* .cbi-value-error input (Lua-CBI) must live here: base's red border-color is a longhand and
* this rule's border shorthand wipes it by layer regardless of specificity.
* The same rejected value shown by a ui.Dropdown: base carried the widget and its closed <li>, theme only the input half.
* The enhanced select's validator fails on the hidden <select>, so its widget (the next sibling) is matched through it;
* (0,5,1) beats 65-dropdown's border shorthand (0,3,0), its hover (0,4,0) and its focus ring (0,2,0); the closed <li> takes the border only, the ring sits on the widget */
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,
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
select.cbi-input-invalid[data-fs-select="1"] + .cbi-dropdown:not(.btn):not(.cbi-button) {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
/* doubled [readonly] states intent at (0,2,1): at (0,1,1) it only tied the typed input rules above and silently lost */
:is(input, select, textarea)[readonly][readonly] {
border-color: color-mix(in srgb, var(--fs-border) calc(var(--fs-disabled-opacity) * 100%), transparent);
pointer-events: auto;
cursor: auto;
}
/* keep base's layout (inline-flex chips + add-item row); only colours, rounding, a compact + button, narrow cap so a chip's × sits near its text */
.cbi-dynlist {
gap: var(--fs-space-1-5);
background: transparent; border: 0; padding: 0;
/* min(240px,100%) not a bare 240: a floor that exceeds the room is an overflow (measured 29px outside a 210px field at 320px) */
min-height: 0; min-width: min(240px, 100%); max-width: var(--fs-field-max); width: 100%;
vertical-align: top;
}
.cbi-dynlist > .item {
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-sm);
color: var(--fs-text);
box-shadow: none;
margin: 0;
transition: var(--fs-field-transition);
}
.item::after {
display: inline-block;
cursor: pointer;
background: linear-gradient(var(--fs-panel), var(--fs-panel) 25%, var(--fs-panel2));
padding: 0 var(--fs-space-3);
color: var(--fs-text);
line-height: 2em;
border: var(--fs-hairline);
border-radius: var(--fs-radius);
transition: var(--fs-field-transition);
}
.item:hover::after { filter: var(--fs-hover-lift); }
.cbi-dynlist > .item::after {
display: inline-flex;
padding: 0 var(--fs-space-2);
color: var(--fs-danger);
/* logical: base pins the × with inset-inline-end, so in RTL the rounded corners must follow it */
border-radius: 0;
border-start-end-radius: var(--fs-radius-sm);
border-end-end-radius: var(--fs-radius-sm);
}
.cbi-dynlist > .add-item {
align-items: stretch;
background: var(--fs-panel2); border: var(--fs-hairline);
border-radius: var(--fs-radius-sm); overflow: hidden;
transition: var(--fs-field-transition);
}
.cbi-dynlist > .add-item > input,
/* width:auto keeps the flex basis in charge — this input sits inside .cbi-value-field, the elastic width above would otherwise hand it 100% on top of flex:1 1 auto */
.cbi-dynlist > .add-item > .cbi-input-text {
flex: 1 1 auto; min-width: 0; width: auto;
border: 0; background: transparent;
box-shadow: none; min-height: var(--fs-ctl-h-sm); border-radius: 0;
}
/* UIDynamicList drop indicator (ui.js puts .drag-over on the dragenter target): (0,4,0) out-ranks the border
* shorthands above and in 55-buttons; [draggable] (ui.js sets it on every .item) lifts the item past .item:focus (0,3,0) */
.cbi-dynlist > .item[draggable].drag-over,
.cbi-dynlist > .add-item > :is(.cbi-input-text, .cbi-button-add).drag-over {
border-top: 1px solid var(--fs-text);
}
/* input + button as ONE seamless group; gap:0 is load-bearing — base gives every .control-group a gap, this is the one whose members must touch */
.control-group:has(> .cbi-input-password) {
display: inline-flex; align-items: stretch; gap: 0;
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
overflow: hidden;
transition: var(--fs-field-transition);
}
.control-group:has(> .cbi-input-password) > .cbi-input-password {
border: 0; border-radius: 0;
background: transparent; box-shadow: none;
min-height: var(--fs-ctl-h);
}
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
/* `.hidden.hidden` beats component rules up to (0,3,0) that come earlier in source, without
* !important (which would invert layer order); it also covers the Enter-key submit legacy Lua
* pages carry (issue #12). The @container table rules and fs-stacked actions outrank it.
* :not([class*=":"]) excludes a Tailwind/UnoCSS class fs-sheets.js re-hosts into this layer —
* docs/css.md "Inputs: `.hidden.hidden`, and the Tailwind/UnoCSS exception". */
.hidden.hidden:not([class*=":"]) { display: none; }
/* the WCAG 2.5.8 target (24px min) split from the 6px track — docs/css.md "Inputs: the range
* slider's hit target and its line-box" has the two-part fix and its numbers */
input[type="range"] {
appearance: none;
width: 100%; height: 24px; min-height: 0; padding: 0;
margin-block: -9px;
background: transparent; border: 0; border-radius: 0;
outline: none; box-shadow: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-moz-range-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel);
cursor: pointer;
/* WebKit centres on the INPUT's box not the track: (6-16)/2 = -5px */
margin-top: -5px;
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel); cursor: pointer;
}
input[type="range"]:focus::-webkit-slider-runnable-track { box-shadow: var(--fs-focus-ring-solo); }
input[type="range"]:focus::-moz-range-track { box-shadow: var(--fs-focus-ring-solo); }
/* same ceiling as the elastic text inputs; vertical-align:middle decouples the line-box from the input's own edge, which moved 6px -> 24px above */
.cbi-range-slider { display: inline-flex; align-items: center; vertical-align: middle; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
.cbi-range-slider-value {
background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
padding: var(--fs-space-1) var(--fs-space-2); color: var(--fs-accent); font-family: var(--fs-font-mono);
/* not 600: no bold mono face bundled, drags in 20 KB or fakes it with synthetic emboldening */
font-weight: var(--fs-weight-normal); font-size: var(--fs-type); white-space: nowrap;
}
.cbi-range-slider-calc, .cbi-range-slider-calc-units { color: var(--fs-dim); font-size: var(--fs-type); }
}
@@ -0,0 +1,387 @@
@layer theme {
/* generic .dropdown-menu popup: nothing in luci-base emits it — coverage vocabulary, since
* stock luci-theme-bootstrap styles it and a third-party app may emit it */
.dropdown-menu { background-color: var(--fs-panel); border-color: var(--fs-border); }
.dropdown-menu a { color: var(--fs-text); text-shadow: none; }
.dropdown-menu a:hover,
.dropdown-menu a:focus-visible { background: var(--fs-panel2); color: var(--fs-accent); }
/* the outer div IS the button, class = ui.js's SELECTED mode; unguarded this erased that
* variant's fill while its ink survived — measured page-on-page at 1.06-1.13:1. */
.cbi-page-actions .cbi-dropdown:not(.btn):not(.cbi-button) {
border: 0; border-radius: var(--fs-radius); background: transparent;
}
/* Apply is the one variant with no fill of its own (theme/55-buttons.css gives the rest theirs); painted by specificity, not source order */
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
.cbi-page-actions .cbi-dropdown > .cbi-button {
background: var(--fs-accent);
border: 0;
color: var(--fs-on-accent);
}
.cbi-page-actions .cbi-dropdown.cbi-button-apply:hover,
.cbi-page-actions .cbi-dropdown > .cbi-button:hover { filter: var(--fs-hover-lift); }
/* ---- cbi-dropdown widget: the JS select used across all forms ---- */
/* machinery absorbed from base, driven by ui.js attributes; the state machine below is
* UNFLAGGED, stating openly what upstream's !important flags resolved — docs/css.md
* "Dropdown: the display state machine, unflagged" has the specificity table. */
.cbi-dropdown {
display: inline-flex;
cursor: pointer;
height: auto;
position: relative;
padding: 0;
}
.cbi-dropdown img {
/* constrain a vector icon of any intrinsic size to the text line */
max-height: 1.6em;
width: auto;
}
.cbi-dropdown > ul {
margin: 0;
padding: 0;
list-style: none;
overflow-x: hidden;
overflow-y: hidden;
display: flex;
width: 100%;
}
/* clears the button's own side inset so the label starts on its text, unflagged (0,3,1) over the rule above's (0,1,1) */
.cbi-dropdown.btn > ul:not(.dropdown),
.cbi-dropdown.cbi-button > ul:not(.dropdown) {
margin-block: 0;
margin-inline: var(--fs-space-3);
}
.cbi-dropdown.btn.spinning > ul:not(.dropdown),
.cbi-dropdown.cbi-button.spinning > ul:not(.dropdown) {
margin: 0;
}
.cbi-dropdown > ul.preview {
display: none;
}
/* no `display` here: `.open` states its own below, `.more` is none until the showers flip it — one declaration per state or the audit's same-selector shadow check fires */
.cbi-dropdown > .open,
.cbi-dropdown > .more {
flex-grow: 0;
flex-shrink: 0;
flex-direction: column;
justify-content: center;
text-align: center;
}
/* currentColor: the variant's own ink, 4.6-5.4:1 on every button fill measured; --fs-border is 1.45:1 */
.cbi-dropdown.btn > .open,
.cbi-dropdown.cbi-button > .open {
border-inline-start: 1px solid currentColor;
}
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
display: none;
}
.cbi-dropdown > ul > li {
display: none;
font-size: var(--fs-type-field);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* ellipsis did nothing until min-width: 0 — a flex item's automatic minimum floors it at its
* own content width — docs/css.md "Dropdown: ellipsis needs `min-width: 0` first". */
min-width: 0;
flex-shrink: 1;
flex-grow: 1;
align-items: center;
align-self: center;
color: inherit;
}
.cbi-dropdown > ul > li .hide-open { display: initial; }
.cbi-dropdown > ul > li .hide-close { display: none; }
/* a button dropdown's label is a button label, not a form field's 16px value; block, not the
* state machine's flex, since text-overflow only draws its "…" on a block container */
.cbi-dropdown:is(.btn, .cbi-button) > ul:not(.dropdown) > li[display] { display: block; font-size: inherit; }
/* 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-block: 0;
margin-inline: 0 var(--fs-space-1);
}
/* ---- the display state machine, on specificity (docs/css.md has the per-selector table) ---- */
/* a closed optional dropdown with an empty value renders EMPTY unflagged too: the placeholder
* hider out-specifies the generic shower. :not([open]) keeps this out of the open list. */
.cbi-dropdown > 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,
.cbi-dropdown:not([open]):not(.btn):not(.cbi-button) > ul > li[placeholder][display] {
flex-grow: 1;
display: flex;
}
.cbi-dropdown[empty] > ul > li,
.cbi-dropdown[optional][open] > ul.dropdown > li[placeholder] {
display: block;
}
.cbi-dropdown[multiple][open] > ul.dropdown > li > form {
display: flex;
}
.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; }
/* min-width: a CHILD with an inline width overflows, wrapping the row cannot reach it —
* form.js's RichListValue assumes an unbounded list (issue #15). */
.cbi-dropdown[open] > ul.dropdown > li .hide-close { display: initial; min-width: 0 !important; }
.cbi-dropdown[open] > ul.dropdown > li:last-child {
margin-bottom: 0;
}
.cbi-dropdown[open] > ul.dropdown > li[unselectable] {
opacity: .7;
}
.cbi-dropdown:not(.btn):not(.cbi-button) {
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
color: var(--fs-text);
min-height: var(--fs-ctl-h);
/* fixed height: without it a closed dropdown grows to its tallest child (Save & Apply's
* 40px button, measured 38 -> 42px on the gallery); --fs-ctl-h, since the min-height floor always won */
height: var(--fs-ctl-h);
font-size: var(--fs-type);
transition: var(--fs-field-transition);
/* absorbed from base/30-forms.css; NOT the dynlist's numbers (240/440) — one value vs a column of them */
min-width: 210px;
max-width: 400px;
width: auto;
}
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { padding: 0; }
.cbi-dropdown:not(.btn):not(.cbi-button):hover { border-color: var(--fs-dim); }
.cbi-dropdown:focus, .cbi-dropdown[open] {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
outline: none;
}
/* `.cbi-dropdown > ul.dropdown > li` is the same row on a BUTTON dropdown, excluded by
* :not(.btn):not(.cbi-button) below — one menu, one row, but two selectors. */
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li,
.cbi-dropdown > ul.dropdown > li {
padding: var(--fs-space-2) var(--fs-space-3); color: var(--fs-text);
font-family: var(--fs-font-sans);
/* a menu row is not a button label: the open list sits inside the button and would inherit its 600 and 1.1 line (a plain dropdown's rows: 400, 1.5) */
font-weight: var(--fs-weight-normal); line-height: var(--fs-leading);
/* a grid step, not calc(--fs-type * --fs-leading) (19.5px) — a half-pixel box edge is what the scale exists to stop */
min-height: var(--fs-space-5);
}
/* a MULTI-select wraps items in <form><label>…</label>, which theme/60-inputs.css's `.cbi-value-field *` would hand mono — inherit keeps it the row's sans, a LABEL not a value */
.cbi-dropdown > ul > li > form,
.cbi-dropdown > ul > li label { font-family: inherit; }
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]:not([display="0"]) { border-inline-start: 0; }
/* same ink base/30-forms.css gives `placeholder`; repeats the row rule's two selectors rather
* than a shorter form, which LOSES to the row's own color and rendered in the value's ink. */
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder],
.cbi-dropdown > ul.dropdown > li[placeholder] { color: var(--fs-placeholder); }
/* the multiselect's inline filter/create inputs, moved from base with the generic input box (theme/60-inputs.css) */
.cbi-dropdown > ul > li input[type="text"] { height: var(--fs-space-5); }
.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child { width: 100%; }
/* `.open` is the only hit target that opens the list (a click elsewhere on a button dropdown
* RUNS the button). `font-size: 0` collapsed the stock line-height to 28x0, putting the
* chevron outside its own box — a real box fixes that. */
.cbi-dropdown > .open {
display: flex;
font-size: 0; line-height: 0; border: 0;
position: relative; padding: 0;
align-self: stretch;
min-width: var(--fs-btn-size); min-height: var(--fs-ctl-h-sm);
align-items: center;
cursor: pointer;
}
/* `.more` was one base rule with `.open`, split when the theme took `.open` for the chevron */
.cbi-dropdown > .more { line-height: 2em; padding: 0 .3em; color: var(--fs-dim); display: none; }
.cbi-dropdown > .open > * { display: none; }
.cbi-dropdown > .open::after {
content: ""; position: absolute; inset: 0; margin: auto;
width: 7px; height: 7px;
border-right: 2px solid var(--fs-dim); border-bottom: 2px solid var(--fs-dim);
transform: translateY(-2px) rotate(45deg); /* optical centre of a "v" */
}
/* --fs-dim reads as a smudge on the accent-filled Save & Apply; currentColor uses the variant's own ink */
.cbi-dropdown.btn > .open::after,
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
/* open list -> design popover */
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
.cbi-dropdown[open] > ul.dropdown {
display: block;
position: absolute;
z-index: var(--fs-z-dropdown);
transition: max-height var(--fs-dur-move);
overflow-y: auto;
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
padding: var(--fs-space-1-5);
margin-top: var(--fs-space-1-5);
/* anchored to its widget: ui.js's touch branch assumes the viewport as containing block; !important beats the inline style */
inset-inline-start: 0 !important;
inset-inline-end: auto !important;
min-width: 100%;
/* desktop only: min(92vw,420px) ignores trigger's left offset, grew past viewport under 767px (113 findings) */
width: max-content;
max-width: min(92vw, 420px);
}
.cbi-dropdown[open] > ul.dropdown > li {
border-radius: var(--fs-radius-sm);
border-inline-start: 0;
position: relative;
/* an OPEN row wraps; only the closed preview ellipsizes — the menu is capped at
* min(92vw, 420px), so a verbose option would otherwise decide the width of every menu */
white-space: normal; overflow-wrap: anywhere;
/* no border-bottom: it curls at the corners of a rounded box; the ::after inset strip below is the divider instead */
border-bottom: 0;
}
/* The row's own wrap above never reaches a `.zonebadge` (base/95-luci.css): `white-space: nowrap`
* there, right for a one-line pill in a table cell, wrong here where a multi-homed interface
* (RichListValue) nests two `.ifacebadge` chips in one badge with no space between them — an
* unbreakable 254-258px at 320px (`R` past the 320px viewport), overflowing
* regardless of the popup's own width. Scoped to the open list only: every other `.zonebadge`
* keeps `nowrap`. */
.cbi-dropdown[open] > ul.dropdown > li .zonebadge {
white-space: normal; overflow-wrap: anywhere;
}
.cbi-dropdown[open] > ul.dropdown > li:not(:last-child)::after {
content: ""; position: absolute; inset-inline: 11px; bottom: 0;
height: 1px; background: var(--fs-border);
}
.cbi-dropdown[open] > ul.dropdown > li:hover,
.cbi-dropdown[open] > ul.dropdown > li.focus { background: var(--fs-panel2); color: var(--fs-accent); }
/* after hover on purpose: hovering the selected row must still read as selected — docs/css.md
* "Dropdown: the open list's rail and the selected-row's contrast". */
.cbi-dropdown[open] > ul.dropdown > li[selected] {
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
}
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
* INSIDE it so a focused+selected row keeps both */
.cbi-dropdown[open] > ul.dropdown > li:focus-visible {
outline: none;
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
}
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; the same declarations as
* `select option:checked` in theme/60-inputs.css, which has no checkbox (kept identical by hand) */
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
}
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: var(--fs-hairline); margin-top: var(--fs-space-1); }
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius); color: var(--fs-text); }
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, wrapper stays transparent: no double frame */
.cbi-dynlist > .add-item:has(> .cbi-dropdown) {
background: none; border: 0; overflow: visible; box-shadow: none;
}
.cbi-section-table .td .cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-section-table .td .cbi-input-select,
.cbi-section-table .td select,
.cbi-section-table .td input[type="text"] {
min-width: 0;
max-width: none;
width: 100%;
}
/* below ~960px a multi-dropdown row no longer fits, so each row cards, same shape as the
* data-table stack (theme/30-tables.css); a @container, not the tables' own measurement, since
* un-collapsing a row to measure it CHANGES what its widgets read. 992, not 960 — docs/css.md
* "Dropdown: 992px, not 960" has the padding subtraction and the regression it fixed. */
@container fs-content (max-width: 992px) {
.table.cbi-section-table,
.table.cbi-section-table .thead,
.table.cbi-section-table .tbody,
.table.cbi-section-table .td { display: block; width: auto; }
/* a card repeats every label, so the header goes in BOTH markups (form.js's .thead children,
* the Lua CBI's bare titles/descr rows) — except the opt-in Table Filters row (25.12 tags it
* -titles too): no card repeats it, and hidden its 4 gallery controls rendered 0x0 */
.table.cbi-section-table .thead > :not(.cbi-section-table-filter),
.table.cbi-section-table .tr.cbi-section-table-titles:not(.cbi-section-table-filter),
.table.cbi-section-table .tr.cbi-section-table-descr { display: none; }
/* stretch (not flex-start) keeps a pair's cells the same height when one wraps */
.table.cbi-section-table .tr {
display: flex; flex-wrap: wrap; width: auto;
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
background: var(--fs-panel2);
padding: var(--fs-space-2) var(--fs-space-3-5) var(--fs-space-3);
margin-bottom: var(--fs-space-3);
}
.table.cbi-section-table .tr:hover { background: var(--fs-panel2); }
/* margin: 0 load-bearing: the Lua CBI gives a table CELL the .cbi-value-field class a form
* field carries, base indents it 20px — without the reset two cells no longer fit a line */
.table.cbi-section-table .td {
flex: 1 1 100%; min-width: 0; margin: 0;
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
/* overflow-wrap: anywhere for opaque tokens (a real IPv6 GUA has no break point) — one
* ran 156px past a 360px viewport before this */
overflow-wrap: anywhere; white-space: normal;
}
/* the filter cells are .th, so the .td rule above never reaches them: one field per card
* line, and no row divider (.cbi-section .table .th's border-bottom) between fields */
.table.cbi-section-table .tr.cbi-section-table-filter > .th {
flex: 1 1 100%; min-width: 0; border: 0;
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5);
}
/* a cell that only READS a value is short, two fit a line; a real widget keeps full width (a dropdown at half a phone's width is unusable) */
.table.cbi-section-table .td[data-widget="CBI.DummyValue"],
.table.cbi-section-table .td[data-widget="CBI.FlagValue"],
.table.cbi-section-table .td[data-widget="CBI.Button"],
.table.cbi-section-table .td[data-widget="dvalue"],
.table.cbi-section-table .td[data-widget="fvalue"],
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
/* a copy of the data table's rule (theme/30-tables.css), needed under a CLASS there and
* under this @container here, and CSS cannot OR the two guards. Keep them identical by hand. */
.table.cbi-section-table .td[data-title]::before {
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);
}
/* the row's buttons are not a label/value pair: full width, sharing the line. Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-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);
}
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
* div is a single flex item. Copy in theme/30-tables.css; same trap on Startup. */
.table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) {
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown {
flex: 0 1 content; width: 0; min-width: 0;
}
.table.cbi-section-table .td .zone-forwards { width: 100%; }
}
/* the table card (theme/30-tables.css) clips with overflow: hidden, but a .cbi-dropdown's open
* list is absolutely positioned — drop the clip, restore corners via separated borders instead */
.table:has(.cbi-dropdown),
.table:has(.cbi-dynlist) {
overflow: visible;
border-collapse: separate;
border-spacing: 0;
}
}
@@ -0,0 +1,44 @@
@layer theme {
/* beats the heading ramp's tight leading in-layer for a dialog's h4 (45-misc.css) */
.modal > * {
line-height: normal;
margin-bottom: .5em;
max-width: 100%;
}
/* wins on specificity, not base's file-order tie */
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
/* moved from base: width:100% loses to the 210px generic box by layer */
.modal > pre,
.modal > textarea {
white-space: pre-wrap;
overflow: auto;
width: 100%;
}
/* 600px is cramped for a table form (.cbi-map cards on a big screen); vw widens toward 1100 on desktop only */
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
#modal_overlay .modal label.btn {
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
}
#modal_overlay .modal li { overflow-wrap: anywhere; }
/* tints come from base's ladder alone (base/95-luci.css); radii sit on the Rounding axis */
#modal_overlay .uci-dialog div > ins,
#modal_overlay .uci-dialog div > del,
#modal_overlay .uci-dialog div > var,
#modal_overlay .uci-change-legend-label > ins,
#modal_overlay .uci-change-legend-label > del,
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); }
#modal_overlay .uci-change-legend-label > ins,
#modal_overlay .uci-change-legend-label > del,
#modal_overlay .uci-change-legend-label > var { overflow: hidden; }
/* fill child sits 3px inset, overflow:hidden never reaches the corner — max() floors at 0 (issue #14) */
#modal_overlay .uci-dialog var > ins,
#modal_overlay .uci-dialog var > del {
border-radius: max(0px, calc(var(--fs-radius-sm) - 3px));
}
}
@@ -0,0 +1,80 @@
@layer theme {
/* page-search palette. fs-search.js parents it to <body>: .fs-sidebar is overflow:auto, which
* would clip a child of the trigger, and the overlay covers the whole viewport anyway. */
/* --fs-scrim is the theme's one colour literal (plain black at .7) on purpose: absence of light behind a dialog, not a tint of any palette token */
.fs-search-ov {
position: fixed; inset: 0; z-index: var(--fs-z-popover);
/* what the `hidden` attribute has to beat — base/10-reset.css covers every element now */
display: flex; justify-content: center;
/* not centred vertically: the box grows DOWNWARD as results come in, centred would slide the input out from under the user's eyes on every keystroke */
align-items: flex-start;
padding: max(8vh, var(--fs-space-6)) var(--fs-space-4) var(--fs-space-4);
background: var(--fs-scrim);
animation: fs-fade var(--fs-dur-fade);
}
/* a SCROLL CONTAINER, not decoration: uncapped the box grows past a short viewport (phone
* landscape ~380px), and a position:fixed popup cannot be scrolled by the page */
.fs-search-box {
display: flex; flex-direction: column; min-height: 0;
width: 100%; max-width: 560px; max-height: min(70dvh, 560px);
background: var(--fs-panel);
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
overflow: hidden;
}
.fs-search-row {
display: flex; align-items: center; gap: var(--fs-space-2-5); flex: 0 0 auto;
padding: var(--fs-space-3) var(--fs-space-3-5);
border-bottom: var(--fs-hairline);
}
.fs-search-ico { display: inline-flex; color: var(--fs-faint); }
.fs-search-ico .fs-ico { width: 18px; height: 18px; }
/* the ROW is the field, the input carries no frame of its own — two classes (0,2,0) needed to
* outrank 60-inputs.css's generic field box (0,1,1) in the same layer regardless of source
* order. --fs-type-floor at every width, not the body size: below 16px iOS Safari zooms the page
* on focus, and this field focuses as the palette opens. */
.fs-search-row .fs-search-input {
flex: 1 1 auto; min-width: 0;
margin: 0; padding: 0; border: 0; min-height: 0; background: transparent;
color: var(--fs-text);
font-family: inherit; font-size: var(--fs-type-floor); font-weight: var(--fs-weight); line-height: 1.4;
}
.fs-search-row .fs-search-input:focus { outline: none; box-shadow: none; }
/* ink comes from base ::placeholder (one for the whole theme); only the weight is restated */
.fs-search-row .fs-search-input::placeholder { font-weight: var(--fs-weight); }
.fs-search-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--fs-space-1-5); }
.fs-search-opt {
display: flex; align-items: baseline; gap: var(--fs-space-2-5);
padding: var(--fs-space-2) var(--fs-space-2-5); border-radius: var(--fs-radius);
color: var(--fs-text); text-decoration: none; cursor: pointer;
}
/* one highlight for pointer AND keyboard: .active is set on both aria-activedescendant and pointermove, so mouse and arrow keys can never disagree which row Enter opens */
.fs-search-opt.active { background: var(--fs-accent-soft); color: var(--fs-accent); }
.fs-search-opt-title { flex: 0 1 auto; font-weight: var(--fs-weight-bold); font-size: var(--fs-type); }
.fs-search-opt-path {
flex: 1 1 auto; min-width: 0;
color: var(--fs-faint); font-size: var(--fs-type); text-align: end;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fs-search-opt.active .fs-search-opt-path { color: inherit; }
.fs-search-note {
flex: 0 0 auto;
padding: var(--fs-space-2-5) var(--fs-space-3-5) var(--fs-space-0-5);
color: var(--fs-eyebrow-color);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
}
/* empty state ("No pages found") reads as a sentence, not an eyebrow label. :has(), not a
* sibling combinator: the note sits ABOVE the list it describes, `+` cannot reach back to it. */
.fs-search-box:has(.fs-search-list:empty) .fs-search-note {
padding: var(--fs-space-6) var(--fs-space-3-5) var(--fs-space-7);
text-align: center; text-transform: none; letter-spacing: 0;
font-size: var(--fs-type); font-weight: var(--fs-weight);
}
}
@@ -0,0 +1,76 @@
@layer theme {
/* phone overflow safety net (<=767px): .fs-main is overflow-x: clip, cutting anything wider silently; tablets were clean */
/* split-button labels wrap under 360px — docs/css.md "Responsive: a split button's label must wrap under 360px" */
@container fs-view (max-width: 360px) {
#view .cbi-button, #view .btn,
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
}
}
@media (max-width: 767px) {
/* foreign-<table> fallback unguarded now in theme/30-tables.css: fit is content/column, never viewport */
/* button dropdowns: no floor, capped to the column */
.cbi-dropdown { min-width: 0; max-width: 100%; }
/* form dropdowns: 65-dropdown.css's (0,3,0) 210px/400px floor outranks the rule above, a @media adds no
* specificity; the doubled class lifts this one to (0,4,0), min() so a 320px modal field is not overflowed */
.cbi-dropdown.cbi-dropdown:not(.btn):not(.cbi-button) { min-width: min(210px, 100%); max-width: 100%; }
/* phone tier over 65-dropdown.css's `width: max-content` (0,3,1): the doubled class makes this (0,4,1); trigger already capped to field width, `auto` keeps the popup on-screen */
.cbi-dropdown[open] > ul.dropdown.dropdown { width: auto; }
/* package-manager.js injects an unlayered `.controls > * > label { min-width: 250px }` (overflows ~360px) and `.controls { display: flex }`; !important beats that sheet, and the two pager rows' inline display:none until the first display() */
:where(#view)[data-page="admin-system-package-manager"] .controls { display: block !important; }
/* :not(.pager): blocking it stacked « / text / » onto three lines */
:where(#view)[data-page="admin-system-package-manager"] .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager {
display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2);
}
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .text { flex: 0 1 auto; min-width: 0; }
/* disk gauge value moved BELOW the bar — docs/css.md "Responsive: the disk gauge value moves below the bar" */
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
width: 100%; margin-bottom: 3em;
}
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar::after {
top: calc(100% + 5px); inset-inline: 0 auto; bottom: auto;
text-align: start; white-space: normal; max-width: 100%;
}
/* `> div > label`: only the TITLE label, a bare `.controls label` also caught nested radio labels */
:where(#view)[data-page="admin-system-package-manager"] .controls > div > label { display: block; min-width: 0; white-space: normal; margin-bottom: var(--fs-space-1); }
/* radio row: stock's &nbsp; text nodes don't stop a mid-choice split, a real gap does */
:where(#view)[data-page="admin-system-package-manager"] .controls > div > div:has(> label > input[type="radio"]) {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-1) var(--fs-space-5);
}
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group { display: flex; flex-wrap: wrap; min-width: 0; gap: var(--fs-space-2); }
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group input { flex: 1 1 140px; min-width: 0; max-width: 100%; }
/* Diagnostics stack not here: fs-select.js writes .fs-rowstack at any width, theme/30-tables.css carries it */
/* no `table.fs-dt { display: block }`: discards the table context — docs/css.md "Responsive: no `display: block` on `table.fs-dt`" */
/* generic caps: a field never exceeds its column */
.cbi-value-field input, .cbi-value-field select { max-width: 100%; }
/* content-sized leaves half the row empty; checkbox/radio/file keep intrinsic size */
.cbi-value-field select,
.cbi-value-field input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
.cbi-value-field .cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-value-field .cbi-dynlist,
.cbi-value-field .cbi-select { width: 100%; }
/* min-width: 0 only, base/40-tables.css already breaks a long value */
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
/* side-by-side squeezes a field to ~120px; not #view-scoped, ui.js appends modals to <body> */
.cbi-value > .cbi-value-title { flex: 1 1 100%; text-align: start; padding-top: 0; }
.cbi-value > .cbi-value-field { flex: 1 1 100%; min-width: 0; }
/* stock phone contract; `.td.hide-xs` not redundant, stacking rules (0,2,1)/(0,2,0) outrank a bare `.hide-xs` (0,1,0) */
.hide-sm, .hide-xs,
.table .td.hide-sm, .table .td.hide-xs,
.table .th.hide-sm, .table .th.hide-xs { display: none; }
/* .col-N weights UNGUARDED now, theme/30-tables.css: flex is inert on a non-flex item */
/* no field-size rule here: the 16px iOS floor is the --fs-type-field token, set under this query in 02-tokens.css */
}
}
@@ -0,0 +1,95 @@
@layer theme {
/* honoured globally, every transition here is decoration. This file is the one place for flags against the theme's own rules,
* and there are two: this block (a layer cannot reach base's own transitions) and the forced-colors focus outline below
* .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
/* re-states the tokens so every rule reading them follows — docs/css.md "A11y: prefers-contrast token shifts, and the numbers behind them" has why each number */
@media (prefers-contrast: more) {
/* theme layer outranks the tokens-layer twins (04-nocolormix.css): without color-mix an unguarded
* re-set leaves --fs-hairline, the focus ring and the placeholder ink invalid at use */
@supports (color: color-mix(in srgb, red 50%, transparent)) {
: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);
}
}
:root {
--fs-accent-line: var(--fs-accent); --fs-accent-line-hi: var(--fs-accent);
--fs-good-line: var(--fs-good); --fs-good-line-hi: var(--fs-good);
--fs-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
/* the one token shipped under AA at rest; both modes buy the ink back here */
--fs-placeholder-mix: 62%;
}
:root[data-darkmode="true"] { --fs-placeholder-mix: 80%; }
}
/* forced-colors: background-color is forced to the system canvas, so state carried by
* BACKGROUND alone vanishes (active tab, checked toggle, open-list selected row) — re-stated with a
* border/outline in system keywords, which forced-colors keeps — docs/css.md "A11y:
* forced-colors state repairs". */
@media (forced-colors: active) {
#tabmenu ul.tabs > li.active > a,
.tabs > li.active > a,
.cbi-tabmenu > li.cbi-tab > a,
.cbi-dropdown[open] > ul.dropdown > li[selected],
.ifacebox .ifacebox-head.active,
.cbi-page-actions .cbi-button-apply {
outline: 2px solid Highlight;
outline-offset: -2px;
color: HighlightText;
background: Highlight;
forced-color-adjust: none; /* keep the fill: it IS the state */
}
/* active #topmenu state was only a 10%-accent background + box-shadow rail, both dropped */
#topmenu li.active > a {
border-inline-start: 3px solid Highlight;
padding-inline-start: calc(var(--fs-space-3) - 3px);
}
/* input[type=checkbox] (60-inputs.css draws the switch there) cannot reach ui.Checkbox's tooltip label */
.cbi-checkbox > input[type="checkbox"] { border: 1px solid ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked {
background: Highlight;
forced-color-adjust: none;
}
.cbi-checkbox > input[type="checkbox"]::after { background: ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked::after { background: HighlightText; }
/* a glyph drawn as a background fill cut out by mask is Canvas on Canvas here: the poll glyph keeps 0 of the
* 103 ink pixels its box shows at rest, and a checked checkbox or radio is pixel-identical to an unchecked
* one. A system colour with the fill un-forced brings it back; ButtonText polling and GrayText paused
* keep the green/grey state — docs/css.md "A11y: forced-colors state repairs". Selectors repeat the
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at (1,7,0) and still win by file order. */
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"],
input[type="checkbox"][type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
input[type="radio"][type="radio"]:checked::after,
:is(.btn, .cbi-button).spinning::before {
background: ButtonText;
forced-color-adjust: none;
}
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
background: GrayText;
}
.cbi-value-description:not(:empty)::before,
.spinning::before {
background: CanvasText;
forced-color-adjust: none;
}
/* icon-only buttons (rail toggle, search) lose their hover fill */
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
/* box-shadow is dropped entirely in forced-colors, and themed rules set outline: none above (0,1,0)
* or in the page layer, so only !important reaches them. #maincontent keeps its deliberate none:
* the router and the skip link focus <main>. */
:focus-visible:not(#maincontent) { outline: 2px solid Highlight !important; outline-offset: 1px; }
}
}
@@ -0,0 +1,31 @@
@layer theme {
/* without this a page prints the dark chrome and only ONE page (shell is 100svh,
* overflow:hidden). No !important: each arm is at least one class over the
* strongest chrome rule it meets. */
@media print {
/* theme layer outranks every tokens-layer palette block without naming data-darkmode */
:root {
--fs-bg: #fff; --fs-panel: #fff; --fs-panel2: #f4f4f4;
--fs-text: #000; --fs-dim: #333; --fs-faint: #555;
--fs-border: #bbb; --fs-track: #ddd;
--fs-shadow-pop: none;
--fs-bar-bg: #fff;
}
/* nav/search/actions unusable on paper; Appearance is a PAGE now, stays and prints */
.fs-sidebar.fs-sidebar.fs-sidebar.fs-sidebar,
.fs-skip.fs-skip,
.fs-search-ov.fs-search-ov,
.cbi-page-actions.cbi-page-actions,
.cbi-tabmenu.cbi-tabmenu,
footer { display: none; }
/* undoes the sidebar layout's scroll frame, the ONE-page bug above */
.fs-shell.fs-shell.fs-shell.fs-shell.fs-shell,
.fs-main.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
.fs-content.fs-content.fs-content.fs-content { max-width: none; padding: 0; }
/* (0,4,0) over 15-wallpaper.css's :root[data-wallpaper="pattern"] .fs-pattern (0,3,0) */
.fs-pattern.fs-pattern.fs-pattern.fs-pattern { display: none; }
/* the uploaded photo — ink, not decoration, on paper (16-login-bg.css) */
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) { background-image: none; }
.cbi-section { break-inside: avoid; }
}
}
@@ -0,0 +1,33 @@
{#
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 under <body>,
and this one stays inside <main> so both layouts order it within the flex column.
The version strings are entityencode()d: an unescaped `&` in a distname would 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');L.require('fs-select')</script>
{% endif %}
</body>
</html>
@@ -0,0 +1,217 @@
{#
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 and the first archive holding the hash wins, so the Appearance layout toggle rendered
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum". Every
Appearance label and every string of the search overlay therefore carries the `footstrap` msgctxt;
the chrome's strings (the Search trigger included), the login and notice sentences and
System/Memory/Storage in fs-overview.js stay msgctxt-free, so a bare msgid
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 chrome strings stay bare for it to adopt.
-#}
{%
import { getuid, getspnam } from 'luci.core';
const boardinfo = ubus.call('system', 'board') ?? {};
/* 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.
Handed over as the whole section: head.ut validates every key it reads, so no list of axes
lives here to fall out of step. `uci` is the cursor dispatcher.uc builds per request; a
second one here would allocate one per page render. */
const fs_defaults = uci.get_all('footstrap', 'settings') ?? {};
http.prepare_content('text/html; charset=UTF-8');
-%}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %}
{#
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
/admin/status renders the overview while request_path is only ['admin','status'], so a
`[data-page='admin-status-overview']` rule would silently not apply. The SPA router stamps the
resolved leaf path, so both ways of arriving agree. Stamped on <body> for the JS modules and
again on .fs-content below for page-scoped CSS: the modules load too late, and the
Overview's stray <h2 name="content"> was painted for 65-138 ms before them.
#}
{% const fs_page = entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) %}
<body class="lang_{{ dispatcher.lang }}" data-page="{{ fs_page }}">
{% if (!blank_page): %}
<script>
/* Records every node a script parks directly on <body>: a page that does so makes the next
* navigation a full load, because the document is spent. fs-router.js's
* strayBodyNode()/bodyLittered() read window.__fsBodyAdds. First script in <body>, before
* any script-built node; chrome pages only, the one place fs-router loads. Own property of
* this <body> instance, never Node.prototype. */
(() => {
const body = document.body;
const adds = window.__fsBodyAdds || (window.__fsBodyAdds = []);
[ 'appendChild', 'insertBefore', 'replaceChild', 'append', 'prepend',
'insertAdjacentElement', 'insertAdjacentHTML' ].forEach((name) => {
const orig = body[name];
Object.defineProperty(body, name, {
configurable: true,
writable: true,
value() {
const before = Array.prototype.slice.call(this.children);
const ret = orig.apply(this, arguments);
const after = this.children;
for (let i = 0; i < after.length; i++) {
if (before.indexOf(after[i]) === -1) adds.push(after[i]);
}
return ret;
}
});
});
})();
</script>
{% endif %}
{#
The wallpaper pattern's paint surface: a mask on this element lets an uploaded SVG take the
theme's ink, and a mask applies to the 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 shows it
there too. Inert unless the axis is on (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. 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; <aside> gets
role="complementary" and leaves the shell with no navigation landmark.
data-fs-chrome marks a zone-1 ROOT: no third-party rule may match it (the fence in
fs-sheets.js) or reach it by inheritance (the pin). Both key off the attribute, never
a class list: the skip link and search overlay sit outside this <nav>. Roots only,
never nested: a direct declaration beats an inherited one, and nesting cost
.fs-label its nowrap and forced text-align left on 302 elements.
#}
<nav class="fs-sidebar" data-fs-chrome aria-label="{{ _('Menu') }}">
<div class="fs-brandrow">
{# Brand block: logo + hostname wordmark. #}
<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.
#}
<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>
{#
The three chrome icons below are the same 24x24 outline differing only in path data;
the stroke is `.fs-ico` in theme/20-shell.css, not attributes, and fs-widgets.js's
svgIcon() emits the same markup client-side. aria-hidden: each sits beside its own label.
#}
<button type="button" class="fs-railtoggle" id="fs-rail-toggle" aria-label="{{ _('Collapse menu') }}" title="{{ _('Collapse menu') }}">
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></svg>
</button>
</div>
{# LuCI's poll status ("Refreshing") and other indicators land here by id. #}
<div id="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, from the theme's own
menu.d entry; nothing of them mounts in this header.
#}
{#
Search trigger (#fs-search-btn), wired by fs-search.js. A `.fs-themerow`, the chrome's
shared row/button class: a square icon button in the bar and the rail, a full-width
labelled row in the vertical sidebar. It shares `order: 2` with the poll pill and Log
out, so DOM order is visual order. 'Search' carries no msgctxt (see the i18n note).
#}
<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') }}">
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/></svg>
<span class="fs-label">{{ _('Search') }}</span>
</button>
{#
Log out, guarded on the node being dispatchable. It is the only logout the theme
renders: fs-chrome.js drops the menu tree's own admin/logout node (fs-search.js likewise).
#}
{% 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.
#}
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><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"/></svg>
<span class="fs-label">{{ _('Log out') }}</span>
</a>
{% endif %}
</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.
#}
<h1 class="fs-title-main fs-sr" data-fs-chrome>{{ entityencode(striptags(dispatched?.title ? _(dispatched.title) : (boardinfo.hostname ?? 'OpenWrt')), true) }}</h1>
{#
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 }}">
{#
Top-of-content notices: no-root-password, initramfs recovery, the <noscript> fallback
and the #tabmenu container. The sentences carry no msgctxt on purpose (see the i18n
note at the top). empty_password is read here, not above: /etc/shadow is only worth
a read on a page that renders the shell.
#}
{% const empty_password = (getuid() == 0 && getspnam('root')?.pwdp === '') %}
{% 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>
{% endif %}
@@ -0,0 +1,410 @@
{#
<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), meaning "no router default".
`fs_defaults` is a required include parameter: header.ut is the only caller, so a `?? {}`
here would only hide that contract. */
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 : '';
/* Absent (null) or '' maps to -1, never 0: 0 is a real value for the four axes whose range
starts at 0 (rounding, tint_strength, photo_dim, pattern_strength) and int(null) is 0. */
function _sd_range(raw, min, max) {
let n = int(raw);
return (type(raw) == 'string' && n >= min && n <= max) ? n : -1;
}
/* A cache-bust TOKEN (the cgi-upload md5 of an uploaded sheet), not a path. Whitelisted to
hex so a hand-edited config cannot inject markup into the inline script that prints it. */
function _sd_token(v) {
return (type(v) == 'string' && match(v, /^[a-f0-9]{6,64}$/)) ? v : '';
}
/* how far the content column may grow past --fs-content-max's 1280px default */
let _sd_cwidth = _sd_range(fs_defaults.content_width, 1280, 3840);
/* Hex or nothing: the four surface axes (cards, inset controls, the chrome bar, hairlines)
* have no hue mode, where a hue such as '5' would pass _sd_color's number branch. */
function _sd_hex(v) {
return (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) ? lc(v) : '';
}
/* 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: the inline scripts are found by matching
the opening tag, so a mention in prose would match too.) */
function _sd_color(v) {
let n = int(v);
return _sd_hex(v) || ((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);
let _sd_card = _sd_hex(fs_defaults.card);
let _sd_ctrl = _sd_hex(fs_defaults.control);
let _sd_bar = _sd_hex(fs_defaults.bar);
let _sd_line = _sd_hex(fs_defaults.line);
let _sd_round = _sd_range(fs_defaults.rounding, 0, 20);
let _sd_lbg = _sd_token(fs_defaults.login_bg);
let _sd_tstr = _sd_range(fs_defaults.tint_strength, 0, 200);
let _sd_pdim = _sd_range(fs_defaults.photo_dim, 0, 100);
let _sd_pat = _sd_token(fs_defaults.pattern);
/* the pattern's tile edge in px and the layer's opacity 0-100 */
let _sd_psize = _sd_range(fs_defaults.pattern_size, 40, 1600);
let _sd_pstr = _sd_range(fs_defaults.pattern_strength, 0, 100);
/* ---- the fonts ----
The theme carries no faces: font_sans / font_mono name a stack, and `fonts` is the cache-bust
md5 of an @font-face sheet at /etc/footstrap/fonts/fonts.css. The package only serves that
directory (kept across a sysupgrade); it installs and fetches no face.
A stack is printed UNESCAPED, so the charset excludes < > ( ) { } ; and the backslash: no way
to close the style element, start a second declaration or write a url(). Both quote kinds stay
allowed and are scanned in order, never counted: the opener wins until its own kind closes
it, and a quote still open at the end refuses the value (it would eat the next stack). */
function _sd_family(v) {
if (!(type(v) == 'string' && length(v) >= 1 && length(v) <= 120 &&
match(v, /^[A-Za-z0-9 ,._"'-]+$/)))
return '';
let q = '';
for (let c in split(v, '')) {
if (q == '')
q = (c == '"' || c == "'") ? c : '';
else if (c == q)
q = '';
}
return (q == '') ? v : '';
}
let _sd_fsans = _sd_family(fs_defaults.font_sans);
let _sd_fmono = _sd_family(fs_defaults.font_mono);
let _sd_ftok = _sd_token(fs_defaults.fonts);
/* ---- writing direction ----
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops, so a router in Arabic,
Persian or Hebrew renders right-to-left text inside a left-to-right document — paragraphs
align to the wrong edge and every mirrored affordance points the wrong way. The theme owns
<html>, so this is the one place that can say it.
Matched on the base subtag (a future `ar-EG` is covered); the list is every base subtag whose
CLDR default script is right-to-left, not the four 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', 'he', 'khw', 'ks', 'ps', 'sd', 'ug', '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="{{ _sd_lay == 'sidebar' ? 'sidebar' : 'top' }}">
<head>
<meta charset="utf-8">
{#
entityencode around striptags: striptags drops tags but escapes neither `&` nor quotes,
so a hostname with `&` breaks entity parsing
#}
<title>{{ entityencode(striptags(`${boardinfo.hostname ?? 'OpenWrt'}${dispatched?.title ? ` | ${_(dispatched.title)}` : ''}`), true) }}</title>
{#
The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's
current*(), so the Appearance controls show the effective default when this browser has no
localStorage. See the sanitiser above for the unset encodings.
#}
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}",content_width:{{ _sd_cwidth }}};</script>
<script>
/* One IIFE pre-paints every Appearance axis before the first frame, duplicating the live
applier in fs-prefs.js (no shared code: this runs before the module loader exists).
It carries no interpolation, unlike the data blob above: an interpolated body is not
JS until rendered.
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
Dark mode: a saved preference, else the router default, else the OS. */
(() => {
/* 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; } };
const root = document.documentElement,
sd = window.__fsSD || {};
/* 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. */
if (lsGet('fs-rail') === 'true') root.setAttribute('data-rail', 'true');
/* The saved layout overrides the server stamp before paint. Only a known value counts, so
a corrupt one leaves the stamp standing instead of blanking the attribute. */
const lay = lsGet('fs-layout');
if (lay === 'top' || lay === 'sidebar') root.setAttribute('data-layout', lay);
try {
let p = lsGet('fs-palette');
/* A stray or retired palette name fails the list below and paints the bare :root, as
listAxis() in fs-prefs.js does. The default palette is a bare :root, every other
colourway opt-in; the list matches fs-prefs.js, or a name in one only would paint
here and be 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
the serve path of fs-axes.js's PATTERN, 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: how far the column may grow past the 1280px default, as a px length
(the slider is continuous). Written only when it differs from the default.
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;
if (cw >= 1280 && cw <= 3840 && cw !== 1280)
root.style.setProperty('--fs-content-max', cw + 'px');
/* Rounding 0-20px (the control and chip radii derive from it). The default is a literal
because this runs before the stylesheet; keep it equal to the token. -1 when unset. */
const rRaw = lsGet('fs-radius');
let r = parseInt(rRaw, 10);
if (rRaw === null) r = sd.rounding;
if (r >= 0 && r <= 20 && r !== 12)
root.style.setProperty('--fs-radius-base', r + 'px');
/* The five colour axes: Tint (page canvas), Accent and the three status colours. Each
is off, a hue 1-360 rotating the palette colour, or #rrggbb replacing it; mirrors
colorAxis()/normColor(). The property is set BEFORE the attribute that switches the
rotation on, or a reload paints one frame in the previous colour. Each mode clears
the other's property, as the live applier does. */
const hex = /^#[0-9a-f]{6}$/i;
/* inkFor() is fs-axes.js's function verbatim: black above OKLab L .62, else white;
the two copies must give equal output. */
const inkFor = (hx) => {
const [ lr, lg, lb ] = [ 1, 3, 5 ].map((i) => {
const u = parseInt(hx.slice(i, i + 2), 16) / 255;
return (u <= 0.04045) ? (u / 12.92) : (((u + 0.055) / 1.055) ** 2.4);
});
const f = (x, y, z) => Math.cbrt((x * lr) + (y * lg) + (z * lb));
return (((0.21045 * f(0.41222, 0.53633, 0.05145)) + (0.79362 * f(0.2119, 0.6807, 0.1074)) - (0.00407 * f(0.0883, 0.28172, 0.62998))) > 0.62) ? '#000' : '#fff';
};
const colour = (key, sdVal, attr, hueProp, colorProp, inkProp) => {
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());
if (inkProp) root.style.setProperty(inkProp, inkFor(v));
root.setAttribute(attr, 'hex');
return;
}
const h = parseInt(v, 10);
if (h >= 1 && h <= 360) {
root.style.removeProperty(colorProp);
if (inkProp) root.style.removeProperty(inkProp);
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', '--fs-on-accent');
colour('fs-good', sd.good, 'data-good', '--fs-good-h', '--fs-good', '--fs-on-good');
colour('fs-warn', sd.warn, 'data-warn', '--fs-warn-h', '--fs-warn', '--fs-on-warn');
colour('fs-danger', sd.danger, 'data-danger', '--fs-danger-h', '--fs-danger', '--fs-on-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 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 to hex above. */
if (sd.login_bg)
root.style.setProperty('--fs-login-bg-url', 'url("/luci-static/footstrap/bg?v=' + sd.login_bg + '")');
/* 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;
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;
if (pd >= 0 && pd <= 100 && pd !== 74)
root.style.setProperty('--fs-photo-dim', pd + '%');
} catch (e) {}
const saved = lsGet('fs-darkmode');
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). Must stay
identical to stampDark() in fs-prefs.js. */
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.darkmode === 'dark') ? true
: (sd.darkmode === '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) => {
const s = lsGet('fs-darkmode');
if (s === 'true' || s === 'false') return;
if (s === null && (sd.darkmode === 'dark' || sd.darkmode === 'light')) return;
set(m.matches);
});
})();
</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,
installed app 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: neither
file can be rendered per request, so the two must stay equal. fs-prefs.js repaints this
tag from the live body background. One tag, no `media` pair: a media-qualified tag would
win for a viewer whose OS is dark but who chose light HERE.
#}
<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 leaves the preload behind and every page keeps asking the router
for files that are gone: six 404s per page, measured.
#}
{#
`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">
{#
Lets an admin install this page as an app (icon, own window, no address bar), worth more
than a tab on the phone LuCI is opened from when the network is broken. Both files are
static, a theme having no dispatcher node to render them per request: start_url is
/cgi-bin/luci/, uhttpd's default (a moved LuCI opens the standard path), and
background/theme_color is the default palette's page colour, read once at install time.
.json, not .webmanifest: uhttpd's compiled-in MIME map has no entry for that extension,
so it answers application/octet-stream.
#}
<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.
#}
<link rel="apple-touch-icon" href="{{ media }}/app-icon-192.png">
{% if (dispatched?.css): %}
{#
The `css` property of the dispatched menu.d node: a stylesheet the server links for the
page instead of the view injecting one at module eval. `dispatched`, not `node`: the
dispatcher exports the resolved node as `dispatched` and never binds `node`, so no ucode
theme emitted this link until openwrt/luci#8920. That commit also adds `css: 'string'` to
the node schema; without it the property is filtered out and this stays false, as on 24.10
and 25.12. Versioned, as uhttpd sends no Cache-Control. Not `data-fs-shell`: the sheet
belongs to one page, so the SPA router must judge and contain it like any 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) that delivers 13
bytes at lang=en, where no en catalogue exists because the msgids already are English.
The process is the cost, so emit those bytes inline when the language has no catalogue
and keep the tag when it has. The probe mirrors the server: load_catalog() reads every
`*.<lang>.lmo` in /usr/lib/lua/luci/i18n, so a router shipping an en catalogue still gets
the tag; deciding by language NAME would drop it. Fails open: a throwing probe keeps the
tag, as a missing catalogue renders English and reports 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,16 @@
{#
One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The
`dispatcher.lookup` guard must not drift: `path` is 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.
include(…, { title, text }) for a plain notice; add { path, label } for the button.
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,185 @@
{#
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 whole chrome renders around the login form with every control dead.
No departure here may drop a field the backend posts.
Do not re-introduce a `<section hidden>` form that a view module reveals: the view bootstraps
before a session exists, its RPCs answer "Access denied", render() never runs and the page is
blank with no way to log in. The server renders the form, so it works with JS off.
The strings carry no msgctxt: luci-base translates them in the ~40 languages without a catalogue.
-#}
{%
/* 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 an
include cannot hand a local back from header.ut: one extra round trip on a page rendered
once per session. It discloses nothing new: the 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 the stylesheet's hook: 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. The `?? 'OpenWrt'` fallback and the
striptags/entityencode pair match head.ut and are load-bearing: the hostname is admin-controlled
text printed to an unauthenticated page. It is the card's visual title but a <p>, not a heading: the h1
`Authorization Required` below stays the one heading (clipped by .fs-sr), or a screen reader
hears the hostname as a section title.
-#}
<p class="fs-login-host">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</p>
{#
Two independent alerts: neither variable alone says which dispatcher branch rendered this.
`fuser` is set in both branches, including the OTP step after a correct password; and
`auth_message` (copied from get_challenges() BEFORE the check, dispatcher.uc:956) is set on
every pending result. `auth_plugin` is set only once session_setup() accepted the
credentials: gate the credentials alert on `!auth_plugin`, print the message beside it.
entityencode, where upstream prints raw: plugin markup travels in `auth_html`, so this is
text on an open page.
-#}
{% 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 have no top-level
heading for a screen reader. Clipped with .fs-sr, not shown: the hostname above is the
card's visible title and the words repeat what the fields say.
-#}
<h1 class="fs-sr">{{ _('Authorization Required') }}</h1>
<div class="cbi-section"><div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="luci_username">{{ _('Username') }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text" type="text" name="luci_username" id="luci_username" autocomplete="username" value="{{ entityencode(duser, true) }}" />
</div>
</div>
<div class="cbi-value cbi-value-last">
<label class="cbi-value-title" for="luci_password">{{ _('Password') }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text" type="password" name="luci_password" id="luci_password" autocomplete="current-password" autofocus />
</div>
</div>
{#
The login backend's own fields: a pluggable backend puts its OTP input in `auth_fields`;
a template that drops them never posts it and nobody can log in with 2FA, so this one
stays a SUPERSET of the generic one's. The leading space in each conditional attribute
is load-bearing: ucode's lexer eats whitespace before `{%` and the newline after `%}`;
measured, the generic spelling renders
`inputmode="numeric"pattern="[0-9]*"maxlength="6"required`.
Every attribute value is entityencoded; maxlength goes through a template literal first, as
entityencode(6) is null.
-#}
{% 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="{{ entityencode(field.type ?? 'text', true) }}"
name="{{ entityencode(field.name, true) }}"
id="{{ entityencode(field.name, true) }}"
{% if (field.placeholder): %} placeholder="{{ entityencode(field.placeholder, true) }}"{% endif %}
{% if (field.inputmode): %} inputmode="{{ entityencode(field.inputmode, true) }}"{% endif %}
{% if (field.pattern): %} pattern="{{ entityencode(field.pattern, true) }}"{% endif %}
{% if (field.maxlength): %} maxlength="{{ entityencode(`${field.maxlength}`, true) }}"{% endif %}
{% if (field.autocomplete): %} autocomplete="{{ entityencode(field.autocomplete, true) }}"{% 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. The script itself is plain JS.
#}
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
<script>
(() => {
/* If the router also serves HTTPS, hop over to it before the password goes over this
* connection; the <img> probe is upstream's trick for proving the TLS port answers first.
* The hop can arrive without the session: Chromium treats http to https as cross-site and
* withholds LuCI's `SameSite=strict` cookie, so a reader logged in over https lands back on
* this form (403 to the hop, 200 to curl with the cookie; Firefox sends it). Where this form
* is what the hop landed on, it is asked again once over the same scheme, which carries the
* cookie. The retry is bounded by the REFERRER, not a URL marker: a `#` marker never runs
* the retry on Firefox, 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 }) %}
+105
View File
@@ -0,0 +1,105 @@
#!/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.
#
# An untranslated _() falls through to its English msgid and nothing else reports it.
# `po/` is what LUCI_LANGUAGES globs and Weblate translates (docs/package.md): do not rename it.
# Not run on the buildbot (luci.mk calls po2lmo); needs perl and gettext.
# The scanner is LuCI's own build/i18n-scan.pl, not a grep: it lexes .ut into JS before xgettext and
# covers the acl.d/*.json title, which a grep would miss.
set -eu
cd "$(dirname "$0")"
# Pinned by commit and sha256 in luci-upstream.pin: a fetched perl script is EXECUTED, and off a
# moving branch the completeness check would be whatever upstream pushed last.
. ./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
case "${1:-}" in
'') ;;
--check) CHECK=1 ;;
# Else a typo like `--chek` rewrites the catalogues instead of checking them.
*) echo "update-po: unknown option: $1" >&2; exit 1 ;;
esac
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), else fetch it. One trap covers every
# temp and exit path; it removes `fetched` only, never 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
trap 'exit 130' 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
# msgid AND msgctxt: po2lmo hashes "ctxt\1msgid", so a .pot with the context dropped is a
# different catalogue. Line-number comments churn on every edit and are ignored.
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
# zero catalogues leaves rc=0 and would print "every string translated", vacuously true
[ "$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