diff --git a/airconnect/files/airconnect.config b/airconnect/files/airconnect.config index 8a297672..5bd84418 100644 --- a/airconnect/files/airconnect.config +++ b/airconnect/files/airconnect.config @@ -4,4 +4,5 @@ config airconnect 'config' option 'interface' 'br-lan' option 'aircast' '1' option 'airupnp' '1' + option 'stream_type' 'broadcast' diff --git a/airconnect/files/airconnect.init b/airconnect/files/airconnect.init index 25d699a9..e89eb351 100644 --- a/airconnect/files/airconnect.init +++ b/airconnect/files/airconnect.init @@ -10,6 +10,7 @@ get_config() { config_get interface $1 interface "br-lan" config_get aircast $1 aircast "1" config_get airupnp $1 airupnp "1" + config_get stream_type $1 stream_type "broadcast" } start_service() { @@ -24,6 +25,7 @@ start_service() { procd_append_param command -l 1000:2000 procd_append_param command -Z procd_append_param command -b $interface + procd_append_param command -S $stream_type procd_set_param stdout 0 procd_set_param stderr 0 procd_set_param respawn diff --git a/luci-app-airconnect/Makefile b/luci-app-airconnect/Makefile index 652060ca..dd18a023 100644 --- a/luci-app-airconnect/Makefile +++ b/luci-app-airconnect/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-airconnect -PKG_VERSION:=1.0.2 -PKG_RELEASE:=1 +PKG_VERSION:=1.0.3 +PKG_RELEASE:=2 LUCI_TITLE:=LuCI support for AirConnect LUCI_DEPENDS:=+airconnect diff --git a/luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js b/luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js index bc54fd51..62028e26 100644 --- a/luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js +++ b/luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js @@ -6,7 +6,7 @@ 'require view'; 'require tools.widgets as widgets'; -var callServiceList = rpc.declare({ +const callServiceList = rpc.declare({ object: 'service', method: 'list', params: ['name'], @@ -14,8 +14,8 @@ var callServiceList = rpc.declare({ }); function getServiceStatus() { - return L.resolveDefault(callServiceList('airconnect'), {}).then(function (res) { - var isRunning = false; + return L.resolveDefault(callServiceList('airconnect'), {}).then(res => { + let isRunning = false; try { isRunning = res['airconnect']['instances']['airupnp']['running'] || res['airconnect']['instances']['aircast']['running']; } catch (e) { } @@ -24,8 +24,8 @@ function getServiceStatus() { } function renderStatus(isRunning) { - var spanTemp = '%s %s'; - var renderHTML; + const spanTemp = '%s %s'; + let renderHTML; if (isRunning) { renderHTML = spanTemp.format('green', 'AirConnect', _('RUNNING')); } else { @@ -36,8 +36,8 @@ function renderStatus(isRunning) { } return view.extend({ - render: function() { - var m, s, o; + render() { + let m, s, o; m = new form.Map('airconnect', _('AirConnect'), _('Send audio to UPnP/Sonos/Chromecast players using AirPlay.')); @@ -45,17 +45,19 @@ return view.extend({ s = m.section(form.TypedSection); s.anonymous = true; s.render = function () { - poll.add(function () { - return L.resolveDefault(getServiceStatus()).then(function (res) { - var view = document.getElementById('service_status'); - view.innerHTML = renderStatus(res); + poll.add(() => { + return L.resolveDefault(getServiceStatus()).then(res => { + const view = document.getElementById('service_status'); + if (view) { + view.innerHTML = renderStatus(res); + } }); }); return E('div', { class: 'cbi-section', id: 'status_bar' }, [ - E('p', { id: 'service_status' }, _('Collecting data...')) + E('p', { id: 'service_status' }, _('Collecting data...')) ]); - } + }; s = m.section(form.NamedSection, 'config', 'airconnect'); @@ -63,19 +65,26 @@ return view.extend({ o.default = o.disabled; o.rmempty = false; - o = s.option(widgets.DeviceSelect, 'interface', - _('Bind interface')); - o.filter = function(section_id, value) { - var dev = this.devices.filter(function(dev) { return dev.getName() == value })[0]; - var excludeDevice = ['docker', 'dummy', 'radio', 'sit', 'teql', 'veth', 'ztly']; - return (dev && dev.getName() != null && !excludeDevice.some(prefix => dev.getName().startsWith(prefix))); - } + o = s.option(widgets.DeviceSelect, 'interface', _('Bind interface')); + o.filter = function (section_id, value) { + const dev = this.devices.find(d => d.getName() === value); + const excludeDevice = ['docker', 'dummy', 'radio', 'sit', 'teql', 'veth', 'ztly']; + return dev && dev.getName() != null && !excludeDevice.some(prefix => dev.getName().startsWith(prefix)); + }; o.rmempty = false; o = s.option(form.Flag, 'airupnp', _('UPnP/Sonos'), _('Enable UPnP/Sonos Device Support')); o.default = o.disabled; o.rmempty = false; + o = s.option(form.ListValue, 'stream_type', _('Sonos stream mode'), _('How Sonos should treat the stream (affects buffering/behavior)')); + o.value('broadcast', _('broadcast')); + o.value('track', _('track')); + o.value('radio', _('radio')); + o.default = 'broadcast'; + o.depends('airupnp', '1'); + o.rmempty = false; + o = s.option(form.Flag, 'aircast', _('Chromecast'), _('Enable Chromecast Device Support')); o.default = o.disabled; o.rmempty = false; diff --git a/luci-app-airconnect/po/templates/airconnect.po b/luci-app-airconnect/po/templates/airconnect.po new file mode 100644 index 00000000..5aef14af --- /dev/null +++ b/luci-app-airconnect/po/templates/airconnect.po @@ -0,0 +1,71 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:42 +#: luci-app-airconnect/root/usr/share/luci/menu.d/luci-app-airconnect.json:3 +msgid "AirConnect" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:68 +msgid "Bind interface" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88 +msgid "Chromecast" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:58 +msgid "Collecting data..." +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88 +msgid "Enable Chromecast Device Support" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76 +msgid "Enable UPnP/Sonos Device Support" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:64 +msgid "Enabled" +msgstr "" + +#: luci-app-airconnect/root/usr/share/rpcd/acl.d/luci-app-airconnect.json:3 +msgid "Grant UCI access for luci-app-airconnect" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80 +msgid "How Sonos should treat the stream (affects buffering/behavior)" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:32 +msgid "NOT RUNNING" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:30 +msgid "RUNNING" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:43 +msgid "Send audio to UPnP/Sonos/Chromecast players using AirPlay." +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80 +msgid "Sonos stream mode" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76 +msgid "UPnP/Sonos" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:81 +msgid "broadcast" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:83 +msgid "radio" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:82 +msgid "track" +msgstr "" diff --git a/luci-app-airconnect/po/zh_Hans/airconnect.po b/luci-app-airconnect/po/zh_Hans/airconnect.po index 025cffac..8615194c 100644 --- a/luci-app-airconnect/po/zh_Hans/airconnect.po +++ b/luci-app-airconnect/po/zh_Hans/airconnect.po @@ -1,36 +1,78 @@ msgid "" msgstr "" -"Content-Type: text/plain; charset=UTF-8\n" "Project-Id-Version: PACKAGE VERSION\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: zh_Hans\n" "MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:42 +#: luci-app-airconnect/root/usr/share/luci/menu.d/luci-app-airconnect.json:3 msgid "AirConnect" msgstr "隔空播放" -msgid "Send audio to UPnP/Sonos/Chromecast players using AirPlay." -msgstr "通过 AirPlay 将音频流传输到 UPnP/Sonos 和 Chromecast 设备。" - -msgid "RUNNING" -msgstr "运行中" - -msgid "NOT RUNNING" -msgstr "未运行" - -msgid "Collecting data..." -msgstr "获取数据中..." - -msgid "Enabled" -msgstr "启用" - +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:68 msgid "Bind interface" msgstr "绑定接口" +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88 +msgid "Chromecast" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:58 +msgid "Collecting data..." +msgstr "获取数据中..." + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:88 +msgid "Enable Chromecast Device Support" +msgstr "启用 Chromecast 设备支持" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76 msgid "Enable UPnP/Sonos Device Support" msgstr "启用 UPnP/Sonos 设备支持" -msgid "Enable Chromecast Device Support" -msgstr "启用 Chromecast 设备支持" +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:64 +msgid "Enabled" +msgstr "启用" + +#: luci-app-airconnect/root/usr/share/rpcd/acl.d/luci-app-airconnect.json:3 +msgid "Grant UCI access for luci-app-airconnect" +msgstr "授予 luci-app-airconnect UCI 访问权限" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80 +msgid "How Sonos should treat the stream (affects buffering/behavior)" +msgstr "指定 Sonos 对流的处理方式(会影响缓冲和播放行为)" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:32 +msgid "NOT RUNNING" +msgstr "未运行" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:30 +msgid "RUNNING" +msgstr "运行中" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:43 +msgid "Send audio to UPnP/Sonos/Chromecast players using AirPlay." +msgstr "通过 AirPlay 将音频流传输到 UPnP/Sonos 和 Chromecast 设备。" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:80 +msgid "Sonos stream mode" +msgstr "Sonos 流模式" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:76 +msgid "UPnP/Sonos" +msgstr "" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:81 +msgid "broadcast" +msgstr "广播" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:83 +msgid "radio" +msgstr "电台" + +#: luci-app-airconnect/htdocs/luci-static/resources/view/airconnect.js:82 +msgid "track" +msgstr "曲目" 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..f90b91bd --- /dev/null +++ b/luci-theme-footstrap/Makefile @@ -0,0 +1,221 @@ +# +# Copyright (C) 2026 +# +# This is free software, licensed under the Apache License, Version 2.0 . +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-theme-footstrap +# luci.mk keys the Build/Prepare hook name on LUCI_NAME, which defaults to the checkout's +# directory name — a differently-named checkout symlinked into the feed would silently skip +# the CSS build. Pin it. +LUCI_NAME:=luci-theme-footstrap + +# Version is git-derived; never hardcode PKG_VERSION. CI injects FOOTSTRAP_VERSION from the +# tag (an SDK build has no .git, so it cannot derive one). +FOOTSTRAP_VERSION?= +ifneq ($(FOOTSTRAP_VERSION),) +PKG_VERSION:=$(FOOTSTRAP_VERSION) +PKG_RELEASE:=1 +endif + +LUCI_TITLE:=Footstrap Theme +# +luci-base is the WHOLE list. The self-updater used to hard-require `curl`, which is NOT in +# OpenWrt's default package set (the base image ships uclient-fetch), so the update button +# failed on a stock router; it falls back to uclient-fetch rather than pull curl onto an 8 MB +# device for one API call. jsonfilter and sha256sum ARE in the base image. Do not add a +# runtime dep for a convenience tool — fall back instead. +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:=VizzleTF +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()/nested calc() and break the layout. build-css.sh minifies instead. +LUCI_MINIFY_CSS:=0 +# +# JS: TWO minify paths, chosen by who is building. +# +# CI (the released packages) pre-minifies with terser BEFORE the SDK build (tools/minify-js.mjs: +# mangles top-level identifiers too, which jsmin cannot — ~41 KB against jsmin's ~57 KB, −27%) +# and signals it with FOOTSTRAP_PREMIN=1. jsmin must then stay AWAY from the already-minified +# output: terser legitimately emits `return/^v/.test(s)`, the exact one-character-lookback trap +# below, so LUCI_MINIFY_JS goes to 0 for that build. +# +# Everyone else (an SDK user, the buildbot — no node) builds the untouched source and KEEPS +# luci.mk's default jsmin (LUCI_MINIFY_JS=1): ~105 KB of this tree's JS is comments, and uhttpd +# serves /www with NO compression, so those are wire AND flash bytes either way. +# +# The catch on the jsmin path, and why the gates exist: jsmin decides `/` = regex-or-division +# from a ONE-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its +# allow-list — so `return /re/` makes it eat the rest of the file AND EXIT 0 +# (openwrt/luci#8299). eslint's `wrap-regex` bans the shape in the SOURCE; tools/jsmin-verify.mjs +# proves jsmin's output is TOKEN-IDENTICAL to it. Both keep guarding the non-CI path. +FOOTSTRAP_PREMIN?= +ifneq ($(FOOTSTRAP_PREMIN),) +LUCI_MINIFY_JS:=0 +endif + +# Apache-2.0, and not a free choice: styles/base began as a fork of luci-theme-bootstrap's +# cascade.css, the ucode templates derive from LuCI's own, and a few JS helpers are verbatim +# copies. The notices travel with it. +# +# THE OFL-1.1 HALF IS GONE WITH THE WEBFONTS. The theme carried subsetted Manrope and JetBrains +# Mono and had to ship the OFL text beside them (§2 requires the notice and licence with every +# copy of the Font Software). It ships no Font Software now, so declaring OFL 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 (fs-prefs.js saveAsDefault()). +# Without this define the package manager owns it as an ordinary file and REPLACES it on upgrade — +# so the admin's saved defaults were wiped by the theme's own one-click Update, silently, reported +# as success. Measured on the dev router: eight options in the live file, package-owned, no +# .conffiles entry beside base-files'/dnsmasq's. OpenWrt honours this for BOTH formats +# (include/package-pack.mk: KEEP_$(1) -> apk .conffiles, ipk CONTROL/conffiles), so one define +# 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 + +# RELOAD, NOT RESTART: rpcd holds sessions in memory, so `restart` logs out every LuCI user — +# including the admin who just clicked Update. `reload` sends SIGHUP, which re-reads +# /usr/share/rpcd/acl.d/*, and that ACL refresh is the only thing this package needs from rpcd. +# Verified on a live router: a session survives `reload` and dies across `restart`; deleting our +# acl.d file + `reload` flips `session access` for the self-update script from true to false. +define Package/luci-theme-footstrap/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + # uci-defaults registers the theme; refresh rpcd + drop caches so LuCI sees it without a + # reboot. Calling it ourselves is belt-and-braces: OpenWrt's default_postinst also runs + # (then deletes) every /etc/uci-defaults/* we ship, so it executes twice per install. It is + # idempotent, and its fresh-vs-upgrade marker is written at the end of the first pass, so + # that pass still sees a fresh install. + [ -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 + /etc/init.d/rpcd reload >/dev/null 2>&1 || true +} +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 (proven on 24.10: +# `prerm upgrade`, then `postrm upgrade`, then the new `postinst configure`) and "remove" on a real +# removal. On upgrade this script MUST change nothing: reverting mediaurlbase to bootstrap, wiping +# the theme registration and deleting the .installed marker here is what flipped every updating +# 24.10 user back to bootstrap — and it defeated uci-defaults' own upgrade guard, because with the +# marker gone uci-defaults treated the run as "fresh", yet postrm had already set mediaurlbase to +# bootstrap, which no "fresh" branch re-activates. 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; real removal +# passes "remove" (opkg) or no arg and proceeds below. +case "$$1" in *upgrade*) exit 0 ;; esac +[ -n "$${IPKG_INSTROOT}" ] || { + # Every theme name this package has ever registered. postrm runs when uci-defaults is + # already deleted, so it cannot source the list: it is duplicated by necessity and + # mirror-pinned so it cannot drift. Only the assignment is byte-identical — the `$$` is + # make's escaping. +# @mirror theme/legacy-names +LEGACY_THEMES="Footstrap FootstrapDark FootstrapLight FootstrapTop FootstrapTopDark FootstrapTopLight FootstrapSidebar FootstrapOnTop" +# @endmirror + for t in $$LEGACY_THEMES; do + uci -q delete "luci.themes.$$t" + done + # The router's DEFAULT layout, set by uci-defaults when migrating an old top-nav install. + # It means nothing to any other theme, and left behind it would silently re-apply the top + # bar on a later reinstall. + uci -q delete luci.main.footstrap_layout + # Don't leave the active theme pointing at the media dir we just removed. The same TWO-part + # check uci-defaults uses: a theme needs its media dir AND its ucode template to render, so + # a one-part check could hand the UI to a half-removed bootstrap — the blank page this + # branch exists to avoid. + 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 "installed before" marker uci-defaults writes to tell fresh from upgrade. Removing it + # makes uninstall-then-reinstall count as fresh again. + rm -rf /usr/share/luci-theme-footstrap >/dev/null 2>&1 || true + # 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 + /etc/init.d/rpcd reload >/dev/null 2>&1 || true # see the postinst note: reload keeps sessions +} +endef + +# Runs right after luci.mk copies luasrc/ucode/htdocs/root/src into PKG_BUILD_DIR, so it +# rewrites the build copy, 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/awk only, no host +# toolchain: that is why it runs on an OpenWrt buildbot. +# 2. Stamp the git-derived version into the theme JS (fs-version.js — the string the Appearance tab +# shows). Skipped when PKG_VERSION is empty, leaving '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 the +# way it does for every luci-app — which is what a package IN THE LUCI TREE has to look like, and +# what Weblate can see (it translates `po/`, and only `po/`). +# +# It was `i18n/` here for a while, with a po2lmo loop in this hook bundling the catalogue into the +# theme package, because a multi-package release was mis-picked by a self-updater that resolved the +# theme by name and took `head -1` (issue #6). That updater is retired and the release is built by +# owfeed, which packages this theme as exactly one artifact per format regardless of what luci.mk +# would do — so the constraint that bought the rename is gone, and the cost of keeping it (a +# catalogue invisible to the project's own translation platform) is not. +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 58% 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. -16 KB of 39, 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 + # …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 + $(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 IT 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. 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 — which is why every luci-* Makefile carries it. +# Deleting it as a useless comment is exactly what broke the build (both formats, silently). +# 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..010ccb77 --- /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 four containers driven by `owlab` from `../owlab.yaml` — see +[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over +ssh: + +```sh +./dev-sync.sh # push to `ssh router` (registers the theme but does NOT activate it) +ssh router 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit luci; rm -f /tmp/luci-indexcache*' +ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back +``` + +Before pushing, run `npm run check` from the repository root. The rules and the traps are in +[`../docs/conventions.md`](../docs/conventions.md); packaging is in +[`../docs/package.md`](../docs/package.md). diff --git a/luci-theme-footstrap/build-apk.sh b/luci-theme-footstrap/build-apk.sh new file mode 100755 index 00000000..0d86edb1 --- /dev/null +++ b/luci-theme-footstrap/build-apk.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# Build luci-theme-footstrap as an OpenWrt .apk via the SDK. +# The theme is noarch (CSS/JS/templates/fonts only), 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 any more — `./tools/stage.sh && owfeed build` is, and +# it produces both formats in seconds without a toolchain (see owfeed.yml). What this script +# exercises is the OTHER path: the Makefile, luci.mk, jsmin, and the SDK's own packaging. That +# path still has to work — the theme should stay buildable in an SDK or a feed by someone who +# has never heard of owfeed — and this is the only way to find out that it does. +set -e + +REL="${OPENWRT_RELEASE:-25.12.2}" +SDK_BASE="https://downloads.openwrt.org/releases/${REL}/targets/mediatek/filogic" +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..d7b63cf7 --- /dev/null +++ b/luci-theme-footstrap/build-css.sh @@ -0,0 +1,255 @@ +#!/bin/sh +# Concatenate the styles/ tree into a single cascade.css. +# +# ./build-css.sh [outfile] [--dev] +# +# Dir order = layer order (styles/, base/, theme/, pages/); filename order = source +# order within a dir. A later layer beats an earlier one whatever the specificity, so +# nothing needs !important to override base (declared in styles/00-header.css). +# +# Minified unless --dev: ~287 KB of source -> ~111 KB (-61%). 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()/color-mix()). Needs only cat/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"' EXIT +mkdir -p "$(dirname "$OUT")" + +# glob expands in filename order +cat "$D"/styles/*.css \ + "$D"/styles/base/*.css \ + "$D"/styles/theme/*.css \ + "$D"/styles/pages/*.css > "$TMP" + +# Strip /* ... */, keep /*! ... */ (the licence banner), drop indentation/blank lines. +# +# STRING-AWARE: the old scanner just hunted for the next "/*", so `content: "/*"` would +# open a comment and eat every rule up to the next "*/". Its only guard was the brace +# count below, and two such literals balance each other — rules could vanish in silence. +# Quoted data-URIs run through here on every build. +strip_comments() { + awk ' + BEGIN { inc = 0; q = "" } + { + line = $0; out = ""; i = 1; n = length(line) + while (i <= n) { + c = substr(line, i, 1) + if (inc) { # inside /* ... */ + if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue } + i++; continue + } + if (q != "") { # inside a "..." or '"'"'...'"'"' string + out = out c + if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue } + if (c == q) q = "" + i++; continue + } + if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue } + if (c == "/" && substr(line, i + 1, 1) == "*") { + # the banner: keep it, and everything after it on this line + if (substr(line, i + 2, 1) == "!") { out = out substr(line, i); break } + inc = 1; i += 2; continue + } + out = out c; i++ + } + sub(/^[ \t]+/, "", out) + sub(/[ \t]+$/, "", out) + if (length(out)) print out + } + ' "$1" +} + +# Squeeze the whitespace CSS ignores — wire AND flash bytes, since uhttpd does not compress. +# +# REMOVED (~9.5 KB): the space after `:`, the spaces around `{ } ; ,`, the last `;` of a block, +# the newline after every declaration (one line per RULE). lightningcss would save ~13 KB, but +# its extra 3.5 KB comes from rewriting colours and merging rules — transforms that can change +# behaviour. These cannot. +# +# LEFT ALONE, each for a reason: +# - the single space between selectors: `.a .b` is a DESCENDANT combinator, `.a.b` is not. +# Whitespace runs collapse to one space; that one stays. +# - spaces inside calc(): required around `*` `/` and the `-` of `calc(100% - 8px)`. +# - the LINE BREAK inside a declaration — whitespace too. This line-oriented scanner used to +# join lines with nothing between them, so a wrapped calc() came out `…))- .004 …`; a `-` +# with no space BEFORE it is a parse error, so the declaration dropped, --fs-tint-c went +# undefined, --fs-bg became invalid at computed-value time and the canvas fell back to +# white — silently (export-tier.mjs caught it: contrast 1.5:1). A newline is now treated +# exactly like a space run. +# - `>` `+` `~` spaces: stripping them is safe but buys only ~200 bytes. +# - anything inside a string: every data-URI here is quoted and full of `:` `;` and spaces. +# - one newline after `}`, so the shipped file stays greppable. +squeeze() { + awk ' + BEGIN { q = ""; ban = 0; lastc = ""; buf = ""; lastreal = "" } + { + line = $0 + # The /*! banner is an Apache-2.0 attribution, not formatting: it must survive + # BYTE FOR BYTE. Squeezing it made "Twitter, Inc" into "Twitter,Inc" and glued its + # lines together. Copy it out untouched. + if (ban) { print line; lastc = ""; lastreal = ""; if (index(line, "*/")) ban = 0; next } + if (substr(line, 1, 3) == "/*!") { + print line; lastc = ""; lastreal = "" + if (!index(substr(line, 4), "*/")) ban = 1 + next + } + # The line BREAK we are about to swallow is whitespace, and a declaration may be + # wrapped across it. Feed it to the whitespace-run logic below as a leading space: + # it survives only where a space would (between two tokens) and is dropped next to + # { } ; , : — lastc == "" means output is already at the start of a line, with + # nothing to glue to. + if (lastc != "" && q == "") line = " " line + out = ""; i = 1; n = length(line) + while (i <= n) { + c = substr(line, i, 1) + if (q != "") { # inside a string: copy verbatim + out = out c + if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue } + if (c == q) q = "" + lastreal = "" # a char inside a string is not structure + i++; continue + } + if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue } + if (c == " " || c == "\t") { # collapse a run of whitespace to one space + while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++ + # the last char EMITTED, which on a continuation line lives on the + # previous output line — hence lastc, not just `out`. + prev = (length(out) ? substr(out, length(out), 1) : lastc) + nxt = (i <= n ? substr(line, i, 1) : "") + # drop it entirely next to a delimiter; otherwise it may be a combinator + if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":") + continue + if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "") + continue + out = out " "; lastreal = " " + continue + } + # THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e. + # INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted onto + # the awk output, and sed cannot see strings: `content: ";}"` came out as + # `content: "}"`, and a data-URI containing `;}` was corrupted the same way (both + # reproduced). Nothing in the tree holds that byte pair today — which is how such + # a bug waits for whoever adds the first one. + # + # The `;` may already sit in the previous line output, so text is held in `buf` + # until the rule closes: a `;` already printed cannot be taken back. + if (c == "}") { + if (length(out) && substr(out, length(out), 1) == ";") + out = substr(out, 1, length(out) - 1) + else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";") + buf = substr(buf, 1, length(buf) - 1) + } + out = out c; lastreal = c; i++ + } + buf = buf out + if (length(out)) lastc = substr(out, length(out), 1) + # newline only after a closing brace — one rule per line. lastreal, not lastc: a + # line ending in a QUOTED `}` (content: "}") is not the end of a rule, and flushing + # there would split the rule and lose the space before its next token. + if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" } + } + END { if (length(buf)) print buf; else printf "\n" } + ' "$1" +} + +# Fail loudly rather than let an unbalanced block ship. +# +# STRING-AWARE, for the same reason the comment stripper is: a brace inside a CSS STRING is not a +# block. The counter used to gsub() over the raw line, so a perfectly valid rule made the build +# REFUSE — measured, all three shapes: `content: ";}"`, `content: "{"`, and a data-URI carrying +# `;}` (an inline + + + + + 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/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js new file mode 100644 index 00000000..91faf8b4 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js @@ -0,0 +1,781 @@ +'use strict'; +'require baseclass'; +'require ui'; +'require dom'; +'require fs-prefs as prefs'; +'require fs-widgets as widgets'; +'require fs-version as ver'; + +/* The Appearance CONTROLS: the DOM that presents the axes. It owns no preference — fs-prefs.js + * holds the axes, fs-version.js the version string; this file is the form they are shown in. + * + * WHERE IT LIVES. It used to be a popover hanging off a button in the chrome; it is now a TAB on + * System -> System (admin/system/system), beside General Settings / Logging / Time Synchronization + * / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — eighteen of them, nine + * carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab, + * place itself against a viewport edge and stay inside a 320px column is the wrong container for + * that. Keeping BOTH would have meant every axis rendered twice, which is the failure this file's + * own history is made of. + * + * It is appended by a MutationObserver rather than by a route of its own, and that is the same + * boundary fs-overview.js sits on: a THEME may not own a dispatcher node, because a node outlives + * the theme that registered it — switch to another theme and the menu keeps an entry whose view is + * gone. So the theme owns no menu.d and no view; it watches for the stock page and adds one + * section to it, additively, and removes nothing. Off the footstrap theme, or on any other page, + * nothing runs at all. + * + * THE VERSION LINE MAKES NO REQUEST and must not grow one. Which version is INSTALLED is what this + * page answers; which version is available is the package manager's question, and a theme polling a + * release API to re-answer it from a settings page is the wrong shape twice over — it reaches the + * network from a page that has no business doing so, and it reimplements `apk upgrade`. */ + +/* THE COLOUR PRESETS WERE HERE — eight chips that wrote the Accent axis, each painted in the + * colour it would set. They are gone, and what they were for is not: the request behind them + * (#20, "the blue theme is cool but sometimes you want grey or black") is answered by the Accent + * row itself, which takes any #rrggbb and sits three rows below where the chips used to be. A + * preset only ever wrote that one axis, so the chips were a second, prettier way to do the thing + * the field already does — and they were the one control on this page that looked like nothing + * else in LuCI: a bare row of coloured pills starting at the card's edge rather than at the field + * column, which is what made the tab read as ragged. */ + +/* Build the whole form. Returns a promise for one element wire() appends to the stock page. + * + * Everything applies IMMEDIATELY — there is no Save button for the axes themselves, because there + * is nothing to save: every axis is this browser's, in localStorage, and the page repaints under + * the control as it moves. The one button that writes anything is "Save as default", which pushes + * the current look to the ROUTER for other browsers. That distinction is the whole model + * (docs/design-system.md) and it is why this page has no Save/Reset footer of LuCI's own. */ +function render() { + /* still a promise: the view awaits it, and keeping the shape means a future asynchronous step + * (or the caller) needs no change. */ + return Promise.resolve(build()); +} + +function build() { + /* every saved axis re-checks the Save button after it applies, so the button greys the moment + * this browser matches the saved default again and un-greys the moment it diverges. Wrapped + * around the appliers because the seg/slider/colour controls call them directly and have no + * other seam back to here. refreshSave is a hoisted function declaration; saveBtn it reads is + * assigned below, before any of these fire (all are user events). */ + 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, a dark-mode flip or a + * preset changes all of that under controls nobody touched, so they are refreshed together + * rather than each listening for what might have moved. */ + 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, and a preset changes the axes themselves */ + const repaint = (fn) => (v) => { fn(v); refreshColours(); }; + + /* One captioned row, in LuCI's OWN row shape: `.cbi-value` > `label.cbi-value-title` + + * `.cbi-value-field`. This tab used to draw its own two-column grid of stacked cards — an + * uppercase eyebrow above each control — which made the theme's settings the one page in LuCI + * that did not look like LuCI. Sitting beside General Settings / Logging / Time Synchronization / + * Language and Style, the odd one out was ours. + * + * Nothing here styles those class names: `.cbi-value` is stock, base/30-forms.css and + * theme/60-inputs.css already lay it out (title column, field column, hairline under each row), + * so this page now inherits every future fix to the form layout instead of keeping a private + * copy of it. That also means the row lives on a SHARED surface — Zone 2, where a third-party + * app is entitled to win on specificity — which is exactly right for a page rendered inside + * #view rather than for chrome. + * + * `make` is handed the SAME label string the caption renders, because every control in here + * needs it a second time as its aria-label (segControl/sliderControl/colorControl take it as + * their last argument) — and stating it twice is how the visible caption and what a screen + * reader announces drift apart. One literal per axis, used by both, with nothing to keep in + * sync. `extra` is for the rows that carry more than a control (the Save row's error line), + * `opts.cls` for the rows CSS has to be able 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 CONTROLS are LuCI's own, not this theme's ------------------------------------------ + * + * Every enum axis is a `ui.Select` and every number is a `ui.RangeSlider` — the same widgets the + * form on the other tabs is built from, so a dropdown here is the dropdown an admin already knows + * and the theme's own stylesheet already dresses (`select` in base/30-forms.css, + * `.cbi-range-slider` in theme/60-inputs.css). Both classes exist on the whole range this theme + * supports — checked against luci's own openwrt-24.10 branch, not only against master. + * + * What this replaces is two primitives of ours: a segmented radiogroup with a roving tabindex and + * a range wrapper with a live readout. They were written when this page was a floating popover + * and a `` would tie us to + * how it happens to be built today. RangeSlider also emits `widget-update` while the handle + * moves, which is what makes the tile resize UNDER the drag rather than on release; both are + * wired, and the appliers are idempotent so the pair 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, calcunits: o.unit || null + }); + const node = w.render(); + node.setAttribute('aria-label', label); + const push = () => apply(parseInt(w.getValue(), 10)); + node.addEventListener('widget-update', push); + node.addEventListener('widget-change', push); + return node; + }; + + /* one colour axis: the shared shape of the rows below. `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 = widgets.colorControl(axis.current(), bump(axis.apply), lbl, { + probe: probe, + read: axis.current, + contrast: contrast, + cls: (opts && opts.cls) || '' + }); + colourCtls.push(ctl); + return ctl; + }, opts); + + /* EVERY LABEL IN HERE CARRIES THE 'footstrap' CONTEXT (`_(str, ctx)`, key `ctx\1str`). LuCI + * serves ONE MERGED catalogue — load_catalog() loads every *..lmo in + * /usr/lib/lua/luci/i18n and a lookup returns the first archive holding the hash — so a msgid is + * a GLOBAL name shared with every luci-app, and readdir order picks the winner: the layout + * toggle rendered "Максимум" on a Russian router (issue #6), because another catalogue + * translates the msgid "Top" as "maximum". Contexting cannot be selective — whatever we leave + * bare is a name anyone may take. The chrome and the login/notice sentences are deliberately + * bare (inheriting luci-base's translation is a feature in the ~40 languages we have no + * catalogue for), as are System/Memory/Storage in fs-overview.js, which MATCH the stock + * headings. */ + + /* ---- 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(prefs.currentPalette(), { + footstrap: 'Footstrap', + hicontrast: 'Hi-Contrast', + /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is — + * so it is a proper noun and stays untranslated, like the two above it */ + bootstrap: 'Bootstrap' + }, bump(repaint(prefs.applyPalette)), label)), + + /* Density: how much air the UI uses. Pure token axis — 02-tokens.css multiplies the type and + * space ladders, so every size, gap and padding in the theme follows at once. */ + group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { + compact: _('Compact', 'footstrap'), + normal: _('Normal', 'footstrap'), + large: _('Large', 'footstrap') + }, bump(prefs.applyDensity), label)), + + group(_('Rounding', 'footstrap'), + (label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label, { unit: 'px' })), + + /* The top layout has no accordion (its sections are hover dropdowns, already exclusive), so + * this switch is meaningless there. ALWAYS BUILT, HIDDEN BY CSS (:root[data-layout="top"] + * .fs-ap-submenus). Do NOT put an `if (currentLayout() !== 'top')` around it: the page is + * built once, so the branch would freeze the control to the layout the page LOADED in — it + * would stay on screen after a switch to the bar and never appear after a switch away from + * it. Toggling the layout re-renders nothing; CSS morphs the chrome. */ + group(_('Submenus', 'footstrap'), (label) => selectCtl( + prefs.currentAutoCollapse() ? 'on' : 'off', { + off: _('Keep open', 'footstrap'), + on: _('Auto-collapse', 'footstrap') + }, bump(prefs.applyAutoCollapse), label), + { cls: 'fs-ap-submenus' }) + ]; + + /* ---- section 2: colours ---- */ + const colours = [ + /* the caption says what the axis is FOR: "Tint" alone reads as decoration and nobody would + * look for the router-identity cue under it. */ + colourGroup(_('Tint (router identification)', 'footstrap'), { + current: prefs.currentTint, apply: prefs.applyTint + }, 'var(--fs-bg)', { + /* the canvas is the one axis with no derived ink: its text is --fs-text, a palette token + * this axis must not move, so the ratio is reported instead of corrected */ + fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap') + }, { cls: 'fs-ap-tint' }), + + /* the STRENGTH half of the Tint — how strong the hue reads. Only meaningful 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 (no tint at all, or a hex one). */ + /* NOT "Density": that is the UI-density segment above, and this string is both the visible + * caption AND the control's aria-label, so two rows would read "Density" and a screen reader + * would announce "Density, radio group" and "Density, slider" with nothing to tell them + * apart. */ + group(_('Tint strength', 'footstrap'), + (label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, { + step: 5, + fmt: (v) => v + '%' + }), { cls: 'fs-ap-tint fs-ap-density' }), + + /* recolours the accented CONTROLS (buttons/toggles/sliders/focus rings), not the canvas the + * way Tint does. Measured as TEXT on a card, which is the use that fails first: as a fill it + * carries derived ink, as a link or a status label it carries only itself. */ + colourGroup(_('Accent', 'footstrap'), { + current: prefs.currentAccent, apply: prefs.applyAccent + }, 'var(--fs-accent)', { + fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') + }), + + colourGroup(_('Good', 'footstrap'), { + current: prefs.currentGood, apply: prefs.applyGood + }, 'var(--fs-good)', { + fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') + }), + + colourGroup(_('Warning', 'footstrap'), { + current: prefs.currentWarn, apply: prefs.applyWarn + }, 'var(--fs-warn)', { + fg: 'var(--fs-warn)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') + }), + + colourGroup(_('Danger', 'footstrap'), { + current: prefs.currentDanger, apply: prefs.applyDanger + }, 'var(--fs-danger)', { + fg: 'var(--fs-danger)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') + }) + ]; + + /* ---- the SURFACES: the sheet the UI is drawn on ---- + * Cards, inset controls, the chrome bar and the hairlines between them. Every one of these is a + * surface that body text is read ON, so what each reports is --fs-text against itself — the one + * measurement that says whether the page is still readable. There is no ink to derive here and + * none is: --fs-text is the palette's, and an axis that silently moved it would be recolouring + * the very thing it is being measured against. + * + * The hairline is the exception and takes the 3:1 UI-component threshold rather than the text + * one, which is what its readout comparing --fs-border to --fs-panel means: a border is a shape, + * not a label, and AA asks 3:1 of it. Below that it is decoration — which a hairline is entitled + * to be, so the readout says the number and leaves the call to the admin. */ + const surfaces = [ + colourGroup(_('Cards', 'footstrap'), { + current: prefs.currentCard, apply: prefs.applyCard + }, 'var(--fs-panel)', { + fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') + }), + + colourGroup(_('Controls', 'footstrap'), { + current: prefs.currentControl, apply: prefs.applyControl + }, 'var(--fs-panel2)', { + fg: 'var(--fs-text)', bg: 'var(--fs-panel2)', label: _('on a control', 'footstrap') + }), + + colourGroup(_('Sidebar and bar', 'footstrap'), { + current: prefs.currentBar, apply: prefs.applyBar + }, 'var(--fs-bar-bg)', { + fg: 'var(--fs-text)', bg: 'var(--fs-bar-bg)', label: _('in the sidebar', 'footstrap') + }), + + + colourGroup(_('Borders', 'footstrap'), { + current: prefs.currentLine, apply: prefs.applyLine + }, 'var(--fs-border)', { + fg: 'var(--fs-border)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap'), kind: 'shape' + }) + ]; + + /* ---- section 3: the wallpaper and everything that depends on which one is picked ---- + * + * Wallpaper is THREE-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an + * uploaded photo). Each value brings rows with it — the SVG plus Scale/Strength/Colours, or the + * photo plus Dim — and those rows are SIBLINGS of the Wallpaper row, not children of its field. + * + * That is the whole point of this shape. They were nested inside the field at first, which put a + * second `.cbi-value` inside a `.cbi-value-field` and therefore a second 180px caption column + * inside the first: measured on the router, Scale and Strength started 216px right of every + * other control on the page. LuCI has no such construct anywhere, and the eye reads it as two + * forms interleaved. Flat rows, hidden as a group, is what the stock pages do with a dependent + * field — one caption column, one field column, top to bottom. + * + * The select is the per-browser switch that decides whether to paint an image, so it is what + * keeps the Save button honest (refreshSave); Choose/Remove only swap the picture behind + * whoever is in that mode and never touch the axis. Both 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 the `hidden` attribute — a bare `hidden=""` still rendered the native + * "Choose File / No file chosen" control; only the styled button below should be visible. */ + 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 in snapshotAxes(), so it moves this browser toward or away from the router default and + * must therefore be bump()-ed like every other saved axis. It was not, on the strength of a + * comment that said it wrote straight to uci: true until "keep every axis per-browser until + * Save as default" made it a propAxis and did not reach this file. The symptom is the one + * thing the Save button IS — its own status. Separate from the Tint's 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 behind the page resizes and fades under the drag with nothing to + * reload. Colours decides whether the file's own palette is kept or thrown away for the + * theme's — a mask uses the alpha only, which is right for line art and wrong for artwork + * that carries its own colours, and only whoever picked the file knows which it is. */ + const patRows = [ + group(_('Pattern', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), + { extra: [ patInput, patPreview, patErr ] }), + group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600, + bump(prefs.applyPatternSize), lbl, { step: 20, unit: 'px' })), + group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100, + bump(prefs.applyPatternStrength), lbl, { step: 5, unit: '%' })), + group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), { + theme: _('Theme', 'footstrap'), + original: _('As in file', 'footstrap') + }, bump(prefs.applyPatternInk), lbl)) + ]; + /* …and the rows the FILE photo brings. */ + const fileRows = [ + group(_('File', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), + { extra: [ fileInput, preview, err ] }), + group(dimLabel, (lbl) => sliderCtl(prefs.currentPhotoDim(), 0, 100, + bump(prefs.applyPhotoDim), lbl, { step: 5, unit: '%' })) + ]; + + function reflect(tok) { + if (tok) { preview.src = prefs.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; } + else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } + } + function reflectPattern(tok) { + if (tok) { patPreview.src = prefs.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; } + else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } + } + /* `hidden` on the ROW, which is why 80-appearance.css restates it at a specificity that beats + * `.cbi-value`'s own display — the UA's bare `[hidden]` rule loses to it. Hidden and not + * removed: the rows are built once and each holds a live control whose value is this + * browser's, so rebuilding them on every switch would be the popover's old bug (a control + * frozen to the state it was constructed in) in a new place. */ + function togglePanel(v) { + patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); + fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); + } + reflect(prefs.currentLoginBg()); + reflectPattern(prefs.currentPattern()); + togglePanel(prefs.currentWallpaper()); + + const setWallpaper = (v) => { prefs.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); }; + + patChoose.addEventListener('click', () => { patErr.hidden = true; patInput.click(); }); + patInput.addEventListener('change', () => { + const f = patInput.files && patInput.files[0]; + patInput.value = ''; /* so re-picking the same file fires change again */ + if (!f) return; + patErr.hidden = true; patChoose.disabled = true; + patChoose.textContent = _('Uploading…', 'footstrap'); + prefs.uploadPattern(f) + .then((tok) => { + reflectPattern(tok); + /* uploadPattern already switched THIS browser onto the pattern, so the control has + * to catch up or the page paints the tile while the dropdown still reads Off. + * `dom.callClassMethod` is how LuCI moves one of its own widgets from the outside; + * setWallpaper is then called directly, because a programmatic setValue does NOT + * emit `widget-change` — the event is the user's, and relying on it here would + * leave the rows and the Save button behind. */ + dom.callClassMethod(seg, 'setValue', 'pattern'); + setWallpaper('pattern'); + }) + .catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; }) + .finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; }); + }); + patRemove.addEventListener('click', () => { + patErr.hidden = true; patRemove.disabled = true; + prefs.removePattern() + .then(() => reflectPattern('')) + .catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; }) + .finally(() => { patRemove.disabled = false; }); + }); + + chooseBtn.addEventListener('click', () => { err.hidden = true; fileInput.click(); }); + fileInput.addEventListener('change', () => { + const f = fileInput.files && fileInput.files[0]; + fileInput.value = ''; /* so re-picking the same file fires change again */ + if (!f) return; + err.hidden = true; chooseBtn.disabled = true; + chooseBtn.textContent = _('Uploading…', 'footstrap'); + prefs.uploadLoginBg(f) + .then(reflect) + .catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; }) + .finally(() => { chooseBtn.disabled = false; chooseBtn.textContent = chooseLabel; }); + }); + removeBtn.addEventListener('click', () => { + err.hidden = true; removeBtn.disabled = true; + prefs.removeLoginBg() + .then(() => reflect('')) + .catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; }) + .finally(() => { removeBtn.disabled = false; }); + }); + + let seg; + const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { + seg = selectCtl(prefs.currentWallpaper(), { + off: _('Off', 'footstrap'), + pattern: _('Pattern', 'footstrap'), + file: _('File', 'footstrap') + }, setWallpaper, label); + return seg; + }); + + return [ wallRow ].concat(patRows, fileRows); + })(); + + /* ---- section 4: the router default and the version ---- */ + /* the version line: read from fs-version.js, which the Makefile stamps at package time. No + * request, no check — `apk upgrade` is what tells this router about a new one. */ + + /* Save the current look as the ROUTER-WIDE default (fs-prefs writes it to /etc/config/footstrap + * via the scoped uci ACL). It does NOT change this browser — localStorage keeps overriding, so + * the saved default only shows on a fresh browser/device. "Reset" is the escape hatch: it clears + * this browser's overrides and reloads onto the saved default (a two-click confirm, since it + * discards local tweaks). + * + * No status text — the Save BUTTON itself is the status: enabled "Save as default" when this + * browser diverges from the saved default, disabled "Saved as default" when it already matches + * (nothing to save). refreshSave() below drives that from prefs.matchesSavedDefault(). */ + const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ _('Save as default', 'footstrap') ]); + /* TWO resets, because there are two things underneath a browser's tweaks (fs-prefs.js): + * "Reset to saved" clears them and lets every axis fall back through the layers — to whatever + * Save as default put on the ROUTER; "Reset to default" writes the THEME's own built-ins + * explicitly, which is the only way to say "as the theme ships" on a router that has a saved + * default of its own. Neither touches /etc/config/footstrap. */ + const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ _('Reset to saved', 'footstrap') ]); + /* The stock destructive class, so the button that throws away every local tweak is the red one + * on the page — LuCI paints .cbi-button-negative/.cbi-button-remove from --fs-danger + * (theme/55-buttons.css). "Reset to saved" stays neutral on purpose: it drops this browser back + * onto whatever the router says, which is a step BACK to a shared state rather than a discard. */ + const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to default', 'footstrap') ]); + /* Save's only visible failure surface. saveAsDefault() writes /etc/config/footstrap over the + * scoped uci ACL; the realistic failure is the rpc REJECTING — an expired session (403), a + * missing ACL, ubus down — which the old code buried in a title tooltip nobody sees. (A DELETED + * config is NOT caught here: rpcd stages the set in the session and commit then silently 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, so that edge is left to the package.) */ + const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + + /* the Save button IS the status: match -> disabled "Saved as default", diverged -> enabled + * "Save as default". Called after every axis change (via bump). */ + function refreshSave() { + const saved = prefs.matchesSavedDefault(); + saveBtn.disabled = saved; + saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap'); + } + saveBtn.addEventListener('click', () => { + saveBtn.disabled = true; + saveErr.hidden = true; + prefs.saveAsDefault() + .then(() => { saveErr.hidden = true; }) + /* On failure re-enable (refreshSave, below) so the user can retry. The usual cause is a + * stale session, which a reload fixes — so say that. The raw rpc error — the one string + * here neither the theme nor LuCI composed — stays in a title tooltip for debugging. */ + .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: the first click arms, the second resets — discarding this browser's + * tweaks is destructive of local work, and a native confirm() is banned in this UI. Arming one + * disarms the other, so a primed button can never be fired by a click meant for its neighbour. + * + * Each reload lands on this tab rather than back on General Settings: a reset is a change to + * what is on THIS tab, and being thrown to the top of the page to find it again is the kind of + * small rudeness that makes a setting feel unfinished. See armReturn() / the mount() flag. */ + 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(); + armReturn(); + location.reload(); + }); + } + twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved); + twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin); + refreshSave(); /* correct label/enabled state before the first paint */ + + const versionLink = E('a', { + 'class': 'fs-ap-version', + 'href': ver.REPO_URL, + 'target': '_blank', + 'rel': 'noopener noreferrer' + }, [ ver.label() ]); + + const defaults = [ + /* the one row whose "control" is a pair of buttons, each already named by its own text — so + * the caption is not re-used as an aria-label here and `make` ignores it */ + group(_('Router default', 'footstrap'), + () => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, resetSavedBtn, resetBtn ]), + { extra: saveErr }) + ]; + + + defaults.push(E('div', { 'class': 'fs-ap-footer' }, [ + E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]) + ])); + + /* NOT .cbi-section: inside a tab pane that class is a card drawn within a card, and the stock + * tabs (General Settings, Logging, …) put their rows straight into the pane. These are grouping + * headings within one pane, so they are the theme's own class and take their rule from + * styles/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 ------------------------------------------------------------------ + * Recolouring is a thing most admins never do, and these are the widest rows on the page — + * nine colour fields and an uploader, which used to sit permanently open in front of someone + * who came here to change the layout. Each is a DISCLOSURE now: the heading is the control, + * and both start closed. + * + * A disclosure and not a switch, which is what these were first. A switch answers "is this + * feature on", and that is the wrong question — turning it off would either revert nine colours + * (destructive, from a control that looks like a disclosure) or change nothing at all, which is + * a switch that lies. Folding answers the question that is actually being asked: am I looking + * at this right now. Nothing is applied, un-applied or disabled by opening or closing one. + * + * It is the W3C APG disclosure pattern, the same one the menu's sections use: a diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut new file mode 100644 index 00000000..5a802f79 --- /dev/null +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut @@ -0,0 +1,172 @@ +{# + The LOGIN page. + + Copyright 2008 Steven Barth + Copyright 2008-2012 Jo-Philipp Wich + Licensed to the public under the Apache License 2.0. + + It exists to pass `blank_page: true`, the only thing it changes about the generic template: + without a theme-local sysauth the dispatcher falls back to that one, which includes the header + WITHOUT blank_page — so the whole chrome (sidebar, menu, footer) rendered around the login + form, every control on it dead. + + It is NOT a copy of bootstrap's sysauth.ut, which hides the form in a `