Compare commits

...
12 Commits
Author SHA1 Message Date
github-actions[bot] dee375ce66 🔥 Sync 2026-09-06 07:02:02
Merge-upstream / merge (push) Canceled after 0s
2026-09-06 07:02:02 +08:00
kiddin9 7c496d5738 Update upstream.yml 2026-09-06 06:59:56 +08:00
github-actions[bot] d3321f4c3c 🎄 Sync 2026-09-06 05:58:26 2026-09-06 05:58:26 +08:00
kiddin9 f5aa63986c Delete .github/diy/patches/filebrowser.patch 2026-09-06 05:56:21 +08:00
github-actions[bot] a2faaa2a01 🛸 Sync 2026-09-06 05:52:01 2026-09-06 05:52:01 +08:00
kiddin9 570b248f8b Update upstream.yml 2026-09-06 05:50:03 +08:00
github-actions[bot] 87d47fbcb2 🔥 Sync 2026-09-06 05:41:59 2026-09-06 05:41:59 +08:00
github-actions[bot] 380ee71a08 🌴 Sync 2026-09-06 03:52:04 2026-09-06 03:52:04 +08:00
github-actions[bot] 8ca86b3a5e 🦄 Sync 2026-09-06 03:35:37 2026-09-06 03:35:37 +08:00
github-actions[bot] dbdd70b00d 🎁 Sync 2026-09-06 02:50:41 2026-09-06 02:50:41 +08:00
kiddin9 e70e8fddee Create filebrowser.patch 2026-09-06 02:48:51 +08:00
github-actions[bot] 02c6c789ba Sync 2026-09-06 02:17:46 2026-09-06 02:17:46 +08:00
30 changed files with 805 additions and 207 deletions
+2 -2
View File
@@ -394,7 +394,7 @@ jobs:
git_sparse_clone master "https://github.com/immortalwrt/packages" net/n2n net/qbittorrent \
net/amule net/cdnspeedtest net/minieap net/ddns-go net/sysuh3c net/3proxy utils/filebrowser net/cloudreve \
net/go-nats net/go-wol net/bitsrunlogin-go net/transfer net/udp2raw net/msd_lite \
net/subconverter net/ngrokc net/scutclient net/gost net/ua2f net/dufs net/frp net/qBittorrent-Enhanced-Edition \
net/subconverter net/ngrokc net/scutclient net/gost net/ua2f net/dufs net/qBittorrent-Enhanced-Edition \
net/tinyportmapper net/nexttrace net/rustdesk-server net/tuic-server net/transmission-web-control \
net/ipset-lists net/ShadowVPN net/nps net/dnsforwarder \
net/ps3netsrv net/brook net/q \
@@ -405,7 +405,7 @@ jobs:
utils/phicomm-k3screenctrl utils/joker utils/7z utils/dhrystone utils/supervisor utils/tinymembench utils/pcat-mgr utils/fan2go \
utils/coremark utils/watchcat multimedia/you-get multimedia/lux multimedia/gmediarender multimedia/ykdl multimedia/gallery-dl \
sound/spotifyd devel/go-rice devel/rust-bindgen admin/gotop \
lang/lua/lua-periphery lang/lua/lua-neturl lang/lua/lua-maxminddb lang/golang devel/gn
lang/lua/lua-periphery lang/lua/lua-neturl lang/lua/lua-maxminddb lang/golang devel/gn lang/node/node-pnpm
git_clone https://github.com/koshev-msk/modemfeed && mv -n modemfeed/*/!(telephony)/!(ookla-speedtest) ./; rm -rf modemfeed
git_sparse_clone openwrt-25.12 "https://github.com/immortalwrt/luci" applications collections/luci-nginx protocols/luci-proto-minieap protocols/luci-proto-quectel
mv -f applications luciapp && rm -rf luciapp/luci-app-firewall
+53 -36
View File
@@ -1,47 +1,43 @@
#
# Copyright (C) 2017-2024
#
# This is free software, licensed under the GNU General Public License v2.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser-q
PKG_VERSION:=1.5.6-stable
PKG_RELEASE=1
PKG_RELEASE:=5
ifeq ($(ARCH),aarch64)
PKG_ARCH:=arm64
PKG_HASH:=skip
else ifeq ($(ARCH),arm)
PKG_ARCH:=armv7
PKG_HASH:=skip
else ifeq ($(ARCH),x86_64)
PKG_ARCH:=amd64
PKG_HASH:=skip
endif
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=skip
PKG_BUILD_DIR:=$(BUILD_DIR)/filebrowser-$(PKG_VERSION)
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=kiddin9
PKG_BUILD_DEPENDS:=golang/host node/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
GO_PKG:=github.com/gtsteffaniak/filebrowser/backend
GO_PKG_BUILD_DIR:=$(PKG_BUILD_DIR)/backend
GO_PKG_LDFLAGS_X:= \
$(GO_PKG)/internal/version.Version=v$(PKG_VERSION) \
$(GO_PKG)/internal/version.CommitSHA=$(PKG_VERSION)
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
define Package/filebrowser-q
SECTION:=net
CATEGORY:=Network
DEPENDS:=@(arm||aarch64||x86_64)
TITLE:=FileBrowser Quantum
SECTION:=utils
CATEGORY:=Utilities
SUBMENU:=Filesystem
TITLE:=FileBrowser Quantum - Modern Web File Manager
URL:=https://github.com/gtsteffaniak/filebrowser
DEPENDS:=$(GO_ARCH_DEPENDS)
endef
define Package/filebrowser-q/description
The best free self-hosted web-based file manager.
endef
PKG_SOURCE:=linux-$(PKG_ARCH)-filebrowser
PKG_SOURCE_URL:=https://github.com/gtsteffaniak/filebrowser/releases/download/v$(PKG_VERSION)
define Build/Prepare
$(call Build/Prepare/Default)
endef
define Build/Compile
FileBrowser Quantum provides a modern, responsive web-based file management
interface with multi-source, real-time search, and enhanced preview features.
endef
define Package/filebrowser-q/conffiles
@@ -49,13 +45,34 @@ define Package/filebrowser-q/conffiles
/etc/config/filebrowser-q
endef
define Build/Prepare
$(call Build/Prepare/Default)
endef
define Build/Compile
( \
pushd $(PKG_BUILD_DIR)/frontend && \
npm install && \
npm run build ; \
)
( \
cd $(PKG_BUILD_DIR)/backend && \
$(GO_PKG_VARS) \
go build \
-trimpath \
-ldflags="-w -s" \
-o $(PKG_BUILD_DIR)/filebrowser . ; \
)
endef
define Package/filebrowser-q/install
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) $(CURDIR)/files/filebrowser.init $(1)/etc/init.d/filebrowser-q
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/filebrowser $(1)/usr/bin/filebrowser-q
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) $(CURDIR)/files/filebrowser.config $(1)/etc/config/filebrowser-q
$(INSTALL_DIR) $(1)/usr/bin/
$(INSTALL_BIN) $(DL_DIR)/linux-$(PKG_ARCH)-filebrowser $(1)/usr/bin/filebrowser-q
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) $(CURDIR)/files/filebrowser.init $(1)/etc/init.d/filebrowser-q
endef
$(eval $(call BuildPackage,filebrowser-q))
-13
View File
@@ -1,13 +0,0 @@
server:
port: 8989
database: "/etc/filebrowser-q/database.db"
sources:
- path: "/" # Do not use a root "/" directory or include the "/var" folder
config:
defaultEnabled: true
auth:
adminUsername: admin
adminPassword: "admin"
userDefaults:
ui:
locale: "zhCN"
+1 -1
View File
@@ -1,5 +1,5 @@
config filebrowser 'config'
option enabled '0'
option listen_port '8989'
option listen_port '8787'
option root_path '/'
+4 -4
View File
@@ -16,7 +16,7 @@ start() {
[ "$enabled" -eq "1" ] || return 1
local listen_port root_path root_name
config_get listen_port "config" "listen_port" "8989"
config_get listen_port "config" "listen_port" "8787"
config_get root_path "config" "root_path" "/"
# 路径为 / 时 name 设为 root,否则留空
@@ -43,9 +43,9 @@ userDefaults:
EOF
else
grep -q " name:" "$CONF_PATH" || sed -i "s,.*- path:.*,&\n name: \"\"," "$CONF_PATH"
sed -e "s/.*port:.*/ port: $listen_port/" \
-e "s,.*- path:.*, - path: \"$root_path\"," \
-e "s/.*name:.*/ name: \"$root_name\"/" \
sed -e "s/ port:.*/ port: $listen_port/" \
-e "s, - path:.*, - path: \"$root_path\"," \
-e "s/ name: \".*\"/ name: \"$root_name\"/" \
-i "$CONF_PATH"
fi
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser
PKG_VERSION:=2.63.23
PKG_RELEASE:=21
PKG_RELEASE:=23
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/filebrowser/filebrowser/tar.gz/v$(PKG_VERSION)?
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=frp
PKG_VERSION:=0.70.1
PKG_RELEASE:=5
PKG_VERSION:=0.71.0
PKG_RELEASE:=6
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/fatedier/frp/tar.gz/v$(PKG_VERSION)?
@@ -76,7 +76,7 @@ return view.extend({
o = s.option(form.Value, 'listen_port', _('Listen port'));
o.datatype = 'port';
o.default = '8989';
o.default = '8787';
o.rmempty = false;
o = s.option(form.Value, 'root_path', _('Root directory'));
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.9.2
PKG_RELEASE:=111
PKG_RELEASE:=112
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -272,9 +272,17 @@ o = s:option(ListValue, "remote_dns_protocol", translate("Remote DNS Protocol"))
o:value("tcp", "TCP")
o:value("doh", "DoH")
o:value("udp", "UDP")
o:value("tls", "TLS(DoT)", { _is_singbox = "1" })
o:value("quic", "QUIC(DoQ)", { _is_singbox = "1" })
o:value("http3", "HTTP3(DoH3)", { _is_singbox = "1" })
if m.is_js_luci then
if current_node.type == "sing-box" then
o:value("tls", "TLS(DoT)")
o:value("quic", "QUIC(DoQ)")
o:value("http3", "HTTP3(DoH3)")
end
else
o:value("tls", "TLS(DoT)", { _is_singbox = "1" })
o:value("quic", "QUIC(DoQ)", { _is_singbox = "1" })
o:value("http3", "HTTP3(DoH3)", { _is_singbox = "1" })
end
o:depends("_show_dns_option", "1")
---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ)
@@ -382,9 +390,8 @@ for k, v in pairs(nodes_table) do
end
end
--m:appendTemplate("/acl/options", {section = arg[1]})
-- Shunt Start
--[[
-- Shunt
if current_node.protocol == "_shunt" then
local shunt_lua = loadfile("/usr/lib/lua/luci/model/cbi/passwall2/client/include/shunt_options.lua")
setfenv(shunt_lua, getfenv(1))(m, s, {
@@ -396,5 +403,6 @@ if current_node.protocol == "_shunt" then
end
m:appendTemplate("/acl/shunt", { shunt_list = api.jsonc.stringify(shunt_list), section = s.section })
]]--
return api.return_map(m)
@@ -201,9 +201,17 @@ o = s:taboption("DNS", ListValue, "remote_dns_protocol", translate("Remote DNS P
o:value("tcp", "TCP")
o:value("doh", "DoH")
o:value("udp", "UDP")
o:value("tls", "TLS(DoT)", { _is_singbox = "1" })
o:value("quic", "QUIC(DoQ)", { _is_singbox = "1" })
o:value("http3", "HTTP3(DoH3)", { _is_singbox = "1" })
if m.is_js_luci then
if current_node.type == "sing-box" then
o:value("tls", "TLS(DoT)")
o:value("quic", "QUIC(DoQ)")
o:value("http3", "HTTP3(DoH3)")
end
else
o:value("tls", "TLS(DoT)", { _is_singbox = "1" })
o:value("quic", "QUIC(DoQ)", { _is_singbox = "1" })
o:value("http3", "HTTP3(DoH3)", { _is_singbox = "1" })
end
---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ)
o = s:taboption("DNS", Value, "remote_dns", translate("Remote DNS"))
@@ -259,11 +267,10 @@ o:value("UseIP")
o:value("UseIPv4")
o:value("UseIPv6")
if current_node.type == "sing-box" then
o = s:taboption("DNS", Value, "remote_rewrite_ttl", translate("Remote DNS") .. " TTL")
o.datatype = "min(1)"
o.default = "30"
end
o = s:taboption("DNS", Value, "remote_rewrite_ttl", translate("Remote DNS") .. " TTL")
o.datatype = "min(1)"
o.default = "30"
o:depends("_is_singbox", "1")
o = s:taboption("DNS", TextValue, "dns_hosts", translate("Domain Override"))
o.rows = 5
@@ -1,56 +0,0 @@
<%
local map = self.map
local api = map.api
local config = map.config
local section = self.section
-%>
<script type="text/javascript">
//<![CDATA[
function setOption(option, val) {
const dom = document.getElementById('cbid.<%=config%>.<%=section%>.' + option);
if (dom) {
dom.value = val;
}
const combobox_dom = document.getElementById('cbi.combobox.cbid.<%=config%>.<%=section%>.' + option);
if (combobox_dom) {
combobox_dom.value = val;
//combobox_dom.selectedIndex = val;
//combobox_dom.dispatchEvent(new Event("change", { bubbles: true }));
}
}
document.addEventListener("DOMContentLoaded", function () {
function dom_event() {
waitForElementId('cbi.combobox.cbid.<%=config%>.<%=section%>.tcp_no_redir_ports', function(el) {
const o_val = el.value;
el.addEventListener("change", () => {
const udp_no_redir_ports = getOption("<%=config%>", "<%=section%>", "udp_no_redir_ports");
if (el.value == "1:65535" && udp_no_redir_ports && udp_no_redir_ports.value == "1:65535") {
setOption("tcp_no_redir_ports", "");
setOption("udp_no_redir_ports", "");
setOption("mode", 0);
cbi_d_update();
}
});
});
waitForElementId('cbi.combobox.cbid.<%=config%>.<%=section%>.udp_no_redir_ports', function(el) {
const o_val = el.value;
el.addEventListener("change", () => {
const tcp_no_redir_ports = getOption("<%=config%>", "<%=section%>", "tcp_no_redir_ports");
if (el.value == "1:65535" && tcp_no_redir_ports && tcp_no_redir_ports.value == "1:65535") {
setOption("tcp_no_redir_ports", "");
setOption("udp_no_redir_ports", "");
setOption("mode", 0);
cbi_d_update();
}
});
});
}
const ori_cbi_d_update = cbi_d_update;
cbi_d_update = function() {
ori_cbi_d_update();
dom_event();
};
dom_event();
});
//]]>
</script>
@@ -1507,15 +1507,17 @@ table td, .table .td {
//Node list option saving logic
document.addEventListener("DOMContentLoaded", function () {
function onChange(option, value) {
function onChange(option, value, refresh) {
ajax.abortAll();
XHR.get('<%=api.url("save_node_list_opt")%>', {
option: option,
value: value
}, function(x) {
if (x && x.status == 200) {
if (refresh) {
document.getElementById("node_list").innerHTML = "";
loadNodeList();
}
} else {
alert("<%:Error%>");
}
@@ -1527,7 +1529,7 @@ table td, .table .td {
el.addEventListener("change", () => {
el.blur();
show_node_info = el.checked ? "1" : "0";
onChange("show_node_info", show_node_info);
onChange("show_node_info", show_node_info, true);
});
});
@@ -1535,32 +1537,47 @@ table td, .table .td {
el.addEventListener("change", () => {
el.blur();
auto_detection_time = el.value;
onChange("auto_detection_time", auto_detection_time);
onChange("auto_detection_time", auto_detection_time, true);
});
});
<% if api.is_js_luci() then -%>
waitForElement('div[id*="cbid.<%=appname%>"][id*="url_test_url"]', function(el) {
el.addEventListener("cbi-dropdown-change", () => {
if (el.value && (!el.new_val || el.new_val != el.value)) {
onChange("url_test_url", el.value, false);
el.new_val = el.value;
}
});
});
<% else -%>
waitForElement('select[id*="<%=appname%>"][id*="url_test_url"]', function(el) {
el.addEventListener("change", () => {
if (el.value) {
onChange("url_test_url", el.value);
if (el.value && (!el.new_val || el.new_val != el.value)) {
onChange("url_test_url", el.value, false);
el.new_val = el.value;
}
});
});
waitForElement('input[id*="<%=appname%>"][id*="url_test_url"]', function(el) {
el.addEventListener("change", () => {
if (el.value) {
onChange("url_test_url", el.value);
if (el.value && (!el.new_val || el.new_val != el.value)) {
onChange("url_test_url", el.value, false);
el.new_val = el.value;
}
});
});
<% end -%>
}
<% if not api.is_js_luci() then -%>
const ori_cbi_d_update = cbi_d_update;
cbi_d_update = function() {
ori_cbi_d_update();
dom_event();
};
<% end -%>
dom_event();
const links = document.querySelectorAll('a');
+3 -3
View File
@@ -15,12 +15,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wwand
PKG_RELEASE:=9
PKG_RELEASE:=10
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
PKG_SOURCE_VERSION:=88a711251296810ea3428d32a3da9c5b635cea0c
PKG_SOURCE_DATE:=2026-08-31
PKG_SOURCE_VERSION:=bd773d76198d2a92063b349ed4f1e1f75475857c
PKG_SOURCE_DATE:=2026-09-05
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
@@ -82,6 +82,24 @@ function buildIwinfoDeviceLookup(devices) {
return lookup;
}
function getQcaIwinfoDevicesFromConfig() {
const radios = uci.sections('wireless', 'wifi-device');
const devices = [];
if (!radios.length || !radios.every((radio) => isQcaWifiHwtype(radio.type)))
return null;
for (const iface of uci.sections('wireless', 'wifi-iface')) {
if (isConfigWifiIfaceDisabled(iface) ||
!isQcaWifiHwtype(uci.get('wireless', iface.device, 'type')))
continue;
pushUnique(devices, iface.ifname || getQcaFallbackIfname(iface.device, iface['.name']));
}
return devices;
}
function getWifiNetIdBySection(section) {
let index = 0;
@@ -130,7 +148,7 @@ function parseIwDevInfoTxPower(stdout) {
return (!isNaN(value) && value > 0) ? value : null;
}
function buildIwinfoResolver(devices) {
function buildIwinfoResolver(devices, configOnly) {
const deviceLookup = buildIwinfoDeviceLookup(devices);
const aliasMap = Object.create(null);
const queryTargets = [];
@@ -190,7 +208,8 @@ function buildIwinfoResolver(devices) {
return {
deviceLookup,
aliasMap,
queryTargets
queryTargets,
configOnly
};
}
@@ -201,8 +220,13 @@ function loadIwinfoResolver(force) {
if (!force && cachedIwinfoResolver != null)
return Promise.resolve(cachedIwinfoResolver);
cachedIwinfoResolverPromise = L.resolveDefault(callIwinfoDevices(), {}).then((res) => {
cachedIwinfoResolver = buildIwinfoResolver(res?.devices);
const qcaDevices = getQcaIwinfoDevicesFromConfig();
const deviceRequest = qcaDevices != null
? Promise.resolve({ devices: qcaDevices })
: L.resolveDefault(callIwinfoDevices(), {});
cachedIwinfoResolverPromise = deviceRequest.then((res) => {
cachedIwinfoResolver = buildIwinfoResolver(res?.devices, qcaDevices != null);
cachedIwinfoResolverPromise = null;
return cachedIwinfoResolver;
}).catch(() => {
@@ -214,7 +238,8 @@ function loadIwinfoResolver(force) {
cachedIwinfoResolver = {
deviceLookup: Object.create(null),
aliasMap: Object.create(null),
queryTargets: []
queryTargets: [],
configOnly: false
};
return cachedIwinfoResolver;
@@ -231,7 +256,9 @@ function loadIwinfoInfoMap(force) {
return Promise.resolve(cachedIwinfoInfoMap);
cachedIwinfoInfoPromise = loadIwinfoResolver(force).then((resolver) => {
const queryTargets = resolver.queryTargets.length ? resolver.queryTargets : getLegacyIwinfoProbeTargets();
const queryTargets = (resolver.configOnly || resolver.queryTargets.length)
? resolver.queryTargets
: getLegacyIwinfoProbeTargets();
return Promise.all(queryTargets.map((name) =>
L.resolveDefault(callIwinfoInfoCompat(name), null).then((info) => [ name, info ])
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=60
PKG_RELEASE:=61
endif
LUCI_TITLE:=Footstrap Theme
@@ -480,6 +480,35 @@ function wireRail() {
* stays in the label for screen readers, and in `title` for the pointer. */
const IND_DOT = '•';
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM same
* shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does
* not export it). */
function syncIndAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", ) ships as a bare
* span: no role, name or tabindex, so Tab skips it and a screen reader
* announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own
* click handler already lives on this exact element this only adds the second, W3C-APG way to
* reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"),
* never invented. Enter/Space call el.click() because a <span>, unlike an <a>, gets neither key
* for free (contrast fs-widgets.js's wireSpaceKey, written for an <a role="button">); calling
* click() cannot double-fire the mouse handler, since a keydown is not a click. */
function wireIndicatorKeyboard(el) {
if (el.dataset.fsWired) return;
el.dataset.fsWired = '1';
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
el.click();
});
}
function wireIndicatorCounts() {
const box = document.getElementById('indicators');
if (!box) return;
@@ -491,6 +520,18 @@ function wireIndicatorCounts() {
el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
el.setAttribute('title', txt);
/* Only a CLICKABLE indicator becomes a control. `ui.showIndicator` sets
* `data-clickable` only when it was handed a handler, and a status-only pill is
* given none (luci-base ui.js; `battstatus.js` calls it with a null handler) a
* `role="button"` tabstop with nothing behind it is worse than the bare span it
* replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to
* `[data-clickable]` for the same reason; the two must not disagree. */
if (el.hasAttribute('data-clickable')) {
syncIndAttr(el, 'role', 'button');
syncIndAttr(el, 'tabindex', '0');
syncIndAttr(el, 'aria-label', txt.trim() || null);
wireIndicatorKeyboard(el);
}
});
}
@@ -3,6 +3,7 @@
'require dom';
'require network';
'require fs-fit as fit';
'require menu-footstrap-common as common';
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
* Storage sections into a grid. Content, data and styling stay luci-mod-status's rendering a
@@ -23,11 +24,15 @@ const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto
/* the data-page value four call sites compare against; a string literal is not mangled, so a
* repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */
function sectionTitle(sec) {
function headerEl(sec) {
/* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits
* a bare `<h3>` as the section's first child. Matching only one silently disables the grid on
* the other. */
const h = sec.querySelector('.cbi-title h3, :scope > h3');
return sec.querySelector('.cbi-title h3, :scope > h3');
}
function sectionTitle(sec) {
const h = headerEl(sec);
if (!h) return '';
/* the first non-empty TEXT node, not `firstChild`: 25.12 appends a hide/show <span> inside the
* same <h3>, so `firstChild` depends on upstream keeping the words first */
@@ -39,6 +44,101 @@ function sectionTitle(sec) {
return '';
}
/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ----
*
* Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements the
* topbar poll pill plus one Hide/Show toggle per card and every one is a bare <span>: no
* tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run
* of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A).
* docs/findings.md, "A card cannot be collapsed from the keyboard".
*
* index.js's own pill keeps doing the actual show/hide untouched, so mouse behaviour for anyone
* clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler,
* not a competing one. */
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
* no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js restated rather than
* imported, since that file does not export it. */
function syncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed
* (it reads "Show") the chevron mirror in pages/20-overview.css reads the same attribute the
* same way. */
function pillExpanded(label) {
return label.getAttribute('data-style') !== 'active';
}
/* The panel `data-style` shows or hides is `.cbi-title`'s next sibling in the 25.12 markup this was
* measured on. 24.10 emits no `.cbi-title` wrapper (headerEl() above) and is left without
* aria-controls rather than guessed at role, tabindex, aria-expanded and the keyboard below still
* apply there; aria-controls is the one piece this function cannot state with confidence. */
function panelFor(h) {
const holder = h.closest('.cbi-title');
return holder ? holder.nextElementSibling : null;
}
let _panelSeq = 0;
/* Promote one card's header to a disclosure control. Idempotent: a tick that finds the header
* already wired touches only aria-expanded, and only when it actually changed; the click/keydown
* listeners are added once (`dataset.fsWired`), never re-added. */
function wireDisclosure(sec) {
const h = headerEl(sec);
const label = h && h.querySelector('.label[data-indicator="poll-status"]');
if (!h || !label) return; /* not this card's shape — left alone rather than guessed at */
const panel = panelFor(h);
if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);
syncAttr(h, 'role', 'button');
syncAttr(h, 'tabindex', '0');
syncAttr(h, 'aria-controls', panel ? panel.id : null);
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
/* the header now carries the pill's name, role and state; a screen reader user tabbing past it
* to a second, unlabelled clickable span would hear an unexplained duplicate control same
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */
syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
h.dataset.fsWired = '1';
h.addEventListener('click', (ev) => {
/* a click landing on the pill itself already ran index.js's own handler; forwarding here
* too would toggle the card twice */
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
});
/* the pill is an <a>-less <span>, so neither key is native here contrast
* fs-widgets.js's wireSpaceKey, written for an <a role="button">, which gets Enter for free
* and needs only Space added */
h.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
label.click();
});
}
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
* "-", the poll pill its only content, 72px tall on the live Overview seventh there (a fixture is
* not proof of position: docs/playground.html puts it first). Real and cosmetic, so it is
* suppressed rather than left as a rung in the tab order with nothing behind its own name. */
function hideEmptyCard(sec) {
if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty');
}
function tidyCards(view) {
view.querySelectorAll('.cbi-section').forEach((sec) => {
hideEmptyCard(sec);
wireDisclosure(sec);
});
}
/* the wrapper we built, so the poll-tick fast path costs one property read */
let _wrapEl = null;
@@ -75,6 +175,18 @@ function arrange() {
if (!view) return;
nameTooltips(view);
/* 20_memory.js, 25_storage.js and 30_network.js each call their OWN local progressbar(), not
* the theme's window.progressbar (menu-footstrap-common.js), so the reading and colour those
* bars need are stamped on here instead on the same poll-tick callback nameTooltips() already
* runs on, ahead of the fast-path return below: dom.content() rewrites a section's title every
* tick even when the wrapper survives, so a bar's reading is stale on every tick this line is
* skipped. */
common.annotateMeters(view);
/* Same reasoning, same placement: dom.content() rewrites a card's body every tick even when the
* wrapper survives, so a card that is only now getting its title text needs wiring on this
* pass, not just the first one. tidyCards() is its own idempotent write (wireDisclosure(),
* hideEmptyCard() above), so a tick that changes nothing here touches no DOM either. */
tidyCards(view);
/* Fast path: the poll lands here on every tick, forever, and the stock poll never rebuilds
* the .cbi-section wrappers, so the grid survives. Deliberately not a disconnect() if a
@@ -276,7 +388,10 @@ function patchOverview() {
/* `progressbar`, `renderBox` and `renderBadge` are defined in menu-footstrap-common.js, not here:
* a stock include calls them bare from its own render(), so they must exist before the view class
* does, and this page module is required during the navigation that races it. */
* does, and this page module is required during the navigation that races it. `annotateMeters` is
* required from that same module (see arrange()) rather than duplicated: it is not a template
* global, so nothing forces it to live there, but the threshold split and label lookup it shares
* with `window.progressbar` would drift into two copies otherwise. */
return baseclass.extend({
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
@@ -316,6 +316,66 @@ function labelCells(t, head) {
}
}
/* ---- WCAG 1.3.1: a `display` override must not cost a table its own roles ----
*
* theme/*.css restyles `display` on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` in over
* twenty places `.fs-stacked`, `.fs-rowstack`, the config table's own `@container`, the phone
* `@media`, the Overview meter row, the software page's carded description column, the 560px
* key/value card added alongside this file scattered across selectors this module cannot see the
* live state of (a media query, a container query, a class some other pass toggled). A `display`
* other than `table`/`table-row`/`table-cell`/ drops the IMPLICIT `table`/`row`/`cell` role
* HTML-AAM derives from it: measured on Overview's key/value cards, `display: block`/`grid`/`block`
* (table/row/cell) at both 1440 and 390px, and on the 390px card stack, `block`/`none`/`block`
* neither carries a role, so the column a value belongs to is never announced.
*
* Rather than pair each of those twenty-odd rules with a role write timed to its own trigger a
* race with every one of them the role is written UNCONDITIONALLY, matching what a plain
* `<table>` already carries implicitly. That costs nothing while the table IS a table and survives
* the moment any of those rules land. tools/table-contract.mjs holds the pairing: a future
* `display` rule on one of these classes with no role to match is what it is written to catch.
*
* The whole chain, every pass a `role="cell"` with nothing above it saying `row`, or a `row` with
* nothing above IT saying `table`/`rowgroup`, reads worse than no roles at all. One flat query per
* class keeps it whole without walking each table by hand: `.tr`/`.th`/`.td` are LuCI's own names,
* always written together with `.table` (adoptMarkup() above never adds one without the others), so
* a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a
* div-based table; a REAL `<thead>`/`<tbody>`/`<tfoot>` this file never classes (20_lan.js's and
* 30_wifi.js's `<tfoot>`, theme/30-tables.css:570/634) is reached by tag name instead. */
const TABLE_ROLE_CLASSES = [
[ '.table', 'table' ],
[ '.thead', 'rowgroup' ],
[ '.tbody', 'rowgroup' ],
[ '.tfoot', 'rowgroup' ],
[ '.tr', 'row' ],
[ '.th', 'columnheader' ],
[ '.td', 'cell' ],
];
const TABLE_ROLE_TAGS = [
[ 'thead', 'rowgroup' ],
[ 'tbody', 'rowgroup' ],
[ 'tfoot', 'rowgroup' ],
];
/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries
* the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches
* no DOM and wakes no attribute observer. Not imported for one line this file has none of that
* module's other exports to justify the dependency. */
function setRole(el, role) {
if (el.getAttribute('role') !== role) el.setAttribute('role', role);
}
/* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/
* `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone the role written
* here changes nothing about that, since a role is inert on a box the engine never generates. */
function roleTables() {
TABLE_ROLE_CLASSES.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
});
TABLE_ROLE_TAGS.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
});
}
/* ---- card-stack a data table that no longer fits ----
*
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the decision.
@@ -890,12 +950,14 @@ return baseclass.extend({
fit.armGate();
/* A table must be tagged .fs-dt before it can be fitted, and re-tagged whenever the poll
* brings a fresh one back, so tagging leads.
* brings a fresh one back, so tagging leads; roleTables() follows it directly so a class
* adoptMarkup() just added carries its role in the same pass rather than waiting a tick.
*
* Five registrations, not one callback: fs-fit catches per registered fitter, and the first
* Six registrations, not one callback: fs-fit catches per registered fitter, and the first
* of these walks third-party markup. Bundled, one throw in `tagDataTables()` leaves no table
* tagged and with the gate above raised a page with no tables on it at all. */
fit.add(tagDataTables);
fit.add(roleTables);
fit.add(fitTables);
fit.add(fitScrollables);
fit.add(unpinActionColumn);
@@ -153,6 +153,88 @@ function loadPlugins() {
});
}
/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a
* plain percentage is kept anyway so every meter on the page reads the same way. */
const FS_METER_WARN = 80;
const FS_METER_DANGER = 92;
/* Write an attribute only when the value actually changes, so a poll tick that reads the same
* numbers back touches no DOM and fires no attribute-mutation observer. `value === null` removes
* the attribute instead of writing the string "null". */
function fsSyncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* The meter's name, if the markup already states one never invented. A `.cbi-value` row's own
* label (the RSSI/RSRP gallery shape) or a key/value table row's first cell (Memory, Storage, CPU
* load on Overview) each stand for the whole row; a bare meter with neither (Software's disk-space
* bar, the package manager) gets no `aria-label` written at all, and Chromium's accessible-name
* computation then falls back to `title` which is the reading, not a name ("95 / 100 (95%)"),
* measured with `Accessibility.getPartialAXTree`. That fallback cannot be closed from here: `title`
* is the ONLY source `::after { content: attr(title) }` has for the visible percentage
* (styles/theme/25-progressbar.css), so removing it would blank the bar for a sighted reader too. */
function findProgressbarLabel(pg) {
const row = pg.closest('.cbi-value');
let label = row ? row.querySelector('.cbi-value-title') : null;
if (!label) {
const tr = pg.closest('.tr');
if (tr) {
const own = pg.closest('.td, td');
const cell = tr.querySelector('.td, td');
if (cell && cell !== own) label = cell;
}
}
return label;
}
/* The percentage inside a meter's `title`, in either shape a caller writes it in: `window.progressbar`
* below composes `'%s / %s (%d%%)'`, parenthesised and preceded by the byte/localised reading, but a
* bare meter markup may carry just `'97%'` with nothing around it (docs/gallery.html). Both are
* anchored at the end of the string so neither can match a stray "%" earlier in a localised reading;
* an empty title or one with no percentage at all yields null, on purpose nothing to annotate.
* `%d` is the unclamped percentage, so the result can still read past 100 or under 0 and is clamped
* by the caller, the same way `window.progressbar` clamps its own `level`, below. Exported for
* tests/meter.test.mjs, which is the only caller that needs the parse on its own. */
function parseMeterPercent(title) {
if (title == null) return null;
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title);
return m ? parseInt(m[1], 10) : null;
}
/* The attribute work `window.progressbar` and a bare `.cbi-progressbar` node both need Status ->
* Overview's own 20_memory.js, 25_storage.js and 30_network.js (luci-mod-status) declare and call
* their OWN local `progressbar()`, a straight copy of the same upstream body, so this theme's
* global never runs on the meters those stock includes actually draw. `annotateMeters()` below
* walks the DOM after the fact instead of owning every writer; the threshold split and the name
* logic stay in this one function either way, so the two callers cannot drift apart. */
function annotateMeter(pg) {
const title = pg.getAttribute('title');
const pc = parseMeterPercent(title);
if (pc == null) return;
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
fsSyncAttr(pg, 'role', 'progressbar');
fsSyncAttr(pg, 'aria-valuemin', '0');
fsSyncAttr(pg, 'aria-valuemax', '100');
fsSyncAttr(pg, 'aria-valuenow', String(level));
fsSyncAttr(pg, 'aria-valuetext', title);
const label = findProgressbarLabel(pg);
fsSyncAttr(pg, 'aria-label', label ? label.textContent.trim() : null);
fsSyncAttr(pg, 'data-fs-level',
level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null));
}
/* Every `.cbi-progressbar[title]` under `root` the markup itself, not who last drew it. Called
* from fs-overview.js's own poll-tick observer (see there for why this file does not run a second
* one); `fsSyncAttr` above makes a re-run over an unchanged bar a no-op read. */
function annotateMeters(root) {
(root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
}
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
*
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
@@ -165,27 +247,43 @@ function loadPlugins() {
* ordering the two. This file is required by the footer on every page and evaluates before the
* router exists.
*
* Bodies are verbatim from upstream except for two deltas: L.itemlist -> window.L.itemlist (the
* two-L trap, docs/spa-router.md), and renderBox's `[title]` dom.append parses a scalar child as
* Bodies are verbatim from upstream except for three deltas: L.itemlist -> window.L.itemlist (the
* two-L trap, docs/spa-router.md); renderBox's `[title]` dom.append parses a scalar child as
* innerHTML (luci.js:1395) and an array member as text (:1383), and this file defines the global on
* every admin page where upstream defines it on Status -> Overview alone. Same output: nothing in
* 24.10, 25.12 or master calls renderBox. The typeof guards make each a no-op on a full page load,
* where the template's own copies win the race. */
* every admin page where upstream defines it on Status -> Overview alone; and progressbar's
* accessibility attributes (role, aria-value*, data-fs-level) upstream's copy writes the reading
* into `title` alone, which nothing reads back for a screen reader off a generated `::after`. Same
* output on the other two: nothing in 24.10, 25.12 or master calls renderBox, and progressbar's
* `title` and fill width are untouched.
*
* `renderBox`/`renderBadge` keep the `typeof` guard: a full page load runs the template's inline
* `<script>` first, in document order, before this module evaluates, so upstream has already
* declared both the guard just avoids overwriting an identical body. `progressbar` CANNOT reuse
* that guard: upstream's copy of the same name wins that exact race, and it is the one WITHOUT the
* accessibility attributes below. Guarded, the theme's version only ever won when a page other than
* Status -> Overview loaded first and the SPA router then navigated here without a reload which
* is a real gap, not the common case. The assignment is unconditional so the accessible copy always
* wins, on a full load and on an SPA arrival alike. */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so
they can be diffed against upstream when it changes. Modernising the `var`s would break
that property, which is what makes carrying the copies safe. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn);
pc = Math.floor((100 / mn) * vn),
reading = '%s / %s (%d%%)'.format(fv, fm, pc);
if (pg) {
pg.firstElementChild.style.width = pc + '%';
pg.setAttribute('title', '%s / %s (%d%%)'.format(fv, fm, pc));
pg.setAttribute('title', reading);
/* Accessible value, colour and name: annotateMeter() re-parses `reading` back out
* of `title` rather than reusing `pc` here directly, so a bare `.cbi-progressbar`
* this function never touched is annotated the identical way the clamp, the
* label lookup and the warn/danger split stay in that one function. */
annotateMeter(pg);
}
};
if (typeof window.renderBox !== 'function')
@@ -235,6 +333,15 @@ return baseclass.extend({
* remember() for what a key is */
remember,
/* the seam fs-overview.js calls, on its own already-coalesced poll-tick observer, to annotate
* the meters a stock Status -> Overview include draws with its own local progressbar() */
annotateMeters,
/* module-private otherwise: tests/meter.test.mjs drives the percent parse and the clamp/threshold
* split directly rather than through a live `.cbi-progressbar[title]` walk */
parseMeterPercent, /* fs:probe */
annotateMeter, /* fs:probe */
init(renderMainMenu) {
/* First, and outside the promise: a third-party sheet that outranks the chrome is already
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to
@@ -12,16 +12,24 @@
* drifted). Only the overview-specific chevron toggle stays.
* index.js emits a span with a raw "Hide"/"Show" text node + data-style (expanded =
* inactive, collapsed = active); data-indicator='poll-status' is unique to these
* toggles. font-size:0 hides the word, ::after borders draw the chevron. */
* toggles. font-size:0 hides the word, ::after borders draw the chevron.
*
* The card HEADER is the disclosure control now (fs-overview.js wireDisclosure()); this pill is
* marked aria-hidden there and stays only as its glyph, so the border and fill that made it read
* as its own 26px button are dropped 16px, matching the rail toggle's own chevron glyph
* (theme/20-shell.css `.fs-railtoggle .fs-ico`), and --fs-faint rather than the pill's --fs-dim,
* a step quieter since it is decoration rather than a target now. */
.cbi-title .label[data-indicator="poll-status"] {
font-size: 0;
width: 26px;
height: 26px;
width: 16px;
height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--fs-radius-sm);
background: none;
border: 0;
color: var(--fs-faint);
}
/* The glyph itself is the Appearance fold's, to the byte one chevron for "this opens", drawn
* in two page files because the two hosts are a stock element keyed by attribute and one of
@@ -47,6 +55,37 @@
/* @endmirror */
}
/* The header itself (fs-overview.js wireDisclosure() stamps role="button"): a hand cursor for a
* pointer user, a focus ring for a keyboard one the same ring every other interactive control
* in this theme draws (theme/35-alerts.css `.cbi-title .label:focus-visible`, the pill's own
* former ring, now the header's).
*
* Two selectors, not one: fs-overview.js's headerEl() reads `.cbi-title h3, :scope > h3` because
* the wrapper itself differs by release 25.12 renders `.cbi-title > h3`, 24.10 a bare `<h3>`
* straight inside `.cbi-section`, no `.cbi-title` at all. `[role="button"]` is the anchor either
* way, so a heading wireDisclosure() left untouched one with nothing to collapse is never
* matched. Measured on a 24.10 FORM: without the second arm the attributes and the keyboard
* worked but `cursor` stayed `auto`, `box-shadow` `none`, and focus drew only the UA outline.
* On a live 24.10 the arm does not fire at all today, and that is not a fault in it: OpenWrt
* 24.10.8 renders no hide/show control on an Overview card, so wireDisclosure() stamps nothing
* there and 0 of the page 14 `<h3>`s carry the role (measured on owlab owrt2410). The arm
* guards a SHAPE of markup, not a release it is what keeps the styling with the behaviour on
* whichever release grows the control next. */
:is(.cbi-title h3, .cbi-section > h3)[role="button"] {
cursor: pointer;
border-radius: var(--fs-radius-sm);
}
:is(.cbi-title h3, .cbi-section > h3)[role="button"]:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
/* An empty `.cbi-section` LuCI still renders when a stock include has nothing to show: title
* "-", the poll pill its only content, 72px tall on the live Overview (seventh there a
* fixture is not proof of position, docs/findings.md "An empty card with `-` as its title").
* Real and cosmetic, so fs-overview.js's hideEmptyCard() marks it and this drops it. */
body[data-page="admin-status-overview"] .cbi-section.fs-ovl-empty { display: none; }
/* Stock overview layout: fs-overview.js wraps the stock System / Memory / Storage
* sections in .fs-ovl; this is the grid only System left across both rows, Memory +
* Storage right. Stock styling and data untouched. */
@@ -192,8 +192,13 @@
}
.fs-ap-verrow { display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-2-5); }
/* links to the project repo — reads as muted text until hovered */
.fs-ap-version { font-size: var(--fs-type-xs); color: var(--fs-dim); font-variant-numeric: tabular-nums; text-decoration: none; }
.fs-ap-version { position: relative; font-size: var(--fs-type-xs); color: var(--fs-dim); font-variant-numeric: tabular-nums; text-decoration: none; }
.fs-ap-version:hover { color: var(--fs-accent); text-decoration: underline; }
/* the link's own box was 84x17 (owrt2512/390x844, Appearance tab), under the 24px floor of
* WCAG 2.2 SC 2.5.8 on height. Same fix as `.cbi-checkbox > label[for]::before` in
* theme/60-inputs.css: an invisible, absolutely-positioned overlay grows the hit box to
* 88x25 without moving the text or growing `.fs-ap-verrow`, since the overlay is out of flow. */
.fs-ap-version::before { content: ""; position: absolute; inset: -4px -2px; }
/* The line that names the missing catalogue package. Same weight as the version beside it
* it is a fact about this router, not a warning: nothing is broken, one package is absent.
@@ -782,4 +782,14 @@
* so "Refreshing" showed a text caret. */
.fs-indicators [data-indicator][data-clickable],
#indicators [data-indicator][data-clickable] { cursor: pointer; }
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so
* it is a control now and takes the control ring the buttons beside it take (55-buttons.css)
* rather than the UA outline it was left with. `:focus-visible`, not `:focus`: a pointer press
* on the pill should not draw a ring, the same rule every other control here follows. */
.fs-indicators [data-indicator][data-clickable]:focus-visible,
#indicators [data-indicator][data-clickable]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
}
@@ -19,6 +19,16 @@
border-radius: var(--fs-radius-pill);
transition: width var(--fs-dur-fill);
}
/* Colour carries the reading too, not length alone: `data-fs-level` is set from JS
* (menu-footstrap-common.js, FS_METER_WARN/FS_METER_DANGER 80/92%, a decision, not a
* measurement) on every poll tick. Solid tokens, matching the accent fill above no
* color-mix, so no static twin is owed to 04-nocolormix.css. */
.cbi-progressbar[data-fs-level="warn"] > div {
background: var(--fs-warn);
}
.cbi-progressbar[data-fs-level="danger"] > div {
background: var(--fs-danger);
}
.cbi-progressbar::after {
content: attr(title);
/* the gap is a scale step, not a literal 4: every other gap in the theme scales with the
@@ -303,6 +303,43 @@
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) {
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
}
/* Below ~560px of container width the 40%/nowrap column above starves the value: measured on
* System -> System at 390px content width, Firmware Version wrapped onto 5 lines and Kernel
* Version/Architecture onto 2 each, most of the 130px label column sitting empty. Stack it like
* the other card idioms in this file label as an eyebrow above its value instead of holding
* a fixed-fraction column at any width. A width win, not a height one: measured on the gallery's
* key/value specimen (a 340px .cbi-map, docs/gallery.html), stacking cost the card +44.9% height
* (202.6 -> 293.5px) for the value column widening x1.67 (183.6 -> 306px) and Firmware Version's
* line count dropping from two to one. (The five lines above are the live 390px page, where the
* value is longer than the specimen carries the shape is the same, the count is not.)
*
* 560 is the second threshold named on fs-content; the config table's card (theme/65-dropdown.css)
* is the first, at 960, for a row full of widgets that bake in a layout width. This one holds two
* plain text cells, so it can stay two-column down to a narrower width before the label needs the
* row to itself.
*
* `:first-child:first-child` / `:last-child:last-child` double the tail pseudo-class to outrank
* the two rules above at identical specificity otherwise the `.hidden.hidden` idiom
* (theme/60-inputs.css) rather than win on source order, which the next edit to either rule
* could invert.
*
* The value's right-flush alignment above is a two-column device: with the label gone from the
* row, right-flushing floats the value away from its own caption. Left-aligned instead, so the
* card reads top-to-bottom as caption then value, matching every other stacked cell in this file. */
@container fs-content (max-width: 560px) {
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr {
display: block;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child:first-child {
display: block; width: auto; white-space: normal;
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
text-transform: uppercase;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
display: block; width: auto; text-align: start;
}
}
/* --- the realtime graph legend (status/realtime/*) ---
* The one no-id `.table` LuCI renders with inline `table-layout:fixed;width:100%` and six cells
@@ -110,8 +110,8 @@
* a container-type, so giving container-type to any new component would silently re-target every
* unnamed query underneath it:
* fs-view the overview's grid (pages/20-overview.css, @container 800)
* fs-content "whatever content column I am in", carried by both .cbi-map and #view, so a query
* resolves to the nearer of the two. */
* fs-content "whatever content column I am in", carried by .cbi-map, #view and .modal (below),
* so a query resolves to the nearest of the three. */
.cbi-map { container-type: inline-size; container-name: fs-content; }
#view { container-type: inline-size; container-name: fs-view fs-content; }
@@ -782,32 +782,78 @@
* `!important` would do it too, and would invert the layer order for every future override. */
.hidden.hidden { display: none; }
/* RangeSlider (UIRangeSlider) the one LuCI widget base leaves to native browser rendering.
* Token-driven track/thumb. */
* Token-driven track/thumb.
* The INPUT box is the WCAG 2.2 SC 2.5.8 target (24px min): it used to be the 6px track
* itself, measured 258x6 on owrt2512/390x844 (System > System). Box and paint split here
* a transparent 24px box, the 6px bar drawn by the track pseudo-element inside it.
*
* A flat `height: 24px` DOES move the row, and not through `.cbi-range-slider`'s own cross
* size that stayed 29.5px throughout, already pinned by the value chip beside it. It moves
* through the enclosing LINE BOX: `.cbi-value-field` lays this element out as ordinary inline
* content, and the line's height is anchored on where `.cbi-range-slider` (inline-flex, no
* baseline-participating child) reports ITS baseline to the ancestor line which tracks the
* first flex item's own edge and moved with the input's box. Measured before either fix below:
* `.cbi-value-field` +2.75px (Chromium) / +2.5px (Firefox), `.cbi-value` +1.5px / +1.0px,
* track/thumb ~1.25px lower.
*
* First half of the fix is the checkbox's own trick (`--hit`/`--box` below): grow the box for
* the hit target, then pull the extra area back out of flow with a symmetric negative margin
* so the box overflows itself around its old centre instead of pushing something taller. 6px
* was the box before this rule; -9px is (6px - 24px) / 2. This is what keeps a BARE range
* input (no `.cbi-range-slider` wrapper, no taller sibling a third-party app's own markup)
* from growing its own row, the same way the checkbox's margin protects a row with no toggle
* label beside it. Measured on its own, though, it left THIS row's growth above unchanged: the
* line-box placement of `.cbi-range-slider` tracks the input's own edge, not the flow space the
* margin trick frees up, so the second half below is still needed on top of it. */
input[type="range"] {
appearance: none;
width: 100%; height: 6px; min-height: 0; padding: 0;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
width: 100%; height: 24px; min-height: 0; padding: 0;
margin-block: -9px;
background: transparent; border: 0; border-radius: 0;
outline: none; box-shadow: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-moz-range-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel);
cursor: pointer;
/* WebKit centres the thumb on the INPUT's box, not the track: (6 - 16) / 2 = -5px pulls
* it back onto the 6px track. Firefox centres ::-moz-range-thumb on the track itself. */
margin-top: -5px;
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel); cursor: pointer;
}
input[type="range"]::-moz-range-track { background: var(--fs-track); height: 6px; border-radius: var(--fs-radius-pill); }
/* -solo: the track sets `border: 0` and `outline: none`, so there is no second channel here
* either the tinted ring was the whole indicator on a 6px bar. */
input[type="range"]:focus { box-shadow: var(--fs-focus-ring-solo); }
* either the tinted ring was the whole indicator on a 6px bar. Set on the track pseudo,
* not the input, so the ring still hugs the 6px bar rather than the 24px hit box. */
input[type="range"]:focus::-webkit-slider-runnable-track { box-shadow: var(--fs-focus-ring-solo); }
input[type="range"]:focus::-moz-range-track { box-shadow: var(--fs-focus-ring-solo); }
/* THE SAME CEILING THE ELASTIC TEXT INPUTS TAKE, and the reason is the whole point of the
* token: a `.cbi-value-field` is `flex: 1`, so `width: 100%` here meant the slider ate the
* entire card while the select two rows above stopped at 210px and the readout chip was flung
* to the card's right edge. Every stretching control in a form row now ends at one x. */
.cbi-range-slider { display: inline-flex; align-items: center; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
* to the card's right edge. Every stretching control in a form row now ends at one x.
*
* `vertical-align: middle` answers the second half of the 24px-target growth explained above
* `input[type="range"]`. Default (`baseline`) places this inline-flex box in its line by the
* edge of its first flex item the range input so that edge moving 6px -> 24px still pushed
* `.cbi-value-field`'s line taller even with the margin trick already in place. `middle` places
* the box by its own total height instead, which the margin trick already held at its old
* 29.5px (pinned by the value chip, unaffected by the input's growth), decoupling the two.
* Measured on the gallery slider fixture, both engines, this rule plus the margin trick against
* HEAD: `.cbi-value-field` +0.000px in both, `.cbi-value` -0.187px (Chromium) / -0.183px
* (Firefox) under a fifth of a CSS px, not a pixel a screen can render differently. Box
* unchanged at 258x24. */
.cbi-range-slider { display: inline-flex; align-items: center; vertical-align: middle; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
.cbi-range-slider-value {
background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
padding: var(--fs-space-1) var(--fs-space-2); color: var(--fs-accent); font-family: var(--fs-font-mono);
+119
View File
@@ -0,0 +1,119 @@
# This is free software, licensed under the GNU General Public License v2.
# See /LICENSE for more information.
#
include $(TOPDIR)/rules.mk
PKG_NPM_NAME:=pnpm
PKG_NAME:=node-$(PKG_NPM_NAME)
PKG_VERSION:=11.15.1
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NPM_NAME)-$(PKG_VERSION).tgz
PKG_SOURCE_URL:=https://mirrors.tencent.com/npm/$(PKG_NPM_NAME)/-/ \
https://registry.npmjs.org/$(PKG_NPM_NAME)/-/
PKG_HASH:=skip
PKG_MAINTAINER:=Tianling Shen <cnsztl@immortalwrt.org>
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
HOST_BUILD_DEPENDS:=node/host
HOST_BUILD_PARALLEL:=1
PKG_BUILD_DEPENDS:=node/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/host-build.mk
define Package/node-pnpm
SUBMENU:=Node.js
SECTION:=lang
CATEGORY:=Languages
TITLE:=Fast, disk space efficient package manager
URL:=https://pnpm.io/
DEPENDS:=+node
endef
define Package/node-pnpm/description
Fast, disk space efficient package manager
endef
TAR_OPTIONS+= --strip-components 1
TAR_CMD=$(HOST_TAR) -C $(1) $(TAR_OPTIONS)
NODEJS_CPU:=$(subst powerpc,ppc,$(subst aarch64,arm64,$(subst x86_64,x64,$(subst i386,ia32,$(ARCH)))))
TMPNPM:=$(shell mktemp -u XXXXXXXXXX)
HOSTTMPNPM:=$(shell mktemp -u XXXXXXXXXX)
NPM_CACHE_DIR:=$(if $(call qstrip,$(CONFIG_NODEJS_NPM_CACHE_DIR)),$(call qstrip,$(CONFIG_NODEJS_NPM_CACHE_DIR)),$(TMP_DIR))
TARGET_CFLAGS+=$(FPIC)
TARGET_CPPFLAGS+=$(FPIC)
define Build/Compile
cd $(PKG_BUILD_DIR); \
$(MAKE_VARS) \
$(MAKE_FLAGS) \
npm_config_nodedir=$(STAGING_DIR)/usr/ \
$(if $(CONFIG_NODEJS_NPM_KEEP_CACHE), npm_config_cache=$(NPM_CACHE_DIR)/npm-cache-$(PKG_NPM_NAME),npm_config_cache=$(TMP_DIR)/npm-cache-$(TMPNPM)) \
npm install --prefer-offline --no-audit --global-style --install-strategy=shallow --no-save --omit=dev --no-package-lock
rm -rf $(TMP_DIR)/npm-cache-$(TMPNPM)
rm -f $(PKG_BUILD_DIR)/node_modules/.package-lock.json
find $(PKG_BUILD_DIR)/node_modules -type d -empty -print0 | xargs -0 -r rmdir || true
endef
define Package/node-pnpm/install
$(INSTALL_DIR) $(1)/usr/lib/node/$(PKG_NPM_NAME)
$(CP) $(PKG_BUILD_DIR)/{package.json,LICENSE} \
$(1)/usr/lib/node/$(PKG_NPM_NAME)/
$(CP) $(PKG_BUILD_DIR)/README.md \
$(1)/usr/lib/node/$(PKG_NPM_NAME)/
$(CP) $(PKG_BUILD_DIR)/bin \
$(1)/usr/lib/node/$(PKG_NPM_NAME)/
$(INSTALL_DIR) $(1)/usr/lib/node/$(PKG_NPM_NAME)/dist
$(CP) $(PKG_BUILD_DIR)/dist/{pnpm.mjs,worker.js} \
$(1)/usr/lib/node/$(PKG_NPM_NAME)/dist/
$(INSTALL_DIR) $(1)/usr/bin
$(LN) ../lib/node/$(PKG_NPM_NAME)/bin/pnpm.mjs $(1)/usr/bin/pnpm
$(LN) ../lib/node/$(PKG_NPM_NAME)/bin/pnpx.mjs $(1)/usr/bin/pnpx
$(INSTALL_DIR) $(1)/usr/lib/node_modules
$(LN) ../node/$(PKG_NPM_NAME) $(1)/usr/lib/node_modules/$(PKG_NPM_NAME)
endef
define Package/node-pnpm/postrm
#!/bin/sh
rm /usr/lib/node_modules/pnpm || true
rm -rf /usr/lib/node/pnpm || true
endef
define Host/Compile
cd $(HOST_BUILD_DIR); \
$(HOST_MAKE_VARS) \
npm_config_nodedir=$(STAGING_DIR)/usr/ \
$(if $(CONFIG_NODEJS_NPM_KEEP_CACHE), npm_config_cache=$(NPM_CACHE_DIR)/host-npm-cache-$(PKG_NPM_NAME),npm_config_cache=$(TMP_DIR)/npm-cache-$(HOSTTMPNPM)) \
npm install --prefer-offline --no-audit --global-style --install-strategy=shallow --no-save --omit=dev --no-package-lock
rm -rf $(TMP_DIR)/npm-cache-$(HOSTTMPNPM)
rm -f $(HOST_BUILD_DIR)/node_modules/.package-lock.json
find $(HOST_BUILD_DIR)/node_modules -type d -empty -print0 | xargs -0 -r rmdir || true
endef
define Host/Install
$(INSTALL_DIR) $(1)/lib/node_modules/$(PKG_NPM_NAME)
$(CP) $(HOST_BUILD_DIR)/{package.json,LICENSE} \
$(1)/lib/node_modules/$(PKG_NPM_NAME)/
$(CP) $(HOST_BUILD_DIR)/README.md \
$(1)/lib/node_modules/$(PKG_NPM_NAME)/
$(CP) $(HOST_BUILD_DIR)/bin \
$(1)/lib/node_modules/$(PKG_NPM_NAME)/
$(INSTALL_DIR) $(1)/lib/node_modules/$(PKG_NPM_NAME)/dist
$(CP) $(HOST_BUILD_DIR)/dist/{pnpm.mjs,worker.js} \
$(1)/lib/node_modules/$(PKG_NPM_NAME)/dist/
$(INSTALL_DIR) $(1)/bin
$(LN) ../lib/node_modules/$(PKG_NPM_NAME)/bin/pnpm.mjs $(1)/bin/pnpm
$(LN) ../lib/node_modules/$(PKG_NPM_NAME)/bin/pnpx.mjs $(1)/bin/pnpx
endef
$(eval $(call HostBuild))
$(eval $(call BuildPackage,node-pnpm))
@@ -1767,6 +1767,9 @@ static int __init fast_classifier_init(void)
printk(KERN_ALERT "fast-classifier: starting up\n");
DEBUG_INFO("SFE CM init\n");
/* Initialize state before registering callbacks that can use it. */
spin_lock_init(&sc->lock);
hash_init(fc_conn_ht);
/*
@@ -1882,8 +1885,6 @@ static int __init fast_classifier_init(void)
printk(KERN_ALERT "fast-classifier: registered\n");
spin_lock_init(&sc->lock);
/*
* Hook the receive path in the network stack.
*/
@@ -2005,4 +2006,3 @@ module_exit(fast_classifier_exit)
MODULE_DESCRIPTION("Shortcut Forwarding Engine - Connection Manager");
MODULE_LICENSE("Dual BSD/GPL");
+3 -3
View File
@@ -43,12 +43,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand
PKG_RELEASE:=21
PKG_RELEASE:=22
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=6ed882e3b53544c9e42f2cc681780d99205141eb
PKG_SOURCE_DATE:=2026-09-03
PKG_SOURCE_VERSION:=1aff5270ef08b380e9c07ad5c62b79704212b779
PKG_SOURCE_DATE:=2026-09-05
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only