mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-01 16:44:45 +08:00
update 2026-10-23 00:28:40
This commit is contained in:
1 parent
3f38c69f75
commit
d64c67f0ed
320 files changed
+109777
-2309
No files matched your search
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,149 @@
|
||||
#
|
||||
# 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); eslint's
|
||||
# `wrap-regex` bans the shape and tools/jsmin-verify.mjs proves the output token-identical.
|
||||
|
||||
# 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 and probes strips, which
|
||||
# take 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 gate-only `/* fs:probe */` exports: the functions stay, the export line goes.
|
||||
$(SHELL) $(CURDIR)/strip-build.sh probes $(PKG_BUILD_DIR)/htdocs/luci-static/resources
|
||||
# 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". tools/scan-marker.sh re-derives
|
||||
# the grep and fails the build if it goes missing.
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -0,0 +1,67 @@
|
||||
# luci-theme-footstrap (the package)
|
||||
|
||||
A LuCI theme for OpenWrt **24.10.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`. Layout (top bar
|
||||
by default, or the side menu), mode, palette, density, wallpaper, rounding and every colour — accent,
|
||||
the three status colours, the four surfaces — are **client** axes on the System → Footstrap page:
|
||||
`localStorage` plus attributes and inline properties on `:root`, and nothing is written to the
|
||||
router until "Save as default" is pressed. There are no per-layout or per-mode themes; every legacy
|
||||
name (`FootstrapSidebar`, `FootstrapOnTop`, `…-dark`/`…-light`) is deleted by
|
||||
`root/etc/uci-defaults/30_luci-theme-footstrap`.
|
||||
|
||||
There is one menu renderer too, `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
|
||||
|
||||
```
|
||||
Makefile luci.mk; LUCI_MINIFY_CSS:=0; Build/Prepare (CSS, mangle, strip,
|
||||
version, po2lmo)
|
||||
styles/ CSS SOURCE: layers tokens / base / theme / pages
|
||||
build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css
|
||||
mangle-tokens.sh shorten the private --fs-* names in a built sheet, then join it to one line
|
||||
strip-build.sh build-tree strip, one mode each: templates, shell, probes, assets
|
||||
po/ translation catalogue (Weblate translates this)
|
||||
ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/
|
||||
htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfonts, and no
|
||||
wallpaper: the Pattern is one the admin uploads, and a font
|
||||
is one the admin installs (repo: fonts/set-font.sh)
|
||||
htdocs/luci-static/resources/ menu-footstrap.js (footer entry), menu-footstrap-common.js,
|
||||
fs-{fit,menutree,prefs,widgets,chrome,router,sheets,search,
|
||||
select,appearance,version,overview}.js
|
||||
root/etc/uci-defaults/ registration and legacy-name migration
|
||||
root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background upload
|
||||
```
|
||||
|
||||
There is no update checker: the theme is installed from the package feed (`install.sh` adds it), so
|
||||
`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The Footstrap
|
||||
tab shows the version it is running and makes no network call to do it.
|
||||
|
||||
**Do not edit `cascade.css`** — it is generated by `build-css.sh` from `styles/` and is gitignored.
|
||||
Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`.
|
||||
|
||||
## Working on a router
|
||||
|
||||
The normal dev stand is the disposable routers `../owlab.yaml` defines, driven by `owlab` — see
|
||||
[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over
|
||||
ssh:
|
||||
|
||||
```sh
|
||||
./dev-sync.sh # push to `ssh router` (registers the theme but does NOT activate it)
|
||||
ssh router 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit luci; rm -f /tmp/luci-indexcache*'
|
||||
ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back
|
||||
```
|
||||
|
||||
Before pushing, run `npm run check` from the repository root. The rules and the traps are in
|
||||
[`../docs/conventions.md`](../docs/conventions.md); packaging is in
|
||||
[`../docs/package.md`](../docs/package.md).
|
||||
Executable
+107
@@ -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
|
||||
Executable
+330
@@ -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 is still the raw concatenation — comments and formatting intact, for reading on a
|
||||
# router rather than shipping.
|
||||
|
||||
SIZE=$(wc -c < "$TMP" | tr -d ' ')
|
||||
mv "$TMP" "$OUT"
|
||||
trap - EXIT
|
||||
|
||||
echo "build-css: $SIZE bytes -> $OUT"
|
||||
Executable
+124
@@ -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 as default".
|
||||
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,639 @@
|
||||
'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 default), 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),
|
||||
|
||||
/* test-only: the observer's lifetime is a navigation, which a test can fire and cannot reach
|
||||
* through build() */
|
||||
watchColours, /* fs:probe */
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
'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,
|
||||
/* test-only: the SVG cleaner and the CSS-escape decoder */
|
||||
_sanitizeSvg, /* fs:probe */
|
||||
_cssUnescape, /* fs:probe */
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
'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: 6.6 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. The axis list is read from the
|
||||
* whole resources directory, so an axis may live in a second file. */
|
||||
|
||||
/* 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 the call is matched by name, and 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,669 @@
|
||||
'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()
|
||||
});
|
||||
},
|
||||
|
||||
/* test-only: the arithmetic driven directly covers every combination of layout, rail and width
|
||||
* without a browser */
|
||||
columnWidth, /* fs:probe */
|
||||
|
||||
/* test-only: the active-leaf rule and the key-repeat guard, and the indicator observer without the
|
||||
* rest of start() */
|
||||
renderMainMenu, /* fs:probe */
|
||||
wireIndicatorCounts, /* fs:probe */
|
||||
});
|
||||
@@ -0,0 +1,643 @@
|
||||
'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. The reader's
|
||||
* place is docs/anchoring.md, "The corrections". */
|
||||
|
||||
/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
|
||||
* without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */
|
||||
function armGate() {
|
||||
if (!fittersEnabled()) return;
|
||||
/* Written as the literal `dataset.fsFit`, never through a helper: the theme's own
|
||||
* repository checks this file for exactly that spelling to prove the gate rule is still armed,
|
||||
* and an indirection hides the write from it. The behaviour survives being factored out; the
|
||||
* contract does not. */
|
||||
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 — docs/anchoring.md, "The dev switches" */
|
||||
function fittersEnabled() {
|
||||
try { return localStorage.getItem('fsFit') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
function run(records) {
|
||||
if (!fittersEnabled()) return;
|
||||
runAll(_fitters, 'fitter');
|
||||
holdFloor(records);
|
||||
if (!_anchorPending) rememberRest();
|
||||
}
|
||||
|
||||
/* ---- the document may not get shorter while a tick is in flight ----
|
||||
* A layout taken while `dom.content()` has the container empty clamps the reader's offset into a
|
||||
* document that was never that short. Written before the tick (a pin in the same statement
|
||||
* sequence is never laid out: 1882px still clamped away) and on the containers, never the column,
|
||||
* where it suppresses the engine's own anchoring (css-scroll-anchoring-1 §2.2.2) and cost 120px of
|
||||
* growth, all 120px. The selector is `dom.content()`'s own three: a section body, a table, and a
|
||||
* table's body — the third was missing and a poll emptying it took 58px from under the reader on
|
||||
* 24.10's Overview. docs/anchoring.md, "The document may not get shorter: `holdFloor()`". */
|
||||
const SHRINKS = '.cbi-section > div, .table';
|
||||
|
||||
const FLOORED = '[data-fs-floor]';
|
||||
|
||||
/* One floor per container, cleared and re-measured in one batched pass: not while the reader
|
||||
* scrolls, and not on a table box — `min-height` is undefined there (CSS 2.1 §10.7) and a 313px
|
||||
* floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest. */
|
||||
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. docs/anchoring-log.md, "The floor is written half a pixel too tall". */
|
||||
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. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
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.
|
||||
* docs/anchoring.md, "Is the page moving: `scrolling()`". */
|
||||
const SCROLL_IDLE = 400;
|
||||
let _deferred = false;
|
||||
function deferMeasurement() { _deferred = true; }
|
||||
let _movingUntil = 0;
|
||||
let _lastOffset = null;
|
||||
let _sampling = false;
|
||||
let _ownWrite = null;
|
||||
function sawOwnWrite(y) {
|
||||
if (_ownWrite === null) return true;
|
||||
if (Math.abs(y - _ownWrite) < 1) return false;
|
||||
_ownWrite = null;
|
||||
return true;
|
||||
}
|
||||
/* Where the browser's clamp last put the offset, read by `applyAnchor()` alone — `scrolling()`
|
||||
* keeps answering for the whole theme. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
let _clampedTo = null;
|
||||
function sawClamp() {
|
||||
if (scrollTop() === _clampedTo) return true;
|
||||
_clampedTo = null;
|
||||
return false;
|
||||
}
|
||||
function writeOffset(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. docs/anchoring-log.md, "Which element scrolls, asked once per width". */
|
||||
let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
|
||||
function layoutKey() {
|
||||
const root = document.documentElement;
|
||||
return (root.getAttribute('data-layout') || '') + (root.hasAttribute('data-narrow') ? '|narrow' : '');
|
||||
}
|
||||
function scroller() {
|
||||
const key = layoutKey();
|
||||
if (_scrollerAt === _resizeSeq && _scrollerKey === key &&
|
||||
(_scroller === null || _scroller.isConnected))
|
||||
return _scroller;
|
||||
const sc = document.getElementById('maincontent');
|
||||
const flow = sc ? window.getComputedStyle(sc).overflowY : '';
|
||||
_scroller = (flow === 'auto' || flow === 'scroll') ? sc : null;
|
||||
_scrollerAt = _resizeSeq;
|
||||
_scrollerKey = key;
|
||||
return _scroller;
|
||||
}
|
||||
function scrollTop() {
|
||||
const sc = scroller();
|
||||
return sc ? sc.scrollTop : window.scrollY;
|
||||
}
|
||||
|
||||
function scrolling() { return Date.now() < _movingUntil; }
|
||||
function sampleMotion() {
|
||||
const y = scrollTop();
|
||||
if (y !== _lastOffset) {
|
||||
const own = !sawOwnWrite(y);
|
||||
_lastOffset = y;
|
||||
if (!own) _movingUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
|
||||
_sampling = false;
|
||||
/* The offset's own response to the write below, not a reference element's drift: one re-established
|
||||
* on wrong ground shows none. docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
const target = _deferredFloor;
|
||||
_deferredFloor = null;
|
||||
const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
|
||||
const offsetBefore = scrollTop();
|
||||
holdFloor();
|
||||
if (target && target.isConnected) {
|
||||
const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
|
||||
if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
|
||||
}
|
||||
rememberRest();
|
||||
if (_deferred) {
|
||||
_deferred = false;
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
function noteMotion() {
|
||||
if (!sawOwnWrite(scrollTop())) return;
|
||||
_movingUntil = Date.now() + SCROLL_IDLE;
|
||||
if (_sampling) return;
|
||||
_sampling = true;
|
||||
requestAnimationFrame(sampleMotion);
|
||||
}
|
||||
|
||||
let _userUntil = 0;
|
||||
function noteIntent() {
|
||||
_userUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
function noteUser() {
|
||||
noteIntent();
|
||||
noteMotion();
|
||||
}
|
||||
|
||||
(function watchMotion() {
|
||||
const opts = { passive: true, capture: true };
|
||||
window.addEventListener('scroll', noteMotion, opts);
|
||||
for (const name of [ 'wheel', 'touchmove' ])
|
||||
window.addEventListener(name, noteUser, opts);
|
||||
for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
|
||||
window.addEventListener(name, noteIntent, opts);
|
||||
})();
|
||||
|
||||
function 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. docs/anchoring-log.md, "Width only: the URL bar is a resize". */
|
||||
let _resizeSeq = 0;
|
||||
const _lastWidth = new WeakMap();
|
||||
function onResize(entries) {
|
||||
let widthMoved = false;
|
||||
for (const e of entries) {
|
||||
const w = Math.round(e.contentRect.width);
|
||||
if (_lastWidth.get(e.target) !== w) {
|
||||
_lastWidth.set(e.target, w);
|
||||
widthMoved = true;
|
||||
}
|
||||
}
|
||||
if (widthMoved) { _resizeSeq++; schedule(); }
|
||||
}
|
||||
|
||||
function watch(el) {
|
||||
if (!_ro) _ro = new ResizeObserver(onResize);
|
||||
_ro.observe(el);
|
||||
}
|
||||
|
||||
/* ---- scroll anchoring, where the engine has none ----
|
||||
* An older WebKit absorbs none of a tick's growth and the page moved on every one, measured on the
|
||||
* reporter's own router: +133px, +134px, +123px, +108px. Computed from the reference, never the
|
||||
* offset: measuring the offset corrects an anchoring engine's own adjustment (16 movements, 1827px).
|
||||
* Whether there is one to leave anything behind is asked of the platform, never of a browser name —
|
||||
* with anchoring suppressed, a 120px growth moves the reader 120px.
|
||||
* docs/anchoring.md, "Who is responsible: `ENGINE_ANCHORS`" and "The corrections". */
|
||||
const ENGINE_ANCHORS = (() => {
|
||||
/* dev switch — docs/anchoring.md, "The dev switches" */
|
||||
try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
|
||||
catch (e) { /* no storage, no switch */ }
|
||||
try { return CSS.supports('overflow-anchor', 'auto'); }
|
||||
catch (e) { return true; }
|
||||
})();
|
||||
|
||||
/* A load-time check cannot see an engine that declines on a given refill: two residuals written
|
||||
* back 419-420ms late move the observer to the 7-36ms path until trust returns. Never a browser
|
||||
* name, only a count. docs/anchoring.md, "When the platform check is not enough: `_engineTrusted`". */
|
||||
const LATE_MISS_LIMIT = 2;
|
||||
let _lateMisses = 0;
|
||||
let _engineTrusted = ENGINE_ANCHORS;
|
||||
/* The witness's own rounding on a 32-36 row lease table at 390 wide is 8-12.25px, none of them a
|
||||
* residual. docs/anchoring-log.md, "The witness is not safe to write on its own". */
|
||||
const LATE_ROUND_TOLERANCE = 16;
|
||||
|
||||
/* Trust returns after this many refills where `_rest.el` itself held: the two cheaper witnesses
|
||||
* tried recovered on an engine still 48px wrong. docs/anchoring-log.md, "Trust that comes back". */
|
||||
const TRUST_RECOVERY_LIMIT = 2;
|
||||
let _lateHits = 0;
|
||||
|
||||
let _rest = null;
|
||||
let _restAt = null, _restPage = null;
|
||||
let _deferredFloor = null;
|
||||
function pageStamp() {
|
||||
return (document.body && document.body.getAttribute('data-page')) || '';
|
||||
}
|
||||
function forgetRest() {
|
||||
_rest = null;
|
||||
_restAt = null;
|
||||
_restPage = null;
|
||||
}
|
||||
/* `force`: only for the caller that has just written the offset itself, since the engine's own
|
||||
* compensation leaves `scrolling()` true for milliseconds after it — `moved 0, 0, -59` without,
|
||||
* `0, 0, 0` with it. docs/anchoring-log.md, "The reference after this file's own write must be
|
||||
* re-taken forced". */
|
||||
function rememberRest(force) {
|
||||
if (scrolling() && !force) return;
|
||||
if (ENGINE_ANCHORS && scrollTop() <= 0) {
|
||||
_rest = null;
|
||||
_restAt = 0;
|
||||
_restPage = pageStamp();
|
||||
return;
|
||||
}
|
||||
const ref = anchorRef();
|
||||
_restAt = scrollTop();
|
||||
_restPage = pageStamp();
|
||||
_rest = ref ? { el: ref.el, top: ref.top, at: _restAt, sec: ref.sec, secTop: ref.secTop } : null;
|
||||
}
|
||||
|
||||
function anchorFor() {
|
||||
const at = scrollTop();
|
||||
const clamped = (_restAt != null && at < _restAt && !scrolling() && _restPage === pageStamp());
|
||||
if (!_rest || !_rest.el.isConnected) {
|
||||
if (clamped) return { by: _restAt - at };
|
||||
if (_rest && _rest.sec && _rest.sec.isConnected && at === _restAt)
|
||||
return { el: _rest.sec, top: _rest.secTop, slack: 0 };
|
||||
return anchorRef();
|
||||
}
|
||||
if (at !== _rest.at && !clamped) return null;
|
||||
return { el: _rest.el, top: _rest.top, slack: Math.max(0, _rest.at - at) };
|
||||
}
|
||||
|
||||
function anchorRef() {
|
||||
if (scrolling()) return null;
|
||||
|
||||
const host = document.getElementById('view');
|
||||
if (!host) return null;
|
||||
const box = host.getBoundingClientRect();
|
||||
const x = Math.round(box.left + (Math.min(box.width, window.innerWidth || box.width) / 2));
|
||||
let y = 1;
|
||||
let el = document.elementFromPoint(x, y);
|
||||
const chrome = el && el.closest('[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 };
|
||||
}
|
||||
|
||||
/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below,
|
||||
* the late drift correction further down) — same shape twice: the last reason recorded, and the
|
||||
* last 8 with a timestamp. They are read back only by the theme repository's
|
||||
* development build; the package exports none of it. One factory, so the cap and the timestamp
|
||||
* format cannot drift between the two. */
|
||||
const TRAIL_MAX = 8;
|
||||
function makeTrail() {
|
||||
let last = null;
|
||||
const entries = [];
|
||||
return {
|
||||
record(w) {
|
||||
last = w;
|
||||
entries.push(w + '@' + Math.round(performance.now()));
|
||||
if (entries.length > TRAIL_MAX) entries.shift();
|
||||
},
|
||||
last: () => last,
|
||||
entries: () => entries
|
||||
};
|
||||
}
|
||||
|
||||
let _anchorPending = null;
|
||||
const _anchor = makeTrail();
|
||||
function awhy(w) { _anchor.record(w); }
|
||||
function anchorEnabled() {
|
||||
try { return localStorage.getItem('fsAnchor') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
/* ---- what the engine's own anchoring leaves behind ----
|
||||
* It keeps a reference still; it does not promise that a section can vanish and come back. An
|
||||
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
|
||||
let _lateFrame = 0;
|
||||
const _late = makeTrail();
|
||||
function why(w) { _late.record(w); }
|
||||
|
||||
function lateDrift(ref, grow, floorShrink) {
|
||||
if (_lateFrame) return why('busy');
|
||||
if (!ref) return why('no-reference');
|
||||
why('armed');
|
||||
_lateFrame = requestAnimationFrame(() => {
|
||||
why('frame');
|
||||
const seen = scrollTop();
|
||||
const settle = () => {
|
||||
_lateFrame = 0;
|
||||
why('settle');
|
||||
if (!anchorEnabled()) return why('anchoring-off');
|
||||
if (Date.now() < _userUntil) return why('reader-intent');
|
||||
if (_restPage !== pageStamp()) return why('page-changed');
|
||||
/* The offset, not the event stream: the engine's own compensation starts the motion sampler, and
|
||||
* in WebKit the event arrives up to 1.2s late. `ref.at`, since run() has re-taken `_restAt` and the
|
||||
* comparison becomes a value against itself (320px through, @1440 side). */
|
||||
if (scrollTop() !== seen) return why('offset-moved');
|
||||
let el = ref.el, was = ref.top;
|
||||
if (!el || !el.isConnected) {
|
||||
if (!ref.sec || !ref.sec.isConnected) return why('reference-gone');
|
||||
el = ref.sec; was = ref.secTop;
|
||||
}
|
||||
let drift = el.getBoundingClientRect().top - was;
|
||||
/* The witness can be blind: the fold's element need not sit below the container this tick refilled,
|
||||
* 13 of 13 refills reading 0px with the reader 120px off, and a gap under the tolerance is `grow`'s
|
||||
* own rounding. docs/anchoring-log.md, "A witness at the fold can be blind". */
|
||||
if (Math.abs(drift) < 1 && grow > 1) {
|
||||
const compensated = seen - ref.at;
|
||||
if (Math.abs(compensated) < 1) drift = grow - compensated;
|
||||
else if (Math.abs(grow - compensated) > LATE_ROUND_TOLERANCE) {
|
||||
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
|
||||
rememberRest(true);
|
||||
return why('engine-partly-' + Math.round(compensated) + '-of-' + Math.round(grow));
|
||||
}
|
||||
}
|
||||
if (Math.abs(drift) < 1) {
|
||||
if (grow > 1 || floorShrink > 1) rememberRest(true);
|
||||
return why('no-drift-grow-' + Math.round(grow)); /* the engine put it back */
|
||||
}
|
||||
if (Math.abs(drift) > (window.innerHeight || 800)) return why('drift-too-big');
|
||||
writeOffset(scrollTop() + drift);
|
||||
why('wrote-' + Math.round(drift));
|
||||
rememberRest(true);
|
||||
if (floorShrink > 1) return;
|
||||
if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
|
||||
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
|
||||
};
|
||||
/* How long to wait is a question about the reader (docs/anchoring.md): where nothing is driving,
|
||||
* the engine is the only mover and its window is 7-36ms — 404-422ms became 8-61ms. */
|
||||
if (!scrolling() && Date.now() >= _userUntil) { why('now'); settle(); }
|
||||
else { why(scrolling() ? 'wait-idle-moving' : 'wait-idle-intent'); _lateFrame = window.setTimeout(settle, SCROLL_IDLE); }
|
||||
});
|
||||
}
|
||||
|
||||
/* A floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching: its
|
||||
* own frame slot, and the offset's own response to the write below as the check. Not gated on
|
||||
* `scrolling()` — the belated write is what the engine reacts to, and the reaction re-arms the
|
||||
* motion window, still reading true one rAF later on all three cells.
|
||||
* docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
let _floorLateFrame = 0;
|
||||
function settleDeferredFloor(offsetBefore, shrink) {
|
||||
if (_floorLateFrame) return;
|
||||
_floorLateFrame = requestAnimationFrame(() => {
|
||||
const seen = scrollTop();
|
||||
_floorLateFrame = requestAnimationFrame(() => {
|
||||
_floorLateFrame = 0;
|
||||
if (!anchorEnabled() || Date.now() < _userUntil) return;
|
||||
if (_restPage !== pageStamp()) return;
|
||||
if (scrollTop() !== seen) return; /* still settling, or the reader has moved */
|
||||
const wanted = offsetBefore - shrink;
|
||||
const gap = wanted - seen;
|
||||
if (Math.abs(gap) <= LATE_ROUND_TOLERANCE) return; /* the engine already gave it back */
|
||||
if (Math.abs(gap) > (window.innerHeight || 800)) return;
|
||||
writeOffset(scrollTop() + gap);
|
||||
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
|
||||
_restAt = scrollTop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAnchor(ref) {
|
||||
if (!ref) return awhy('no-reference');
|
||||
if (!anchorEnabled()) return awhy('anchoring-off');
|
||||
if (_anchorPending) return awhy('pending-kept-first');
|
||||
_anchorPending = ref;
|
||||
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. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
if (scrolling() && !sawClamp()) return awhy('refused-moving');
|
||||
const at = scrollTop();
|
||||
if (ref.by != null) {
|
||||
if (ref.by < 1) return awhy('by-under-1');
|
||||
writeOffset(at + ref.by);
|
||||
awhy('wrote-by-' + Math.round(ref.by));
|
||||
return true;
|
||||
}
|
||||
if (at <= 0) return awhy('at-top');
|
||||
if (!ref.el.isConnected) return awhy('reference-gone');
|
||||
const drift = ref.el.getBoundingClientRect().top - ref.top;
|
||||
if (Math.abs(drift) < 1) return awhy('no-drift'); /* nothing needed correcting here */
|
||||
_lateHits = 0;
|
||||
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return awhy('drift-too-big');
|
||||
writeOffset(at + drift);
|
||||
awhy('wrote-' + Math.round(drift));
|
||||
return true;
|
||||
}
|
||||
|
||||
function observeContent() {
|
||||
if (_mo) return;
|
||||
const hosts = [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]
|
||||
.filter(Boolean);
|
||||
const viewHost = hosts[0];
|
||||
_mo = new MutationObserver((records) => {
|
||||
const settled = _rest;
|
||||
const trustEngine = _engineTrusted;
|
||||
const ref = trustEngine ? null : anchorFor();
|
||||
/* The record's own target, read before run() rewrites its floor: the growth in pixels, rather than
|
||||
* whichever element the fold happened to hit, and read on every tick so recovery can tell a tick
|
||||
* that tested the engine from one that did not. The floored box the record sits in, since
|
||||
* `dom.content()` refills a node inside the pinned box on 1 of 12 nodes (docs/anchoring-log.md,
|
||||
* "A witness that cannot see the box it is measuring"). */
|
||||
let box = null;
|
||||
for (const m of records) {
|
||||
if (m.type !== 'childList') continue;
|
||||
const b = m.target.closest(FLOORED);
|
||||
if (b) { box = b; break; }
|
||||
}
|
||||
const before = box && (parseFloat(box.style.minHeight) || 0);
|
||||
let grew = before ? box.offsetHeight - before : 0;
|
||||
/* only growth above the reader's reference: counting a box below it wrote the whole growth back,
|
||||
* offset +120 and reader -120 (docs/anchoring-log.md, "Growth below the reader") */
|
||||
if (grew > 1 && settled && settled.el.isConnected
|
||||
&& box.getBoundingClientRect().top >= settled.el.getBoundingClientRect().top) grew = 0;
|
||||
if (!trustEngine && grew > 1 && _rest && _rest.el.isConnected && Date.now() >= _userUntil
|
||||
&& !scrolling() && _restPage === pageStamp()
|
||||
&& Math.abs(_rest.el.getBoundingClientRect().top - _rest.top) < 1
|
||||
&& ++_lateHits >= TRUST_RECOVERY_LIMIT) {
|
||||
_engineTrusted = true;
|
||||
_lateMisses = _lateHits = 0;
|
||||
}
|
||||
const wasScrolling = scrolling();
|
||||
if (before && wasScrolling) _deferredFloor = box;
|
||||
run(records);
|
||||
if (!wasScrolling) _deferredFloor = null;
|
||||
const floorShrink = before ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
|
||||
/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
|
||||
* out and written over a correct restoreScroll(). docs/anchoring.md, "The commit is not a refill". */
|
||||
let gone = 0, came = 0, took = 0, gave = 0;
|
||||
for (const m of records) {
|
||||
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
|
||||
if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
|
||||
}
|
||||
if (gone && came) {
|
||||
forgetRest();
|
||||
return;
|
||||
}
|
||||
/* Not on a batch that only took nodes away: arming on the empty half wrote -834px, and superseding
|
||||
* with the later batch cost 12 findings. docs/anchoring-log.md, "The later batch must not win". */
|
||||
if (trustEngine) {
|
||||
if (took && !gave && grew <= 0 && floorShrink <= 1) why('emptying');
|
||||
else lateDrift(settled, grew, floorShrink);
|
||||
}
|
||||
else scheduleAnchor(ref);
|
||||
});
|
||||
for (const host of hosts) {
|
||||
_mo.observe(host, { childList: true, subtree: true });
|
||||
watch(host);
|
||||
}
|
||||
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 (docs/anchoring-log.md, "A tab
|
||||
* switch moves no node"). A same-value write is not a change, though: the fitters re-apply classes
|
||||
* every pass, and an unguarded `class` watch froze the tab, 391 callbacks in 432ms. */
|
||||
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,
|
||||
/* test-only: the two trails of `makeTrail()` above */
|
||||
lateWhy: () => _late.last(), /* fs:probe */
|
||||
lateTrail: () => _late.entries().slice(), /* fs:probe */
|
||||
anchorWhy: () => _anchor.last(), /* fs:probe */
|
||||
anchorTrail: () => _anchor.entries().slice(), /* fs:probe */
|
||||
deferMeasurement,
|
||||
|
||||
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
|
||||
* of the anchor. No probe marker: packaging strips those, and the sweep reads the installed package. */
|
||||
restAt: () => _restAt,
|
||||
|
||||
/* -> whether the engine is still trusted with a refill; unmarked, like `restAt` above */
|
||||
engineTrusted: () => _engineTrusted,
|
||||
|
||||
forgetRest,
|
||||
|
||||
armGate,
|
||||
|
||||
schedule,
|
||||
|
||||
frame,
|
||||
|
||||
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,325 @@
|
||||
'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,
|
||||
|
||||
/* test-only: the percent parse and the clamp/threshold split, driven directly rather than through
|
||||
* a live `.cbi-progressbar[title]` walk */
|
||||
parseMeterPercent, /* fs:probe */
|
||||
annotateMeter, /* fs:probe */
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
'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) {
|
||||
/* the names stay literal: a gate reads this function's source, and a hoisted const would
|
||||
* look like no attribute at all */
|
||||
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,830 @@
|
||||
'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 sessionExpired() { return _expired; }
|
||||
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,
|
||||
/* test-only, driven against a hand-broken `L`: the one way to see the off branch */
|
||||
contractBreaks, /* fs:probe */
|
||||
/* test-only: the navigation sweep around a visibilitychange, and the session verdict the
|
||||
* interceptors reached */
|
||||
clearViewIntervals, /* fs:probe */
|
||||
sessionExpired, /* fs:probe */
|
||||
/* test-only: the deferred `poll-stop` listener's own check, called directly so a test need not
|
||||
* fake a microtask tick */
|
||||
hidePollIndicatorIfEmpty, /* fs:probe */
|
||||
/* test-only: the body-litter classifier and counter, and navigate() to prove the early return
|
||||
* reaches them */
|
||||
strayBodyNode, /* fs:probe */
|
||||
bodyLittered, /* fs:probe */
|
||||
navigate, /* fs:probe */
|
||||
/* test-only: the progress bar's start/end pair, to drive the fade without a navigation */
|
||||
progressStart, /* fs:probe */
|
||||
progressEnd, /* fs:probe */
|
||||
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,606 @@
|
||||
'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. */
|
||||
/* A `display` rule on any of these classes/tags is checked against its role by a regex over these
|
||||
* two names: keep both, and each entry a `'.class'` or `'tag'` string literal. */
|
||||
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({
|
||||
/* test-only: the slot-cache key, driven directly */
|
||||
shapeOf, /* fs:probe */
|
||||
/* test-only: the token-cell predicate */
|
||||
markTokenCells, /* fs:probe */
|
||||
|
||||
__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,458 @@
|
||||
'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;
|
||||
head.insertBefore(_layerStmt, head.firstChild);
|
||||
}
|
||||
|
||||
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,
|
||||
/* test-only: the duplicate sweep, the fence rewrite, the owner stamp and the re-host, driven
|
||||
* directly instead of through a live document walk */
|
||||
dedupeViewSheets, /* fs:probe */
|
||||
fenceRules, /* fs:probe */
|
||||
_owner, /* fs:probe */
|
||||
rehostIntoThemeLayer, /* fs:probe */
|
||||
});
|
||||
@@ -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,160 @@
|
||||
'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() {
|
||||
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so
|
||||
they can be diffed against upstream when it changes. Modernising the `var`s would break
|
||||
that property, which is what makes carrying the copies safe. */
|
||||
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))
|
||||
]);
|
||||
};
|
||||
/* eslint-enable no-var */
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
# Single source of the openwrt/luci commit whose two build tools we download and run as gates, and
|
||||
# of their checksums. Sourced as shell (`. luci-upstream.pin`) by update-po.sh and CI.
|
||||
# - jsmin.c compiled; its regex-vs-division lookback can silently eat a shipped JS file
|
||||
# - i18n-scan.pl run by perl; decides whether the translation catalogue is complete
|
||||
# Off a moving `master` the gate would be whatever upstream pushed last.
|
||||
# To bump: change LUCI_PIN, re-fetch both files at the new commit, update their sha256s.
|
||||
LUCI_PIN=5cf20c5741dc34eea0faaf7324ef3751b05fa1f3
|
||||
JSMIN_SHA256=0568b14873f27f339cf66a9c43603d0bc8ca501ff62522ea924c6e8daf1be215
|
||||
I18N_SCAN_SHA256=a1823d1fdd942f31a105e1e53b401e146bc10527f231e016930b1a5123ef21d3
|
||||
|
||||
# No ucode pin, deliberately: the `verify` job runs the router's own interpreter over the installed
|
||||
# templates on both release lines (docs/ci.md), 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
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/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. Verify with `npm run computed-diff` (tools/computed-diff.mjs),
|
||||
# mangled against plain.
|
||||
# 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)"
|
||||
@@ -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 (task 0165): translating a brand name is a mistake,
|
||||
# not a missing string.
|
||||
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
|
||||
msgid "Footstrap"
|
||||
msgstr "Footstrap"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js: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"
|
||||
@@ -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 (task 0165): translating a brand name is a mistake,
|
||||
# not a missing string.
|
||||
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
|
||||
msgid "Footstrap"
|
||||
msgstr "Footstrap"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js: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,41 @@
|
||||
#!/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
|
||||
|
||||
# A fresh install may pick the theme; an upgrade must never move a router off its theme. `changed`
|
||||
# tells the two apart (on an upgrade the entry exists) and carries the decision alone: 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
|
||||
Executable
+343
@@ -0,0 +1,343 @@
|
||||
#!/bin/sh
|
||||
# Strip build-time-only bytes from a BUILD TREE, one mode per kind of file:
|
||||
# ./strip-build.sh templates|shell|probes|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)
|
||||
# probes the `/* fs:probe */` export lines out of the JS
|
||||
# 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|probes|assets <dir>" >&2; exit 2; }
|
||||
|
||||
MODE="${1:-}"
|
||||
DIR="${2:-}"
|
||||
case "$MODE" in templates|shell|probes|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' ;;
|
||||
probes) find "$DIR" -type f -name '*.js' ;;
|
||||
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 ;;
|
||||
# a few module-private functions are also exported for one reason: a gate here calls them.
|
||||
# The function stays, the export line goes, with the comment block directly above it. Only a
|
||||
# complete export entry may go; anything else fails the build, since a half-removed object
|
||||
# literal is a module that does not parse.
|
||||
probes) grep -q '/\* fs:probe \*/' "$1" ;;
|
||||
# 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 ;;
|
||||
probes)
|
||||
pre=$(grep -c '/\* fs:probe \*/$' "$1" || true)
|
||||
awk '
|
||||
# a comment block directly above a probe entry describes that export, so it is held back
|
||||
# and goes with it; above anything else it is released untouched
|
||||
function flush() { if (nb) { printf "%s", buf; lastblank = 0 } buf = ""; nb = 0 }
|
||||
incom { buf = buf $0 "\n"; nb++; if ($0 ~ /\*\//) { incom = 0 } ; next }
|
||||
/^[ \t]*\/\*/ && !/\/\* fs:probe \*\/$/ {
|
||||
flush()
|
||||
buf = $0 "\n"; nb = 1
|
||||
if ($0 !~ /\*\//) incom = 1
|
||||
else if ($0 !~ /\*\/[ \t]*$/) flush()
|
||||
next
|
||||
}
|
||||
/\/\* fs:probe \*\/$/ {
|
||||
line = $0
|
||||
sub(/[ \t]*\/\* fs:probe \*\/$/, "", line)
|
||||
# a complete entry, in either form the modules use: `name,` or `name: expression,`
|
||||
if (line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*,[ \t]*$/ ||
|
||||
line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*:.*,[ \t]*$/) { buf = ""; nb = 0; skipblank = lastblank; next }
|
||||
print "strip-build: " FILENAME ":" NR ": not a whole export entry: " line > "/dev/stderr"
|
||||
bad = 1
|
||||
}
|
||||
# a drop between two blank lines would leave a run the source did not have
|
||||
skipblank && /^[ \t]*$/ { skipblank = 0; next }
|
||||
{ flush(); skipblank = 0; lastblank = ($0 ~ /^[ \t]*$/); print }
|
||||
END { flush(); exit bad }
|
||||
' "$1" > "$CUR"
|
||||
post=$(grep -c '/\* fs:probe \*/$' "$CUR" || true)
|
||||
dropped=$((dropped + pre - post)) ;;
|
||||
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
|
||||
dropped=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; } ;;
|
||||
probes) [ "$found" -gt 0 ] || { echo "strip-build: no marked export in $DIR" >&2; exit 1; } ;;
|
||||
assets) [ "$found" -gt 0 ] || { echo "strip-build: no .svg or .json in $DIR" >&2; exit 1; } ;;
|
||||
esac
|
||||
case "$MODE" in
|
||||
probes) echo "strip-build: probes: $dropped export(s) dropped from $found file(s)" ;;
|
||||
assets) echo "strip-build: assets: $found file(s)" ;;
|
||||
*) echo "strip-build: $MODE: $found file(s), $before -> $after bytes (-$((before - after)))" ;;
|
||||
esac
|
||||
@@ -0,0 +1,16 @@
|
||||
/*!
|
||||
* luci-theme-footstrap
|
||||
* Copyright 2026 footstrap contributors
|
||||
* Licensed under the Apache License v2.0
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Portions of styles/base are derived from luci-theme-bootstrap, whose notices
|
||||
* the Apache License requires this file to carry for as long as that code ships:
|
||||
* Copyright 2012 Nut & Bolt <david@nut-bolt.nl>
|
||||
* Copyright 2011 Twitter, Inc
|
||||
*/
|
||||
|
||||
/* Layer order, declared here and nowhere else. Directory-to-layer map, concatenation order and
|
||||
* the hijack risk from a foreign sheet naming a layer first: docs/css.md "One directory per
|
||||
* cascade layer" and "Layer order is fixed by the FIRST mention of a name". */
|
||||
@layer tokens, base, theme, page;
|
||||
@@ -0,0 +1,242 @@
|
||||
@layer tokens {
|
||||
/* PRIVATE --fs-* only; the EXPORT --*-color-* tier is derived elsewhere and unread here.
|
||||
* docs/design-system.md "Two tiers, and the split is load-bearing". */
|
||||
:root {
|
||||
/* palette colours live in 03-palettes.css */
|
||||
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30); /* pop surfaces: menus, dropdown lists, the select picker */
|
||||
/* which of the three a control takes: docs/design-system.md "The derived ladder" */
|
||||
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
|
||||
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
|
||||
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
|
||||
/* opaque, not a tint (4.21:1 in dark); pinned by tools/mirror.mjs
|
||||
* (`selected-row/paint`), docs/css.md "@mirror" */
|
||||
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
|
||||
|
||||
/* every separator is this one line, stated once rather than in 69 longhand declarations */
|
||||
--fs-hairline: 1px solid var(--fs-border);
|
||||
/* ring and border-colour transition together, or the ring arrives before the edge does */
|
||||
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
|
||||
|
||||
/* the theme's one colour literal on purpose. docs/design-system.md "The derived ladder". */
|
||||
--fs-scrim: rgba(0, 0, 0, .7);
|
||||
|
||||
/* Role tint ladder, derived so it follows the palette. docs/design-system.md "The derived
|
||||
* ladder". */
|
||||
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
|
||||
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
|
||||
|
||||
--fs-accent-fill: color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||
--fs-good-fill: color-mix(in srgb, var(--fs-good) 18%, transparent);
|
||||
--fs-warn-fill: color-mix(in srgb, var(--fs-warn) 18%, transparent);
|
||||
--fs-danger-fill: color-mix(in srgb, var(--fs-danger) 18%, transparent);
|
||||
|
||||
--fs-accent-line: color-mix(in srgb, var(--fs-accent) 40%, transparent);
|
||||
--fs-good-line: color-mix(in srgb, var(--fs-good) 40%, transparent);
|
||||
--fs-danger-line: color-mix(in srgb, var(--fs-danger) 40%, transparent);
|
||||
|
||||
--fs-accent-line-hi: color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
|
||||
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
|
||||
|
||||
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
|
||||
* tokens, not fetched". */
|
||||
--fs-icon-refresh: url("data:image/svg+xml,<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>");
|
||||
/* css-dup cannot police a glyph inlined once, sized at two call sites — keep in step by hand */
|
||||
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
|
||||
|
||||
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
|
||||
--fs-hover-lift: brightness(.90);
|
||||
|
||||
/* four durations, no easing token. docs/design-system.md "Scales" ("Motion") */
|
||||
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
|
||||
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
|
||||
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
|
||||
--fs-dur-fill: .4s; /* a progress bar growing to its value */
|
||||
--fs-content-max: 1280px; /* content column cap */
|
||||
/* also read by the bar's centering calc and the notification-width calc */
|
||||
--fs-content-pad: calc(28px * var(--fs-density-space));
|
||||
/* tokens, not literals — fs-chrome.js's fitShell() reads them back. docs/design-system.md
|
||||
* "Scales" ("Shell geometry"). */
|
||||
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
|
||||
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
|
||||
--fs-content-min: 500px; /* below this a content column stops being readable */
|
||||
/* opaque on purpose. docs/design-system.md "The derived ladder". */
|
||||
--fs-bar-bg: var(--fs-panel);
|
||||
/* 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 comes from here */
|
||||
--fs-z-raise: 2;
|
||||
--fs-z-sticky: 50;
|
||||
--fs-z-flyout: 70;
|
||||
--fs-z-header: 800;
|
||||
--fs-z-popover: 850;
|
||||
--fs-z-overlay: 900;
|
||||
--fs-z-tooltip: 1000;
|
||||
--fs-z-dropdown: 1100;
|
||||
|
||||
/* outbound only (audit.py --strict fails a styles/ read of one). docs/design-system.md
|
||||
* "The export tier is a RAMP" */
|
||||
--background-color-high: var(--fs-panel);
|
||||
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
|
||||
--background-color-low: var(--fs-panel2);
|
||||
|
||||
--text-color-highest: var(--fs-text);
|
||||
--text-color-high: var(--fs-text);
|
||||
--text-color-medium: var(--fs-dim);
|
||||
--text-color-low: var(--fs-faint);
|
||||
|
||||
/* borders carry no text, so -low may fade toward the surface — a hairline is meant to
|
||||
* disappear into it */
|
||||
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
|
||||
--border-color-medium: var(--fs-border);
|
||||
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
|
||||
|
||||
/* tools/export-tier.mjs proves the whole tier across palette x mode */
|
||||
--primary-color-high: var(--fs-accent);
|
||||
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
|
||||
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
|
||||
--on-primary-color: var(--fs-on-accent);
|
||||
|
||||
--error-color-high: var(--fs-danger);
|
||||
--error-color-medium: color-mix(in oklch, var(--fs-danger) 78%, var(--fs-dim));
|
||||
--error-color-low: color-mix(in oklch, var(--fs-danger) 45%, var(--fs-dim));
|
||||
--on-error-color: var(--fs-on-danger);
|
||||
|
||||
--success-color-high: var(--fs-good);
|
||||
--success-color-medium: color-mix(in oklch, var(--fs-good) 78%, var(--fs-dim));
|
||||
--success-color-low: color-mix(in oklch, var(--fs-good) 45%, var(--fs-dim));
|
||||
--on-success-color: var(--fs-on-good);
|
||||
|
||||
--warn-color-high: var(--fs-warn);
|
||||
--warn-color-medium: color-mix(in oklch, var(--fs-warn) 78%, var(--fs-dim));
|
||||
--warn-color-low: color-mix(in oklch, var(--fs-warn) 45%, var(--fs-dim));
|
||||
--on-warn-color: var(--fs-on-warn);
|
||||
|
||||
--fs-disabled-opacity: .6;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* round(…) tested where a length is expected, not by a calc() probe */
|
||||
@supports (width: round(1px, 1px)) {
|
||||
:root {
|
||||
--fs-type-2xs: calc(round(10px * var(--fs-density-type), 1px));
|
||||
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px));
|
||||
--fs-type: calc(round(13px * var(--fs-density-type), 1px));
|
||||
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px));
|
||||
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px));
|
||||
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px));
|
||||
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
|
||||
}
|
||||
}
|
||||
|
||||
@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. */
|
||||
}
|
||||
@@ -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, held by `npm run placeholder`.
|
||||
* docs/design-system.md "The fourth ink is the one". */
|
||||
--fs-placeholder-mix: 55%;
|
||||
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
|
||||
/* declared here too, so the sheet stands alone — an undefined var() invalidates the rule */
|
||||
--fs-accent-h: 0;
|
||||
--fs-tint-h: 0;
|
||||
--fs-good-h: 0;
|
||||
--fs-warn-h: 0;
|
||||
--fs-danger-h: 0;
|
||||
/* multiplier on the tint chroma below (Tint strength, not Density). docs/design-system.md
|
||||
* "Tint and Accent". */
|
||||
--fs-tint-strength: 1;
|
||||
/* inert default; head.ut/fs-prefs.js stamp the real url() inline */
|
||||
--fs-login-bg-url: none;
|
||||
/* scrim over a File photo, mixed from --fs-bg-base so tint never leaks onto it */
|
||||
--fs-photo-dim: 74%;
|
||||
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
|
||||
/* tiled pattern axes; .2 is where a hairline reads as a drawing, not texture (.15 is swallowed) */
|
||||
--fs-pattern-url: none;
|
||||
--fs-pattern-size: 440px;
|
||||
--fs-pattern-strength: .2;
|
||||
/* floor + two cos() terms, not flat chroma. docs/design-system.md "Tint and Accent". */
|
||||
--fs-tint-c: calc((.016 + .012 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))) * var(--fs-tint-strength));
|
||||
/* ---- 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 css-floor.mjs support decision first. */
|
||||
.tr.drag-over-above {
|
||||
border-top: 2px solid var(--fs-accent);
|
||||
}
|
||||
|
||||
.tr.drag-over-below {
|
||||
border-bottom: 2px solid var(--fs-accent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@layer base {
|
||||
/* Page chrome: the sticky <header> band, dropdown anchoring, the tab strip, breadcrumbs and the
|
||||
* footer. styles/theme/ repaints these; what stays here is the geometry a view can rely on. */
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--fs-z-header);
|
||||
overflow: visible;
|
||||
color: var(--fs-dim);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
li.menu, .dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown:hover ul.dropdown-menu {
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
/* Tab-strip visuals (margins, gradients, per-state colours): theme/40-tabs.css —
|
||||
* absorbed. Left: strip geometry. */
|
||||
.tabs > li, .cbi-tabmenu > li {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabs > li > a, .cbi-tabmenu > li > a {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE
|
||||
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full
|
||||
* (same selectors), so a copy left behind would be a dead declaration tools/audit.py --strict
|
||||
* reports. */
|
||||
|
||||
.cbi-tab-disabled[data-errors]::after {
|
||||
content: attr(data-errors);
|
||||
/* badge fill: theme/40-tabs.css — absorbed */
|
||||
color: var(--fs-on-danger);
|
||||
height: var(--fs-space-4);
|
||||
min-width: var(--fs-space-4);
|
||||
/* a pill, so it says so, rather than half the height above restated by hand */
|
||||
border-radius: var(--fs-radius-pill);
|
||||
text-align: center;
|
||||
margin-inline-end: var(--fs-space-1);
|
||||
padding: 0 var(--fs-space-1);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
font-size: var(--fs-type);
|
||||
}
|
||||
|
||||
.cbi-tab-descr {
|
||||
/* the negative top pulls it under the tab strip it describes */
|
||||
margin: calc(var(--fs-space-2) * -1) 0 var(--fs-space-4) 0;
|
||||
}
|
||||
|
||||
/* hangs directly under the tab strip: 100% IS the strip's height by definition, where a literal
|
||||
* would be a hidden pair with whatever sizes the tab */
|
||||
.tabs .menu-dropdown, .tabs .dropdown-menu {
|
||||
top: 100%;
|
||||
border-width: 1px;
|
||||
/* square 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"] [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, -webkit-box lays children as lines */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
/* clamped to 2 lines never 3: unclamped nowrap name sets every tile's min width */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: var(--fs-leading);
|
||||
font-size: var(--fs-type); 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 "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 (npm run css-floor) */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
|
||||
|
||||
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
|
||||
* first column already resolved to. Data tables excluded by the same shape test above. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td:first-child {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* .active fill (theme/45-misc.css) makes three saturated bars at once here; "Up" said in
|
||||
* colour+text on --fs-panel2 instead. Network -> Interfaces keeps the fill (tells boxes apart
|
||||
* there), so this is scoped to Overview only. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox .ifacebox-head.active {
|
||||
background: var(--fs-panel2);
|
||||
color: var(--fs-good);
|
||||
font-weight: var(--fs-weight);
|
||||
}
|
||||
|
||||
/* L.itemlist wraps every caption in <strong> at 700 weight — the boldest text on the card,
|
||||
* inverted like the key/value rule above; the value keeps --fs-text with no rule of its own */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox-body strong {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's
|
||||
* bars something at full strength. Overview only — on Software the value sits beside its own
|
||||
* field label. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .cbi-progressbar::after {
|
||||
font-size: var(--fs-type);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
/* row height in DATA tables (DHCP leases, station list, attendedsysupgrade's 11_upgrades.js):
|
||||
* theme/30-tables.css pads for a page that IS a table, here it's a card read at a glance — 8
|
||||
* leases measured 740px against a 440px System card. Positive test is the complement of the
|
||||
* key/value shape above (:has(.th) or a 3rd .td), not .tr.table-titles — a stock header .th
|
||||
* emitted through initFromMarkup never carries that class. One step down the space ladder,
|
||||
* only here. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .td,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .th {
|
||||
padding-block: var(--fs-space-1-5);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@layer page {
|
||||
/* Package list and disk-space bar. :where(#view): docs/css.md ":where(#view), not bare
|
||||
* #view". Full rationale for every rule below: docs/css.md "Software: the disk-space bar and
|
||||
* the carded package row". */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:first-child { position: relative; display: block; flex: 1 1 100%; width: 100%; margin: 0 0 var(--fs-space-1-5); }
|
||||
|
||||
#packages:not(.fs-stacked) .tr > .td:first-child { white-space: nowrap; }
|
||||
|
||||
/* the id beats theme/30-tables.css's header-hiding rule in card mode — load-bearing. */
|
||||
#packages.fs-stacked .tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr) {
|
||||
display: block; padding: var(--fs-space-3) var(--fs-space-4);
|
||||
}
|
||||
#packages.fs-stacked .td {
|
||||
display: flex; align-items: baseline; justify-content: flex-end;
|
||||
gap: 0; padding: var(--fs-space-1) 0; border: 0; text-align: end;
|
||||
flex: none;
|
||||
}
|
||||
#packages.fs-stacked .td[data-title]::before {
|
||||
content: attr(data-title); flex: 0 0 auto; text-align: start; margin-bottom: 0;
|
||||
margin-inline-end: auto; padding-inline-end: var(--fs-space-3-5);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color); white-space: nowrap;
|
||||
}
|
||||
/* column 4 = Description, matched by index — a `[data-title]` value is a translated string. */
|
||||
#packages.fs-stacked .td:nth-child(4) { display: block; text-align: start; }
|
||||
#packages.fs-stacked .td:nth-child(4)::before { display: block; margin-bottom: var(--fs-space-1); }
|
||||
#packages.fs-stacked .td.cbi-section-actions { 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, "Pattern colours". */
|
||||
:root[data-wallpaper="pattern"]:not([data-pattern-ink="original"]) .fs-pattern {
|
||||
background-color: var(--fs-text);
|
||||
-webkit-mask-image: var(--fs-pattern-url);
|
||||
mask-image: var(--fs-pattern-url);
|
||||
-webkit-mask-repeat: repeat;
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-size: var(--fs-pattern-size) auto;
|
||||
mask-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
/* original: the file's own palette, plain tiled background, no mask. */
|
||||
:root[data-wallpaper="pattern"][data-pattern-ink="original"] .fs-pattern {
|
||||
background-image: var(--fs-pattern-url);
|
||||
background-repeat: repeat;
|
||||
background-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
|
||||
/* No wash layer behind #view: cards paint their own opaque --fs-panel, so nothing needs
|
||||
* dimming globally. */
|
||||
}
|
||||
@@ -0,0 +1,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,569 @@
|
||||
@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);
|
||||
/* @mirror poll-glyph/mask */
|
||||
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
/* @endmirror */
|
||||
}
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
|
||||
background: var(--fs-dim);
|
||||
}
|
||||
/* every OTHER indicator collapses to the same box and shows the badge fs-chrome.js lifted out of
|
||||
* its label — the count, or a dot when there is no number — exactly as the collapsed rail does.
|
||||
* min-width rather than width: three digits must not be clipped, and here there is room to grow. */
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"]) {
|
||||
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
|
||||
}
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
|
||||
/* @mirror ind-badge/paint */
|
||||
content: attr(data-fs-badge);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
|
||||
/* @endmirror */
|
||||
}
|
||||
|
||||
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
|
||||
* FIRST one actually rendered. #indicators is earlier in the DOM, so when the poll pill
|
||||
* shows IT opens the gap and .fs-themerow's auto must stand down — otherwise flexbox
|
||||
* SPLITS the free space between the two and the pill drifts away from the buttons.
|
||||
* .fs-themerow keeps its own auto for when the pill is empty and collapsed. */
|
||||
.fs-sidebar #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
|
||||
/* 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, same heading as above. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
|
||||
content: attr(data-title);
|
||||
position: static; inset: auto; height: auto;
|
||||
padding: var(--fs-space-1) var(--fs-space-3) var(--fs-space-2);
|
||||
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
|
||||
}
|
||||
/* bridge the 10px gap so the pointer can travel icon -> flyout without a hover drop */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub::after {
|
||||
content: ""; position: absolute; inset-inline-start: 100%; top: 0;
|
||||
width: 12px; height: 100%;
|
||||
}
|
||||
|
||||
/* "Refreshing" -> a green masked refresh glyph. Needs the FULL guard, not [data-rail]
|
||||
* alone: data-rail stamps pre-paint, data-narrow lands later from an async module, so a
|
||||
* narrow phone would paint a bare glyph in the phone bar until the modules land.
|
||||
* --fs-ctl-h-sm follows the Density axis, one step under the bar's pill. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
|
||||
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
|
||||
border: 0; box-shadow: none; background: var(--fs-good);
|
||||
/* @mirror poll-glyph/mask */
|
||||
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
/* @endmirror */
|
||||
/* Static, deliberately: this is a poll running quietly forever, not something being
|
||||
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
|
||||
}
|
||||
|
||||
/* paused: the same glyph gone quiet — without it the rail kept saying GREEN while the
|
||||
* poll was stopped, the one thing the indicator exists to report. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
|
||||
background: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
|
||||
* collapsed-rail indicator badges its count". One rule per box: two would need an extra
|
||||
* attribute + :not() past stylelint's selector-max-specificity (1,7,0). `.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 (mirror-pinned) but cannot SHARE this
|
||||
* rule: it applies at every width, this one must stay inside the 521px floor. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
|
||||
/* @mirror ind-badge/paint */
|
||||
content: attr(data-fs-badge);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
|
||||
/* @endmirror */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- main column ---------- */
|
||||
.fs-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--fs-bg);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.fs-sidebar #indicators:empty { display: none; }
|
||||
.fs-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). Outside the @media: table-contract refuses overflow-wrap in a width query */
|
||||
.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,85 @@
|
||||
@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. @mirror pins the shared declarations. */
|
||||
.cbi-value-field > .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
margin-block-start: 0;
|
||||
/* @endmirror */
|
||||
--fs-meter-value: calc(var(--fs-space-10) * 2);
|
||||
}
|
||||
.cbi-value-field > .cbi-progressbar::after {
|
||||
/* @mirror meter/beside-value */
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/* @endmirror */
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
margin-block-start: 0;
|
||||
/* @endmirror */
|
||||
--fs-meter-value: var(--fs-space-10);
|
||||
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
/* @mirror meter/beside-value */
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/* @endmirror */
|
||||
}
|
||||
|
||||
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
|
||||
* the row). Back over the bar with a one-line top margin (was 6-9px overlap).
|
||||
* `:has(.tr.table-titles)` stays for SPECIFICITY, matching the beside-rule's (0,6,0). */
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
margin-inline-end: 0;
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
inset-inline: auto 0;
|
||||
bottom: calc(100% + var(--fs-space-1));
|
||||
top: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
@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) — anchoring.md, "What the engine is told". */
|
||||
.table.fs-dt { overflow-anchor: none; }
|
||||
|
||||
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
|
||||
.cbi-section .table:is([id], .fs-dt) .tr.table-titles .th { border-bottom-color: var(--fs-border); }
|
||||
/* contour (border-collapse:separate, or the radius is silently dropped); #view too —
|
||||
* Startup renders .table.fs-dt with no section */
|
||||
.cbi-section .table[id], #view .table[id],
|
||||
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, :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, "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. Under css-dup's floor — keep both in step by hand. */
|
||||
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
|
||||
.table.fs-stacked tfoot:not(:has(> .tr)),
|
||||
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
|
||||
.table.fs-stacked .td {
|
||||
display: block; min-width: 0; width: auto;
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* the column label, lifted above its value. PINNED: the config table needs this under its
|
||||
* own @container guard, which cannot share a block with a class. */
|
||||
.table.fs-stacked .td[data-title]::before {
|
||||
/* @mirror table-card/label */
|
||||
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */
|
||||
.table.fs-stacked .td.cbi-section-actions::before,
|
||||
.table.fs-stacked .td:not([data-title])::before,
|
||||
.table.fs-stacked .td[data-title=""]::before,
|
||||
.table.fs-stacked tfoot .td[data-title]::before,
|
||||
.table.fs-stacked .tfoot .td[data-title]::before { content: none; }
|
||||
/* row buttons are not a label/value pair — full width below the pairs. PINNED, see above. */
|
||||
.table.fs-stacked .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
|
||||
* the tail button. PINNED to the config table's copy. */
|
||||
.table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* 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`). PINNED. */
|
||||
.table.fs-stacked .td.cbi-section-actions > .cbi-dropdown {
|
||||
/* @mirror table-card/actions-dropdown */
|
||||
flex: 0 1 content; width: 0; min-width: 0;
|
||||
/* @endmirror */
|
||||
}
|
||||
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
|
||||
* it does not scroll to an over-wide child */
|
||||
.table.fs-stacked .td > *,
|
||||
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* a <tfoot> is still a table-footer-group in a display:block table, drawing beside the cards
|
||||
* instead of under them; `:has(> .tr)` tells the two shapes upstream writes it in apart. */
|
||||
.table.fs-stacked tfoot:has(> .tr),
|
||||
.table.fs-stacked .tfoot:has(> .tr) { display: block; }
|
||||
.table.fs-stacked tfoot .td:empty,
|
||||
.table.fs-stacked .tfoot .td:empty { display: none; }
|
||||
|
||||
/* --- DATA table flavour (L.ui.Table, tagged .fs-dt) --- 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 — docs/chrome.md "Tab strips" has the measurement */
|
||||
.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 — docs/chrome.md "Tab strips" has the contrast
|
||||
* numbers. box-shadow, not outline, because the strip clips and a shadow is not */
|
||||
#tabmenu ul.tabs > li > a:focus-visible,
|
||||
.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,534 @@
|
||||
@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 {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
}
|
||||
|
||||
/* .cbi-select — legacy luci-compat shell around a native <select>; 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,397 @@
|
||||
@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] {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
|
||||
* INSIDE it so a focused+selected row keeps both */
|
||||
.cbi-dropdown[open] > ul.dropdown > li:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; pinned byte-identical to
|
||||
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: var(--fs-hairline); margin-top: var(--fs-space-1); }
|
||||
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius); color: var(--fs-text); }
|
||||
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, wrapper stays transparent: no double frame */
|
||||
.cbi-dynlist > .add-item:has(> .cbi-dropdown) {
|
||||
background: none; border: 0; overflow: visible; box-shadow: none;
|
||||
}
|
||||
|
||||
.cbi-section-table .td .cbi-dropdown: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%; }
|
||||
/* PINNED to the data table's copy (theme/30-tables.css, tools/mirror.mjs): needed under a
|
||||
* CLASS there and under this @container here, and CSS cannot OR the two guards. */
|
||||
.table.cbi-section-table .td[data-title]::before {
|
||||
/* @mirror table-card/label */
|
||||
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* the row's buttons are not a label/value pair: full width, sharing the line. PINNED — see above. */
|
||||
.table.cbi-section-table .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
|
||||
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
|
||||
* div is a single flex item. PINNED to the data table's copy, same trap on Startup. */
|
||||
.table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* 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`). PINNED. */
|
||||
.table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown {
|
||||
/* @mirror table-card/actions-dropdown */
|
||||
flex: 0 1 content; width: 0; min-width: 0;
|
||||
/* @endmirror */
|
||||
}
|
||||
.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,46 @@
|
||||
@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 {
|
||||
/* @mirror btn-wrap/anywhere */
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
/* @endmirror */
|
||||
}
|
||||
#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,78 @@
|
||||
@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"] {
|
||||
/* @mirror btn-wrap/anywhere */
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
/* @endmirror */
|
||||
}
|
||||
}
|
||||
|
||||
@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 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
|
||||
* (.stylelintrc.json holds the allowlist). .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 the stylelint ceiling (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,411 @@
|
||||
{#
|
||||
<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, so it is exempt from the inline-script lint and anything merged
|
||||
into it would go unchecked.
|
||||
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,186 @@
|
||||
{#
|
||||
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 and eslint's .ut processor
|
||||
enforces that. The script itself is plain JS and is linted.
|
||||
#}
|
||||
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
|
||||
<script>
|
||||
(() => {
|
||||
/* If the router also serves HTTPS, hop over to it before the password goes over this
|
||||
* connection; the <img> probe is upstream's trick for proving the TLS port answers first.
|
||||
* The hop can arrive without the session: 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 }) %}
|
||||
Executable
+105
@@ -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. The CI gate.
|
||||
#
|
||||
# An untranslated _() falls through to its English msgid and nothing reports it, so --check is a gate.
|
||||
# `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 gate 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
|
||||
Reference in new issue
Block a user