diff --git a/luci-theme-footstrap/LICENSE b/luci-theme-footstrap/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/luci-theme-footstrap/LICENSE @@ -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. diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile new file mode 100644 index 00000000..ebe75e39 --- /dev/null +++ b/luci-theme-footstrap/Makefile @@ -0,0 +1,257 @@ +# +# Copyright (C) 2026 +# +# This is free software, licensed under the Apache License, Version 2.0 . +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-theme-footstrap +# luci.mk keys the Build/Prepare hook name on LUCI_NAME, which defaults to the checkout's +# directory name — a differently-named checkout symlinked into the feed would silently skip +# the CSS build. Pin it. +LUCI_NAME:=luci-theme-footstrap + +# Version is git-derived; never hardcode PKG_VERSION. luci.mk falls back to PKG_SRC_VERSION, +# which it always defines, when nothing here sets it — see docs/package.md for why no override +# lives here. +LUCI_TITLE:=Footstrap Theme +# +luci-base is the WHOLE list, and keeping it that way is the constraint. Nothing this package ships +# fetches anything at run time, so there is no second name to justify — and the rule that got it here +# stands: the one convenience tool that ever wanted a dep was `curl`, which is NOT in OpenWrt's +# default set, on an 8 MB device, for one API call. Fall back to what the base image has +# (uclient-fetch, jsonfilter, sha256sum) or do without. +LUCI_DEPENDS:=+luci-base +LUCI_PKGARCH:=all +# Else luci.mk defaults these to LuCI's own, and the package claims MAINTAINER "OpenWrt LuCI +# community" / URL openwrt/luci. +LUCI_MAINTAINER:=Ivan Kvashonkin +LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap + +# CSS: ship verbatim. luci.mk would run csstidy over it, which is old enough to mangle :has(), +# color-mix() and nested calc() and break the layout. build-css.sh minifies instead. +LUCI_MINIFY_CSS:=0 +# +# JS: the released package is built by owfeed (tools/stage.sh), never by this Makefile through +# the SDK — so LUCI_MINIFY_JS keeps luci.mk's default jsmin here. jsmin decides regex-or-division +# from a one-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its +# allow-list, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299). +# eslint's `wrap-regex` bans the shape in the source; tools/jsmin-verify.mjs proves jsmin's output +# is token-identical to it. + +# Apache-2.0, and not a free choice: styles/base began as a fork of luci-theme-bootstrap's +# cascade.css, the ucode templates derive from LuCI's own, and a few JS helpers are verbatim copies. +# The notices travel with it. +# +# No OFL-1.1: the theme ships no Font Software, so declaring it would be a false statement about the +# package's contents. +PKG_LICENSE:=Apache-2.0 +PKG_LICENSE_FILES:=LICENSE + +# /etc/config/footstrap is SHIPPED as an empty stub and WRITTEN AT RUNTIME: Appearance -> +# "Save as default" has rpcd uci-set the router-wide axes into that very file. Without this define +# the package manager owns it as an ordinary file and REPLACES it on upgrade, so the admin's saved +# defaults are wiped by the theme's own one-click Update, silently and reported as success. +# OpenWrt honours the define for BOTH formats (include/package-pack.mk), so one covers 24.10's opkg +# and 25.12's apk alike. Any future root/etc/config/* must be listed here too. +define Package/luci-theme-footstrap/conffiles +/etc/config/footstrap +endef + +# NO `rpcd reload` HERE, on install or removal — it used to be, and it was the more dangerous of +# the two rpcd calls this package could make. Measured on an owrt2512 stand: +# +# 1. RELOAD IS A RE-EXEC, NOT A REFRESH. `/etc/init.d/rpcd reload` is `procd_send_signal rpcd` -> +# SIGHUP -> rpcd freezes sessions and calls exec_self(): a full re-exec that re-scans the plugin +# dirs and dlopen()s every .so. A plugin file that is absent or mid-write at that instant is +# DROPPED, past one stderr line, and does NOT come back on its own: +# +# mv /usr/lib/rpcd/file.so /tmp/ ; rpcd reload -> `ubus list` loses `file` +# file restored, no reload -> still missing +# reload (or restart) -> back +# +# First measured this way on the `luci` ucode plugin (24.10/25.12: /usr/libexec/rpcd/luci) — a +# reload racing another package's file replacement left every luci/getFeatures, +# luci/getTimezones and luci/getMountPoints call answering `-32000 Object not found`, reported +# from the field on a SNAPSHOT router, cleared only by a reboot. The SAME race hits `file`, and +# losing `file` is worse: it is what makes System -> Backup/Flash Firmware's "Reset to +# defaults" row silently disappear. view/system/flash.js computes has_rootfs_data from +# /proc/mtd and /proc/mounts read through fs.trimmed(), wrapped in L.resolveDefault(..., '') — +# so the -32000 `file` answers with reads as "no overlay" instead of as an error (OpenWrt forum +# thread 251930, posts 97 and 109 — "fixed" by installing an unrelated package whose OWN +# postinst reload happened to re-register `file`). +# +# 2. THIS PACKAGE HAS NOTHING FOR A RELOAD TO REFRESH. It registers no rpcd object at all — no +# /usr/libexec/rpcd/* script, no /usr/lib/rpcd/*.so, only root/usr/share/rpcd/acl.d/*.json — so +# a reload here only runs the dlopen risk above for every OTHER plugin, for zero gain of its own. +# +# 3. rpcd READS acl.d/*.json AT LOGIN, not only at start or on SIGHUP. Measured: narrow +# rpcd.@login[0].read/write from '*' to an explicit group list, remove the ACL file with rpcd +# already running -> `session access` for uci footstrap write answers false; put the file back +# with NO reload, log in again -> true. A fresh login already sees a just-installed ACL. +# +# 4. On a stock router, root's rpcd login grants read='*' write='*' — blanket. `session access` +# answers true even for an INVENTED object (measured against `uci totally_made_up_pkg_xyz`), so +# the theme's ACL group is irrelevant to a root session from the start. +# +# The one case #3 does not cover: a session that logged in BEFORE this install, on a RESTRICTED +# (non-'*') rpcd login — its in-memory ACL set predates the new grant, and only a fresh login +# re-reads acl.d. Measured: that session stays false until it is forced to re-authenticate. +# Ending the session (not reloading rpcd) fixes exactly that, with a narrower blast radius: the +# postinst below ends a session only when a fresh login would actually gain the scope — denied +# uci/footstrap/write right now, AND its own username's rpcd login lists this theme's ACL group — +# so a restricted login the theme's grant was never added to (a second admin account, a monitoring +# script's login) is left running: re-authenticating would deny it exactly the same as before. +# Verified verbatim on the stand: a blanket-root session survived, a stale restricted session was +# destroyed, the unauthenticated all-zero session was left untouched, and a fresh login answered +# true. The one visible price: an admin on a restricted account WHOSE LOGIN ALREADY LISTS the +# theme's group is logged out by their own install action and must log back in — the narrowest set +# this can be, since a session whose login never grants the group gains nothing from re-logging in. +define Package/luci-theme-footstrap/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + # uci-defaults registers the theme; drop the LuCI caches so it is seen without a reboot. + # default_postinst also runs it; the second pass is idempotent. + [ -f /etc/uci-defaults/30_luci-theme-footstrap ] && \ + sh /etc/uci-defaults/30_luci-theme-footstrap >/dev/null 2>&1 || true + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true + # See the note above the define: no reload. Instead, end a session only when re-authenticating + # would actually hand it the theme's scope: it must be denied uci/footstrap/write right now + # AND its own rpcd login entry must list this theme's ACL group, in either read or write — a + # denied session whose login never grants that group (a second admin account or a monitoring + # script's login the theme was never added to) stays denied after a fresh login too, so ending + # it would only cost someone a session for nothing. Guarded on the `session` object existing at + # all (a router with rpcd built without ubus session support just does nothing here). + ubus list 2>/dev/null | grep -qx session && { + for s in $$(ubus call session list 2>/dev/null | grep -o '"ubus_rpc_session": "[a-f0-9]*"' | cut -d'"' -f4); do + # skips the all-zero session: that one is the UNAUTHENTICATED default and is never + # denied anything by an ACL, so it is not this sweep's business. + case "$$s" in *[!0]*) ;; *) continue ;; esac + ubus call session access "{\"ubus_rpc_session\":\"$$s\",\"scope\":\"uci\",\"object\":\"footstrap\",\"function\":\"write\"}" 2>/dev/null | grep -q true && continue + # A '*' login already answered true above and never reaches here. What is left is a + # denied session: only destroy it if ITS OWN username's rpcd login actually lists the + # theme's ACL group, so a fresh login would gain the scope this sweep is chasing. + u=$$(ubus call session get "{\"ubus_rpc_session\":\"$$s\"}" 2>/dev/null | grep -o '"username": *"[^"]*"' | cut -d'"' -f4) + [ -n "$$u" ] || continue + # Enumerate every login section from `uci show`, not a `uci -q get` index count: the + # latter stops at the FIRST section with no username option (a login block missing that + # option is legal UCI) and never looks at any section past it — fails safe (a session is + # kept, never wrongly destroyed) but skips real sections. `while read` takes one line at + # a time instead of `for`'s word-splitting, because a username may itself contain a + # space, which would otherwise split one section's line into two and skip it. The loop + # runs in a subshell, so its answer cannot survive in a variable — it is echoed out and + # captured instead. A show line's value comes single-quoted + # (`rpcd.@login[0].username='root'`), stripped below. + grant=$$(uci show rpcd 2>/dev/null | grep -F .username= | while read -r kv; do + sect=$${kv%%.username=*} + un=$${kv#*.username=} + un=$${un#\'} + un=$${un%\'} + [ "$$un" = "$$u" ] || continue + for grp in $$(uci -q get "$$sect.read") $$(uci -q get "$$sect.write"); do + [ "$$grp" = luci-theme-footstrap ] && { echo 1; break 2; } + done + done) + [ -n "$$grant" ] || continue + ubus call session destroy "{\"ubus_rpc_session\":\"$$s\"}" >/dev/null 2>&1 || true + done + } +} +exit 0 +endef + +define Package/luci-theme-footstrap/postrm +#!/bin/sh +# opkg runs the OLD package's postrm with arg "upgrade" during a version upgrade and "remove" on a +# real removal. On upgrade this script MUST change nothing: reverting mediaurlbase and wiping the +# theme registration here is what flipped every updating 24.10 user back to bootstrap. apk never +# runs this on upgrade (it uses the new package's pre/post-upgrade), so guarding on the arg is +# correct for both managers. +case "$$1" in *upgrade*) exit 0 ;; esac +[ -n "$${IPKG_INSTROOT}" ] || { + uci -q delete luci.themes.Footstrap + # Don't leave the active theme pointing at the media dir we just removed: a theme needs its + # media dir AND its ucode template to render, so both are checked before handing the UI over. + case "$$(uci -q get luci.main.mediaurlbase)" in + /luci-static/footstrap*) + [ -d /www/luci-static/bootstrap ] && \ + [ -f /usr/share/ucode/luci/template/themes/bootstrap/header.ut ] && \ + uci set luci.main.mediaurlbase=/luci-static/bootstrap ;; + esac + uci commit luci + # The admin-uploaded login background (kept out of the package on purpose, so it survives an + # upgrade). A real removal is the one time it should go — this branch never runs on upgrade. + rm -rf /etc/footstrap >/dev/null 2>&1 || true + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true + # No rpcd reload here either — see the postinst note. A removal drops nothing rpcd needs to + # be told about (no plugin object, ever); a session still holding the now-gone ACL group is + # simply denied uci/footstrap/write on its next call, which is the correct answer anyway. +} +endef + +# Runs right after luci.mk copies luasrc/ucode/htdocs/root/src into PKG_BUILD_DIR, so it rewrites +# the build copy and never the source tree. +# +# 1. cascade.css is generated, not committed — and styles/ is not in luci.mk's copy list, so +# build-css.sh reads it from $(CURDIR) and writes into the build tree. cat and awk only, which +# is why it runs on an OpenWrt buildbot. +# 2. Stamp the git-derived version into fs-version.js. Unconditional: with PKG_VERSION empty it +# falls back to PKG_SRC_VERSION, which luci.mk always defines, so only an un-built source +# checkout keeps the literal '0.0.0-dev'. +# +# The catalogue is luci.mk's business and nothing here compiles it: `po/` is the directory +# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-` package per language. +define Build/Prepare/luci-theme-footstrap + # luci.mk's Build/Prepare copies only src/ luasrc/ htdocs/ root/ ucode/ into + # $(PKG_BUILD_DIR), and PKG_LICENSE_FILES resolves against THAT — so the Apache text has to + # be put there by hand or the declaration points at nothing. + $(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/ + $(SHELL) $(CURDIR)/build-css.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css + # The private `--fs-*` names are 16% of the sheet and mean nothing to a browser — the same trade + # terser makes for the JS. BEFORE strip-templates.sh on purpose: the reserved set is derived by + # reading the JS and the templates, so it must see them whole. Over-reserving costs bytes, + # under-reserving breaks the theme silently. + # The reserved set is read from $(CURDIR), the SOURCE, never from $(PKG_BUILD_DIR): in CI the + # build tree's JS has already been through terser, so its comments are gone and five names that + # are only MENTIONED in a comment stop being reserved. That made the shipped sheet depend on WHO + # built it — 10 reserved names via CI, 15 via a plain SDK build, same source. Reading the source + # over-reserves by ~1 KB and is the safe direction. + $(SHELL) $(CURDIR)/mangle-tokens.sh \ + $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \ + $(CURDIR)/htdocs/luci-static/resources $(CURDIR)/ucode + # Templates are mostly comments and shipped them all. jsmin/terser strip the JS, build-css.sh the + # CSS; the .ut files were simply never included in that trade. Only `{# … #}` goes — see the + # script for why the ucode-code `/* … */` deliberately stays. -22 KB of 60, git keeps every word, + # and dev-sync.sh does NOT run this: a router you are debugging on keeps its comments. + $(SHELL) $(CURDIR)/strip-templates.sh $(PKG_BUILD_DIR)/ucode + # The gate-only exports (`/* fs:probe */`). A handful of module-private functions are also + # listed in a module's baseclass so a gate in the source repository can call them — nothing on a + # router does. The functions stay; the export line goes, so the surface a package carries is the + # one the theme itself uses. + $(SHELL) $(CURDIR)/strip-probes.sh $(PKG_BUILD_DIR)/htdocs/luci-static/resources + # …and the same for the shell under root/ (71% and 95% comment lines). Whole-line `#` only. + $(SHELL) $(CURDIR)/strip-shell.sh $(PKG_BUILD_DIR)/root + # The two static assets luci.mk has no step for: the SVG favicon carries a 753-byte comment and + # the manifest its indentation, and both are fetched by every browser over a link uhttpd does + # not compress. awk only, so this runs on a buildbot with no node. + $(SHELL) $(CURDIR)/strip-assets.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap + $(SED) "s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$(if $(PKG_VERSION),$(PKG_VERSION),$(PKG_SRC_VERSION))'#" \ + $(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js +endef + +# Absolute, not a relative path: CI rsyncs this package into package/, not into the feed. +include $(TOPDIR)/feeds/luci/luci.mk + +# The line below is load-bearing and is NOT a signature — do not delete it as boilerplate. +# +# include/scan.mk builds the package list by GREPPING the Makefiles, not by parsing them: +# find -L package -name Makefile | xargs grep -aHE 'call (Build/DefaultTargets|BuildPackage|KernelPackage)' +# A Makefile that does not match is not in the list, so it is never dumped, no CONFIG_PACKAGE_* +# symbol is emitted for it, and `make package//compile` answers "No rule to make target" with +# no error naming the package anywhere in the build. +# +# This theme never calls BuildPackage itself; luci.mk does, at the include above, and the grep +# cannot see that. So the only thing that puts this package into the SDK's list is the literal text +# on the next line. tools/scan-marker.sh re-derives the grep from scan.mk and fails the build if it +# goes missing. +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-theme-footstrap/README.md b/luci-theme-footstrap/README.md new file mode 100644 index 00000000..4090763f --- /dev/null +++ b/luci-theme-footstrap/README.md @@ -0,0 +1,67 @@ +# luci-theme-footstrap (the package) + +A LuCI theme for OpenWrt **24.10 and newer** (ucode templates). Installing and using it: +[the repository README](../README.md). Developer documentation: [`../docs/`](../docs/README.md). + +Internal name: `footstrap`. Media path: `/luci-static/footstrap`. + +## One theme, one entry + +`luci.themes` carries **exactly one** entry, `Footstrap` → `/luci-static/footstrap`. Layout (top bar +by default, or the side menu), mode, palette, density, wallpaper, rounding and every colour — accent, +the three status colours, the four surfaces — are **client** axes on the Footstrap tab of +System → System: `localStorage` plus attributes and inline properties on `:root`, and nothing is +written to the router until "Save as default" is pressed. +There are no per-layout or per-mode themes; every legacy name (`FootstrapSidebar`, `FootstrapOnTop`, +`…-dark`/`…-light`) is deleted by `root/etc/uci-defaults/30_luci-theme-footstrap`. + +There is one menu renderer too, `menu-footstrap.js`. The top bar is its own markup, morphed by CSS +through `:root[data-layout]`. No second template, no second renderer, no `footstrap-top` symlink. + +**The theme's boundary:** it supplies the chrome and the design language; page content is drawn by +the view JS of `luci-mod-*`. The one exception is `fs-overview.js`, which draws no content of its own +and only re-arranges the stock overview sections. + +## Layout + +``` +Makefile luci.mk; LUCI_MINIFY_CSS:=0; Build/Prepare (CSS, mangle, strip, + version, po2lmo) +styles/ CSS SOURCE: layers tokens / base / theme / pages +build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css +mangle-tokens.sh shorten the private --fs-* names in a built sheet +strip-templates.sh strip-shell.sh drop comments from .ut and from root/**.sh +po/ translation catalogue (Weblate translates this) +ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/ +htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfonts, and no + wallpaper: the Pattern is one the admin uploads, and a font + is one the admin installs (repo: fonts/set-font.sh) +htdocs/luci-static/resources/ menu-footstrap.js (renderer), menu-footstrap-common.js, + fs-{fit,menutree,prefs,widgets,chrome,router,sheets,search, + select,appearance,version,overview}.js +root/etc/uci-defaults/ registration and legacy-name migration +root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background upload +``` + +There is no update checker: the theme is installed from the package feed (`install.sh` adds it), so +`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The Footstrap +tab shows the version it is running and makes no network call to do it. + +**Do not edit `cascade.css`** — it is generated by `build-css.sh` from `styles/` and is gitignored. +Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`. + +## Working on a router + +The normal dev stand is the disposable routers `../owlab.yaml` defines, driven by `owlab` — see +[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over +ssh: + +```sh +./dev-sync.sh # push to `ssh router` (registers the theme but does NOT activate it) +ssh router 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit luci; rm -f /tmp/luci-indexcache*' +ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back +``` + +Before pushing, run `npm run check` from the repository root. The rules and the traps are in +[`../docs/conventions.md`](../docs/conventions.md); packaging is in +[`../docs/package.md`](../docs/package.md). diff --git a/luci-theme-footstrap/build-apk.sh b/luci-theme-footstrap/build-apk.sh new file mode 100755 index 00000000..2035de05 --- /dev/null +++ b/luci-theme-footstrap/build-apk.sh @@ -0,0 +1,120 @@ +#!/bin/sh +# Build luci-theme-footstrap as an OpenWrt .apk via the SDK. The theme is noarch, so the package +# installs on any router of that release whatever its CPU architecture. +# +# ./build-apk.sh # download SDK if needed, then build +# BUILD_DIR=~/x ./build-apk.sh +# +# This is NOT how the release is built — `./tools/stage.sh && owfeed build` is, and it produces both +# formats in seconds without a toolchain. What this exercises is the OTHER path: the Makefile, +# luci.mk, jsmin and the SDK's own packaging, which has to keep working for anyone building the +# theme in an SDK or a feed without owfeed. +set -e + +# The point release and the compiler are both baked into the SDK's filename, so this default goes +# stale twice over: downloads.openwrt.org keeps only the CURRENT point release of a branch, and a +# toolchain bump changes the gcc string. Kept in step with owfeed.lock's `point:` for the 25.12 +# branch, which is what the release builds against. Override with OPENWRT_RELEASE=… and +# OPENWRT_SDK_FILE=… for any other. +REL="${OPENWRT_RELEASE:-25.12.5}" +SDK_BASE="https://downloads.openwrt.org/releases/${REL}/targets/mediatek/filogic" +SDK_FILE="${OPENWRT_SDK_FILE:-openwrt-sdk-${REL}-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst}" +SDK_URL="$SDK_BASE/$SDK_FILE" +# MUST be a case-sensitive fs (ext4/…), NOT an NTFS/9p Windows mount. +BUILD_DIR="${BUILD_DIR:-/tmp/ow-footstrap-build}" +# FORCE=1 overrides buildroot's host-prereq bail-outs (see step 4). +export FORCE=1 +THEME_DIR="$(cd "$(dirname "$0")" && pwd)" # this package +REPO="$(cd "$THEME_DIR/.." && pwd)" # repo root (holds tools/, .github/) +SDK_DIR="$BUILD_DIR/sdk" +# The SDK channel (major.minor) the release SDK is signed under — derived from REL, never a +# second copy: 25.12.2 -> 25.12. +CHANNEL="${REL%.*}" + +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +# 1. SDK +if [ ! -d "$SDK_DIR" ]; then + echo ">> downloading SDK $REL ..." + # --https-only: GNU wget follows https -> http redirects, and this tarball is a toolchain + # that will build a package a maintainer may hand to someone. + wget -q --https-only -O sdk.tar.zst "$SDK_URL" + + # Verify the SDK: signature first, checksum under it. This is the least verified input in this + # repo and the only one that ends up INSIDE the built package (jsmin.c and i18n-scan.pl are + # LINTERS pinned by commit and sha256; the toolchain that compiles the artifact arrives on TLS). + # `sha256sums` alone is NOT a verification — it is served by the same host from the same + # directory, unsigned, so whoever can replace the tarball replaces the checksum beside it (see + # docs/conventions.md on GitHub's asset digest). What makes it one is the ed25519 signature over that file, + # checked with a key pinned from a DIFFERENT host (github.com/openwrt/keyring). Fails CLOSED. + # + # Every pin comes from luci-upstream.pin — the keyring commit, usign, and the branch's own + # signing key. The keys used to live in .github/workflows/build.yml's matrix and were read out + # of it here; that matrix went with the SDK build when the release moved to owfeed, so they now + # have one home and this script has one source. + . "$THEME_DIR/luci-upstream.pin" + [ -n "${OPENWRT_KEYRING_PIN:-}" ] || { echo "OPENWRT_KEYRING_PIN missing from luci-upstream.pin" >&2; exit 1; } + + # sdk_key / sdk_key_sha256 for CHANNEL: 25.12 -> OPENWRT_SDK_KEY_2512. Indirection through + # eval because the pin file is plain shell assignments and /bin/sh has no associative arrays. + CH_VAR="$(printf '%s' "$CHANNEL" | tr -d .)" + eval "SDK_KEY=\${OPENWRT_SDK_KEY_$CH_VAR:-}" + eval "SDK_KEY_SHA256=\${OPENWRT_SDK_KEY_${CH_VAR}_SHA256:-}" + [ -n "$SDK_KEY" ] && [ -n "$SDK_KEY_SHA256" ] \ + || { echo "no SDK signing key for channel $CHANNEL in luci-upstream.pin" >&2; exit 1; } + + echo ">> verifying SDK signature (key $SDK_KEY, channel $CHANNEL) ..." + rm -rf "$BUILD_DIR/usign" + U="$("$REPO/tools/build-usign.sh" "$BUILD_DIR/usign")" + wget -q --https-only -O sha256sums "$SDK_BASE/sha256sums" + wget -q --https-only -O sha256sums.sig "$SDK_BASE/sha256sums.sig" + wget -q --https-only -O openwrt.pub \ + "https://raw.githubusercontent.com/openwrt/keyring/$OPENWRT_KEYRING_PIN/usign/$SDK_KEY" + echo "$SDK_KEY_SHA256 openwrt.pub" | sha256sum -c - >/dev/null \ + || { echo "OpenWrt's $CHANNEL key does not match its pin — refusing to verify with it" >&2; exit 1; } + "$U" -V -m sha256sums -p openwrt.pub -x sha256sums.sig \ + || { echo "BAD SIGNATURE on OpenWrt's sha256sums — refusing to build with this SDK." >&2; exit 1; } + echo ">> signature verified; now the checksum means something." + + WANT="$(grep -F " *$SDK_FILE" sha256sums | cut -d' ' -f1)" + [ -n "$WANT" ] || { echo "no sha256 published for $SDK_FILE" >&2; exit 1; } + GOT="$(sha256sum sdk.tar.zst | cut -d' ' -f1)" + [ "$WANT" = "$GOT" ] || { echo "SDK checksum mismatch: want $WANT, got $GOT" >&2; exit 1; } + rm -f sha256sums sha256sums.sig openwrt.pub + echo ">> SDK verified." + + echo ">> extracting ..." + mkdir -p "$SDK_DIR" + tar --zstd -xf sdk.tar.zst -C "$SDK_DIR" --strip-components=1 + rm -f sdk.tar.zst +fi +cd "$SDK_DIR" + +# 2. feeds (need luci for luci.mk + BuildPackage macros) +if [ ! -f feeds/luci.index ] && [ ! -d feeds/luci ]; then + ./scripts/feeds update base luci +fi +./scripts/feeds install -a -p luci >/dev/null 2>&1 || true + +# 3. drop our theme into the luci themes feed (fresh copy) +DEST="feeds/luci/themes/luci-theme-footstrap" +rm -rf "$DEST" +cp -a "$THEME_DIR" "$DEST" +rm -rf "$DEST/build-apk.sh" "$DEST/dev-sync.sh" "$DEST/.git" 2>/dev/null || true + +./scripts/feeds update -i luci +./scripts/feeds install luci-theme-footstrap + +# 4. build. ncurses is only needed for interactive menuconfig, not for a noarch +# theme, so satisfy the host prereq stamp to skip that check. +mkdir -p staging_dir/host +touch staging_dir/host/.prereq-build +make defconfig FORCE=1 +make package/luci-theme-footstrap/clean FORCE=1 V=s >/dev/null 2>&1 || true +make package/luci-theme-footstrap/compile FORCE=1 V=s + +# 5. locate the artifact +echo +echo ">> built packages:" +find bin -name 'luci-theme-footstrap*' \( -name '*.apk' -o -name '*.ipk' \) -print diff --git a/luci-theme-footstrap/build-css.sh b/luci-theme-footstrap/build-css.sh new file mode 100755 index 00000000..dc3f7356 --- /dev/null +++ b/luci-theme-footstrap/build-css.sh @@ -0,0 +1,274 @@ +#!/bin/sh +# Concatenate the styles/ tree into a single cascade.css. +# +# ./build-css.sh [outfile] [--dev] +# +# Directory order is layer order (styles/, base/, theme/, pages/); filename order is source order +# within a directory. A later layer beats an earlier one whatever the specificity, so nothing needs +# !important to override base. +# +# Minified unless --dev: ~468 KB of source to ~136 KB. uhttpd serves CSS with NO gzip, so every byte +# is a wire byte. Comments and whitespace go; a selector or a declaration is never rewritten, which +# is why LuCI's csstidy stays off (it mangles :has() and color-mix()). Needs only cat and awk, so an +# OpenWrt buildbot can run it. +set -e + +D="$(cd "$(dirname "$0")" && pwd)" +OUT="" +DEV=0 +for a in "$@"; do + case "$a" in + --dev) DEV=1 ;; + # An unknown option used to fall through to `OUT="$a"`: a typo like `--devv` + # wrote the stylesheet to a file named "--devv". + -*) echo "build-css: unknown option: $a" >&2; exit 1 ;; + *) OUT="$a" ;; + esac +done +[ -n "$OUT" ] || OUT="$D/htdocs/luci-static/footstrap/cascade.css" + +for d in styles styles/base styles/theme styles/pages; do + [ -d "$D/$d" ] || { echo "build-css: $D/$d missing" >&2; exit 1; } +done + +TMP="$OUT.tmp.$$" +# $TMP.min too: an awk failure used to leave it behind next to the real output. +trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT +mkdir -p "$(dirname "$OUT")" + +# One `@layer X{` per LAYER, not one per FILE. Every source file carries its own wrapper so it can +# be read and edited alone; concatenated, that is 37 copies of the same six-to-eleven bytes, 542 of +# them. +# +# The wrapper now comes from the DIRECTORY, so a file filed under the wrong layer would be silently +# re-layered instead of just being wrong — hence the check that each file opens with the layer its +# directory means. A file with no wrapper at all (00-header.css: the banner and the layer-order +# statement) is copied through, and must come before the wrapped ones or it would land inside the +# block. +emit_layer() { + layer="$1"; shift + body="$TMP.layer" + : > "$body" + for f in "$@"; do + if head -1 "$f" | grep -q '^@layer '; then + head -1 "$f" | grep -q "^@layer $layer {\$" || { + echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2 + exit 1; } + # the file's own wrapper: its first line, and its last line, which is that wrapper's `}` + sed '1d;$d' "$f" >> "$body" + elif [ -s "$body" ]; then + echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2 + echo "build-css: swallowed into the $layer block instead of staying above it." >&2 + exit 1 + else + cat "$f" + fi + done + if [ -s "$body" ]; then + printf '@layer %s {\n' "$layer" + cat "$body" + printf '}\n' + fi + # not covered by the EXIT trap: the success path runs `trap - EXIT` first + rm -f "$body" +} + +# glob expands in filename order +{ + emit_layer tokens "$D"/styles/*.css + emit_layer base "$D"/styles/base/*.css + emit_layer theme "$D"/styles/theme/*.css + emit_layer page "$D"/styles/pages/*.css +} > "$TMP" + +# Comments, and — unless told not to — the whitespace CSS ignores anyway, in one string-aware +# pass rather than two chained ones. squeeze=0 (the pre-compression rule count below, and every +# `--dev` build) strips comments and trims each line; squeeze=1 also does the ~9.5 KB whitespace +# pass. Why a scan has to be string-aware, what the whitespace pass leaves alone and why, and the +# sed bug this replaced: docs/css.md, "The build". +squeeze() { + awk -v SQUEEZE="$2" ' + BEGIN { q = ""; inc = 0; banner = 0; lastc = ""; buf = ""; lastreal = "" } + { + line = $0 + # a banner already open on an earlier line: nothing on a pure continuation line can + # change q or inc, so the whole line is banner content until its own closing "*/". + if (banner) { + trimmed = line + sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed) + print trimmed + lastc = ""; lastreal = "" + if (index(line, "*/")) banner = 0 + next + } + # The line BREAK a squeeze pass is about to swallow is whitespace, and a declaration + # may be wrapped across it. Feed it to the whitespace-run logic below as a leading + # space: it survives only where a space would and is dropped next to { } ; , : — + # lastc == "" means output is already at the start of a line, with nothing to glue to. + if (SQUEEZE && lastc != "" && q == "" && !inc) line = " " line + out = ""; i = 1; n = length(line); opened = 0 + while (i <= n) { + c = substr(line, i, 1) + if (inc) { # inside a stripped /* ... */ + if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue } + i++; continue + } + if (q != "") { # inside a "..." or '"'"'...'"'"' string + out = out c + if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue } + if (c == q) q = "" + lastreal = "" + i++; continue + } + if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue } + if (c == "/" && substr(line, i + 1, 1) == "*") { + if (substr(line, i + 2, 1) == "!") { + # a genuine banner open: reached only with q=="" and inc==0 AT THIS + # CHARACTER, the same guard strip_comments used before this pass absorbed + # it — so a "/*!" that is really bytes inside a string or an already-open + # ordinary comment never lands here. Copied raw from the marker on, byte + # for byte: it is an Apache-2.0 attribution, not formatting. + banner = 1 + rest = substr(line, i) + out = out rest + if (index(rest, "*/")) banner = 0 + opened = 1 + break + } + inc = 1; i += 2; continue + } + if (SQUEEZE) { + if (c == " " || c == "\t") { # collapse a run of whitespace to one space + while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++ + # the last char EMITTED, which on a continuation line lives on the + # previous output line — hence lastc, not just `out`. + prev = (length(out) ? substr(out, length(out), 1) : lastc) + nxt = (i <= n ? substr(line, i, 1) : "") + # drop it entirely next to a delimiter; otherwise it may be a combinator + # + # `>` is a delimiter too, and the only one that is itself a combinator: a + # space either side of it is decoration. Safe because a `>` outside a + # string can only be the child combinator — the sheet has no media range + # syntax (`@media (width > 600px)`) — and a `>` inside a string never + # reaches here, the scanner having copied it verbatim above. `~` and `+` + # are deliberately NOT joined: `[attr~=v]` and `calc(100% - 10px)` make + # them ambiguous without tracking bracket depth. + if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">") + continue + if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">") + continue + out = out " "; lastreal = " " + continue + } + # THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e. + # INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted + # onto the awk output, and sed cannot see strings: `content: ";}"` came out as + # `content: "}"`, and a data-URI containing `;}` was corrupted the same way + # (both reproduced). Nothing in the tree holds that byte pair today — which is + # how such a bug waits for whoever adds the first one. + # + # The `;` may already sit in the previous line output, so text is held in + # `buf` until the rule closes: a `;` already printed cannot be taken back. + if (c == "}") { + if (length(out) && substr(out, length(out), 1) == ";") + out = substr(out, 1, length(out) - 1) + else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";") + buf = substr(buf, 1, length(buf) - 1) + } + out = out c; lastreal = c; i++ + } else { + out = out c; i++ + } + } + if (opened) { + trimmed = out + sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed) + print trimmed + lastc = ""; lastreal = "" + next + } + if (SQUEEZE) { + buf = buf out + if (length(out)) lastc = substr(out, length(out), 1) + # newline only after a closing brace — one rule per line. lastreal, not lastc: a + # line ending in a QUOTED `}` (content: "}") is not the end of a rule, and + # flushing there would split the rule and lose the space before its next token. + if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" } + } else { + sub(/^[ \t]+/, "", out) + sub(/[ \t]+$/, "", out) + if (length(out)) print out + } + } + END { if (SQUEEZE) { if (length(buf)) print buf; else printf "\n" } } + ' "$1" +} + +# Fail loudly rather than let an unbalanced block ship. +# +# String-aware, for the same reason the comment stripper is: a brace inside a CSS STRING is not a +# block, so a counter that gsub()s over the raw line makes a perfectly valid rule fail the build. +# Measured, all three shapes: `content: ";}"`, `content: "{"`, and a data-URI carrying `;}`. It +# fails closed, so nothing is ever corrupted; what it costs is a hunt for an imbalance that is not +# there. +brace_count() { + awk ' + BEGIN { q = "" } + { + line = $0; n = length(line); i = 1 + while (i <= n) { + ch = substr(line, i, 1) + if (q != "") { # inside a string + if (ch == "\\") { i += 2; continue } # escape: skip the pair + if (ch == q) q = "" + i++; continue + } + if (ch == "\"" || ch == "'"'"'") { q = ch; i++; continue } + if (ch == "{") o++ + else if (ch == "}") c++ + i++ + } + } + END { + if (o != c) { printf "build-css: %s: unbalanced braces (%d { vs %d })\n", FILENAME, o, c > "/dev/stderr"; exit 1 } + if (o < 100) { printf "build-css: %s: suspiciously few rules (%d)\n", FILENAME, o > "/dev/stderr"; exit 1 } + print o + }' "$1" +} + +# Always brace-check a COMMENT-STRIPPED, UNSQUEEZED copy, --dev included: counting braces in the +# raw file made a stray "{" in prose fail the build on perfectly valid CSS. +squeeze "$TMP" 0 > "$TMP.min" +RULES_BEFORE=$(brace_count "$TMP.min") || exit 1 +rm -f "$TMP.min" + +if [ "$DEV" -eq 0 ]; then + squeeze "$TMP" 1 > "$TMP.min" + mv "$TMP.min" "$TMP" + + # AND AGAIN, on what actually ships: the check above only saw the squeeze's INPUT, yet + # the squeeze is the pass most able to corrupt the sheet — it tracks strings, joins lines + # and deletes the `;` before a `}`. An unchanged rule count is what says it did not. + # + # That equality — not brace balance on its own — is also why the 80 KB FS_CSS_FLOOR size + # guard once here is gone. Balance alone misses a cut right after any of the four + # `@layer NAME { … }` wrappers closes: everything up to that point is still a matched set, so + # a truncated write — a full disk, a compression that ate the tail — that stops exactly there + # reads as a valid, complete-looking file. Fed the real sheet, a cut right after the base + # layer's own close counts a balanced 283 rules against the file's true 1057. What catches + # that is the COUNT: RULES_BEFORE is measured from the untruncated concatenation before this + # pass runs, so a write that stops early can only ever come up short against it. + RULES_AFTER=$(brace_count "$TMP") || exit 1 + if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then + echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2 + exit 1 + fi +fi +# --dev: $TMP is still the raw concatenation — comments and formatting intact, for reading on a +# router rather than shipping. + +SIZE=$(wc -c < "$TMP" | tr -d ' ') +mv "$TMP" "$OUT" +trap - EXIT + +echo "build-css: $SIZE bytes -> $OUT" diff --git a/luci-theme-footstrap/dev-sync.sh b/luci-theme-footstrap/dev-sync.sh new file mode 100755 index 00000000..e11956d8 --- /dev/null +++ b/luci-theme-footstrap/dev-sync.sh @@ -0,0 +1,146 @@ +#!/bin/sh +# Push the footstrap theme to a HARDWARE router over ssh (containers use owlab). +# ONE theme, one luci.themes entry; layout (top/sidebar) is a client axis on the Footstrap tab of +# System -> System, not a theme entry. This registers the theme; it does NOT activate it. +set -e + +R="${1:-router2512}" +N=footstrap +D="$(cd "$(dirname "$0")" && pwd)" + +# cascade.css is generated from styles/ and is not in git. --dev keeps the comments so the +# file on the router still reads like the source. +"$D"/build-css.sh "$D/htdocs/luci-static/$N/cascade.css" --dev + +ssh "$R" "mkdir -p /usr/share/ucode/luci/template/themes/$N \ + /www/luci-static/$N \ +" + +# the ONE template (+ partials/). Sidebar and top bar are the same markup, morphed by +# :root[data-layout] — there is no second theme dir to copy. +scp -q "$D"/ucode/template/themes/$N/*.ut "$R":/usr/share/ucode/luci/template/themes/$N/ +ssh "$R" "mkdir -p /usr/share/ucode/luci/template/themes/$N/partials" +scp -q "$D"/ucode/template/themes/$N/partials/*.ut "$R":/usr/share/ucode/luci/template/themes/$N/partials/ + +# shared static: cascade.css and the two logo files, which is all this directory holds. No +# wallpapers — the doodles this theme once downloaded from GitHub are gone, and nothing in the +# package fetches anything at run time; the Background axis paints an SVG the admin uploads. +scp -qr "$D"/htdocs/luci-static/$N/* "$R":/www/luci-static/$N/ + +# EVERY resource JS, by GLOB — never by name. This used to list the four files +# individually, so a FIFTH would be shipped by the package (luci.mk copies htdocs/ +# wholesale) yet silently never reach the dev router — first tested after a release. +# +# A TAR OF THE TREE, not `scp *.js`: the flat glob does not descend, so anything a subdirectory +# holds shipped in the package (luci.mk copies htdocs/ wholesale) and reached the dev router never — +# the same class of miss the glob above was written to end, one directory level down. Every path +# under resources/ is ours to place. +tar -C "$D/htdocs/luci-static/resources" -cf - . | ssh "$R" "mkdir -p /www/luci-static/resources && tar -C /www/luci-static/resources -xf -" + +# stamp the git-derived version into the deployed fs-version.js (the package does the same in +# Build/Prepare) so the Footstrap tab shows a real version instead of "(dev)". The FILE NAME is part +# of the contract: FS_VERSION lives in fs-version.js and moving it means changing every sed. +FS_V="$(git -C "$D" describe --tags --always 2>/dev/null | sed 's/^v//')" +# if-form, not `[ -n ] && ssh`: under `set -e` a bare `&&`-list whose LAST command fails is what +# aborts, so `[ -n "$FS_V" ] && ssh …` would take the sync down whenever git describe yielded +# nothing (a copied tree without .git). An `&&` guarding an ASSIGNMENT is a different shape and is +# safe. expr refuses a tag with sed- or shell-special characters rather than interpolating it. +if [ -n "$FS_V" ] && expr "$FS_V" : '[0-9A-Za-z._-]*$' >/dev/null; then + ssh "$R" "sed -i \"s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$FS_V'#\" /www/luci-static/resources/fs-version.js" +fi + +# The catalogue. Nothing in Build/Prepare compiles it, and the release does not come from luci.mk +# either — owfeed bundles the catalogues inside the theme package — so a dev router gets them from +# nowhere unless this sync makes them. po2lmo is a luci-base host tool, absent from a dev machine +# by default, so this is best-effort: without it the strings stay English and the sync still +# succeeds. (Build it once from the pinned luci checkout: make -C modules/luci-base/src po2lmo — +# plural_formula.c is GENERATED by contrib/lemon, so a bare cc over the .c files cannot work.) +# +# The basename is `footstrap-theme`, matching owfeed.yml and NOT the po file's own name: +# lmo_load_catalog globs *..lmo so any basename loads, and a v0.8.4 router still owns +# footstrap.ru.lmo through the old luci-i18n package, where the same path is a file conflict. +if command -v po2lmo >/dev/null 2>&1; then + ssh "$R" "mkdir -p /usr/lib/lua/luci/i18n" + for po in "$D"/po/*/*.po; do + [ -e "$po" ] || continue + lang="$(basename "$(dirname "$po")")" + po2lmo "$po" "/tmp/footstrap-theme.$lang.lmo" + scp -q "/tmp/footstrap-theme.$lang.lmo" "$R":/usr/lib/lua/luci/i18n/ + rm -f "/tmp/footstrap-theme.$lang.lmo" + done +else + echo " (po2lmo not found — skipping the translation catalogue; strings stay English)" +fi + +# root/ -> / as a TREE, never as a list of names. luci.mk installs root/ wholesale, so a file named +# here one-by-one is a file that ships in the package and silently never reaches the dev router — +# first noticed after a release. The top-level dirs come from a GLOB for the same reason: this used +# to tar `usr` literally, which is a hand-written list of one, and root/etc/ was the counterexample +# already sitting in the tree — root/etc/config/footstrap shipped in the package and never reached +# the router. tar, not `scp -r`, because scp's merge semantics on an existing /usr are ambiguous. +# +# Two subtrees have semantics a blind tar would break, so each is excluded and handled explicitly +# below. Both exclusions are about BEHAVIOUR, not about naming files: a new file under either dir +# still arrives for free. +# - etc/uci-defaults: deliberately run from /tmp (see below). Landing it in /etc/uci-defaults +# would make the router's next boot execute it and then DELETE it. +# - etc/config: conffiles. The package manager does not overwrite them on upgrade (Makefile's +# conffiles define) and neither may dev-sync — the live file holds the admin's "Save as +# default", and clobbering it here would be the very wipe that define exists to prevent. +set -- +for _d in "$D"/root/*/; do set -- "$@" "$(basename "$_d")"; done +tar -C "$D/root" --exclude=etc/uci-defaults --exclude=etc/config -cf - "$@" | ssh "$R" "tar -C / -xf -" + +# conffiles: install only when ABSENT, which is exactly what the package manager does with them. +for _f in "$D"/root/etc/config/*; do + [ -f "$_f" ] || continue + _b=$(basename "$_f") + scp -q "$_f" "$R":"/tmp/.fs-conf-$_b" + ssh "$R" "[ -f /etc/config/$_b ] || { mv /tmp/.fs-conf-$_b /etc/config/$_b; echo ' installed /etc/config/$_b (was absent)'; }; rm -f /tmp/.fs-conf-$_b" +done +# No rpcd reload here — same race as the Makefile's postinst note (Package/luci-theme- +# footstrap/postinst): this copies files onto a router where the theme registers no rpcd object, +# rpcd re-reads acl.d/*.json at LOGIN (not only on reload), and a developer's root session already +# carries read='*' write='*', so the theme's ACL group is irrelevant to it regardless. +ssh "$R" "rm -f /tmp/luci-indexcache*" + +ssh "$R" " +# Sweep every pre-consolidation name, including $N-top: the top bar is not a theme any more, so a +# stale mediaurlbase pointing at its media dir would still render a theme LuCI no longer lists. +# +# rm -rf, not rm -f: these are DIRECTORIES (and $N-top a symlink to one, which rm -rf removes as the +# link rather than its target). \`rm -f\` refuses a directory, 2>/dev/null swallows the error, and +# this block runs without set -e — so the sweep silently does nothing. Every path is a literal built +# from \$N: no glob, no user input. +cd /www/luci-static +rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light +cd /usr/share/ucode/luci/template/themes +rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light +# The overview layout must not be left in luci-mod-status's global include dir, where LuCI loads +# every *.js under any theme: it is a chrome module now. A package upgrade drops the old file, but +# dev-sync copies rather than installs, so it has to be swept by name — otherwise the dev router +# runs both copies and the grid is arranged twice. +rm -f /www/luci-static/resources/view/status/include/05_footstrap_overview_layout.js +for db in /lib/apk/db/installed /usr/lib/opkg/status; do [ -f \"\$db\" ] && touch \"\$db\"; done +rm -f /tmp/luci-indexcache*" + +# Register by running the package's own uci-defaults script — the single source of truth +# (ONE entry, luci.themes.Footstrap; layout, mode and palette are client-side toggles). +# PKG_UPGRADE=1 forces its upgrade branch, so the dev router's active theme is left alone. +scp -q "$D"/root/etc/uci-defaults/30_luci-theme-footstrap "$R":/tmp/30_luci-theme-footstrap +ssh "$R" "PKG_UPGRADE=1 sh /tmp/30_luci-theme-footstrap; rm -f /tmp/30_luci-theme-footstrap /tmp/luci-indexcache*" + + +# ---- bust the browser cache, or a dev deploy is invisible to the device you are testing on ---- +# +# head.ut versions every asset with `?v={{ pkgs_update_time }}`, and luci-base derives that from the +# mtime of the PACKAGE DATABASE. Installing a package moves it; scp'ing files over an existing +# install does not — so a dev deploy leaves the URL identical while the bytes change, and a browser +# is entitled to keep serving what it has. On a phone that is not a theory: an afternoon of "still +# shakes" reports were taken against a stylesheet several deploys old. +# +# Touching the database file is what an install would have done to it, so the next page carries a +# new `?v=` for every asset. Router-only, dev-only: nothing in the package does this. +ssh "$R" 'touch /lib/apk/db/installed 2>/dev/null || touch /usr/lib/opkg/status 2>/dev/null || true' + +echo "synced to $R (single Footstrap theme registered, active theme unchanged)" diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png new file mode 100644 index 00000000..25993664 Binary files /dev/null and b/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png differ diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo.svg b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo.svg new file mode 100644 index 00000000..477fd37d --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png new file mode 100644 index 00000000..f29b31f9 Binary files /dev/null and b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png differ diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json new file mode 100644 index 00000000..1694b462 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "/cgi-bin/luci/", + "name": "LuCI", + "short_name": "LuCI", + "start_url": "/cgi-bin/luci/", + "scope": "/cgi-bin/luci/", + "display": "standalone", + "background_color": "#f6f8fa", + "theme_color": "#f6f8fa", + "icons": [ + { + "src": "/luci-static/footstrap/app-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js new file mode 100644 index 00000000..87c126c6 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js @@ -0,0 +1,842 @@ +'use strict'; +'require baseclass'; +'require ui'; +'require dom'; +'require fs-prefs as prefs'; +'require fs-axes as axes'; +'require fs-assets as assets'; +'require fs-version as ver'; + +/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js + * holds the axes and fs-version.js the version string; this file is the form they are shown in. + * + * Built once, for the dispatched page: `view/footstrap/appearance.js` — a menu entry, an ordinary + * route — calls `renderStandalone()` below, the whole surface this file exposes. Before the page + * existed the same form was stapled onto a tab on System -> System instead, watching for the stock + * form's own tab strip with a MutationObserver, a 5 s deadline, a `body[data-page]` tracker, a + * stale-group WeakSet disqualifying the outgoing page's own DOM from a false match, and a + * sessionStorage flag so a reset's reload landed back on the tab rather than whichever one LuCI + * remembered. A Save & Apply on a real router once lost that tab outright, never reproduced on + * either stand — a mount stapled onto another app's own render is a timing dependency on that + * render, by construction, and the most likely account of a user report that could never be + * reproduced either way. All of it is gone now that the page owns a route of its own. Twenty-one + * axes, nine of them with a colour field, a swatch and a contrast readout, do not fit a floating + * popover that has to trap Tab and stay inside a 320px column, and keeping both containers would + * render every axis twice. + * + * The version line makes no request and must not grow one: which version is INSTALLED is what this + * page answers, and which is available is the package manager's question. */ + +/* ---- colour: reading what the page is actually painted ---- + * + * This lived in fs-widgets.js, which the menu and the search palette also require — so the whole + * colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe, + * canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was + * fs-widgets' only colour export and this file its only consumer. */ + +/* ---- colour: reading what the page is actually painted ---- + * + * Two questions no stored value answers: what colour a role is right now (the palette's own while + * the axis is off — there is deliberately no copy of the palette in JS), and what contrast the + * user's colour lands at. Both are about the computed cascade, so both are asked of the browser. + * + * `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property + * computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back + * unevaluated. Setting the expression as a real `color` and reading it back makes the browser + * resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of. + * One hidden probe is reused; an element per query would thrash layout on every slider drag. */ +let _probe = null; +function probeColor(expr) { + if (!_probe) { + /* Off-screen rather than display:none, so the reading does not depend on a display:none + * element computing `color` in every engine. It has no text and no size, so it paints + * nothing. + * + * Every declaration is !important: this is an unmarked element in a document + * shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks + * a layer and a plain inline style alike. A probe that loses its own colour reports the + * app's, which then becomes the admin's saved axis on the next confirm. */ + /* the id is this probe's escape from fs-router.js's strayBodyNode(): it is + * appended once and never removed, so without an fs-* id it would read as body litter and + * force every navigation away from this page into a full load. */ + _probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' }); + _probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;' + + 'width:0!important;height:0!important;overflow:hidden!important;' + + 'pointer-events:none!important;'; + document.body.appendChild(_probe); + } + /* cleared first: an expression the engine rejects leaves the previous colour standing, which + * would report a stale answer as a fresh one */ + _probe.style.setProperty('color', ''); + _probe.style.setProperty('color', expr, 'important'); + return getComputedStyle(_probe).color; +} + +/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps + * the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the + * wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read", + * in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine + * convert instead (the export tier is checked the same way). Canvas 2D is universal on every + * engine 24.10 ships against, so there is no second path. */ +let _cx = null; +function rasterCtx() { + if (!_cx) { + const cv = document.createElement('canvas'); + cv.width = cv.height = 1; + _cx = cv.getContext('2d', { willReadFrequently: true }); + } + return _cx; +} +function parseColor(s) { + const cx = rasterCtx(); + /* fillStyle keeps the last value it could parse, so a colour this engine rejects would + * report the previous one as a fresh reading — the trap probeColor() clears for */ + cx.fillStyle = '#000'; + cx.fillStyle = String(s || ''); + cx.clearRect(0, 0, 1, 1); + cx.fillRect(0, 0, 1, 1); + const d = cx.getImageData(0, 0, 1, 1).data; + /* translucent: composite over nothing is meaningless for a readout */ + return d[3] === 255 ? [ d[0], d[1], d[2] ] : null; +} + +/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states + * what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css + * derives the ink over a fill, which is a different question). */ +function luminance(rgb) { + const c = rgb.map((v) => { + const x = v / 255; + return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4); + }); + return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]); +} +function contrastRatio(fgExpr, bgExpr) { + const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr)); + if (!fg || !bg) return null; + const a = luminance(fg), b = luminance(bg); + return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); +} + +/* #rrggbb, because accepts nothing else. An unparseable colour becomes black + * rather than throwing: the text field beside the swatch is the authoritative one. */ +function toHex(s) { + const rgb = parseColor(s) || [ 0, 0, 0 ]; + return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join(''); +} + +/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour. + * Reports through onPick as a hex string, or 0 for "back to the palette", either of which the + * caller hands straight to fs-prefs.js's colorAxis. + * + * There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma, + * so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet + * still rotates the palette by one, so a saved value goes on working. + * + * `opts.probe` is the live token the effective colour is read back from, so the field shows the + * palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is + * the pair whose ratio is reported under the row. */ +function colorControl(onPick, label, opts) { + const o = opts; + + /* type=color leaves the picker to the browser: accessible without reimplementing a colour + * wheel, and native on a phone. The text field beside it takes a pasted hex and is the + * fallback where the browser draws no picker. */ + const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' }); + const field = E('input', { + 'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off', + 'inputmode': 'text', 'maxlength': '7', 'aria-label': label || '' + }); + const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]); + const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null; + + /* Repaint everything that mirrors the axis. Called after every edit, and through the returned + * refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's + * own colour is while this axis stays off. */ + function reflect(v) { + const live = probeColor(o.probe); + const hex = (typeof v === 'string') ? v : toHex(live); + swatch.value = hex; + /* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on + * every keystroke made the input impossible to type into */ + if (document.activeElement !== field) field.value = hex; + /* the button back to the palette doubles as the axis state readout: enabled means the axis + * holds a colour of its own, disabled means the field shows the palette's */ + clear.disabled = !v; + if (!ratio) return; + const r = contrastRatio(o.contrast.fg, o.contrast.bg); + if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; } + /* The readout states what the ratio means; the number itself stays in the title. + * Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a + * hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint + * border is a legitimate choice. + * + * Class names are written out whole: dead CSS is found by matching fs-* tokens in the + * source, and a concatenated name is invisible to that match. */ + const where = o.contrast.label; + const grade = (o.contrast.kind === 'shape') + ? ((r >= 3) + ? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) } + : { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) }) + : (r >= 4.5) + ? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) } + : (r >= 3) + ? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) } + : { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) }; + ratio.className = 'fs-color-contrast ' + grade.cls; + ratio.textContent = grade.text; + ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap') + .format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5'); + } + + const pick = (v) => { onPick(v); reflect(v); }; + + swatch.addEventListener('input', () => pick(swatch.value.toLowerCase())); + /* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page + * under the cursor. An unparseable value snaps back to what the axis holds, so the field + * cannot claim a colour the page is not painted in. */ + const commit = () => { + const v = field.value.trim().toLowerCase(); + if ((/^#[0-9a-f]{6}$/).test(v)) pick(v); + /* re-read rather than a cached copy: the page can change what the axis holds behind this + * control (a preset, Reset to default), which a snapshot taken at build time would miss */ + else reflect(o.read()); + }; + field.addEventListener('blur', commit); + field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); + clear.addEventListener('click', () => pick(0)); + + const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [ + E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ]) + ].concat(ratio ? [ ratio ] : [])); + /* the caller decides when this runs: probeColor() needs the document, and this control is not + * in it yet */ + wrap.fsRefresh = () => reflect(o.read()); + return wrap; +} + +/* Build the whole form. Returns the root element, for renderStandalone() below to hand to the + * page's own render(). + * + * Everything applies immediately and there is nothing to save: every axis is this browser's, in + * localStorage, and the page repaints under the control as it moves. Only "Save to router" writes + * anything, pushing the current look to the ROUTER for other browsers. That distinction is the + * model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own — + * view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, which is what actually + * drops it: view.js's own default footer (Save & Apply | Apply unchecked | Save | Reset, wired to + * apply_rollback) renders and runs otherwise, POSTing on a form with nothing staged (measured, + * live, before that null). */ +function build() { + /* every saved axis re-checks the Save button after applying, so it greys the moment this + * browser matches the saved default and un-greys when it diverges. Wrapped around the appliers + * because the controls call them directly and have no other seam back here. */ + const bump = (fn) => (v) => { fn(v); refreshSave(); }; + + /* Every colour control mirrors something it does not own: the palette's colour while its own + * axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves + * all of it under controls nobody touched, so they refresh together. */ + const colourCtls = []; + const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh()); + /* wrap an applier so the colour readouts follow it: mode and palette change what every axis is + * measured against */ + const repaint = (fn) => (v) => { fn(v); refreshColours(); }; + + /* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` + + * `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and + * theme/60-inputs.css already lay them out — so this page inherits every future fix to the form + * layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2, + * where an app may win on specificity), which is right for a page inside #view. + * + * `make` is handed the same label string the caption renders, because every control needs it a + * second time as its aria-label; stating it twice is how the visible caption and what a screen + * reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks + * the rows CSS has to single out. */ + const group = (label, make, opts) => { + const o = opts || {}; + return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [ + E('label', { 'class': 'cbi-value-title' }, [ label ]), + E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || [])) + ]); + }; + + /* the three literals every colour row repeats; a string literal survives minification intact */ + const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap'); + + /* ---- the controls are LuCI's own ---- + * + * Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other + * tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css, + * `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports + * — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further + * back. + * + * Listen for `widget-change` from UIElement, not for an event on the inner element: that is the + * seam the widget publishes, and reaching past it ties this to how the widget happens to be + * built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes + * the page under the drag; the appliers are idempotent, so wiring both costs nothing. */ + const selectCtl = (current, choices, apply, label) => { + const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) }); + const node = w.render(); + node.setAttribute('aria-label', label); + node.addEventListener('widget-change', () => apply(w.getValue())); + return node; + }; + + const sliderCtl = (current, min, max, apply, label, opts) => { + const o = opts || {}; + const w = new ui.RangeSlider(String(current), { + min: min, max: max, step: o.step || 1 + }); + const node = w.render(); + node.setAttribute('aria-label', label); + const push = () => apply(parseInt(w.getValue(), 10)); + /* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column + * the Appearance page is drawn in, so applying mid-drag slides the handle out from under + * the pointer, the pointer catches up, and one small drag runs the value to the maximum — + * reported from a router, and not a thing any other slider here can do: rounding and the + * tint sliders repaint around a control that stays where it is. On release the page still + * follows, and a keyboard user is unaffected either way: an arrow key on a range input + * fires `change` as well as `input`, so the value applies at once. */ + if (o.live !== false) node.addEventListener('widget-update', push); + node.addEventListener('widget-change', push); + return node; + }; + + /* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `probe` is + * the live token the control reads the effective colour back from, `contrast` the pair it + * reports */ + const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => { + const ctl = colorControl(bump(axis.apply), lbl, { + probe: probe, + read: axis.current, + contrast: contrast, + cls: opts.cls + }); + colourCtls.push(ctl); + return ctl; + }, opts); + + /* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves + * one merged catalogue — load_catalog() loads every *..lmo and a lookup returns the first + * archive holding the hash — so a bare msgid is a global name any luci-app may take, and + * readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router + * because another catalogue translates "Top" as "maximum". Contexting cannot be + * selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are + * deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme + * has no catalogue for. */ + + /* ---- section 1: the shell ---- */ + const shell = [ + group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), { + sidebar: _('Sidebar', 'footstrap'), + top: _('Top', 'footstrap') + }, bump(prefs.applyLayout), label)), + + group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), { + auto: _('Auto', 'footstrap'), + light: _('Light', 'footstrap'), + dark: _('Dark', 'footstrap') + }, bump(repaint(prefs.applyMode)), label)), + + group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), { + footstrap: 'Footstrap', + hicontrast: 'Hi-Contrast', + /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is — + * so it is a proper noun and stays untranslated, like the two above it */ + bootstrap: 'Bootstrap', + /* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */ + '2020': 'OpenWrt 2020', + /* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is — + * a proper noun like the three above it, not the English common noun "forum" */ + forum: 'Forum' + }, bump(repaint(axes.palette.apply)), label)), + + group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { + compact: _('Compact', 'footstrap'), + normal: _('Normal', 'footstrap'), + large: _('Large', 'footstrap') + }, bump(prefs.applyDensity), label)), + + /* the content column's own cap, separate from Density (which moves type and + * air, not the column's ceiling). The slider STARTS at the 1280px the theme has always + * shipped and only ever widens — there is no reason to offer a column narrower than the + * one every page was designed against, and keeping 1280 as the left end also keeps + * --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach + * by construction rather than by a rule someone has to remember. */ + group(_('Content width', 'footstrap'), + (label) => sliderCtl(axes.contentWidth.current(), 1280, 3840, + bump(axes.contentWidth.apply), label, { step: 40, live: false })), + + group(_('Rounding', 'footstrap'), + (label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)), + + /* The top layout has no accordion, so this switch is meaningless there: always built, + * hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an + * `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze + * the control to the layout the page loaded in while CSS morphs the chrome live. */ + group(_('Submenus', 'footstrap'), (label) => selectCtl( + prefs.currentAutoCollapse() ? 'on' : 'off', { + off: _('Keep open', 'footstrap'), + on: _('Auto-collapse', 'footstrap') + }, bump(prefs.applyAutoCollapse), label), + { cls: 'fs-ap-submenus' }) + ]; + + /* ---- section 2: colours ---- */ + const colours = [ + /* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody + * would look for the router-identity cue under it */ + colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', { + /* the canvas is the one axis with no derived ink: its text is --fs-text, a palette + * token this axis must not move, so the ratio is reported instead of corrected */ + fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap') + }, { cls: 'fs-ap-tint' }), + + /* The strength half of the Tint, meaningful only in hue mode: a hex canvas is the colour + * asked for, with no chroma of ours to scale. CSS hides it in the other two states. + * + * Not called "Density": that is the select above, and this string is both the caption and + * the aria-label, so a screen reader would announce two rows under one name. */ + group(_('Tint strength', 'footstrap'), + (label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, { + step: 5 + }), { cls: 'fs-ap-tint fs-ap-tintstr' }), + + /* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas. + * Measured as text on a card, the use that fails first: as a fill it carries derived ink, + * as a link or status label it carries only itself. It is also what answers #20 ("sometimes + * you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here + * are not coming back. */ + /* Four status roles, one shape: the role's own colour read against a card. Written out + * eight times between here and the surfaces below, they cost their repeated literals in + * full — a string is not mangled — so the rows are data and the row is stated once. */ + ...[ + [ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ], + [ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ], + [ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ], + [ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ] + ].map(([ label, axis, ink ]) => + colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })) + ]; + + /* ---- the surfaces: the sheet the UI is drawn on ---- + * Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on + * each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the + * palette's, and moving it would recolour the very thing being measured against. + * + * The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label + * — and below that it is decoration, which a hairline is entitled to be, so the readout states + * the number and leaves the call to the admin. */ + const surfaces = [ + /* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text + * for the three that carry body text and the hairline itself for the border. */ + ...[ + [ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ], + [ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ], + [ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ], + [ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ] + ].map(([ label, axis, probe, fg, bg, where, kind ]) => + colourGroup(label, axis, probe, { fg, bg, label: where, kind })) + ]; + + /* ---- section 3: the wallpaper and the rows each value brings ---- + * + * Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an + * uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of + * its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column + * inside the first, and those controls started 216px right of every other one on the page, + * measured on the router. Flat rows hidden as a group is what the stock pages do with a + * dependent field. + * + * The select is the per-browser switch deciding whether to paint an image, so it is what keeps + * the Save button honest; Choose/Remove only swap the picture and never touch the axis. The + * native file inputs stay hidden — the styled buttons trigger them. */ + const wallpaper = (() => { + const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); + /* display:none, not `hidden`: a bare `hidden=""` still renders the native + * "Choose File / No file chosen" control */ + const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' }); + const chooseLabel = _('Choose image', 'footstrap'); + const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]); + const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); + + const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); + const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' }); + const patChooseLabel = _('Choose SVG', 'footstrap'); + const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]); + const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); + + /* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS + * and snapshotAxes(), so it moves this browser toward or away from the router default and + * must be bump()-ed like every other saved axis, or the Save button misreports its own + * status. Separate from the Tint strength above. */ + const dimLabel = _('Dim', 'footstrap'); + const scaleLabel = _('Scale', 'footstrap'); + const strengthLabel = _('Strength', 'footstrap'); + const inkLabel = _('Colours', 'footstrap'); + + /* The rows the pattern brings. Scale and Strength are live: the appliers write a custom + * property, so the tile resizes and fades under the drag. Colours decides whether the + * file's own palette is kept or replaced by the theme's — a mask uses the alpha only, + * which is right for line art and wrong for artwork that carries its own colours. */ + const patRows = [ + group(_('Pattern', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), + { extra: [ patInput, patPreview, patErr ] }), + group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600, + bump(axes.patternSize.apply), lbl, { step: 20 })), + group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100, + bump(axes.patternStrength.apply), lbl, { step: 5 })), + group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), { + theme: _('Theme', 'footstrap'), + original: _('As in file', 'footstrap') + }, bump(axes.patternInk.apply), lbl)) + ]; + /* …and the rows the FILE photo brings. */ + const fileRows = [ + group(_('File', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), + { extra: [ fileInput, preview, err ] }), + group(dimLabel, (lbl) => sliderCtl(axes.photoDim.current(), 0, 100, + bump(axes.photoDim.apply), lbl, { step: 5 })) + ]; + + function reflect(tok) { + if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; } + else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } + } + function reflectPattern(tok) { + if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; } + else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } + } + /* `hidden` on the row, which 80-appearance.css restates at a specificity beating + * `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not + * removed: each row holds a live control, so rebuilding on every switch would freeze it to + * the state it was constructed in. */ + function togglePanel(v) { + patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); + fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); + } + reflect(axes.loginBg.current()); + reflectPattern(axes.pattern.current()); + togglePanel(axes.wallpaper.current()); + + const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); }; + + /* Both uploads present the same three controls and the same four states — pick, upload, + * report, remove — so the wiring is stated once. What differs is `after`: the pattern also + * has to move the Wallpaper dropdown, because the upload switched this browser onto the + * tile and the page would otherwise paint it while the control still read Off. + * + * The file input is cleared on every change so re-picking the SAME file fires `change` + * again, and the button carries its own busy state: the label is restored in `finally`, or + * a failed upload leaves "Uploading…" standing for the life of the form. */ + const wireUploader = (u) => { + const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; }; + u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); }); + u.input.addEventListener('change', () => { + const f = u.input.files && u.input.files[0]; + u.input.value = ''; + if (!f) return; + u.err.hidden = true; u.choose.disabled = true; + u.choose.textContent = _('Uploading…', 'footstrap'); + u.upload(f) + .then((tok) => { u.reflect(tok); if (u.after) u.after(tok); }) + .catch(fail) + .finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; }); + }); + u.remove.addEventListener('click', () => { + u.err.hidden = true; u.remove.disabled = true; + u.drop().then(() => u.reflect('')).catch(fail) + .finally(() => { u.remove.disabled = false; }); + }); + }; + + wireUploader({ + choose: patChoose, remove: patRemove, input: patInput, err: patErr, + label: patChooseLabel, reflect: reflectPattern, + upload: assets.uploadPattern, drop: assets.removePattern, + /* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is + * then called directly, because a programmatic setValue emits no `widget-change`. */ + after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); } + }); + + wireUploader({ + choose: chooseBtn, remove: removeBtn, input: fileInput, err: err, + label: chooseLabel, reflect: reflect, + upload: assets.uploadLoginBg, drop: assets.removeLoginBg + }); + + let seg; + const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { + seg = selectCtl(axes.wallpaper.current(), { + off: _('Off', 'footstrap'), + pattern: _('Pattern', 'footstrap'), + file: _('File', 'footstrap') + }, setWallpaper, label); + return seg; + }); + + return [ wallRow ].concat(patRows, fileRows); + })(); + + /* ---- section 4: the router default and the version ---- + * + * Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over + * the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the + * saved default only shows on a fresh browser. The two Reset buttons below are the escape + * hatches, and they do not land in the same place. */ + /* the button/status label said three times below, inside this one build() call — hoisting the + * RESULT, not the msgid, so update-po.sh still sees the literal `_()` argument elsewhere + * (measured: 22 B x3 -> 27 B, 39 B saved) */ + const SAVE_TO_ROUTER = _('Save to router', 'footstrap'); + /* said twice below, same build() call (measured: 24 B x2 -> 30 B, 18 B saved) */ + const RESET_TO_ROUTER = _('Reset to router', 'footstrap'); + const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]); + /* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to + * router" clears them and lets every axis fall back to whatever the router holds, while "Reset + * to built-in" writes the theme's built-ins explicitly — the only way to say "as the theme + * ships" on a router that has a look of its own. Neither touches /etc/config/footstrap. + * + * ONE word per state, and "default" is not one of them: it used to name the router's look in + * "Save as default" and the theme's in "Reset to default", while the router's look also + * answered to "saved" — three names for two states, asked about on the forum (topic 251930, + * post 92). The row now reads as one save and two resets, over `router` and `built-in`. */ + const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]); + /* the stock destructive class, so the button discarding every local tweak is the red one + * (theme/55-buttons.css). "Reset to router" stays neutral: it steps back to the shared state + * rather than discarding. */ + const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to built-in', 'footstrap') ]); + /* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired + * session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set + * in the session and commit then no-ops without writing the file, returning success (measured + * on the router). The package owns that file and the read side falls back to built-in + * defaults. */ + const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + + /* The Save button is the status: matching disables it, diverging enables it. Called after every + * axis change (via bump). + * + * Unless the browser refuses storage, where the comparison would always be true — nothing was + * written, so every current*() reads the router default back however far the page has been + * dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to + * the router is the one thing that still works. */ + function refreshSave() { + if (prefs.storageBroken()) { + saveBtn.disabled = false; + saveBtn.textContent = SAVE_TO_ROUTER; + saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap'); + saveErr.hidden = false; + return; + } + const saved = axes.matchesSavedDefault(); + saveBtn.disabled = saved; + saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER; + } + saveBtn.addEventListener('click', () => { + saveBtn.disabled = true; + saveErr.hidden = true; + axes.saveAsDefault() + .then(() => { saveErr.hidden = true; }) + /* on failure refreshSave re-enables the button so the user can retry; the usual cause + * is a stale session, which a reload fixes. The raw rpc error stays in a title + * tooltip. */ + .catch((e) => { + saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap'); + saveErr.title = String((e && e.message) || e); + saveErr.hidden = false; + }) + .finally(refreshSave); + }); + /* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is + * banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a + * click meant for its neighbour. Each reload lands back on this same page — an ordinary route, + * unlike the tab this once needed a sessionStorage flag to return to. */ + const armed = new Map(); + function disarm(btn, label) { + armed.delete(btn); + btn.textContent = label; + btn.classList.remove('fs-ap-armed'); + } + function twoClick(btn, label, run) { + btn.addEventListener('click', () => { + if (!armed.has(btn)) { + [ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other))); + armed.set(btn, label); + btn.textContent = _('Confirm reset', 'footstrap'); + btn.classList.add('fs-ap-armed'); + return; + } + disarm(btn, label); + run(); + location.reload(); + }); + } + twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved); + twoClick(resetBtn, _('Reset to built-in', 'footstrap'), axes.resetToBuiltin); + refreshSave(); /* correct label and enabled state before the first paint */ + + const versionLink = E('a', { + 'class': 'fs-ap-version', + 'href': ver.REPO_URL, + 'target': '_blank', + /* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it + * that way */ + 'rel': 'noreferrer' + }, [ ver.label() ]); + + /* One clause per button, in LuCI's own help-sentence idiom and under the buttons themselves, + * because that is where the question is asked: with all three named "save"/"reset" and two of + * them resets, the row does not say which state each lands in (forum topic 251930, post 90 — + * "what is the difference on 'reset to saved' and 'reset to default'?"). Three lines rather + * than a paragraph: three parts want a list, and each is read on its own. + * + * Text nodes separated by
, NOT a child per line: `.cbi-value-field *` in + * theme/60-inputs.css hands mono to every descendant and excludes `.cbi-value-description` + * itself, so a wrapped line becomes a descendant that the exclusion does not reach — measured, + * all three lines came out monospace. Text nodes inherit the sans face from the container, and + * one container also means one `?` glyph with every line aligned under it. */ + const buttonHelp = E('div', { 'class': 'cbi-value-description' }, [ + _('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap'), + E('br'), + _('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap'), + E('br'), + _('Reset to built-in — go back to the look the theme ships with.', 'footstrap') + ]); + + const defaults = [ + /* the one row whose control is a pair of buttons, each named by its own text, so `make` + * ignores the caption rather than re-using it as an aria-label */ + group(_('Saved look', 'footstrap'), + () => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]), + { extra: [ buttonHelp, saveErr ] }) + ]; + + + /* ---- the catalogue this router has not got ---- + * + * The theme's translations are their own packages since 0.14.4, the way every `luci-app-*` + * ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right + * one: apk learns it from `install-if` against `luci-i18n-base-` (owfeed.yml), opkg has no + * conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply + * loses the catalogue that used to ride inside the theme. `install.sh` covers its + * own path; this covers the one nobody ran a script on. + * + * ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not + * grow one for this. `_()` returns its argument unchanged when no catalogue answers, so asking + * for a string the catalogue certainly carries is the whole test. + * + * `Layout` is that string — a caption this very form renders, present in every catalogue under `po/`. A word + * that only LOOKS certain is worse than no check: `Appearance` is in the source but obsolete in + * the catalogues (`#~ msgid`), so testing it reported "not translated" on a router whose + * Russian catalogue was installed and working. + * + * The language comes from the document, not from `L.env`, which carries no language field at + * all — the dispatcher stamps `` and that is what the page knows. + * + * Nothing is shown on an English or `auto` router, where there is no catalogue to miss. */ + const lang = (document.documentElement.getAttribute('lang') || '').trim(); + const untranslated = lang && lang !== 'en' && lang !== 'auto' && + _('Layout', 'footstrap') === 'Layout'; + const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [ + E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]), + E('code', {}, [ 'luci-i18n-footstrap-' + lang ]) + ]) : ''; + + defaults.push(E('div', { 'class': 'fs-ap-footer' }, [ + E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]), + missing + ])); + + /* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put + * their rows straight into the pane. These are grouping headings within one pane, styled by + * pages/80-appearance.css. */ + const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [ + E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ]) + ].concat(rows)); + + /* ---- the folded groups ---- + * Nine colour fields and an uploader are the widest rows on the page and most admins never + * touch them, so each group is a disclosure, closed by default. + * + * A disclosure and not a switch: a switch answers "is this feature on", and turning it off + * would either revert nine colours or change nothing at all. Opening or closing a fold applies, + * un-applies and disables nothing. + * + * W3C APG disclosure pattern, as the menu's sections use: a