🎄 Sync 2026-08-27 02:13:09

This commit is contained in:
github-actions[bot] committed 2026-08-27 02:13:09 +08:00
1 parent 70f89c19b4
commit 1b91eb3175
154 files changed
+9225 -8782

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease-common-bin
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=15
PKG_RELEASE:=16
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
PKG_SOURCE_VERSION:=b0d9136d243ac1a5c5290190ffa6f66265aa7fba
PKG_SOURCE_VERSION:=29037745f8dba998fb03142d5c349700672b8531
PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=18
PKG_RELEASE:=19
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
PKG_SOURCE_VERSION:=b0d9136d243ac1a5c5290190ffa6f66265aa7fba
PKG_SOURCE_VERSION:=29037745f8dba998fb03142d5c349700672b8531
PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+2 -2
View File
@@ -11,7 +11,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkeasefull
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=3.0.13
PKG_RELEASE:=17
PKG_RELEASE:=18
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64
PKG_HASH:=skip
endif
PKG_SOURCE_VERSION:=b0d9136d243ac1a5c5290190ffa6f66265aa7fba
PKG_SOURCE_VERSION:=29037745f8dba998fb03142d5c349700672b8531
PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH)
+2 -2
View File
@@ -7,11 +7,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for linkeasefull
LUCI_DEPENDS:=+linkeasefull +luci-lib-linkeasefile
LUCI_DEPENDS:=+linkeasefull +luci-lib-linkeaseauth +luci-lib-linkeasefile
LUCI_PKGARCH:=all
PKG_VERSION:=1.0.0
PKG_RELEASE:=4
PKG_RELEASE:=5
include $(TOPDIR)/feeds/luci/luci.mk
@@ -2,7 +2,21 @@ module("luci.controller.linkeasefull", package.seeall)
function index()
entry({"admin", "services", "linkeasefull_status"}, call("linkeasefull_status"))
entry({"admin", "services", "linkeasefull", "auth"}, call("linkeasefull_auth")).leaf = true
local open = entry({"admin", "services", "linkeasefull", "open"}, call("linkeasefull_open"))
open.leaf = true
open.dependent = false
open.sysauth = "root"
open.sysauth_authenticator = "htmlauth"
local auth = entry({"admin", "services", "linkeasefull", "auth"}, call("linkeasefull_auth"))
auth.leaf = true
auth.dependent = false
auth.sysauth = "root"
auth.sysauth_authenticator = "htmlauth"
local auth_finish = entry({"admin", "services", "linkeasefull", "auth_finish"}, call("linkeasefull_auth_finish"))
auth_finish.leaf = true
auth_finish.dependent = false
auth_finish.sysauth = "root"
auth_finish.sysauth_authenticator = "htmlauth"
if not nixio.fs.access("/etc/config/linkeasefull") then
return
@@ -23,10 +37,18 @@ local function uhttpd_has_apps_proxy_prefix()
return false
end
local function uhttpd_supports_proxy_prefix()
local sys = require "luci.sys"
return sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0
end
local function uhttpd_apps_proxy_available()
return uhttpd_supports_proxy_prefix() and uhttpd_has_apps_proxy_prefix()
end
function linkeasefull_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local linkease_enabled = (uci:get_first("linkease", "linkease", "enabled") == "1")
local status = {
running = (sys.call("pidof linkease-full >/dev/null") == 0),
@@ -34,88 +56,76 @@ function linkeasefull_status()
legacy_port = 8897,
base_path = "/apps/",
lan_ip = uci:get("network", "lan", "ipaddr") or "",
proxy_prefix_enabled = uhttpd_has_apps_proxy_prefix(),
conflict = linkease_enabled,
conflict_service = linkease_enabled and "linkease" or ""
proxy_prefix_supported = uhttpd_supports_proxy_prefix(),
proxy_prefix_enabled = uhttpd_apps_proxy_available(),
conflict = false,
conflict_service = ""
}
luci.http.prepare_content("application/json")
luci.http.write_json(status)
end
local function retrieve_luci_session()
local http = require "luci.http"
local util = require "luci.util"
for _, key in ipairs({"sysauth_https", "sysauth_http", "sysauth"}) do
local sid = http.getcookie(key)
if sid and sid ~= "" then
local sdat = util.ubus("session", "get", { ubus_rpc_session = sid })
if sdat and type(sdat.values) == "table" then
return sid
end
end
end
return nil
local function cookie_encode(value)
return tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char)
return string.format("%%%02X", char:byte())
end)
end
local function valid_apps_return(value)
if not value or value == "" then
return false
local function authority_host(authority)
if not authority or authority == "" then
return ""
end
local function valid_path(path)
if path == "/apps" then
return true
end
local prefix = path:sub(1, 6)
return prefix == "/apps/" or prefix == "/apps?" or prefix == "/apps#"
if authority:sub(1, 1) == "[" then
return authority:match("^%[([^%]]+)%]") or ""
end
if value:sub(1, 1) == "/" then
return valid_path(value)
end
local scheme, authority, path = value:match("^(https?://)([^/]+)(/.*)$")
if not scheme or not authority or not valid_path(path) then
return false
end
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local request_host = http.getenv("HTTP_HOST") or ""
local lan_host = uci:get("network", "lan", "ipaddr") or ""
local allowed_authorities = {
request_host,
request_host .. ":19290",
lan_host,
lan_host .. ":19290"
}
for _, host in ipairs(allowed_authorities) do
if host ~= "" and authority == host then
return true
end
end
return false
return authority:match("^([^:]+)") or authority
end
local function valid_cookie_value(value)
return value and value:match("^[A-Za-z0-9._%-_]+$") ~= nil
local function url_authority(host, port)
if not host or host == "" then
host = "127.0.0.1"
end
if host:find(":") and host:sub(1, 1) ~= "[" then
host = "[" .. host .. "]"
end
return host .. ":" .. tostring(port)
end
local function request_or_lan_host()
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local host = authority_host(http.getenv("HTTP_HOST") or "")
if host ~= "" then
return host
end
return uci:get("network", "lan", "ipaddr") or "127.0.0.1"
end
local function linkease_auth_url(name)
local dispatcher = require "luci.dispatcher"
return dispatcher.build_url("admin", "services", "linkease_auth", name)
end
local function linkeasefull_entry_url()
if uhttpd_apps_proxy_available() then
return "/apps/"
end
return "http://" .. url_authority(request_or_lan_host(), 19290) .. "/apps/"
end
function linkeasefull_open()
local http = require "luci.http"
http.redirect(linkease_auth_url("auth") .. "?return=" .. cookie_encode(linkeasefull_entry_url()))
end
function linkeasefull_auth()
local http = require "luci.http"
local sid = retrieve_luci_session()
if not valid_cookie_value(sid) then
http.status(403, "Forbidden")
return
end
local target = http.formvalue("return") or "/apps/"
if not valid_apps_return(target) then
target = "/apps/"
end
http.redirect(linkease_auth_url("auth") .. "?return=" .. cookie_encode(target))
end
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
http.redirect(target)
function linkeasefull_auth_finish()
local http = require "luci.http"
http.redirect(linkease_auth_url("auth_finish"))
end
@@ -10,9 +10,7 @@
? window.location.protocol + "//" + window.location.host + "/apps/"
: "http://" + lanHost + ":" + (st.full_port || 19290) + "/apps/";
var legacyUrl = "http://" + window.location.hostname + ":" + (st.legacy_port || 8897) + "/";
var overallStatus = st.conflict
? '<br/><em style=\"color:red\"><%:LinkEase is enabled. Please disable LinkEase before starting LinkEase Full.%></em>'
: st.running
var overallStatus = st.running
? '<br/><em style=\"color:green\"><%:The LinkEase Full service is running.%></em>'
: '<br/><em style=\"color:red\"><%:The LinkEase Full service is not running.%></em>';
tb.innerHTML = overallStatus;
+1 -1
View File
@@ -6,7 +6,7 @@ LUCI_DEPENDS:=+appfilter +kmod-oaf +luci-compat +luci-lib-jsonc
PKG_NAME:=luci-app-oaf
PKG_VERSION:=7.0
PKG_RELEASE:=4
PKG_RELEASE:=5
include $(TOPDIR)/feeds/luci/luci.mk
@@ -1,9 +1,9 @@
module("luci.controller.oaf_app_filter", package.seeall)
local utl = require "luci.util"
local nixio = require "nixio"
function index()
if not nixio.fs.access("/etc/config/appfilter") then
local fs = require "nixio.fs"
if not fs.access("/etc/config/appfilter") then
return
end
entry({"admin", "services", "oaf", "app_filter"}, alias("admin", "services", "oaf", "app_filter", "rules"), _("App Filter"), 30).dependent = true
@@ -1,9 +1,9 @@
module("luci.controller.oaf_mac_filter", package.seeall)
local utl = require "luci.util"
local nixio = require "nixio"
function index()
if not nixio.fs.access("/etc/config/macfilter") then
local fs = require "nixio.fs"
if not fs.access("/etc/config/macfilter") then
return
end
@@ -1,6 +1,5 @@
module("luci.controller.oaf_whitelist", package.seeall)
local nixio = require "nixio"
local util = require "luci.util"
local jsonc = require "luci.jsonc"
@@ -30,9 +29,10 @@ local function get_record_whitelist_page(page, page_size)
end
function index()
local has_app_filter = nixio.fs.access("/etc/config/appfilter")
local has_access_control = nixio.fs.access("/etc/config/macfilter")
local has_record = nixio.fs.access("/etc/config/fwx_record")
local fs = require "nixio.fs"
local has_app_filter = fs.access("/etc/config/appfilter")
local has_access_control = fs.access("/etc/config/macfilter")
local has_record = fs.access("/etc/config/fwx_record")
if not has_app_filter and not has_access_control and not has_record then
return
@@ -178,4 +178,4 @@ function get_all_users()
end
write_json({code = 2000, data = {list = all_list}, message = "success"})
end
end
+110
View File
@@ -4,6 +4,116 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
## [2.1.1] - 2026-08-27
本次维护以 OpenClaw 2026.7.1-2 为基准,逐项核对配置写入与上游 schema 的一致性。
当前适配版本已等于 npm `latest`,因此**未升级 OpenClaw 版本**,修的是项目自身
与现行 schema 的既有偏离。
> 版本号说明:`v2.1.0` 标签(2026-07-27)出自 `release/v2.1.0-openclaw-2026.6.33`
> 分支,适配的是更旧的 OpenClaw 2026.6.33,与 main 的 2.0.12 语义倒挂。
> 为避免版本号回退,本次直接发布 2.1.1 并明确适配 2026.7.1-2。
### 修复配置丢失(严重)
- **配置管理界面写入可能清空整份配置**:`readConfig()` 在 `JSON.parse` 失败时返回 `{}`,
调用方随后 `writeConfig()` 把空对象写回磁盘。实测一份仅多出尾随逗号的配置
(OpenClaw 自身的 JSON5 解析器可容忍),用户只改一个网关端口,文件即从 124 字节
(含 apiKey)变成 40 字节,`models.providers` 与 API Key 全部丢失。
现改为解析失败即中止写入,并给出 `doctor --fix` / `config validate` / `last-good` 恢复指引。
- **服务启动时同样会清空配置**:`init.d` 的 `sync_uci_to_json()` 有同类缺陷,且位于
`start_service` 路径上自动执行,不需要用户操作。实测一次普通的
`/etc/init.d/openclaw start` 就会让 apiKey 与 `channels.openclaw-weixin` 消失。
现改为解析失败即跳过同步,配置原样保留。
- 配置写入改为原子操作:写前备份(`.luci-pre-write`,不占用 OpenClaw 自己的 `.bak` 轮转链)
→ 临时文件 → 回读校验 → `rename` 替换,并保留原文件权限位。
### 修复配置类型与键名(严重)
- **所有配置值都被写成字符串**:`json_set` 原实现"读取值并作为字符串保存",
而上游严格校验类型,实测拒绝 `gateway.port: Invalid input`、
`acp.dispatch.enabled: Invalid input (allowed: true, false)`、
`channels.telegram.enabled: must be boolean`。现按 schema 类型表自动判定
number/boolean/string/json。注意 `init.d` 只在冷启动修正这些字段,而
`restart_gateway` 走 SIGUSR1 快速重载不经过修正——这正是"改完配置重启后网关起不来"的路径。
- **日志级别设置从未生效**:`gateway.logLevel` 不在 schema 中
(`gateway.additionalProperties: false`),`config set` 直接报 `Unrecognized key`,
手写进文件则被静默忽略。改为正确键 `logging.level`,菜单补齐上游 7 档枚举
(silent/fatal/error/warn/info/debug/trace),并清理旧配置里的错误键。
- **绑定地址提供了上游不接受的值**:`gateway.bind=all` 实测被拒
(允许 auto/lan/loopback/custom/tailnet)。菜单改用上游枚举,旧值 `all`
自动映射为 `custom` + `customBindHost=0.0.0.0`。
- 修复一类"假成功":写入失败时界面仍打印 ✅。`--set` 与端口/模式/ACP/绑定地址
菜单现在都检查返回码;端口另加 1–65535 校验;仅在 JSON 写入成功后才同步 UCI。
### 修复功能不可达
- **备份与重置菜单有三个选项打不开**:菜单打印 1–5,`case` 分支却只有
`1) 2) 3) c) d)`,导致「查看备份列表」「从最新备份恢复」「完全恢复出厂」
按提示操作只会得到"无效选择",真正的逻辑挂在从不显示的 `c`/`d` 上。已修正键位。
### 修复 Telegram 配对(issue #98)
- 「Telegram 配对助手」调用的 `openclaw models auth login-telegram-bot` 在 2026.6+
已移除,实测报 `Too many arguments for this command`。改为对齐上游
`openclaw pairing list` / `pairing approve`,与 shell 侧早已正确实现的流程一致。
配对与 Bot Token 配置是两件不同的事,本次明确区分,不合并为同一入口。
### 修复打包与 feeds 集成(issue #60)
- OpenWrt 25.x 下 `Ignoring feed 'openclaw' - index missing`:不再依赖 `luci.mk`
的隐式 Package 生成,统一 `include package.mk` 并显式定义 `Package/...`。
- Makefile 漏装交互式菜单:`oc-config-interactive.js` 与 `oc-menu-engine.js` 只在
build 脚本里被 `cp *.js` 带上,走 feeds/SDK 编译的包会缺这两个文件,此时
`can_use_interactive()` 静默回落到功能较少的传统菜单——用户看不到报错,
只会觉得界面与教程不一致。现三条打包路径清单一致。
- 保留 `libstdcpp6` 依赖(issue #28:缺失会导致 Node.js 无法运行)。
### AI Provider / Model 清单改造
- **不再硬编码易过期的模型 ID**。用 OpenClaw 2026.7.1-2 实测核对,原菜单里
`openai/gpt-5.2`、`gpt-5-mini`、`gpt-4.1`、`o3`、`o4-mini`、
`claude-sonnet-4-20250514`、`claude-opus-4-20250514`、`claude-sonnet-4.5`、
`xai/grok-4`、`grok-3`、`deepseek/deepseek-r1`、`meta-llama/llama-4-maverick`、
`01-ai/Yi-1.5-34B-Chat-16K`、`Qwen/Qwen2.5-*`、`THUDM/glm-4-9b-chat`、
`github-copilot/gpt-4.1`、`gpt-4o` 等在上游 catalog 中均已不存在。
- 改为三层架构:精选预设 `model-presets.json`(shell 与 JS 共读的唯一数据源,
14 provider / 44 模型,32 条 builtin 预设已逐条核对存在)→ 动态发现
`openclaw models list --provider <id>`(带 6s 超时,失败静默回落)→ 手动输入
(永久保留的兼容出口)。上游模型迭代时不必再改本插件。
- SiliconFlow 非 OpenClaw 内置/官方插件 provider,无法用上游 catalog 核实模型 ID,
改为引导用户从官方模型广场复制当前 ID——与其留一份会腐坏的列表,不如给可靠来源。
### 安全
- **状态变更端点缺少 CSRF 保护**:`service_ctl`(启停服务)、`uninstall`(删除运行环境)、
`plugin_upgrade`(下载执行 .run)、`backup`(create/restore/delete)、
`get_token`(返回网关与 PTY 凭据)原为 `call()`,允许 GET 触发且不校验 token,
诱导已登录管理员访问一个链接即可卸载环境或读出凭据。现改为 `post()`,
前端 8 处调用同步改为带 CSRF token 的 POST。只读端点保持 `call()` 不变。
- **Web PTY 头部过度开放**:移除 `Access-Control-Allow-Origin: *` 与
`X-Frame-Options: ALLOWALL`,CSP 从 `default-src *` 收紧到 `'self'`
(保留 `unsafe-inline`——页面确有内联块;去掉 `unsafe-eval`),
新增 `Referrer-Policy: no-referrer`。WebSocket token 校验未削弱。
- **PTY token 泄漏**:页面原先把含 token 的 WebSocket URL 写进可见调试文本与 console。
现读取后立即用 `history.replaceState` 从地址栏移除,展示与日志一律用脱敏 URL。
- **恢复 profile 隔离能力**:`openclaw-env` 生成的 CLI wrapper 无条件 export
`OPENCLAW_HOME` 等变量,把官方文档承诺的 per-instance 环境变量与
`openclaw --profile` 全部覆盖,导致无法按官方文档跑多实例网关、
也无法做隔离的配置写入测试。改为仅在调用方未提供时填默认值。
### 测试
- 新增测试 runner `tests/run_all.sh`,测试数从 7 项增至 16 项,全部通过。
- 新增契约测试:配置写入安全、配置类型、菜单按键覆盖、打包清单一致性
(含真实构建 `.ipk` 并解包校验)、模型预设架构、Telegram 配对语义、
init.d 启动同步安全、LuCI CSRF、Web PTY 安全。
- 上游 schema 关键字段类型固化为 `tests/fixtures/openclaw-schema-types.tsv`。
- 每项契约测试均用变异测试反向验证过:故意退化实现后测试必须失败,
确保护栏不是"永远通过"的空壳。
---
## [2.0.12] - 2026-08-22
### 适配 OpenClaw v2026.7.1-2
+33 -23
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openclaw
PKG_VERSION:=$(strip $(shell cat $(CURDIR)/VERSION 2>/dev/null || echo "1.0.0"))
PKG_RELEASE:=25
PKG_RELEASE:=26
PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com>
PKG_LICENSE:=GPL-3.0
@@ -16,35 +16,42 @@ LUCI_TITLE:=OpenClaw AI 网关 LuCI 管理插件
LUCI_DEPENDS:=+luci-compat +luci-base +curl +openssl-util +script-utils +tar +libstdcpp6
LUCI_PKGARCH:=all
# 优先使用 luci.mk (feeds 模式), 不可用时回退 package.mk
ifeq ($(wildcard $(TOPDIR)/feeds/luci/luci.mk),)
# 始终使用 package.mk 并显式定义 Package/...。
#
# 历史实现在检测到 feeds/luci/luci.mk 时改走 luci.mk 分支,且不定义
# Package/...,依赖 luci.mk 由 LUCI_* 变量自动生成。该机制在 OpenWrt 25.x
# 的 feeds 布局下失效,表现为 (issue #60):
# Ignoring feed 'openclaw' - index missing
# 且 menuconfig 的 LuCI → Applications 下找不到本插件。
#
# 显式声明对两种集成方式都有效,不再依赖 luci.mk 的隐式行为:
# 1. 作为 feeds 源: echo "src-git openclaw ..." >> feeds.conf.default
# 2. 直接放入 package/ 目录
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=$(LUCI_TITLE)
DEPENDS:=$(LUCI_DEPENDS)
PKGARCH:=all
endef
define Package/$(PKG_NAME)
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=$(LUCI_TITLE)
DEPENDS:=$(LUCI_DEPENDS)
PKGARCH:=all
endef
define Package/$(PKG_NAME)/description
OpenClaw AI Gateway 的 LuCI 管理插件。
支持 12+ AI 模型提供商和 Telegram/Discord 等多种消息渠道。
endef
else
include $(TOPDIR)/feeds/luci/luci.mk
endif
define Package/$(PKG_NAME)/description
OpenClaw AI Gateway 的 LuCI 管理插件。
支持 12+ AI 模型提供商和 Telegram/Discord 等多种消息渠道。
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/openclaw
endef
# 安装清单必须与 scripts/build_ipk.sh / scripts/build_run.sh 保持一致。
# 特别是 /usr/share/openclaw 下的 oc-config-interactive.js 与 oc-menu-engine.js:
# 缺失时 oc-config.sh 的 can_use_interactive() 会静默回落到功能较少的传统
# 数字菜单,用户只会觉得"界面和教程不一样",不会看到任何报错。
# tests/test_packaging_parity.sh 会校验三条打包路径的清单一致性。
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./root/etc/config/openclaw $(1)/etc/config/openclaw
@@ -77,6 +84,9 @@ define Package/$(PKG_NAME)/install
$(INSTALL_DATA) ./VERSION $(1)/usr/share/openclaw/VERSION
$(INSTALL_BIN) ./root/usr/share/openclaw/oc-config.sh $(1)/usr/share/openclaw/oc-config.sh
$(INSTALL_DATA) ./root/usr/share/openclaw/web-pty.js $(1)/usr/share/openclaw/web-pty.js
$(INSTALL_BIN) ./root/usr/share/openclaw/oc-config-interactive.js $(1)/usr/share/openclaw/oc-config-interactive.js
$(INSTALL_DATA) ./root/usr/share/openclaw/oc-menu-engine.js $(1)/usr/share/openclaw/oc-menu-engine.js
$(INSTALL_DATA) ./root/usr/share/openclaw/model-presets.json $(1)/usr/share/openclaw/model-presets.json
$(INSTALL_DIR) $(1)/usr/share/openclaw/ui
$(CP) ./root/usr/share/openclaw/ui/* $(1)/usr/share/openclaw/ui/
endef
+24 -1
View File
@@ -140,9 +140,25 @@ opkg install python3-light
- 插件注册缺失:重新安装插件,启动时也会自动补齐 `openclaw-weixin` 配置。
- 目录权限错误:按页面日志提示修复 OpenClaw 数据目录权限。
## AI 模型选择机制
配置菜单里的模型清单分三层,避免硬编码的模型 ID 随上游迭代而过期:
1. **精选模型** —— 内置于 `model-presets.json`,每个 Provider 给推荐/最强/均衡/快速四档。
离线可用、首屏即显示。
2. **从 OpenClaw 获取完整模型列表** —— 调用 `openclaw models list --provider <id>`
动态发现当前实际可用的模型(带 6 秒超时,失败或超时自动回落到精选列表)。
未安装对应 Provider 插件、或未配置 API Key 时可能返回空,属正常情况。
3. **手动输入模型 ID** —— 永久保留。上游发布新模型时无需等待本插件更新。
因此 GPT / Claude / Gemini 等更新后,通常不必升级本插件即可使用新模型。
## 已知说明
- OpenClaw 的 diagnostic heartbeat 可能在日志中出现类似周期性探测记录。它不是一次真实用户对话请求;如需降低噪音,优先在 OpenClaw 配置或日志采集侧降低诊断日志级别,不建议直接修改模型调用逻辑。
- 配置管理界面依赖 `oc-config-interactive.js`;若该文件缺失(例如自行裁剪打包清单),
界面会静默回落到功能较少的传统数字菜单。`tests/test_packaging_parity.sh` 会校验
三条打包路径的文件清单一致性。
- 当前仓库提供源码、OpenWrt feeds 集成方式、本地 `.run` / `.ipk` 构建脚本入口;推送 `v*` 标签后 CI 会自动构建并发布 Release 产物。
## 📂 目录结构
@@ -167,12 +183,19 @@ luci-app-openclaw/
│ └── usr/
│ ├── libexec/ # 共享 shell helper
│ ├── bin/openclaw-env # 环境管理工具
│ └── share/openclaw/ # 配置终端资源
│ └── share/openclaw/
│ ├── oc-config.sh # 配置管理入口(无 TTY 时的回退菜单)
│ ├── oc-config-interactive.js # 交互式配置菜单(默认路径)
│ ├── oc-menu-engine.js # 方向键菜单引擎
│ ├── model-presets.json # 精选模型预设(shell 与 JS 共读)
│ ├── web-pty.js # Web PTY 服务
│ └── ui/ # 配置终端前端资源
├── scripts/
│ ├── build_ipk.sh # 本地 IPK 构建
│ ├── build_run.sh # .run 安装包构建
│ ├── gen-release-body.sh # Release 说明生成
│ └── build-node-musl.sh # 编译 Node.js musl 静态链接版本
├── tests/ # 契约测试(sh tests/run_all.sh)
└── .github/workflows/
├── build.yml # 在线构建 + 发布
└── build-node-musl.yml # Node.js musl 构建
+1 -1
View File
@@ -1 +1 @@
2.0.12
2.1.1
@@ -325,7 +325,10 @@ function index()
entry({"admin", "services", "openclaw", "status_api"}, call("action_status"), nil).leaf = true
-- 服务控制 API
entry({"admin", "services", "openclaw", "service_ctl"}, call("action_service_ctl"), nil).leaf = true
-- 会改状态的端点必须用 post(): LuCI 的 test_post_security() 同时要求
-- POST 方法与匹配的 CSRF token,call() 允许 GET 触发,
-- 诱导已登录管理员访问一个链接即可启停服务。
entry({"admin", "services", "openclaw", "service_ctl"}, post("action_service_ctl"), nil).leaf = true
-- 安装/升级日志 API (轮询)
entry({"admin", "services", "openclaw", "setup_log"}, call("action_setup_log"), nil).leaf = true
@@ -333,20 +336,22 @@ function index()
-- 版本检查 API (仅检查插件版本)
entry({"admin", "services", "openclaw", "check_update"}, call("action_check_update"), nil).leaf = true
-- 卸载运行环境 API
entry({"admin", "services", "openclaw", "uninstall"}, call("action_uninstall"), nil).leaf = true
-- 卸载运行环境 API (破坏性操作,必须 POST + CSRF)
entry({"admin", "services", "openclaw", "uninstall"}, post("action_uninstall"), nil).leaf = true
-- 获取网关 Token API (仅认证用户可访问)
entry({"admin", "services", "openclaw", "get_token"}, call("action_get_token"), nil).leaf = true
-- 获取网关 Token API (返回凭据,必须 POST + CSRF 防止被第三方页面读取)
entry({"admin", "services", "openclaw", "get_token"}, post("action_get_token"), nil).leaf = true
-- 插件升级 API
entry({"admin", "services", "openclaw", "plugin_upgrade"}, call("action_plugin_upgrade"), nil).leaf = true
-- 插件升级 API (会下载并执行 .run,必须 POST + CSRF)
entry({"admin", "services", "openclaw", "plugin_upgrade"}, post("action_plugin_upgrade"), nil).leaf = true
-- 插件升级日志 API (轮询)
entry({"admin", "services", "openclaw", "plugin_upgrade_log"}, call("action_plugin_upgrade_log"), nil).leaf = true
-- 配置备份 API (v2026.3.8+: openclaw backup create/verify)
entry({"admin", "services", "openclaw", "backup"}, call("action_backup"), nil).leaf = true
-- 含 create/restore/delete 等破坏性动作,必须 POST + CSRF。
-- 只读的 list 也走同一入口,一并要求 POST 以保持调用方式统一。
entry({"admin", "services", "openclaw", "backup"}, post("action_backup"), nil).leaf = true
-- 系统配置检测 API (安装前检测)
entry({"admin", "services", "openclaw", "check_system"}, call("action_check_system"), nil).leaf = true
@@ -1949,8 +1954,22 @@ fs.writeFileSync(p, JSON.stringify(d, null, 2));
-- 清理临时文件
sys.exec("rm -f /tmp/openclaw-wechat-*.log /tmp/openclaw-wechat-*.pid /tmp/openclaw-wechat-*.exit /tmp/openclaw-wechat-qrcode.txt")
-- 重启网关让卸载真正生效。
--
-- 不重启会留下登录态残留: 网关进程仍在内存中持有已加载的微信渠道,
-- 会把会话游标(get_updates_buf, 内含账号标识)写回刚被删除的
-- .openclaw/openclaw-weixin/accounts/ 目录,于是目录被重新创建。
-- 实测卸载后该文件 mtime 与卸载时刻相同 —— 是删除后被重建,而非漏删。
--
-- action_wechat_login / action_wechat_logout 本来就会重启网关,
-- 只有卸载遗漏了这一步,属实现不一致。
sys.exec("/etc/init.d/openclaw restart &")
-- 重启是异步的,等待一小段时间让网关放开插件文件句柄后再清理残留状态目录。
sys.exec("(sleep 6; rm -rf " .. shellquote(wechat_state_dir) .. " 2>/dev/null) &")
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件已卸载" })
http.write_json({ status = "ok", message = "微信插件已卸载,网关正在重启" })
end
-- ═══════════════════════════════════════════
@@ -1961,7 +1980,12 @@ function action_wechat_check_upgrade()
local sys = require "luci.sys"
local install_path = get_install_path()
local npx_bin = install_path .. "/node/bin/npx"
-- view 是 npm 的子命令, 不是 npx 的。
-- 历史实现用 npx view,npx 会把 view 当成待执行的包去解析并失败:
-- npm error could not determine executable to run
-- 该错误被 2>/dev/null 吞掉后 latest_version 恒为空,
-- is_newer_version("" , x) 恒为 false —— 升级检测从未真正工作过。
local npm_bin = install_path .. "/node/bin/npm"
local oc_data = install_path .. "/data"
-- 获取当前已安装版本
@@ -1975,17 +1999,24 @@ function action_wechat_check_upgrade()
current_version = content:match('"version"%s*:%s*"([^"]+)"') or ""
end
-- 检测最新版本 (通过 npm view)
-- 检测最新版本 (npm view)
local latest_version = ""
local check_err = ""
local env_prefix = string.format(
"HOME=%s PATH=%s/node/bin:%s/global/bin:$PATH",
oc_data, install_path, install_path
)
-- 保留 stderr 以便查询失败时能给出可诊断的提示,而不是静默显示"已是最新"
local check_cmd = string.format(
"%s %s view @tencent-weixin/openclaw-weixin version 2>/dev/null",
env_prefix, npx_bin
"%s %s view @tencent-weixin/openclaw-weixin version 2>&1",
env_prefix, npm_bin
)
latest_version = sys.exec(check_cmd):gsub("%s+", "")
local raw = sys.exec(check_cmd) or ""
-- npm 输出可能夹带告警行,取最后一个形如 x.y.z 的 token
latest_version = raw:match("(%d+%.%d+%.%d+[%w%.%-]*)%s*$") or ""
if latest_version == "" then
check_err = raw:gsub("%s+$", ""):sub(1, 200)
end
local has_upgrade = false
if is_newer_version(latest_version, current_version) then
@@ -1993,11 +2024,15 @@ function action_wechat_check_upgrade()
end
http.prepare_content("application/json")
-- 查询失败时必须显式区分"已是最新"与"查不到",
-- 否则用户永远看到"已是最新版"而不知道检测其实没跑通。
http.write_json({
status = "ok",
status = (latest_version ~= "") and "ok" or "error",
current_version = current_version,
latest_version = latest_version,
has_upgrade = has_upgrade
has_upgrade = has_upgrade,
message = (latest_version ~= "") and "" or
("无法查询最新版本 (请检查网络或 npm 源): " .. check_err)
})
end
@@ -28,8 +28,14 @@ act.cfgvalue = function(self, section)
local plugin_upgrade_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade")
local plugin_upgrade_log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade_log")
local check_system_url = luci.dispatcher.build_url("admin", "services", "openclaw", "check_system")
-- CSRF token: 会改状态或返回凭据的端点已改为 post(),
-- LuCI 的 test_post_security() 要求表单里带上与会话匹配的 token。
local csrf_token = luci.dispatcher.context.authtoken or ""
local html = {}
html[#html+1] = '<script type="text/javascript">var ocCsrfToken=' ..
string.format('%q', csrf_token) .. ';</script>'
-- 按钮区域
html[#html+1] = '<div style="display:flex;gap:10px;flex-wrap:wrap;margin:10px 0;">'
html[#html+1] = '<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocShowSetupDialog()" id="btn-setup" title="下载 Node.js 并安装 OpenClaw">📦 安装运行环境</button>'
@@ -182,7 +188,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
html[#html+1] = 'logEl.textContent+="安装路径: "+install_path+"\\n";'
html[#html+1] = 'logEl.textContent+="正在启动安装...\\n";'
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version)+"&install_path="+encodeURIComponent(install_path),null,function(x){'
html[#html+1] = '(new XHR()).post("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version)+"&install_path="+encodeURIComponent(install_path),{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
html[#html+1] = 'ocPollSetupLog();'
html[#html+1] = '});'
@@ -307,7 +313,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'function ocServiceCtl(action){'
html[#html+1] = 'var el=document.getElementById("action-result");'
html[#html+1] = 'el.innerHTML="<span style=\\"color:#999\\">⏳ 正在执行...</span>";'
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action="+action,null,function(x){'
html[#html+1] = '(new XHR()).post("' .. ctl_url .. '?action="+action,{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){el.innerHTML="<span style=\\"color:green\\">✅ "+action+" 已完成</span>";}'
html[#html+1] = 'else{el.innerHTML="<span style=\\"color:red\\">❌ "+(r.message||"失败")+"</span>";}'
@@ -407,7 +413,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'titleEl.textContent="📋 插件升级日志";'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 插件升级中...</span>";'
html[#html+1] = 'resultEl.style.display="none";'
html[#html+1] = '(new XHR()).get("' .. plugin_upgrade_url .. '?version="+encodeURIComponent(ver),null,function(x){'
html[#html+1] = '(new XHR()).post("' .. plugin_upgrade_url .. '?version="+encodeURIComponent(ver),{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
html[#html+1] = 'ocPollPluginUpgradeLog();'
html[#html+1] = '});'
@@ -481,7 +487,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'var el=document.getElementById("action-result");'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在卸载...";'
html[#html+1] = 'el.innerHTML="<span style=\\"color:#999\\">正在停止服务并清理文件...</span>";'
html[#html+1] = '(new XHR()).get("' .. uninstall_url .. '",null,function(x){'
html[#html+1] = '(new XHR()).post("' .. uninstall_url .. '",{token:ocCsrfToken},function(x){'
html[#html+1] = 'btn.disabled=false;btn.textContent="🗑️ 卸载环境";'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
@@ -542,7 +548,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'function ocLoadBackupList(){'
html[#html+1] = 'var el=document.getElementById("oc-backup-list");'
html[#html+1] = 'el.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 加载备份列表...</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=list",null,function(x){'
html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=list",{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"&&r.backups&&r.backups.length>0){'
html[#html+1] = 'var h="<table style=\\"width:100%;border-collapse:collapse;font-size:12px;\\">";'
@@ -584,7 +590,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'btnC.disabled=true;btnF.disabled=true;'
html[#html+1] = 'resEl.style.display="block";'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在创建备份..."+(onlyConfig?"(仅配置)":"(完整备份,可能需要较长时间)")+"</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=create&only_config="+onlyConfig,null,function(x){'
html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=create&only_config="+onlyConfig,{token:ocCsrfToken},function(x){'
html[#html+1] = 'btnC.disabled=false;btnF.disabled=false;'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
@@ -603,7 +609,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
html[#html+1] = 'resEl.style.display="block";'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在恢复配置...</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=restore&file="+encodeURIComponent(filename),null,function(x){'
html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=restore&file="+encodeURIComponent(filename),{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
@@ -622,7 +628,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
html[#html+1] = 'resEl.style.display="block";'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在删除...</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=delete&file="+encodeURIComponent(filename),null,function(x){'
html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=delete&file="+encodeURIComponent(filename),{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
@@ -47,13 +47,15 @@ local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
var ptyPort = '<%=pty_port%>';
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
// get_token 返回凭据,已改为 post(),需带 CSRF token
var ocCsrfToken = '<%=token%>';
var container = document.getElementById('oc-terminal-container');
var loading = document.getElementById('oc-terminal-loading');
var ptyToken = '';
function checkAndLoadTerminal() {
// 先获取 PTY token
(new XHR()).get(tokenUrl, null, function(tx) {
(new XHR()).post(tokenUrl, { token: ocCsrfToken }, function(tx) {
try {
var td = JSON.parse(tx.responseText);
ptyToken = td.pty_token || '';
@@ -97,6 +97,8 @@ local port = uci:get("openclaw", "main", "port") or "18789"
var gwToken = '';
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
// get_token 返回凭据,已改为 post(),需带 CSRF token
var ocCsrfToken = '<%=token%>';
var container = document.getElementById('oc-console-container');
var loading = document.getElementById('oc-console-loading');
var addrEl = document.getElementById('oc-console-addr');
@@ -116,7 +118,7 @@ local port = uci:get("openclaw", "main", "port") or "18789"
addrEl.textContent = window.location.hostname + ':' + gwPort;
// 先获取 token,再检查状态
(new XHR()).get(tokenUrl, null, function(tx) {
(new XHR()).post(tokenUrl, { token: ocCsrfToken }, function(tx) {
try {
var td = JSON.parse(tx.responseText);
gwToken = td.token || '';
@@ -834,10 +834,17 @@ local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
'当前版本: ' + d.current_version + '<br><br>' +
'<button class="btn cbi-button cbi-button-apply" onclick="ocUpgradeWechatPlugin(\'' + d.latest_version + '\')">立即升级</button>' +
'</div>';
} else if (d.current_version) {
resultEl.innerHTML = '<div class="oc-result-success">✅ 当前已是最新版本: ' + d.current_version + '</div>';
} else {
} else if (!d.current_version) {
resultEl.innerHTML = '<div class="oc-result-error">❌ 插件未安装,请先安装</div>';
} else if (d.status !== 'ok' || !d.latest_version) {
// 查询失败不能显示"已是最新": 否则真有新版时用户也看不到。
// 后端在 latest_version 为空时会返回 status=error 与 message。
resultEl.innerHTML = '<div class="oc-result-error">⚠️ 无法确认最新版本' +
'<br>当前已安装: ' + d.current_version +
(d.message ? '<br><span style="font-size:12px;">' + d.message + '</span>' : '') +
'</div>';
} else {
resultEl.innerHTML = '<div class="oc-result-success">✅ 当前已是最新版本: ' + d.current_version + '</div>';
}
} catch(e) {
document.getElementById('oc-upgrade-progress').style.display = 'none';
+18 -1
View File
@@ -285,7 +285,24 @@ OC_SYNC_PORT="$port" OC_SYNC_BIND="$bind" OC_SYNC_TOKEN="$token" OC_SYNC_FILE="$
const fs=require('fs');
const f=process.env.OC_SYNC_FILE;
let d={};
try{d=JSON.parse(fs.readFileSync(f,'utf8'));}catch(e){}
// 配置存在但解析失败时必须中止,绝不能用 {} 继续往下写。
// 否则本函数会把空对象连同下面的默认值一起落盘,用户的
// models.providers(含 apiKey)、channels、plugins 全部丢失。
// 本函数在每次 start_service 时自动执行,一旦退化影响面比手工操作大得多。
let raw='';
try{raw=fs.readFileSync(f,'utf8');}catch(e){raw='';}
if(raw.trim()!==''){
try{
d=JSON.parse(raw);
}catch(e){
console.error('openclaw: 配置文件解析失败,已跳过 UCI 同步以避免覆盖: '+e.message);
process.exit(3);
}
if(d===null||typeof d!=='object'||Array.isArray(d)){
console.error('openclaw: 配置根节点不是对象,已跳过 UCI 同步');
process.exit(3);
}
}
if(!d.gateway)d.gateway={};
d.gateway.port=parseInt(process.env.OC_SYNC_PORT)||18789;
d.gateway.bind=process.env.OC_SYNC_BIND||'lan';
+16 -5
View File
@@ -247,13 +247,24 @@ install_openclaw_cli_wrapper() {
# npm 默认生成的 openclaw 可能是指向 openclaw.mjs 的 symlink。
# 写 wrapper 前必须先 unlink,否则重定向会顺着 symlink 覆盖真实 CLI 入口。
rm -f "$OC_GLOBAL/bin/openclaw" 2>/dev/null || true
# 用 : "${VAR:=default}" 而非 export VAR=... 赋默认值。
#
# 历史实现无条件 export,把 OpenClaw 官方文档承诺的 per-instance 环境变量
# (OPENCLAW_HOME / OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH) 全部覆盖,
# 连 `openclaw --profile <name>` 也随之失效 —— profile 会解析回同一份
# 生产配置。后果是无法做隔离的写入测试,也无法按官方文档跑多实例网关。
#
# 现改为"仅当调用方未设置时才填默认值",默认行为不变,但允许覆盖。
cat > "$OC_GLOBAL/bin/openclaw" << EOF
#!/bin/sh
export HOME="${OC_DATA}"
export OPENCLAW_HOME="${OC_DATA}"
export OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw"
export OPENCLAW_CONFIG_PATH="${OC_DATA}/.openclaw/openclaw.json"
export NODE_ICU_DATA="${NODE_BASE}/share/icu"
# luci-app-openclaw 生成的 OpenClaw CLI 包装器。
# 只在调用方未提供时设置默认值,保留官方的 per-instance / --profile 隔离能力。
: "\${HOME:=${OC_DATA}}"
: "\${OPENCLAW_HOME:=${OC_DATA}}"
: "\${OPENCLAW_STATE_DIR:=\${OPENCLAW_HOME}/.openclaw}"
: "\${OPENCLAW_CONFIG_PATH:=\${OPENCLAW_STATE_DIR}/openclaw.json}"
: "\${NODE_ICU_DATA:=${NODE_BASE}/share/icu}"
export HOME OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH NODE_ICU_DATA
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:\$PATH"
exec "${NODE_BIN}" "${oc_entry}" "\$@"
EOF
@@ -0,0 +1,156 @@
{
"_comment": [
"精选模型预设 —— shell 与 JS 两侧共读的唯一数据源。",
"",
"架构分三层 (见 docs/notes/):",
" 1. 本文件的精选列表: 离线可用、首屏快,每个 provider 给 4 档",
" (推荐 / 最强 / 均衡 / 快速低价)。",
" 2. 动态发现: 菜单里的「从 OpenClaw 获取模型列表」调用",
" openclaw models list --provider <id> --plain,带超时并在失败时",
" 静默回落到本文件。",
" 3. 手动输入: 永久保留的兼容出口。",
"",
"维护约定:",
" - model 字段必须是 provider 侧的裸模型 ID (不含 'provider/' 前缀)。",
" - 只收录已用 openclaw models list --provider <id> 核实存在的 ID,",
" 不要凭文档或印象填写。",
" - verified 记录核实日期与 OpenClaw 版本,便于判断是否过期。",
" - catalogSource=builtin 表示上游内置 catalog 可直接发现该 provider;",
" plugin 表示需先安装官方 provider 插件,模型列表以插件为准。"
],
"verified": {
"date": "2026-08-26",
"openclawVersion": "2026.7.1-2",
"method": "openclaw models list --provider <id> --plain"
},
"providers": {
"openai": {
"label": "OpenAI",
"catalogSource": "builtin",
"models": [
{ "model": "gpt-5.6-sol", "tier": "recommended", "desc": "GPT-5.6 Sol 旗舰 (推荐)" },
{ "model": "gpt-5.6", "tier": "strongest", "desc": "GPT-5.6 通用最强" },
{ "model": "gpt-5.5", "tier": "balanced", "desc": "GPT-5.5 均衡" },
{ "model": "gpt-5.4-mini", "tier": "fast", "desc": "GPT-5.4 mini 快速低价" }
]
},
"anthropic": {
"label": "Anthropic",
"catalogSource": "builtin",
"models": [
{ "model": "claude-sonnet-5", "tier": "recommended", "desc": "Claude Sonnet 5 (推荐, 1M 上下文)" },
{ "model": "claude-opus-4-8", "tier": "strongest", "desc": "Claude Opus 4.8 顶级推理" },
{ "model": "claude-sonnet-4-6", "tier": "balanced", "desc": "Claude Sonnet 4.6 均衡" },
{ "model": "claude-haiku-4-5", "tier": "fast", "desc": "Claude Haiku 4.5 轻量快速" }
]
},
"google": {
"label": "Google Gemini",
"catalogSource": "builtin",
"models": [
{ "model": "gemini-3.1-pro-preview", "tier": "recommended", "desc": "Gemini 3.1 Pro 预览 (推荐)" },
{ "model": "gemini-2.5-pro", "tier": "strongest", "desc": "Gemini 2.5 Pro 稳定旗舰" },
{ "model": "gemini-3.5-flash", "tier": "balanced", "desc": "Gemini 3.5 Flash 均衡" },
{ "model": "gemini-3.1-flash-lite", "tier": "fast", "desc": "Gemini 3.1 Flash-Lite 极速低价" }
]
},
"openrouter": {
"label": "OpenRouter",
"catalogSource": "builtin",
"models": [
{ "model": "auto", "tier": "recommended", "desc": "自动路由 (推荐)" },
{ "model": "moonshotai/kimi-k2.6", "tier": "balanced", "desc": "Kimi K2.6" }
]
},
"github-copilot": {
"label": "GitHub Copilot",
"catalogSource": "builtin",
"models": [
{ "model": "gpt-5.5", "tier": "recommended", "desc": "GPT-5.5 (推荐, 400k 上下文)" },
{ "model": "claude-opus-4.8", "tier": "strongest", "desc": "Claude Opus 4.8" },
{ "model": "claude-sonnet-4.6", "tier": "balanced", "desc": "Claude Sonnet 4.6" },
{ "model": "gpt-5.4-mini", "tier": "fast", "desc": "GPT-5.4 mini" }
]
},
"xai": {
"label": "xAI Grok",
"catalogSource": "builtin",
"models": [
{ "model": "grok-4.3", "tier": "recommended", "desc": "Grok 4.3 (推荐)" },
{ "model": "grok-4.20-beta-latest-reasoning", "tier": "strongest", "desc": "Grok 4.20 推理版" },
{ "model": "grok-4.20-beta-latest-non-reasoning", "tier": "fast", "desc": "Grok 4.20 非推理版" }
]
},
"mistral": {
"label": "Mistral",
"catalogSource": "builtin",
"models": [
{ "model": "mistral-large-latest", "tier": "recommended", "desc": "Mistral Large (推荐)" },
{ "model": "mistral-medium-3-5", "tier": "balanced", "desc": "Mistral Medium 3.5" },
{ "model": "codestral-latest", "tier": "strongest", "desc": "Codestral 代码专用" },
{ "model": "mistral-small-latest", "tier": "fast", "desc": "Mistral Small 快速" }
]
},
"deepseek": {
"label": "DeepSeek",
"catalogSource": "builtin",
"models": [
{ "model": "deepseek-chat", "tier": "recommended", "desc": "DeepSeek Chat (推荐)" },
{ "model": "deepseek-reasoner", "tier": "strongest", "desc": "DeepSeek Reasoner 推理" }
]
},
"moonshot": {
"label": "Moonshot Kimi",
"catalogSource": "builtin",
"models": [
{ "model": "kimi-k2.6", "tier": "recommended", "desc": "Kimi K2.6 (推荐)" },
{ "model": "kimi-k2.7-code", "tier": "strongest", "desc": "Kimi K2.7 Code 代码专用" }
]
},
"minimax": {
"label": "MiniMax",
"catalogSource": "builtin",
"models": [
{ "model": "MiniMax-M3", "tier": "recommended", "desc": "MiniMax M3 (推荐)" },
{ "model": "MiniMax-M2.7", "tier": "balanced", "desc": "MiniMax M2.7" },
{ "model": "MiniMax-M2.7-highspeed", "tier": "fast", "desc": "MiniMax M2.7 高速版" }
]
},
"zai": {
"label": "智谱 GLM / Z.AI",
"catalogSource": "plugin",
"models": [
{ "model": "glm-5.2", "tier": "recommended", "desc": "GLM-5.2 (推荐)" },
{ "model": "glm-5.1", "tier": "strongest", "desc": "GLM-5.1" },
{ "model": "glm-4.7", "tier": "balanced", "desc": "GLM-4.7 均衡" },
{ "model": "glm-4.7-flash", "tier": "fast", "desc": "GLM-4.7 Flash 快速" }
]
},
"qwen": {
"label": "阿里云通义千问 Qwen",
"catalogSource": "plugin",
"models": [
{ "model": "qwen3.5-plus", "tier": "recommended", "desc": "Qwen3.5 Plus (推荐)" },
{ "model": "qwen3.6-plus", "tier": "strongest", "desc": "Qwen3.6 Plus" },
{ "model": "qwen3-coder-next", "tier": "balanced", "desc": "Qwen3 Coder Next 代码专用" },
{ "model": "qwen3-coder-plus", "tier": "fast", "desc": "Qwen3 Coder Plus" }
]
},
"qianfan": {
"label": "百度千帆",
"catalogSource": "plugin",
"models": [
{ "model": "ernie-5.0-thinking-preview", "tier": "recommended", "desc": "ERNIE 5.0 Thinking 预览 (推荐)" },
{ "model": "deepseek-v3.2", "tier": "balanced", "desc": "DeepSeek V3.2 (千帆托管)" }
]
},
"tencent-tokenhub": {
"label": "腾讯混元 TokenHub",
"catalogSource": "plugin",
"models": [
{ "model": "hy3", "tier": "recommended", "desc": "混元 HY3 (推荐)" },
{ "model": "hy3-preview", "tier": "strongest", "desc": "混元 HY3 预览版" }
]
}
}
}
@@ -33,6 +33,18 @@ const CONFIG_FILE = process.env.OPENCLAW_CONFIG_PATH || `${OC_STATE_DIR}/opencla
const NODE_BIN = `${NODE_BASE}/bin/node`;
const PERMISSIONS_HELPER = '/usr/libexec/openclaw-permissions.sh';
// 写前备份的后缀。特意不用 .bak: OpenClaw 自身维护 openclaw.json.bak
// 及 .bak.1~.bak.4 轮转,占用同名文件会破坏上游备份链。
const CONFIG_BACKUP_SUFFIX = '.luci-pre-write';
// 精选模型预设 (shell 与 JS 共读的唯一数据源)
const MODEL_PRESETS_FILE = process.env.OC_MODEL_PRESETS
|| '/usr/share/openclaw/model-presets.json';
// 动态发现模型列表的超时。OpenWrt 上要避免菜单长时间卡住,
// 超时或失败时回落到本地精选预设。
const MODEL_DISCOVERY_TIMEOUT_MS = 6000;
// ═══════════════════════════════════════════════════════════════════════════
// 辅助函数 (与 oc-config.sh 逻辑对应)
// ═══════════════════════════════════════════════════════════════════════════
@@ -84,26 +96,135 @@ function fixStatePermissions() {
}
}
function readConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
}
} catch (e) {
console.error(`${C.red}读取配置失败:${C.reset} ${e.message}`);
/**
* 配置文件损坏时抛出的专用错误。
* 用于把“文件存在但内容不是合法 JSON 对象”与“文件不存在”区分开。
*/
class ConfigParseError extends Error {
constructor(message) {
super(message);
this.name = 'ConfigParseError';
}
return {};
}
/**
* 写入 JSON 配置文件
* 配置损坏时给用户的恢复指引。
* 不直接自动改文件,避免在用户不知情的情况下丢配置。
*/
function configRecoveryHint() {
const lines = [
`${C.yellow}配置文件可能已损坏,为避免覆盖后丢失全部配置,本次操作已中止。${C.reset}`,
`${C.yellow}文件:${C.reset} ${CONFIG_FILE}`,
'',
`${C.cyan}可尝试的恢复方式:${C.reset}`,
` 1. 让 OpenClaw 自行修复: openclaw doctor --fix`,
` 2. 查看具体语法错误: openclaw config validate`,
];
const lastGood = `${CONFIG_FILE}.last-good`;
if (fs.existsSync(lastGood)) {
lines.push(` 3. 从上次正常配置恢复: cp ${lastGood} ${CONFIG_FILE}`);
}
const preWrite = `${CONFIG_FILE}${CONFIG_BACKUP_SUFFIX}`;
if (fs.existsSync(preWrite)) {
lines.push(` 4. 从上次修改前的副本恢复: cp ${preWrite} ${CONFIG_FILE}`);
}
return lines.join('\n');
}
/**
* 读取 JSON 配置文件。
*
* 语义 (与旧实现的关键差异):
* - 文件不存在 / 内容为空 → 返回 {} (首次安装属正常情况)
* - 文件存在但解析失败 → 抛出 ConfigParseError,绝不返回 {}
*
* 旧实现在解析失败时返回 {},调用方随后 writeConfig() 会把这个空对象
* 写回磁盘,导致 models.providers / apiKey / channels 等全部配置被静默清空。
* 因此这里必须 fail closed,让写入路径中止。
*/
function readConfig() {
if (!fs.existsSync(CONFIG_FILE)) return {};
let raw;
try {
raw = fs.readFileSync(CONFIG_FILE, 'utf8');
} catch (e) {
throw new ConfigParseError(`无法读取配置文件: ${e.message}`);
}
if (raw.trim() === '') return {};
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new ConfigParseError(`配置文件不是合法 JSON: ${e.message}`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new ConfigParseError('配置根节点必须是 JSON 对象');
}
return parsed;
}
/**
* 只读场景下的安全读取: 配置损坏时返回 {} 而不抛错。
* 仅供“显示当前值”这类不会回写磁盘的路径使用 (如菜单标题里的当前配置)。
* 任何会写回磁盘的逻辑都必须用 readConfig()。
*/
function readConfigForDisplay() {
try {
return readConfig();
} catch (e) {
if (e instanceof ConfigParseError) return {};
throw e;
}
}
/**
* 写入 JSON 配置文件 (原子写 + 写前备份 + 写后校验)。
*
* 旧实现直接 fs.writeFileSync 覆盖目标文件,写入中断会留下截断的 JSON,
* 且没有任何备份可回退。这里改为:
* 1. 先把现有配置复制为 .luci-pre-write 备份
* 2. 写入同目录临时文件
* 3. 回读校验临时文件可被 JSON.parse
* 4. rename 原子替换目标文件
*
* 备份后缀特意不用 .bak: OpenClaw 自身维护 openclaw.json.bak 及 .bak.1~.bak.4
* 轮转,占用同名文件会破坏上游的备份链。
*/
function writeConfig(config) {
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
throw new Error('拒绝写入非对象配置');
}
const dir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); try { execSync(`chown openclaw:openclaw "${dir}"`, { stdio: "ignore" }); } catch {}
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
// 保留原文件权限位,避免 rename 后权限被临时文件的 0600 覆盖
let mode = 0o644;
if (fs.existsSync(CONFIG_FILE)) {
try { mode = fs.statSync(CONFIG_FILE).mode & 0o777; } catch {}
// 写前备份 (best effort: 备份失败不应阻止用户修改配置)
try { fs.copyFileSync(CONFIG_FILE, `${CONFIG_FILE}${CONFIG_BACKUP_SUFFIX}`); } catch {}
}
const payload = `${JSON.stringify(config, null, 2)}\n`;
const tmpFile = `${CONFIG_FILE}.tmp-${process.pid}`;
try {
fs.writeFileSync(tmpFile, payload, { mode: 0o600 });
// 回读校验: 确认落盘内容可解析,避免把坏内容 rename 成正式配置
JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
try { fs.chmodSync(tmpFile, mode); } catch {}
fs.renameSync(tmpFile, CONFIG_FILE);
} catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
throw new Error(`写入配置失败,原配置未被修改: ${e.message}`);
}
try {
execSync(`chown openclaw:openclaw "${CONFIG_FILE}"`, { stdio: 'ignore' });
fixStatePermissions();
@@ -112,9 +233,12 @@ function writeConfig(config) {
/**
* 获取 JSON 配置值 (对应 json_get)
*
* 只读路径: 配置损坏时返回 null 而不抛错,保证菜单仍能打开并显示恢复提示。
* 需要回写磁盘的逻辑必须直接用 readConfig(),以便在损坏时中止写入。
*/
function jsonGet(keyPath) {
const config = readConfig();
const config = readConfigForDisplay();
const keys = keyPath.split('.');
let value = config;
for (const key of keys) {
@@ -148,7 +272,8 @@ function jsonSet(keyPath, value) {
function getCurrentModel() {
const primary = jsonGet('agents.defaults.model.primary');
if (primary) return primary;
const config = readConfig();
// 只读路径: 与 jsonGet 保持一致,配置损坏时不抛错
const config = readConfigForDisplay();
if (config.models?.defaultModel) return config.models.defaultModel;
return null;
}
@@ -192,6 +317,207 @@ async function ocCmd(...args) {
});
}
/**
* 静默执行 OpenClaw CLI 并返回输出。
*
* ocCmd() 会把子进程输出直接透传到终端,适合交互式命令;
* 但解析 `pairing list --json` 这类结果时需要拿到纯输出且不污染界面,
* 因此单独提供同步捕获版本。
*
* 返回 { ok, stdout }: 命令不存在或非零退出时 ok=false,不抛异常。
*/
function ocCmdCapture(...args) {
const ocEntry = findOpenClawEntry();
if (!ocEntry) return { ok: false, stdout: '' };
try {
const stdout = execFileSync(NODE_BIN, [ocEntry, ...args], {
encoding: 'utf8',
timeout: 20000,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
OPENCLAW_HOME: OC_DATA,
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
OPENCLAW_STATE_DIR: OC_STATE_DIR,
HOME: OC_DATA,
},
});
return { ok: true, stdout: stdout || '' };
} catch (e) {
// 保留 stdout: 部分命令失败时仍会输出有用信息
return { ok: false, stdout: (e && (e.stdout || '')) ? String(e.stdout) : '' };
}
}
/**
* 从 `openclaw pairing list <channel> --json` 的输出中提取配对码。
* 优先按 JSON 解析;输出夹带非 JSON 前后缀时回退到正则扫描。
*/
function parsePairingCodes(raw) {
if (!raw || !raw.trim()) return [];
const codes = [];
const pushFrom = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { node.forEach(pushFrom); return; }
if (typeof node.code === 'string' && node.code.trim()) codes.push(node.code.trim());
Object.keys(node).forEach((k) => pushFrom(node[k]));
};
try {
pushFrom(JSON.parse(raw));
} catch {
const re = /"code"\s*:\s*"([^"]+)"/g;
let m;
while ((m = re.exec(raw)) !== null) codes.push(m[1]);
}
return [...new Set(codes)];
}
/**
* 读取精选模型预设 (shell 与 JS 共读的唯一数据源)。
* 文件缺失或损坏时返回空结构,调用方回落到"手动输入"。
*/
function loadModelPresets() {
try {
const raw = fs.readFileSync(MODEL_PRESETS_FILE, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.providers) return parsed;
} catch {}
return { providers: {} };
}
/**
* 动态发现某个 provider 当前实际可用的模型。
*
* 调 `openclaw models list --provider <id> --plain`,输出形如
* "openai/gpt-5.5" 每行一个,这里剥掉 provider 前缀只留裸模型 ID。
*
* 注意上游行为 (已实测): `models list --all` 不是各 provider 的超集,
* 必须按 provider 查询才能拿到完整列表;未安装对应插件的 provider
* 会返回 "No models found."。因此失败/空结果都属正常,调用方回落预设。
*/
function discoverProviderModels(providerId) {
const ocEntry = findOpenClawEntry();
if (!ocEntry) return [];
try {
const stdout = execFileSync(
NODE_BIN,
[ocEntry, 'models', 'list', '--provider', providerId, '--plain'],
{
encoding: 'utf8',
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
env: {
...process.env,
OPENCLAW_HOME: OC_DATA,
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
OPENCLAW_STATE_DIR: OC_STATE_DIR,
HOME: OC_DATA,
},
}
);
const prefix = `${providerId}/`;
return [...new Set(
String(stdout || '')
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(prefix))
.map((l) => l.slice(prefix.length))
.filter(Boolean)
)];
} catch {
// 超时 / 命令不可用 / provider 无模型: 均回落到精选预设
return [];
}
}
/**
* 构建模型选择菜单项: 精选预设 + 动态发现 + 手动输入。
*
* 三层设计的取舍 (架构决定):
* - 完全硬编码: 上游更新后必然过期。本项目此前正因如此积累了
* openai/gpt-5.2、claude-sonnet-4、grok-4 等一批上游已不存在的 ID。
* - 完全动态: 依赖 CLI 可用且已配置 Key,未装插件时返回 0 条,
* 且逐 provider 调用在路由器上较慢,不能作为唯一来源。
* - 精选 + 动态 + 手动: 首屏快、能自动跟进、离线仍可用。
*/
function buildModelMenuItems(providerId, opts = {}) {
const presets = loadModelPresets();
const entry = presets.providers[providerId] || {};
const presetModels = Array.isArray(entry.models) ? entry.models : [];
const keys = 'abcdefghijkmnpqrstuvwxyz'.split('');
const items = [];
let ki = 0;
if (presetModels.length) {
items.push({ label: `${C.bold}── 精选模型 ──${C.reset}`, disabled: true });
for (const m of presetModels) {
items.push({
key: keys[ki++] || undefined,
label: m.model,
desc: m.desc || '',
value: m.model,
});
}
}
// 动态发现: 只展示不在精选列表里的,避免重复
if (opts.discovered && opts.discovered.length) {
const known = new Set(presetModels.map((m) => m.model));
const extra = opts.discovered.filter((m) => !known.has(m));
if (extra.length) {
items.push({ label: `${C.bold}── OpenClaw 当前可用 ──${C.reset}`, disabled: true });
for (const m of extra) {
items.push({ key: keys[ki++] || undefined, label: m, desc: '', value: m });
}
}
}
items.push({ label: '', disabled: true });
if (!opts.discovered) {
items.push({ key: 'r', label: '从 OpenClaw 获取完整模型列表', desc: '动态发现当前可用模型', value: '__discover__' });
}
// 手动输入是永久保留的兼容出口: 上游新增模型时无需等插件更新
items.push({ key: 'z', label: '手动输入模型 ID', desc: '', value: '__custom__' });
return items;
}
/**
* 统一的模型选择流程: 处理动态发现与手动输入两个特殊分支。
* 返回裸模型 ID (不含 provider 前缀),取消时返回 null。
*/
async function selectProviderModel(providerId, title, fallbackDefault) {
let discovered = null;
for (let round = 0; round < 2; round++) {
const choice = await select({
title,
showSearch: true,
items: buildModelMenuItems(providerId, discovered ? { discovered } : {}),
});
if (!choice) return null;
if (choice.value === '__discover__') {
console.log(`\n${C.cyan}正在从 OpenClaw 获取模型列表...${C.reset}`);
discovered = discoverProviderModels(providerId);
if (!discovered.length) {
console.log(`${C.yellow}未获取到模型列表 (该 Provider 可能需要先安装插件或配置 API Key),`
+ `已显示内置精选列表。${C.reset}\n`);
discovered = [];
} else {
console.log(`${C.green}获取到 ${discovered.length} 个模型${C.reset}\n`);
}
continue;
}
if (choice.value === '__custom__') {
const manual = await input({ prompt: '请输入模型 ID', defaultValue: fallbackDefault || '' });
const trimmed = manual ? String(manual).trim() : '';
return trimmed || null;
}
return choice.value;
}
return null;
}
/**
* 注册模型并设为默认 (对应 register_and_set_model)
*/
@@ -485,12 +811,12 @@ async function showChannelsMenu() {
{ label: `${C.dim}提示: 微信配置请使用 LuCI 界面「微信配置」菜单${C.reset}`, disabled: true },
{ label: '', disabled: true },
{ key: '1', label: 'QQ 机器人', desc: '腾讯QQ', value: 'qq' },
{ key: '2', label: 'Telegram', desc: `${C.green}最常用${C.reset}`, value: 'telegram' },
{ key: '2', label: 'Telegram', desc: `${C.green}最常用${C.reset} — 配置 Bot Token`, value: 'telegram' },
{ key: '3', label: 'Discord', desc: '', value: 'discord' },
{ key: '4', label: '飞书 (Feishu)', desc: '', value: 'feishu' },
{ key: '5', label: 'Slack', desc: '', value: 'slack' },
{ key: '6', label: 'WhatsApp', desc: '需通过 Web 控制台扫码', value: 'whatsapp' },
{ key: '7', label: 'Telegram 配对助手', desc: '', value: 'telegram-pairing' },
{ key: '7', label: 'Telegram 配对助手', desc: '审批用户配对请求 (需先配置 Token)', value: 'telegram-pairing' },
{ key: '8', label: '官方完整渠道配置向导', desc: '', value: 'wizard' },
{ label: '', disabled: true },
{ key: '0', label: '返回', desc: '', value: 'back' },
@@ -508,7 +834,9 @@ async function showAdvancedMenu() {
const gwPort = jsonGet('gateway.port') || '18789';
const gwBind = jsonGet('gateway.bind') || 'lan';
const gwMode = jsonGet('gateway.mode') || 'local';
const logLevel = jsonGet('gateway.logLevel') || '未设置';
// 正确键是顶层 logging.level;gateway.logLevel 不在 OpenClaw schema 中
// (gateway.additionalProperties=false),仅为显示旧配置残留值做兼容读取。
const logLevel = jsonGet('logging.level') || jsonGet('gateway.logLevel') || '未设置';
const acpDispatch = jsonGet('acp.dispatch.enabled') || 'false';
const result = await select({
@@ -590,26 +918,8 @@ async function configureOpenAI() {
const apiKey = await input({ prompt: '请输入 OpenAI API Key (sk-...)', placeholder: 'sk-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'OpenAI 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'gpt-5.2', desc: '最强编程与代理旗舰 (推荐)', value: 'gpt-5.2' },
{ key: 'b', label: 'gpt-5-mini', desc: '高性价比推理', value: 'gpt-5-mini' },
{ key: 'c', label: 'gpt-5-nano', desc: '极速低成本', value: 'gpt-5-nano' },
{ key: 'd', label: 'gpt-4.1', desc: '最强非推理模型', value: 'gpt-4.1' },
{ key: 'e', label: 'o3', desc: '推理模型', value: 'o3' },
{ key: 'f', label: 'o4-mini', desc: '推理轻量', value: 'o4-mini' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-5.2' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('openai', 'OpenAI 模型选择', 'gpt-5.6-sol');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('openai', apiKey);
registerAndSetModel(`openai/${modelName}`);
@@ -625,25 +935,8 @@ async function configureAnthropic() {
const apiKey = await input({ prompt: '请输入 Anthropic API Key (sk-ant-...)', placeholder: 'sk-ant-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'Anthropic 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' },
{ key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4 顶级推理', value: 'claude-opus-4-20250514' },
{ key: 'c', label: 'claude-haiku-4-5', desc: 'Claude Haiku 4.5 轻量快速', value: 'claude-haiku-4-5' },
{ key: 'd', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' },
{ key: 'e', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' },
{ key: 'f', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('anthropic', 'Anthropic 模型选择', 'claude-sonnet-5');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('anthropic', apiKey);
registerAndSetModel(`anthropic/${modelName}`);
@@ -659,25 +952,8 @@ async function configureGoogle() {
const apiKey = await input({ prompt: '请输入 Google AI API Key', placeholder: 'AIza...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'Google Gemini 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'gemini-2.5-pro', desc: '旗舰推理 (推荐)', value: 'gemini-2.5-pro' },
{ key: 'b', label: 'gemini-2.5-flash', desc: '快速均衡', value: 'gemini-2.5-flash' },
{ key: 'c', label: 'gemini-2.5-flash-lite', desc: '极速低成本', value: 'gemini-2.5-flash-lite' },
{ key: 'd', label: 'gemini-3-flash-preview', desc: 'Gemini 3 Flash 预览', value: 'gemini-3-flash-preview' },
{ key: 'e', label: 'gemini-3-pro-preview', desc: 'Gemini 3 Pro 预览', value: 'gemini-3-pro-preview' },
{ key: 'f', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gemini-2.5-pro' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('google', 'Google Gemini 模型选择', 'gemini-2.5-pro');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('google', apiKey);
registerAndSetModel(`google/${modelName}`);
@@ -694,26 +970,9 @@ async function configureOpenRouter() {
const apiKey = await input({ prompt: '请输入 OpenRouter API Key', placeholder: 'sk-or-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'OpenRouter 常用模型',
showSearch: false,
items: [
{ key: 'a', label: 'anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 (推荐)', value: 'anthropic/claude-sonnet-4' },
{ key: 'b', label: 'anthropic/claude-opus-4', desc: 'Claude Opus 4', value: 'anthropic/claude-opus-4' },
{ key: 'c', label: 'openai/gpt-5.2', desc: 'GPT-5.2', value: 'openai/gpt-5.2' },
{ key: 'd', label: 'google/gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'google/gemini-2.5-pro' },
{ key: 'e', label: 'deepseek/deepseek-r1', desc: 'DeepSeek R1', value: 'deepseek/deepseek-r1' },
{ key: 'f', label: 'meta-llama/llama-4-maverick', desc: 'Meta Llama 4', value: 'meta-llama/llama-4-maverick' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称 (格式: provider/model)', defaultValue: 'anthropic/claude-sonnet-4' });
if (!modelName) return false;
}
// OpenRouter 的模型 ID 本身带上游 provider 前缀 (如 moonshotai/kimi-k2.6)
const modelName = await selectProviderModel('openrouter', 'OpenRouter 模型选择', 'auto');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('openrouter', apiKey);
registerCustomProvider('openrouter', 'https://openrouter.ai/api/v1', apiKey, modelName, modelName);
@@ -734,31 +993,9 @@ async function configureCopilot() {
await ocCmd('models', 'auth', 'login-github-copilot', '--yes');
console.log(`\n${C.green}✅ GitHub Copilot OAuth 认证成功${C.reset}\n`);
const modelChoice = await select({
title: 'GitHub Copilot 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'gpt-4.1', desc: 'GPT-4.1 (推荐)', value: 'gpt-4.1' },
{ key: 'b', label: 'gpt-4o', desc: 'GPT-4o', value: 'gpt-4o' },
{ key: 'c', label: 'gpt-5', desc: 'GPT-5', value: 'gpt-5' },
{ key: 'd', label: 'gpt-5-mini', desc: 'GPT-5 mini', value: 'gpt-5-mini' },
{ key: 'e', label: 'gpt-5.1', desc: 'GPT-5.1', value: 'gpt-5.1' },
{ key: 'f', label: 'gpt-5.2', desc: 'GPT-5.2', value: 'gpt-5.2' },
{ key: 'g', label: 'gpt-5.2-codex', desc: 'GPT-5.2 Codex', value: 'gpt-5.2-codex' },
{ key: 'h', label: 'claude-sonnet-4', desc: 'Claude Sonnet 4', value: 'claude-sonnet-4' },
{ key: 'i', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' },
{ key: 'j', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' },
{ key: 'k', label: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro' },
{ key: 'm', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (modelChoice) {
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-4.1' });
if (!modelName) return false;
}
// 登录成功后动态发现通常可用,因此这里默认就尝试拉取一次
const modelName = await selectProviderModel('github-copilot', 'GitHub Copilot 模型选择', 'gpt-5.5');
if (modelName) {
registerAndSetModel(`github-copilot/${modelName}`);
console.log(`\n${C.green}✅ 活跃模型已设置: github-copilot/${modelName}${C.reset}\n`);
}
@@ -777,26 +1014,8 @@ async function configureXAI() {
const apiKey = await input({ prompt: '请输入 xAI API Key', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'xAI Grok 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'grok-4', desc: 'Grok 4 旗舰 (推荐)', value: 'grok-4' },
{ key: 'b', label: 'grok-4-fast', desc: 'Grok 4 Fast', value: 'grok-4-fast' },
{ key: 'c', label: 'grok-3', desc: 'Grok 3', value: 'grok-3' },
{ key: 'd', label: 'grok-3-fast', desc: 'Grok 3 Fast', value: 'grok-3-fast' },
{ key: 'e', label: 'grok-3-mini', desc: 'Grok 3 Mini', value: 'grok-3-mini' },
{ key: 'f', label: 'grok-3-mini-fast', desc: 'Grok 3 Mini Fast', value: 'grok-3-mini-fast' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'grok-4' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('xai', 'xAI Grok 模型选择', 'grok-4.3');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('xai', apiKey);
registerAndSetModel(`xai/${modelName}`);
@@ -916,34 +1135,19 @@ async function configureSiliconFlow() {
const apiKey = await input({ prompt: '请输入 SiliconFlow API Key', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
console.log(`\n${C.cyan}可用模型分类说明:${C.reset}`);
console.log(`${C.yellow}* Pro模型: 仅支持充值余额支付${C.reset}`);
console.log(`${C.yellow}* 非Pro模型: 支持代金券/免费额度${C.reset}\n`);
// SiliconFlow 不是 OpenClaw 内置/官方插件 provider,走 OpenAI 兼容接入,
// 因此 `openclaw models list --provider siliconflow` 拿不到列表,
// 也无法用上游 catalog 核实模型 ID。
//
// 此处不再维护硬编码清单: 原列表里的 Qwen2.5-7B/72B、Yi-1.5-34B-Chat-16K、
// glm-4-9b-chat 均已明显过期,而我们无法自动判断哪些仍然有效。
// 让用户从官方模型广场复制当前 ID 更可靠,也不会随时间腐坏。
console.log(`\n${C.cyan}请从官方模型广场复制模型 ID:${C.reset}`);
console.log(` ${C.cyan}https://cloud.siliconflow.cn/models${C.reset}`);
console.log(`${C.dim}格式形如 deepseek-ai/DeepSeek-V3.2;Pro/ 前缀的模型仅支持充值余额支付${C.reset}\n`);
const modelChoice = await select({
title: 'SiliconFlow 模型选择',
showSearch: false,
items: [
{ label: `${C.cyan}── 非Pro模型 (支持代金券) ──${C.reset}`, disabled: true },
{ key: '1', label: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (推荐)', value: 'deepseek-ai/DeepSeek-V3' },
{ key: '2', label: 'deepseek-ai/DeepSeek-R1', desc: 'DeepSeek-R1 推理模型', value: 'deepseek-ai/DeepSeek-R1' },
{ key: '3', label: 'Qwen/Qwen2.5-72B-Instruct', desc: '通义千问 2.5 72B', value: 'Qwen/Qwen2.5-72B-Instruct' },
{ key: '4', label: 'Qwen/Qwen2.5-7B-Instruct', desc: '通义千问 2.5 7B', value: 'Qwen/Qwen2.5-7B-Instruct' },
{ key: '5', label: 'THUDM/glm-4-9b-chat', desc: '智谱 GLM-4 9B', value: 'THUDM/glm-4-9b-chat' },
{ key: '6', label: '01-ai/Yi-1.5-34B-Chat-16K', desc: '零一万物 Yi-1.5', value: '01-ai/Yi-1.5-34B-Chat-16K' },
{ label: `${C.cyan}── Pro模型 (仅支持充值余额) ──${C.reset}`, disabled: true },
{ key: '7', label: 'Pro/deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (Pro)', value: 'Pro/deepseek-ai/DeepSeek-V3' },
{ key: '8', label: 'Pro/zai-org/GLM-5', desc: '智谱 GLM-5', value: 'Pro/zai-org/GLM-5' },
{ key: '0', label: '手动输入其他模型名称', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型详细名称', defaultValue: 'deepseek-ai/DeepSeek-V3' });
if (!modelName) return false;
}
const modelName = await input({ prompt: '请输入模型 ID', placeholder: 'deepseek-ai/DeepSeek-V3.2' });
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('siliconflow', apiKey);
registerCustomProvider('siliconflow', 'https://api.siliconflow.cn/v1', apiKey, modelName, modelName);
@@ -1001,24 +1205,8 @@ async function configureBaidu() {
const apiKey = await input({ prompt: '请输入百度千帆 API Key (Access Token)', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: '百度千帆模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'ernie-4.0-8k', desc: '文心一言 4.0 (推荐)', value: 'ernie-4.0-8k' },
{ key: 'b', label: 'ernie-3.5-8k', desc: '文心一言 3.5', value: 'ernie-3.5-8k' },
{ key: 'c', label: 'ernie-4.0-turbo-8k', desc: '文心一言 4.0 Turbo', value: 'ernie-4.0-turbo-8k' },
{ key: 'd', label: 'ernie-speed-8k', desc: '文心一言 Speed 极速', value: 'ernie-speed-8k' },
{ key: 'e', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'ernie-4.0-8k' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('qianfan', '百度千帆模型选择', 'ernie-5.0-thinking-preview');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('qianfan', apiKey);
registerCustomProvider('qianfan', 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop', apiKey, modelName, modelName);
@@ -1065,26 +1253,8 @@ async function configureZhipu() {
const apiKey = await input({ prompt: '请输入智谱 API Key', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: '智谱 GLM 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'glm-5.1', desc: 'GLM-5.1 (推荐)', value: 'glm-5.1' },
{ key: 'b', label: 'glm-5', desc: 'GLM-5', value: 'glm-5' },
{ key: 'c', label: 'glm-4.7', desc: 'GLM-4.7', value: 'glm-4.7' },
{ key: 'd', label: 'glm-4.7-flash', desc: 'GLM-4.7 Flash', value: 'glm-4.7-flash' },
{ key: 'e', label: 'glm-4.5', desc: 'GLM-4.5', value: 'glm-4.5' },
{ key: 'f', label: 'glm-4.5-flash', desc: 'GLM-4.5 Flash (免费)', value: 'glm-4.5-flash' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'glm-5.1' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('zai', '智谱 GLM 模型选择', 'glm-5.2');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
// 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持)
registerCustomProvider('zai', baseUrl, apiKey, modelName, modelName, 128000, 4096);
@@ -1219,22 +1389,10 @@ async function configureCustomAnthropic() {
const apiKey = await input({ prompt: 'API Key', placeholder: 'sk-ant-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'Anthropic 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' },
{ key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4', value: 'claude-opus-4-20250514' },
{ key: 'c', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' });
if (!modelName) return false;
}
// 复用 anthropic 的精选预设作为候选 (第三方兼容服务通常沿用同名模型 ID),
// 但保留手动输入出口: 自建/代理服务的模型名可能完全不同。
const modelName = await selectProviderModel('anthropic', '模型选择 (可手动输入)', 'claude-sonnet-5');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const config = readConfig();
if (!config.models) config.models = {};
@@ -1375,7 +1533,7 @@ async function handleSetActiveModel() {
if (choice.value === 'manual') {
const manualModel = await input({
prompt: '请输入模型 ID',
placeholder: 'openai/gpt-4o',
placeholder: 'openai/gpt-5.6-sol',
defaultValue: currentModel || '',
});
if (manualModel) {
@@ -1531,10 +1689,100 @@ async function handleChannels() {
break;
}
case 'telegram-pairing': {
console.log(`\n${C.cyan}启动 Telegram 配对助手...${C.reset}\n`);
// 配对 (pairing) 与 Bot Token 配置是两件不同的事:
// - Token 配置 -> 上面的 'telegram' 分支 / channels add --bot-token
// - 用户配对 -> openclaw pairing list / pairing approve
// 旧实现调用的 `models auth login-telegram-bot` 在 2026.6+ 已不存在
// (实测报 Too many arguments for this command),这里改为对齐上游
// pairing 命令,与 oc-config.sh 的 telegram_pairing() 行为保持一致。
console.log(`\n${C.bold}🤝 Telegram 配对助手${C.reset}\n`);
const tgToken = jsonGet('channels.telegram.botToken');
if (!tgToken) {
console.log(`${C.yellow}未检测到 Telegram Bot Token,请先在「Telegram」中配置 Token。${C.reset}\n`);
await input({ prompt: '按回车返回', defaultValue: '' });
break;
}
// 先确认 Token 与网络可用,避免用户在配对环节盲等
console.log(`${C.cyan}诊断 Telegram API 连通性...${C.reset}`);
let botName = '';
try {
await ocCmd('models', 'auth', 'login-telegram-bot');
} catch (e) {}
const probe = execSync(
`curl -s --connect-timeout 5 --max-time 10 "https://api.telegram.org/bot${tgToken}/getMe"`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
);
if (/"ok"\s*:\s*true/.test(probe)) {
const m = probe.match(/"username"\s*:\s*"([^"]+)"/);
botName = m ? m[1] : '';
console.log(`${C.green}✅ Telegram API 连通正常${botName ? ` — @${botName}` : ''}${C.reset}`);
} else {
console.log(`${C.red}❌ Telegram API 连通检测未通过${C.reset}`);
console.log(`${C.yellow} 可能原因: Token 不正确、网络不通 或 Telegram 被屏蔽${C.reset}`);
console.log(`${C.cyan} 建议: 重新配置 Token 或检查代理/网络设置${C.reset}\n`);
await input({ prompt: '按回车返回', defaultValue: '' });
break;
}
} catch {
console.log(`${C.yellow}⚠️ 无法完成连通性检测,继续尝试配对${C.reset}`);
}
console.log('');
console.log(`${C.green}请在 Telegram 中向 Bot 发送 /start,然后回到这里继续${C.reset}`);
console.log('');
const go = await input({ prompt: '发送 /start 后按回车继续 (输入 q 退出)', defaultValue: '' });
if (String(go).toLowerCase() === 'q') break;
const approveCode = (code) => {
const res = ocCmdCapture('pairing', 'approve', 'telegram', code);
return res.ok || /approved|success|ok/i.test(res.stdout);
};
let paired = false;
for (let attempt = 1; attempt <= 3 && !paired; attempt++) {
console.log(`${C.cyan}检测配对请求... (第 ${attempt}/3 轮)${C.reset}`);
const listed = ocCmdCapture('pairing', 'list', 'telegram', '--json');
const codes = parsePairingCodes(listed.stdout);
for (const code of codes) {
console.log(`${C.cyan}发现配对请求: ${code}${C.reset}`);
if (approveCode(code)) {
console.log(`\n${C.green}${C.bold}🎉 Telegram 配对成功!${C.reset}`);
paired = true;
} else {
console.log(`${C.yellow}配对码 ${code} 处理失败${C.reset}`);
}
}
if (!paired && attempt < 3) {
console.log(`${C.yellow}未检测到,等待 8 秒后重试...${C.reset}`);
await new Promise((r) => setTimeout(r, 8000));
}
}
if (!paired) {
console.log('');
console.log(`${C.yellow}未自动检测到配对请求。${C.reset}`);
console.log(`${C.cyan}如果 Bot 已回复配对码,可手动输入 (回车跳过):${C.reset}`);
const manual = await input({ prompt: '配对码', defaultValue: '' });
if (manual) {
if (approveCode(String(manual).trim())) {
console.log(`${C.green}${C.bold}🎉 Telegram 配对成功!${C.reset}`);
paired = true;
} else {
console.log(`${C.yellow}配对失败,请确认配对码是否正确或重新发送 /start${C.reset}`);
}
}
}
if (paired) {
console.log(`\n${C.cyan}正在重启 Gateway 使配对生效...${C.reset}`);
await restartGateway();
console.log(`${C.green}✅ 现在可以在 Telegram 中与 Bot 对话了!${C.reset}\n`);
} else {
console.log('');
await input({ prompt: '按回车返回', defaultValue: '' });
}
break;
}
case 'wizard': {
@@ -1673,12 +1921,21 @@ async function handleAdvancedConfig() {
title: '绑定地址选项',
showSearch: false,
items: [
// 取值必须与 schema 的 gateway.bind 枚举一致:
// auto/lan/loopback/custom/tailnet。旧选项 all 不被上游接受
// (实测: gateway.bind: Invalid input),监听所有接口用 custom。
{ key: '1', label: 'lan', desc: '仅 LAN 接口 (推荐)', value: 'lan' },
{ key: '2', label: 'loopback', desc: '仅本机访问', value: 'loopback' },
{ key: '3', label: 'all', desc: '所有接口 (0.0.0.0)', value: 'all' },
{ key: '3', label: 'auto', desc: '自动选择', value: 'auto' },
{ key: '4', label: 'custom', desc: '自定义地址 (0.0.0.0 = 所有接口)', value: 'custom' },
{ key: '5', label: 'tailnet', desc: 'Tailscale 网络', value: 'tailnet' },
],
});
if (bindChoice) {
if (bindChoice.value === 'custom') {
const host = await input({ prompt: '监听地址', defaultValue: jsonGet('gateway.customBindHost') || '0.0.0.0' });
if (host) jsonSet('gateway.customBindHost', host);
}
jsonSet('gateway.bind', bindChoice.value);
try { execSync(`uci set openclaw.main.bind="${bindChoice.value}" && uci commit openclaw`, { stdio: 'ignore' }); } catch {}
console.log(`\n${C.green}✅ 绑定地址已设置为 ${bindChoice.value}${C.reset}\n`);
@@ -1707,14 +1964,28 @@ async function handleAdvancedConfig() {
title: '日志级别选项',
showSearch: false,
items: [
{ key: '1', label: 'debug', desc: '详细调试', value: 'debug' },
{ key: '2', label: 'info', desc: '常规信息', value: 'info' },
{ key: '3', label: 'warn', desc: '警告及以上', value: 'warn' },
{ key: '4', label: 'error', desc: '仅错误', value: 'error' },
// 取值与 schema 的 logging.level 枚举一致 (7 档)
{ key: '1', label: 'info', desc: '常规信息 (默认)', value: 'info' },
{ key: '2', label: 'warn', desc: '警告及以上', value: 'warn' },
{ key: '3', label: 'error', desc: '仅错误', value: 'error' },
{ key: '4', label: 'debug', desc: '详细调试', value: 'debug' },
{ key: '5', label: 'trace', desc: '最详细 (排障用)', value: 'trace' },
{ key: '6', label: 'fatal', desc: '仅致命错误', value: 'fatal' },
{ key: '7', label: 'silent', desc: '完全静默', value: 'silent' },
],
});
if (levelChoice) {
jsonSet('gateway.logLevel', levelChoice.value);
// 正确键是顶层 logging.level;gateway.logLevel 不在 schema 中,
// 写入会被上游忽略,表现为"显示已设置但从未生效"。
jsonSet('logging.level', levelChoice.value);
// 清理旧配置里可能残留的错误键,避免界面回显到失效值
try {
const cfg = readConfig();
if (cfg.gateway && Object.prototype.hasOwnProperty.call(cfg.gateway, 'logLevel')) {
delete cfg.gateway.logLevel;
writeConfig(cfg);
}
} catch {}
console.log(`\n${C.green}✅ 日志级别已设置为 ${levelChoice.value}${C.reset}\n`);
await askRestart();
}
@@ -2076,6 +2347,13 @@ async function main() {
}
main().catch(e => {
if (e instanceof ConfigParseError) {
// 配置损坏: 给出可直接执行的恢复步骤,而不是丢一句解析错误了事
console.error(`\n${C.red}配置读取失败:${C.reset} ${e.message}\n`);
console.error(configRecoveryHint());
console.error('');
process.exit(2);
}
console.error(`${C.red}错误:${C.reset}`, e.message);
process.exit(1);
});
@@ -126,18 +126,166 @@ oc_doctor_as_openclaw() {
fi
}
# ── 精选模型预设 (与 JS 侧共读同一份数据源) ──
# 见 root/usr/share/openclaw/model-presets.json。
# 三层架构: 精选预设 -> 动态发现 (openclaw models list --provider) -> 手动输入。
OC_MODEL_PRESETS="${OC_MODEL_PRESETS:-/usr/share/openclaw/model-presets.json}"
OC_MODEL_DISCOVERY_TIMEOUT="${OC_MODEL_DISCOVERY_TIMEOUT:-6}"
# oc_preset_models <provider> — 输出该 provider 的精选模型,每行 "model<TAB>desc"
oc_preset_models() {
[ -f "$OC_MODEL_PRESETS" ] || return 0
[ -x "$NODE_BIN" ] || return 0
_OC_PP="$1" _OC_PF="$OC_MODEL_PRESETS" "$NODE_BIN" -e '
const fs=require("fs");
try{
const j=JSON.parse(fs.readFileSync(process.env._OC_PF,"utf8"));
const p=(j.providers||{})[process.env._OC_PP];
if(!p||!Array.isArray(p.models))process.exit(0);
for(const m of p.models){
if(m&&m.model)console.log(m.model+"\t"+(m.desc||""));
}
}catch(e){}
' 2>/dev/null
}
# oc_discover_models <provider> — 动态发现当前可用模型 (失败/超时输出空)
# 上游行为: models list --all 不是各 provider 的超集,必须按 provider 查询;
# 未安装对应插件时返回 "No models found.",属正常情况。
# 带 timeout: 避免在 OpenWrt 上把菜单卡死。
oc_discover_models() {
local provider="$1"
[ -x "$NODE_BIN" ] || return 0
[ -n "$OC_ENTRY" ] || return 0
timeout "$OC_MODEL_DISCOVERY_TIMEOUT" "$NODE_BIN" "$OC_ENTRY" models list \
--provider "$provider" --plain 2>/dev/null \
| sed -n "s|^${provider}/||p" \
| grep -v '^[[:space:]]*$'
}
# oc_pick_model <provider> <默认模型> — 交互式选择模型,结果写入全局 OC_PICKED_MODEL
# 菜单: 精选预设 (数字) / d 动态发现 / m 手动输入
oc_pick_model() {
local provider="$1" fallback="$2"
local list idx line model desc choice
OC_PICKED_MODEL=""
list="$(oc_preset_models "$provider")"
echo ""
if [ -n "$list" ]; then
echo -e " ${CYAN}精选模型:${NC}"
idx=1
printf '%s\n' "$list" | while IFS="$(printf '\t')" read -r model desc; do
[ -n "$model" ] || continue
echo -e " ${CYAN}${idx})${NC} ${model}${desc:+ — ${desc}}"
idx=$((idx + 1))
done
else
echo -e " ${YELLOW}该 Provider 无内置精选列表${NC}"
fi
echo -e " ${CYAN}d)${NC} 从 OpenClaw 获取完整模型列表"
echo -e " ${CYAN}m)${NC} 手动输入模型 ID"
echo ""
prompt_with_default "请选择模型" "1" choice
case "$choice" in
d|D)
echo -e " ${CYAN}正在获取模型列表...${NC}"
local found
found="$(oc_discover_models "$provider")"
if [ -z "$found" ]; then
echo -e " ${YELLOW}未获取到模型列表 (可能需先安装插件或配置 API Key)${NC}"
prompt_with_default "请手动输入模型 ID" "$fallback" OC_PICKED_MODEL
else
echo ""
idx=1
printf '%s\n' "$found" | while read -r model; do
[ -n "$model" ] || continue
echo -e " ${CYAN}${idx})${NC} ${model}"
idx=$((idx + 1))
done
echo ""
prompt_with_default "请输入序号或直接输入模型 ID" "1" choice
case "$choice" in
''|*[!0-9]*) OC_PICKED_MODEL="$choice" ;;
*) OC_PICKED_MODEL="$(printf '%s\n' "$found" | sed -n "${choice}p")" ;;
esac
fi
;;
m|M)
prompt_with_default "请输入模型 ID" "$fallback" OC_PICKED_MODEL
;;
''|*[!0-9]*)
# 非数字直接当作手动输入的模型 ID
OC_PICKED_MODEL="$choice"
;;
*)
OC_PICKED_MODEL="$(printf '%s\n' "$list" | sed -n "${choice}p" | cut -f1)"
;;
esac
[ -n "$OC_PICKED_MODEL" ] || OC_PICKED_MODEL="$fallback"
[ -n "$OC_PICKED_MODEL" ]
}
# ── JSON 读写 (使用 Node.js) ──
json_get() {
if [ ! -f "$CONFIG_FILE" ]; then echo ""; return; fi
_JS_KEY="$1" "$NODE_BIN" -e "
const fs=require('fs');
_OC_JS_KEY="$1" _OC_JS_CONFIG="$CONFIG_FILE" "$NODE_BIN" -e '
const fs=require("fs");
try{
const d=JSON.parse(fs.readFileSync('${CONFIG_FILE}','utf8'));
const ks=process.env._JS_KEY.split('.');let v=d;
for(const k of ks){v=v[k];if(v===undefined){console.log('');process.exit(0);}}
if(typeof v==='object')console.log(JSON.stringify(v));else console.log(v);
}catch(e){console.log('');}
" 2>/dev/null
const d=JSON.parse(fs.readFileSync(process.env._OC_JS_CONFIG,"utf8"));
const ks=process.env._OC_JS_KEY.split(".");let v=d;
for(const k of ks){v=v[k];if(v===undefined||v===null){console.log("");process.exit(0);}}
if(typeof v==="object")console.log(JSON.stringify(v));else console.log(v);
}catch(e){console.log("");}
' 2>/dev/null
}
# ── 配置写入类型表 ──
# 依据: OpenClaw 官方 schema,基线见 tests/fixtures/openclaw-schema-types.tsv
#
# 上游对配置类型做严格校验: gateway.port 必须是 integer、
# acp.dispatch.enabled / channels.*.enabled 必须是 boolean。
# 写成字符串会导致 openclaw config validate 失败,进而网关拒绝启动
# (实测报错: gateway.port: Invalid input / acp.dispatch.enabled: Invalid input)。
#
# 用集中表而非在每个调用点传类型,保证现有与将来新增的调用点都默认正确。
oc_schema_type_for_key() {
case "$1" in
gateway.port|gateway.handshakeTimeoutMs|gateway.channelHealthCheckMinutes)
echo "number" ;;
gateway.channelStaleEventThresholdMinutes|gateway.channelMaxRestartsPerHour)
echo "number" ;;
acp.dispatch.enabled|update.checkOnStart|plugins.enabled)
echo "boolean" ;;
gateway.controlUi.allowInsecureAuth)
echo "boolean" ;;
gateway.controlUi.dangerouslyDisableDeviceAuth)
echo "boolean" ;;
gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback)
echo "boolean" ;;
gateway.allowRealIpFallback)
echo "boolean" ;;
channels.*.enabled)
echo "boolean" ;;
*)
echo "string" ;;
esac
}
# 枚举白名单。写入前校验,避免把上游不接受的值(如 gateway.bind=all)落盘。
oc_schema_enum_for_key() {
case "$1" in
gateway.bind) echo "auto lan loopback custom tailnet" ;;
gateway.mode) echo "local remote" ;;
gateway.auth.mode) echo "none token password trusted-proxy" ;;
logging.level) echo "silent fatal error warn info debug trace" ;;
tools.profile) echo "minimal coding messaging full" ;;
models.mode) echo "merge replace" ;;
*) echo "" ;;
esac
}
json_set() {
@@ -188,53 +336,121 @@ json_set() {
return 1
fi
# 步骤4: 使用临时文件传递值,避免环境变量转义问题
# 步骤4: 解析目标类型并校验枚举
# 第三个参数可显式指定类型 (string/number/boolean/json),
# 未指定时按 schema 类型表自动判定。
local val_type="${3:-}"
[ -n "$val_type" ] || val_type="$(oc_schema_type_for_key "$key")"
local allowed
allowed="$(oc_schema_enum_for_key "$key")"
if [ -n "$allowed" ]; then
local matched=0 a
for a in $allowed; do
[ "$value" = "$a" ] && { matched=1; break; }
done
if [ "$matched" -ne 1 ]; then
echo "ERROR: $key 的值 '$value' 不被 OpenClaw 接受" >&2
echo "HINT: 允许的值: $allowed" >&2
return 1
fi
fi
# 步骤5: 使用临时文件传递值,避免环境变量转义问题
local tmp_val_file="/tmp/.oc_json_val_$$"
if ! printf '%s' "$value" > "$tmp_val_file" 2>/dev/null; then
echo "ERROR: 无法创建临时文件 $tmp_val_file" >&2
return 1
fi
# 步骤5: 执行 JSON 写入
_JS_KEY="$key" _JS_DEBUG="${OC_CONFIG_DEBUG:-0}" "$NODE_BIN" -e "
const fs=require('fs');let d={};
const debug=process.env._JS_DEBUG==='1';
try{
const content=fs.readFileSync('${CONFIG_FILE}','utf8');
d=JSON.parse(content);
}catch(e){
if(debug)console.error('JSON parse warning:',e.message);
# 步骤6: 执行 JSON 写入
# 与 JS 侧同样的安全要求:
# - 配置存在但解析失败时必须中止,不能用 {} 覆盖 (否则整份配置连同
# models.providers 里的 apiKey 一起丢失)
# - 按 schema 类型落值,不能一律写成字符串
# - 原子写: 临时文件 -> 回读校验 -> rename
_OC_JS_KEY="$key" _OC_JS_TYPE="$val_type" _OC_JS_VALFILE="$tmp_val_file" \
_OC_JS_CONFIG="$CONFIG_FILE" _OC_JS_DEBUG="${OC_CONFIG_DEBUG:-0}" "$NODE_BIN" -e '
const fs=require("fs");
const debug=process.env._OC_JS_DEBUG==="1";
const target=process.env._OC_JS_CONFIG;
let d={};
let raw="";
try{ raw=fs.readFileSync(target,"utf8"); }catch(e){ raw=""; }
if(raw.trim()!==""){
try{
d=JSON.parse(raw);
}catch(e){
console.error("ERROR: 配置文件不是合法 JSON: "+e.message);
console.error("HINT: 为避免覆盖后丢失全部配置,本次写入已中止。");
console.error("HINT: 可尝试 openclaw doctor --fix 或 openclaw config validate 定位问题。");
process.exit(2);
}
if(d===null||typeof d!=="object"||Array.isArray(d)){
console.error("ERROR: 配置根节点必须是 JSON 对象,本次写入已中止。");
process.exit(2);
}
}
const ks=process.env._JS_KEY.split('.');let o=d;
const rawVal=fs.readFileSync(process.env._OC_JS_VALFILE,"utf8");
const wantType=process.env._OC_JS_TYPE||"string";
let v;
if(wantType==="number"){
v=Number(rawVal.trim());
if(!Number.isFinite(v)){
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要数字,收到: "+rawVal.trim());
process.exit(3);
}
}else if(wantType==="boolean"){
const t=rawVal.trim().toLowerCase();
if(t==="true"||t==="1")v=true;
else if(t==="false"||t==="0")v=false;
else{
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要 true/false,收到: "+rawVal.trim());
process.exit(3);
}
}else if(wantType==="json"){
try{ v=JSON.parse(rawVal); }catch(e){
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要合法 JSON: "+e.message);
process.exit(3);
}
}else{
v=rawVal;
}
const ks=process.env._OC_JS_KEY.split(".");let o=d;
for(let i=0;i<ks.length-1;i++){
if(!o[ks[i]]||typeof o[ks[i]]!=='object')o[ks[i]]={};
if(!o[ks[i]]||typeof o[ks[i]]!=="object"||Array.isArray(o[ks[i]]))o[ks[i]]={};
o=o[ks[i]];
}
// 读取值并作为字符串保存
let v=fs.readFileSync('${tmp_val_file}','utf8');
o[ks[ks.length-1]]=v;
let mode=0o644;
try{ if(fs.existsSync(target)) mode=fs.statSync(target).mode & 0o777; }catch(e){}
const tmp=target+".tmp-"+process.pid;
try{
fs.writeFileSync('${CONFIG_FILE}',JSON.stringify(d,null,2));
if(debug)console.log('JSON saved successfully');
fs.writeFileSync(tmp,JSON.stringify(d,null,2)+"\n",{mode:0o600});
JSON.parse(fs.readFileSync(tmp,"utf8"));
try{ fs.chmodSync(tmp,mode); }catch(e){}
fs.renameSync(tmp,target);
if(debug)console.log("JSON saved successfully ("+wantType+")");
}catch(e){
console.error('ERROR: Failed to write config:',e.message);
try{ if(fs.existsSync(tmp)) fs.unlinkSync(tmp); }catch(e2){}
console.error("ERROR: Failed to write config: "+e.message);
process.exit(1);
}
" 2>&1
' 2>&1
local _js_rc=$?
# 清理临时文件
rm -f "$tmp_val_file" 2>/dev/null
# 步骤6: 检查执行结果
# 步骤7: 检查执行结果
if [ $_js_rc -ne 0 ]; then
echo "ERROR: JSON 写入失败 (exit code: $_js_rc)" >&2
return 1
fi
# 步骤7: 修复文件所有权
# 步骤8: 修复文件所有权
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
return 0
}
@@ -765,26 +981,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey openai "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} gpt-5.2 — 最强编程与代理旗舰 (推荐)"
echo -e " ${CYAN}b)${NC} gpt-5-mini — 高性价比推理"
echo -e " ${CYAN}c)${NC} gpt-5-nano — 极速低成本"
echo -e " ${CYAN}d)${NC} gpt-4.1 — 最强非推理模型"
echo -e " ${CYAN}e)${NC} o3 — 推理模型"
echo -e " ${CYAN}f)${NC} o4-mini — 推理轻量"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="gpt-5.2" ;;
b) model_name="gpt-5-mini" ;;
c) model_name="gpt-5-nano" ;;
d) model_name="gpt-4.1" ;;
e) model_name="o3" ;;
f) model_name="o4-mini" ;;
g) prompt_with_default "请输入模型名称" "gpt-5.2" model_name ;;
*) model_name="gpt-5.2" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model openai "gpt-5.6-sol"
model_name="$OC_PICKED_MODEL"
register_and_set_model "openai/${model_name}"
echo -e " ${GREEN}✅ OpenAI 已配置,活跃模型: openai/${model_name}${NC}"
fi
@@ -798,24 +998,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey anthropic "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} claude-sonnet-4-20250514 — Claude Sonnet 4 (推荐)"
echo -e " ${CYAN}b)${NC} claude-opus-4-20250514 — Claude Opus 4 顶级推理"
echo -e " ${CYAN}c)${NC} claude-haiku-4-5 — Claude Haiku 4.5 轻量快速"
echo -e " ${CYAN}d)${NC} claude-sonnet-4.5 — Claude Sonnet 4.5"
echo -e " ${CYAN}e)${NC} claude-sonnet-4.6 — Claude Sonnet 4.6"
echo -e " ${CYAN}f)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="claude-sonnet-4-20250514" ;;
b) model_name="claude-opus-4-20250514" ;;
c) model_name="claude-haiku-4-5" ;;
d) model_name="claude-sonnet-4-5" ;;
e) model_name="claude-sonnet-4-6" ;;
f) prompt_with_default "请输入模型名称" "claude-sonnet-4-20250514" model_name ;;
*) model_name="claude-sonnet-4-20250514" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model anthropic "claude-sonnet-5"
model_name="$OC_PICKED_MODEL"
register_and_set_model "anthropic/${model_name}"
echo -e " ${GREEN}✅ Anthropic 已配置,活跃模型: anthropic/${model_name}${NC}"
fi
@@ -829,24 +1015,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey google "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} gemini-2.5-pro — 旗舰推理 (推荐)"
echo -e " ${CYAN}b)${NC} gemini-2.5-flash — 快速均衡"
echo -e " ${CYAN}c)${NC} gemini-2.5-flash-lite — 极速低成本"
echo -e " ${CYAN}d)${NC} gemini-3-flash-preview — Gemini 3 Flash 预览"
echo -e " ${CYAN}e)${NC} gemini-3-pro-preview — Gemini 3 Pro 预览"
echo -e " ${CYAN}f)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="gemini-2.5-pro" ;;
b) model_name="gemini-2.5-flash" ;;
c) model_name="gemini-2.5-flash-lite" ;;
d) model_name="gemini-3-flash-preview" ;;
e) model_name="gemini-3-pro-preview" ;;
f) prompt_with_default "请输入模型名称" "gemini-2.5-pro" model_name ;;
*) model_name="gemini-2.5-pro" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model google "gemini-2.5-pro"
model_name="$OC_PICKED_MODEL"
register_and_set_model "google/${model_name}"
echo -e " ${GREEN}✅ Google Gemini 已配置,活跃模型: google/${model_name}${NC}"
fi
@@ -861,26 +1033,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey openrouter "$api_key"
echo ""
echo -e " ${CYAN}常用模型 (格式: provider/model):${NC}"
echo -e " ${CYAN}a)${NC} anthropic/claude-sonnet-4 — Claude Sonnet 4 (推荐)"
echo -e " ${CYAN}b)${NC} anthropic/claude-opus-4 — Claude Opus 4"
echo -e " ${CYAN}c)${NC} openai/gpt-5.2 — GPT-5.2"
echo -e " ${CYAN}d)${NC} google/gemini-2.5-pro — Gemini 2.5 Pro"
echo -e " ${CYAN}e)${NC} deepseek/deepseek-r1 — DeepSeek R1"
echo -e " ${CYAN}f)${NC} meta-llama/llama-4-maverick — Meta Llama 4"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="anthropic/claude-sonnet-4" ;;
b) model_name="anthropic/claude-opus-4" ;;
c) model_name="openai/gpt-5.2" ;;
d) model_name="google/gemini-2.5-pro" ;;
e) model_name="deepseek/deepseek-r1" ;;
f) model_name="meta-llama/llama-4-maverick" ;;
g) prompt_with_default "请输入模型名称" "anthropic/claude-sonnet-4" model_name ;;
*) model_name="anthropic/claude-sonnet-4" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model openrouter "auto"
model_name="$OC_PICKED_MODEL"
register_and_set_model "openrouter/${model_name}"
echo -e " ${GREEN}✅ OpenRouter 已配置,活跃模型: openrouter/${model_name}${NC}"
fi
@@ -900,40 +1056,10 @@ configure_model() {
echo -e " ${CYAN}选择默认模型:${NC}"
echo ""
echo -e " ${CYAN}── GPT 系列 ──${NC}"
echo -e " ${CYAN}a)${NC} github-copilot/gpt-4.1 — GPT-4.1 ${GREEN}(推荐)${NC}"
echo -e " ${CYAN}b)${NC} github-copilot/gpt-4o — GPT-4o"
echo -e " ${CYAN}c)${NC} github-copilot/gpt-5 — GPT-5"
echo -e " ${CYAN}d)${NC} github-copilot/gpt-5-mini — GPT-5 mini"
echo -e " ${CYAN}e)${NC} github-copilot/gpt-5.1 — GPT-5.1"
echo -e " ${CYAN}f)${NC} github-copilot/gpt-5.2 — GPT-5.2"
echo -e " ${CYAN}g)${NC} github-copilot/gpt-5.2-codex — GPT-5.2 Codex"
echo ""
echo -e " ${CYAN}── Claude 系列 ──${NC}"
echo -e " ${CYAN}h)${NC} github-copilot/claude-sonnet-4 — Claude Sonnet 4"
echo -e " ${CYAN}i)${NC} github-copilot/claude-sonnet-4.5 — Claude Sonnet 4.5"
echo -e " ${CYAN}j)${NC} github-copilot/claude-sonnet-4.6 — Claude Sonnet 4.6"
echo ""
echo -e " ${CYAN}── Gemini 系列 ──${NC}"
echo -e " ${CYAN}k)${NC} github-copilot/gemini-2.5-pro — Gemini 2.5 Pro"
echo ""
echo -e " ${CYAN}m)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="github-copilot/gpt-4.1" ;;
b) model_name="github-copilot/gpt-4o" ;;
c) model_name="github-copilot/gpt-5" ;;
d) model_name="github-copilot/gpt-5-mini" ;;
e) model_name="github-copilot/gpt-5.1" ;;
f) model_name="github-copilot/gpt-5.2" ;;
g) model_name="github-copilot/gpt-5.2-codex" ;;
h) model_name="github-copilot/claude-sonnet-4" ;;
i) model_name="github-copilot/claude-sonnet-4.5" ;;
j) model_name="github-copilot/claude-sonnet-4.6" ;;
k) model_name="github-copilot/gemini-2.5-pro" ;;
m) prompt_with_default "请输入模型名称" "github-copilot/gpt-4.1" model_name ;;
*) model_name="github-copilot/gpt-4.1" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model github-copilot "gpt-5.5"
model_name="github-copilot/$OC_PICKED_MODEL"
register_and_set_model "$model_name"
echo -e " ${GREEN}✅ 活跃模型已设置: ${model_name}${NC}"
else
@@ -973,50 +1099,10 @@ configure_model() {
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}── 千问商业版 ──${NC}"
echo -e " ${CYAN}a)${NC} qwen-max — 千问Max 旗舰模型 (推荐)"
echo -e " ${CYAN}b)${NC} qwen-plus — 千问Plus 均衡之选 (已升级Qwen3.5)"
echo -e " ${CYAN}c)${NC} qwen-flash — 千问Flash 速度最快 (已升级Qwen3.5)"
echo -e " ${CYAN}d)${NC} qwen-turbo — 千问Turbo 经济实惠"
echo -e " ${CYAN}e)${NC} qwen-long — 千问Long 超长上下文 (1000万Token)"
echo -e " ${CYAN}── 千问Coder ──${NC}"
echo -e " ${CYAN}f)${NC} qwen3-coder-plus — 代码专用旗舰 (100万上下文)"
echo -e " ${CYAN}g)${NC} qwen3-coder-flash — 代码专用极速"
echo -e " ${CYAN}── 推理模型 ──${NC}"
echo -e " ${CYAN}h)${NC} qwq-plus — QwQ推理模型 (数学/代码强化)"
echo -e " ${CYAN}── 千问开源版 ──${NC}"
echo -e " ${CYAN}i)${NC} qwen3-235b-a22b — Qwen3 235B MoE"
echo -e " ${CYAN}j)${NC} qwen3-32b — Qwen3 32B"
echo -e " ${CYAN}k)${NC} qwen3-30b-a3b — Qwen3 30B MoE"
echo -e " ${CYAN}── 第三方模型 ──${NC}"
echo -e " ${CYAN}l)${NC} deepseek-r1 — DeepSeek R1 推理"
echo -e " ${CYAN}m)${NC} deepseek-v3 — DeepSeek V3"
echo -e " ${CYAN}n)${NC} kimi-k2.5 — Kimi K2.5"
echo -e " ${CYAN}o)${NC} glm-5 — 智谱 GLM-5"
echo -e " ${CYAN}p)${NC} MiniMax-M2.5 — MiniMax M2.5"
echo -e " ${CYAN}────────────${NC}"
echo -e " ${CYAN}z)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="qwen-max" ;;
b) model_name="qwen-plus" ;;
c) model_name="qwen-flash" ;;
d) model_name="qwen-turbo" ;;
e) model_name="qwen-long" ;;
f) model_name="qwen3-coder-plus" ;;
g) model_name="qwen3-coder-flash" ;;
h) model_name="qwq-plus" ;;
i) model_name="qwen3-235b-a22b" ;;
j) model_name="qwen3-32b" ;;
k) model_name="qwen3-30b-a3b" ;;
l) model_name="deepseek-r1" ;;
m) model_name="deepseek-v3" ;;
n) model_name="kimi-k2.5" ;;
o) model_name="glm-5" ;;
p) model_name="MiniMax-M2.5" ;;
z) prompt_with_default "请输入模型名称" "qwen-max" model_name ;;
*) model_name="qwen-max" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model qwen "qwen3.5-plus"
model_name="$OC_PICKED_MODEL"
auth_set_apikey dashscope "$api_key"
register_custom_provider dashscope "https://dashscope.aliyuncs.com/compatible-mode/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "dashscope/${model_name}"
@@ -1075,26 +1161,10 @@ configure_model() {
prompt_with_default "请输入 xAI API Key" "" api_key
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} grok-4 — Grok 4 旗舰 (推荐)"
echo -e " ${CYAN}b)${NC} grok-4-fast — Grok 4 Fast"
echo -e " ${CYAN}c)${NC} grok-3 — Grok 3"
echo -e " ${CYAN}d)${NC} grok-3-fast — Grok 3 Fast"
echo -e " ${CYAN}e)${NC} grok-3-mini — Grok 3 Mini"
echo -e " ${CYAN}f)${NC} grok-3-mini-fast — Grok 3 Mini Fast"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="grok-4" ;;
b) model_name="grok-4-fast" ;;
c) model_name="grok-3" ;;
d) model_name="grok-3-fast" ;;
e) model_name="grok-3-mini" ;;
f) model_name="grok-3-mini-fast" ;;
g) prompt_with_default "请输入模型名称" "grok-4" model_name ;;
*) model_name="grok-4" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model xai "grok-4.3"
model_name="$OC_PICKED_MODEL"
auth_set_apikey xai "$api_key"
register_and_set_model "xai/${model_name}"
echo -e " ${GREEN}✅ xAI Grok 已配置,活跃模型: xai/${model_name}${NC}"
@@ -1117,30 +1187,15 @@ configure_model() {
echo -e " ${CYAN}── 非Pro模型 (支持代金券/免费额度) ──${NC}"
echo -e " ${CYAN}1)${NC} deepseek-ai/DeepSeek-V3 — DeepSeek-V3 (推荐)"
echo -e " ${CYAN}2)${NC} deepseek-ai/DeepSeek-R1 — DeepSeek-R1 (推理模型)"
echo -e " ${CYAN}3)${NC} Qwen/Qwen2.5-72B-Instruct — 通义千问 2.5 72B"
echo -e " ${CYAN}4)${NC} Qwen/Qwen2.5-7B-Instruct — 通义千问 2.5 7B"
echo -e " ${CYAN}5)${NC} THUDM/glm-4-9b-chat — 智谱 GLM-4 9B"
echo -e " ${CYAN}6)${NC} 01-ai/Yi-1.5-34B-Chat-16K — 零一万物 Yi-1.5 34B"
# SiliconFlow 不是 OpenClaw 内置/官方插件 provider (走 OpenAI 兼容接入),
# 无法用上游 catalog 核实模型 ID。原硬编码清单里的 Qwen2.5-7B/72B、
# Yi-1.5-34B-Chat-16K、glm-4-9b-chat 均已明显过期,因此不再维护,
# 改为引导用户从官方模型广场复制当前 ID。
echo -e " ${CYAN}请从官方模型广场复制模型 ID:${NC}"
echo -e " ${CYAN}https://cloud.siliconflow.cn/models${NC}"
echo -e " ${DIM}格式形如 deepseek-ai/DeepSeek-V3.2;Pro/ 前缀模型仅支持充值余额${NC}"
echo ""
echo -e " ${CYAN}── Pro模型 (仅支持充值余额) ──${NC}"
echo -e " ${CYAN}7)${NC} Pro/deepseek-ai/DeepSeek-V3 — DeepSeek-V3 (Pro增强侧)"
echo -e " ${CYAN}8)${NC} Pro/zai-org/GLM-5 — 智谱 GLM-5"
echo ""
echo -e " ${CYAN}0)${NC} 手动输入其他任意模型名称"
echo ""
prompt_with_default "请选择模型 [0-8]" "1" model_choice
case "$model_choice" in
1) model_name="deepseek-ai/DeepSeek-V3" ;;
2) model_name="deepseek-ai/DeepSeek-R1" ;;
3) model_name="Qwen/Qwen2.5-72B-Instruct" ;;
4) model_name="Qwen/Qwen2.5-7B-Instruct" ;;
5) model_name="THUDM/glm-4-9b-chat" ;;
6) model_name="01-ai/Yi-1.5-34B-Chat-16K" ;;
7) model_name="Pro/deepseek-ai/DeepSeek-V3" ;;
8) model_name="Pro/zai-org/GLM-5" ;;
0) prompt_with_default "请输入模型详细名称" "deepseek-ai/DeepSeek-V3" model_name ;;
*) model_name="deepseek-ai/DeepSeek-V3" ;;
esac
prompt_with_default "请输入模型 ID" "" model_name
auth_set_apikey siliconflow "$api_key"
register_custom_provider siliconflow "https://api.siliconflow.cn/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "siliconflow/${model_name}"
@@ -1276,33 +1331,10 @@ configure_model() {
echo ""
echo -e " ${CYAN}可用模型 (Coding Plan 套餐内):${NC}"
echo -e " ${CYAN}── 智能推荐 ──${NC}"
echo -e " ${CYAN}a)${NC} tc-code-latest — 自动路由 (由平台选择最佳模型) ${GREEN}★ 推荐${NC}"
echo -e " ${CYAN}── 推理模型 ──${NC}"
echo -e " ${CYAN}b)${NC} hunyuan-t1 — 混元 T1 深度推理"
echo -e " ${CYAN}c)${NC} hunyuan-2.0-thinking — 混元 2.0 Thinking"
echo -e " ${CYAN}── 旗舰模型 ──${NC}"
echo -e " ${CYAN}d)${NC} hunyuan-turbos — 混元 TurboS 旗舰"
echo -e " ${CYAN}e)${NC} hunyuan-2.0-instruct — 混元 2.0 Instruct"
echo -e " ${CYAN}── 第三方模型 ──${NC}"
echo -e " ${CYAN}f)${NC} glm-5 — 智谱 GLM-5"
echo -e " ${CYAN}g)${NC} kimi-k2.5 — Moonshot Kimi K2.5"
echo -e " ${CYAN}h)${NC} minimax-m2.5 — MiniMax M2.5"
echo -e " ${CYAN}────────────${NC}"
echo -e " ${CYAN}z)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择默认模型" "a" model_choice
case "$model_choice" in
a) model_name="tc-code-latest" ;;
b) model_name="hunyuan-t1" ;;
c) model_name="hunyuan-2.0-thinking" ;;
d) model_name="hunyuan-turbos" ;;
e) model_name="hunyuan-2.0-instruct" ;;
f) model_name="glm-5" ;;
g) model_name="kimi-k2.5" ;;
h) model_name="minimax-m2.5" ;;
z) prompt_with_default "请输入模型名称" "tc-code-latest" model_name ;;
*) model_name="tc-code-latest" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model tencent-tokenhub "hy3"
model_name="$OC_PICKED_MODEL"
echo ""
echo -e " ${CYAN}正在注册腾讯云 Coding Plan 提供商 (含全部套餐模型)...${NC}"
auth_set_apikey lkeap "$api_key"
@@ -1324,21 +1356,10 @@ configure_model() {
auth_set_apikey qianfan "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} ernie-4.0-8k — 文心一言 4.0 (推荐)"
echo -e " ${CYAN}b)${NC} ernie-3.5-8k — 文心一言 3.5"
echo -e " ${CYAN}c)${NC} ernie-4.0-turbo-8k — 文心一言 4.0 Turbo"
echo -e " ${CYAN}d)${NC} ernie-speed-8k — 文心一言 Speed 极速"
echo -e " ${CYAN}e)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="ernie-4.0-8k" ;;
b) model_name="ernie-3.5-8k" ;;
c) model_name="ernie-4.0-turbo-8k" ;;
d) model_name="ernie-speed-8k" ;;
e) prompt_with_default "请输入模型名称" "ernie-4.0-8k" model_name ;;
*) model_name="ernie-4.0-8k" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model qianfan "ernie-5.0-thinking-preview"
model_name="$OC_PICKED_MODEL"
# 百度千帆使用 OpenAI 兼容接口
# 注: OpenClaw v2026.3.28+ 支持 qianfan 原生 provider
register_custom_provider qianfan "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop" "$api_key" "$model_name" "$model_name"
@@ -1382,25 +1403,10 @@ configure_model() {
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} glm-5.1 — GLM-5.1 (推荐)"
echo -e " ${CYAN}b)${NC} glm-5 — GLM-5"
echo -e " ${CYAN}c)${NC} glm-4.7 — GLM-4.7"
echo -e " ${CYAN}d)${NC} glm-4.7-flash — GLM-4.7 Flash"
echo -e " ${CYAN}e)${NC} glm-4.5 — GLM-4.5"
echo -e " ${CYAN}f)${NC} glm-4.5-flash — GLM-4.5 Flash (免费额度)"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="glm-5.1" ;;
b) model_name="glm-5" ;;
c) model_name="glm-4.7" ;;
d) model_name="glm-4.7-flash" ;;
e) model_name="glm-4.5" ;;
f) model_name="glm-4.5-flash" ;;
g) prompt_with_default "请输入模型名称" "glm-5.1" model_name ;;
*) model_name="glm-5.1" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model zai "glm-5.2"
model_name="$OC_PICKED_MODEL"
# 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持)
auth_set_apikey zai "$api_key"
register_custom_provider zai "$zai_base_url" "$api_key" "$model_name" "$model_name" "128000" "4096"
@@ -1431,7 +1437,7 @@ configure_model() {
echo ""
prompt_with_default "API Base URL (如 https://api.anthropic.com)" "" base_url
prompt_with_default "API Key" "" api_key
prompt_with_default "模型名称" "claude-sonnet-4-20250514" model_name
prompt_with_default "模型名称" "claude-sonnet-5" model_name
if [ -n "$base_url" ] && [ -n "$api_key" ] && [ -n "$model_name" ]; then
_ACP_URL="${base_url%/}" _ACP_KEY="$api_key" _ACP_MID="$model_name" "$NODE_BIN" -e "
const fs=require('fs');
@@ -2345,7 +2351,7 @@ reset_to_defaults() {
echo -e " ${CYAN}已取消${NC}"
fi
;;
c)
4)
echo ""
echo -e " ${RED}╔══════════════════════════════════════════════════════╗${NC}"
echo -e " ${RED}║ ⚠️ 完全恢复出厂设置 ║${NC}"
@@ -2527,7 +2533,7 @@ backup_restore_menu() {
oc_cmd backup verify "$latest" 2>&1
fi
;;
c)
4)
echo ""
if [ -d "$backup_dir" ]; then
local count=$(ls "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | wc -l)
@@ -2545,7 +2551,7 @@ backup_restore_menu() {
echo ""
echo -e " ${DIM}备份目录: ${backup_dir}${NC}"
;;
d)
5)
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
if [ -z "$latest" ]; then
echo -e " ${YELLOW}未找到备份文件,请先创建备份${NC}"
@@ -2713,7 +2719,9 @@ advanced_menu() {
gw_port=$(json_get "gateway.port" 2>/dev/null || echo "18789")
gw_bind=$(json_get "gateway.bind" 2>/dev/null || echo "lan")
gw_mode=$(json_get "gateway.mode" 2>/dev/null || echo "local")
log_level=$(json_get "gateway.logLevel" 2>/dev/null || echo "")
# 读 logging.level (正确键);兼容旧配置里残留的 gateway.logLevel 以便显示
log_level=$(json_get "logging.level" 2>/dev/null || echo "")
[ -n "$log_level" ] || log_level=$(json_get "gateway.logLevel" 2>/dev/null || echo "")
acp_dispatch=$(json_get "acp.dispatch.enabled" 2>/dev/null || echo "false")
echo ""
@@ -2738,32 +2746,57 @@ advanced_menu() {
echo ""
prompt_with_default "请输入 Gateway 端口" "$gw_port" new_port
if [ -n "$new_port" ] && [ "$new_port" != "$gw_port" ]; then
json_set "gateway.port" "$new_port"
# 同步到 UCI
uci set openclaw.main.port="$new_port" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 端口已设置为 ${new_port}${NC}"
ask_restart
# 端口必须是 1-65535 的整数,先自行校验再写入,
# 避免把非法值交给 json_set 后只得到一条底层报错。
case "$new_port" in
''|*[!0-9]*)
echo -e " ${YELLOW}无效端口: 必须是数字${NC}"
;;
*)
if [ "$new_port" -lt 1 ] || [ "$new_port" -gt 65535 ]; then
echo -e " ${YELLOW}无效端口: 需在 1-65535 之间${NC}"
elif json_set "gateway.port" "$new_port"; then
# 仅在 JSON 写入成功后才同步 UCI,避免两边不一致
uci set openclaw.main.port="$new_port" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 端口已设置为 ${new_port}${NC}"
ask_restart
fi
;;
esac
fi
;;
2)
echo ""
# 取值必须与 OpenClaw schema 的 gateway.bind 枚举一致:
# auto/lan/loopback/custom/tailnet。历史上这里提供的 all
# 不被上游接受 (实测: gateway.bind: Invalid input),
# 想监听所有接口应使用 custom + customBindHost。
echo -e " ${CYAN}绑定地址选项:${NC}"
echo " lan - 仅 LAN 接口 (推荐)"
echo " loopback - 仅本机访问"
echo " all - 所有接口 (0.0.0.0)"
echo " auto - 自动选择"
echo " custom - 自定义地址 (可配合 0.0.0.0 监听所有接口)"
echo " tailnet - Tailscale 网络"
echo ""
prompt_with_default "请输入绑定地址" "$gw_bind" new_bind
if [ -n "$new_bind" ]; then
# 兼容旧值: 用户/旧配置里的 all 等价于 custom + 0.0.0.0
if [ "$new_bind" = "all" ]; then
echo -e " ${YELLOW}提示: OpenClaw 已不接受 all,已改用 custom + 0.0.0.0${NC}"
new_bind="custom"
json_set "gateway.customBindHost" "0.0.0.0" || true
fi
case "$new_bind" in
lan|loopback|all)
json_set "gateway.bind" "$new_bind"
uci set openclaw.main.bind="$new_bind" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 绑定地址已设置为 ${new_bind}${NC}"
ask_restart
auto|lan|loopback|custom|tailnet)
if json_set "gateway.bind" "$new_bind"; then
uci set openclaw.main.bind="$new_bind" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 绑定地址已设置为 ${new_bind}${NC}"
ask_restart
fi
;;
*) echo -e " ${YELLOW}无效选项${NC}" ;;
*) echo -e " ${YELLOW}无效选项 (允许: auto/lan/loopback/custom/tailnet)${NC}" ;;
esac
fi
;;
@@ -2775,21 +2808,27 @@ advanced_menu() {
echo ""
prompt_with_default "请输入运行模式" "$gw_mode" new_mode
if [ -n "$new_mode" ] && [ "$new_mode" != "$gw_mode" ]; then
json_set "gateway.mode" "$new_mode"
echo -e " ${GREEN}✅ 运行模式已设置为 ${new_mode}${NC}"
ask_restart
if json_set "gateway.mode" "$new_mode"; then
echo -e " ${GREEN}✅ 运行模式已设置为 ${new_mode}${NC}"
ask_restart
fi
fi
;;
4)
echo ""
# 正确键是顶层 logging.level。gateway.logLevel 不存在于
# OpenClaw schema (gateway.additionalProperties=false),
# 实测 config set 报 Unrecognized key,手写进文件则被静默忽略,
# 表现为"界面显示已设置但从未生效"。
echo -e " ${CYAN}日志级别选项:${NC}"
echo " debug, info, warn, error"
echo " silent, fatal, error, warn, info, debug, trace"
echo ""
prompt_with_default "请输入日志级别" "${log_level:-info}" new_level
if [ -n "$new_level" ]; then
json_set "gateway.logLevel" "$new_level"
echo -e " ${GREEN}✅ 日志级别已设置为 ${new_level}${NC}"
ask_restart
if json_set "logging.level" "$new_level"; then
echo -e " ${GREEN}✅ 日志级别已设置为 ${new_level}${NC}"
ask_restart
fi
fi
;;
5)
@@ -2801,11 +2840,12 @@ advanced_menu() {
prompt_with_default "请输入设置" "$acp_dispatch" new_acp
case "$new_acp" in
true|false)
json_set "acp.dispatch.enabled" "$new_acp"
echo -e " ${GREEN}✅ ACP Dispatch 已设置为 ${new_acp}${NC}"
ask_restart
if json_set "acp.dispatch.enabled" "$new_acp"; then
echo -e " ${GREEN}✅ ACP Dispatch 已设置为 ${new_acp}${NC}"
ask_restart
fi
;;
*) echo -e " ${YELLOW}无效选项${NC}" ;;
*) echo -e " ${YELLOW}无效选项 (只接受 true / false)${NC}" ;;
esac
;;
6)
@@ -2891,11 +2931,19 @@ case "${1:-}" in
;;
--set)
if [ -n "${2:-}" ] && [ -n "${3:-}" ]; then
json_set "$2" "$3"
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
echo -e "${GREEN}✅ 已设置 $2${NC}"
# 必须检查 json_set 的返回码: 类型/枚举校验失败或配置损坏时
# 写入会被拒绝,此时不能再报告成功 (否则就是"界面显示已设置、
# 实际未生效"的假成功)。
if json_set "$2" "$3"; then
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
echo -e "${GREEN}✅ 已设置 $2${NC}"
else
echo -e "${RED}❌ 设置失败: $2${NC}" >&2
exit 1
fi
else
echo "用法: oc-config.sh --set <key> <value>"
echo "用法: oc-config.sh --set <key> <value> [类型]"
echo "类型可选: string | number | boolean | json (默认按 schema 自动判定)"
fi
;;
--get)
@@ -131,7 +131,19 @@
// 从 URL 参数或 hash 获取 PTY token 用于 WebSocket 认证
const urlParams = new URLSearchParams(location.search);
const ptyToken = urlParams.get('pty_token') || '';
// 取到 token 后立刻从地址栏移除: 否则它会留在浏览器历史、
// 以及任何 Referer/截图里。内存中的 ptyToken 仍可用于建连。
if (ptyToken && window.history && history.replaceState) {
try {
const clean = new URL(location.href);
clean.searchParams.delete('pty_token');
history.replaceState(null, '', clean.pathname + (clean.search || '') + clean.hash);
} catch (e) {}
}
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
// 展示用 URL: 隐去 token。真实 wsUrl 只用于建立连接,
// 不写入页面文本或 console —— 否则 token 会留在截图、录屏与浏览器历史里。
const wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
let ws = null;
let connected = false;
let retryCount = 0;
@@ -142,8 +154,8 @@
console.log('[oc-config] protocol:', location.protocol);
console.log('[oc-config] host:', location.host, 'port:', location.port);
console.log('[oc-config] WebSocket URL:', wsUrl);
loadingDebug.textContent = wsUrl;
console.log('[oc-config] WebSocket URL:', wsUrlSafe);
loadingDebug.textContent = wsUrlSafe;
function connect() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
@@ -151,11 +163,11 @@
if (ws) { try { ws.close(); } catch(e){} ws = null; }
retryCount++;
console.log('[oc-config] Connecting to', wsUrl, '(attempt', retryCount + ')');
console.log('[oc-config] Connecting to', wsUrlSafe, '(attempt', retryCount + ')');
loadingMsg.textContent = retryCount > 1
? '正在重新连接... (第 ' + retryCount + ' 次)'
: '正在连接终端...';
loadingDebug.textContent = wsUrl;
loadingDebug.textContent = wsUrlSafe;
// 先做一个 HTTP 预检确认服务器可达
fetch('/health').then(function(r) {
@@ -75,10 +75,33 @@ function getMimeType(ext) {
return types[ext] || 'application/octet-stream';
}
// 供 LuCI 页面以 iframe 嵌入本服务所需的响应头。
//
// 原实现是 ACAO:* + X-Frame-Options:ALLOWALL + CSP default-src:*,
// 等于对任意站点开放。收紧依据(已逐项核实):
// - 页面内所有资源都是本地的 /lib/*.js|css,无任何外链 -> default-src 可收到 'self'
// - 唯一的 fetch 是 /health,与 iframe 自身同源 -> 不需要 Access-Control-Allow-Origin
// - 存在内联 <style> 与 <script> 块 -> 必须保留 'unsafe-inline'
// - xterm.js 不需要 eval -> 去掉 'unsafe-eval'
// - LuCI 与本服务端口不同(80 vs 18793)属跨源嵌入,
// 跨源 iframe 只需 frame-ancestors 放行,与 CORS 无关
//
// frame-ancestors 无法在此处限定到具体 LAN 地址(服务端拿不到父页面 origin,
// 且用户访问 LuCI 的主机名/IP 各不相同),因此保留通配但去掉 X-Frame-Options:
// 两者同时存在时旧浏览器会以更宽松的 ALLOWALL 为准。
// WebSocket 升级仍由 token 校验保护(无 token / 错误 token 均返回 403)。
const IFRAME_HEADERS = {
'Access-Control-Allow-Origin': '*',
'X-Frame-Options': 'ALLOWALL',
'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *",
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' ws: wss:",
'frame-ancestors *',
].join('; '),
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
};
// ── WebSocket 帧处理 (RFC 6455) ──
@@ -269,11 +292,12 @@ function handleRequest(req, res) {
let fp = url.pathname;
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*' });
// 本服务只被同源页面 fetch(/health),不需要放开 CORS
res.writeHead(204, { 'Allow': 'GET, OPTIONS' });
return res.end();
}
if (fp === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
return res.end(JSON.stringify({ status: 'ok', port: PORT, uptime: process.uptime() }));
}
if (fp === '/' || fp === '') fp = '/index.html';
+2
View File
@@ -82,6 +82,8 @@ cp "$PKG_DIR/VERSION" "$DATA_DIR/usr/share/openclaw/VERSION"
cp "$PKG_DIR/root/usr/share/openclaw/oc-config.sh" "$DATA_DIR/usr/share/openclaw/"
chmod +x "$DATA_DIR/usr/share/openclaw/oc-config.sh"
cp "$PKG_DIR/root/usr/share/openclaw/"*.js "$DATA_DIR/usr/share/openclaw/"
# 精选模型预设 (shell 与 JS 共读的唯一数据源)
cp "$PKG_DIR/root/usr/share/openclaw/model-presets.json" "$DATA_DIR/usr/share/openclaw/"
# Web PTY UI
cp -r "$PKG_DIR/root/usr/share/openclaw/ui" "$DATA_DIR/usr/share/openclaw/"
+2
View File
@@ -85,6 +85,8 @@ install_files() {
cp "$PKG_DIR/root/usr/share/openclaw/oc-config.sh" "$dest/usr/share/openclaw/"
chmod +x "$dest/usr/share/openclaw/oc-config.sh"
cp "$PKG_DIR/root/usr/share/openclaw/"*.js "$dest/usr/share/openclaw/"
# 精选模型预设 (shell 与 JS 共读的唯一数据源)
cp "$PKG_DIR/root/usr/share/openclaw/model-presets.json" "$dest/usr/share/openclaw/"
# Web PTY UI (recursive copy)
cp -r "$PKG_DIR/root/usr/share/openclaw/ui" "$dest/usr/share/openclaw/"
@@ -0,0 +1,36 @@
# OpenClaw 官方 schema 关键字段类型基线
# 来源: openclaw config schema (OpenClaw 2026.7.1-2, 提取日期 2026-08-26)
# 格式: <key><TAB><type><TAB><enum(逗号分隔, - 表示无枚举)>
#
# 说明:
# - 本文件是配置写入类型契约的唯一依据,不要凭记忆或历史版本修改。
# - 上游升级后需重新执行: openclaw config schema 并重新生成本文件。
# - gateway 的 additionalProperties=false,即 gateway.* 不接受未列出的键
# (历史上项目误写过 gateway.logLevel,正确键是顶层 logging.level)。
# - plugins 的 additionalProperties=false,且不含 installs
# (plugins.installs 由插件索引托管,config set 明确拒绝写入)。
gateway.port integer -
gateway.bind string auto,lan,loopback,custom,tailnet
gateway.mode string local,remote
gateway.customBindHost string -
gateway.auth.mode string none,token,password,trusted-proxy
gateway.auth.token string -
gateway.controlUi.allowInsecureAuth boolean -
gateway.controlUi.dangerouslyDisableDeviceAuth boolean -
gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback boolean -
logging.level string silent,fatal,error,warn,info,debug,trace
acp.dispatch.enabled boolean -
tools.profile string minimal,coding,messaging,full
update.checkOnStart boolean -
models.mode string merge,replace
agents.defaults.model string|object -
plugins.enabled boolean -
channels.<id>.enabled boolean -
channels.<id>.botToken string -
models.providers.<id>.api string openai-completions,openai-responses,openai-chatgpt-responses,anthropic-messages,google-generative-ai,google-vertex,github-copilot,bedrock-converse-stream,ollama,azure-openai-responses
models.providers.<id>.baseUrl string -
models.providers.<id>.apiKey string -
models.providers.<id>.models array -
# 结构性约束 (非字段类型)
!gateway.additionalProperties false -
!plugins.properties enabled,allow,deny,load,slots,entries,bundledDiscovery -
1 # OpenClaw 官方 schema 关键字段类型基线
2 # 来源: openclaw config schema (OpenClaw 2026.7.1-2, 提取日期 2026-08-26)
3 # 格式: <key><TAB><type><TAB><enum(逗号分隔, - 表示无枚举)>
4 #
5 # 说明:
6 # - 本文件是配置写入类型契约的唯一依据,不要凭记忆或历史版本修改。
7 # - 上游升级后需重新执行: openclaw config schema 并重新生成本文件。
8 # - gateway 的 additionalProperties=false,即 gateway.* 不接受未列出的键
9 # (历史上项目误写过 gateway.logLevel,正确键是顶层 logging.level)。
10 # - plugins 的 additionalProperties=false,且不含 installs
11 # (plugins.installs 由插件索引托管,config set 明确拒绝写入)。
12 gateway.port integer -
13 gateway.bind string auto,lan,loopback,custom,tailnet
14 gateway.mode string local,remote
15 gateway.customBindHost string -
16 gateway.auth.mode string none,token,password,trusted-proxy
17 gateway.auth.token string -
18 gateway.controlUi.allowInsecureAuth boolean -
19 gateway.controlUi.dangerouslyDisableDeviceAuth boolean -
20 gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback boolean -
21 logging.level string silent,fatal,error,warn,info,debug,trace
22 acp.dispatch.enabled boolean -
23 tools.profile string minimal,coding,messaging,full
24 update.checkOnStart boolean -
25 models.mode string merge,replace
26 agents.defaults.model string|object -
27 plugins.enabled boolean -
28 channels.<id>.enabled boolean -
29 channels.<id>.botToken string -
30 models.providers.<id>.api string openai-completions,openai-responses,openai-chatgpt-responses,anthropic-messages,google-generative-ai,google-vertex,github-copilot,bedrock-converse-stream,ollama,azure-openai-responses
31 models.providers.<id>.baseUrl string -
32 models.providers.<id>.apiKey string -
33 models.providers.<id>.models array -
34 # 结构性约束 (非字段类型)
35 !gateway.additionalProperties false -
36 !plugins.properties enabled,allow,deny,load,slots,entries,bundledDiscovery -
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# 运行全部契约测试。
# 用法: sh tests/run_all.sh
# 必须在仓库根目录执行 (部分测试依赖相对路径 ./root/...)。
set -u
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
cd "$REPO_ROOT" || exit 1
pass=0
fail=0
failed_list=""
for t in tests/test_*.sh; do
[ -f "$t" ] || continue
printf '%-46s' "$t"
if out=$(sh "$t" 2>&1); then
echo "ok"
pass=$((pass + 1))
else
echo "FAIL"
echo "$out" | sed 's/^/ /'
fail=$((fail + 1))
failed_list="${failed_list} ${t}"
fi
done
# Lua 测试 (可选: 仅在有 lua 解释器时运行)
for t in tests/test_*.lua; do
[ -f "$t" ] || continue
lua_bin=""
for cand in lua5.1 lua; do
command -v "$cand" >/dev/null 2>&1 && { lua_bin="$cand"; break; }
done
printf '%-46s' "$t"
if [ -z "$lua_bin" ]; then
echo "SKIP (no lua interpreter)"
continue
fi
if out=$("$lua_bin" "$t" 2>&1); then
echo "ok"
pass=$((pass + 1))
else
echo "FAIL"
echo "$out" | sed 's/^/ /'
fail=$((fail + 1))
failed_list="${failed_list} ${t}"
fi
done
echo ""
echo "passed=${pass} failed=${fail}"
if [ "$fail" -ne 0 ]; then
echo "failed tests:${failed_list}" >&2
exit 1
fi
echo "ALL OK"
+174
View File
@@ -0,0 +1,174 @@
#!/bin/sh
# 配置写入类型契约。
#
# 背景: 旧的 json_set() 把所有值都当字符串落盘 (源码注释原文"读取值并作为
# 字符串保存")。上游对类型做严格校验,实测报错:
# gateway.port: Invalid input (要求 integer)
# acp.dispatch.enabled: Invalid input (allowed: true, false)
# channels.telegram.enabled: invalid config: must be boolean
# 结果是配置写进去了、openclaw config validate 却失败,网关拒绝启动。
#
# 另外两个键名/枚举问题:
# - gateway.logLevel 不存在于 schema (gateway.additionalProperties=false),
# 正确键是顶层 logging.level;错写时被静默忽略,界面显示已设置但从不生效。
# - gateway.bind 的旧选项 all 不被上游接受 (允许: auto/lan/loopback/custom/tailnet)。
#
# 本测试同时做静态契约与真实写入行为验证。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
SH_CONFIG="$REPO_ROOT/root/usr/share/openclaw/oc-config.sh"
JS_CONFIG="$REPO_ROOT/root/usr/share/openclaw/oc-config-interactive.js"
FIXTURE="$REPO_ROOT/tests/fixtures/openclaw-schema-types.tsv"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$SH_CONFIG" ] || fail "missing $SH_CONFIG"
[ -f "$JS_CONFIG" ] || fail "missing $JS_CONFIG"
[ -f "$FIXTURE" ] || fail "missing schema baseline $FIXTURE"
# ── 静态契约 ──
# json_set 必须有类型表与枚举白名单,而不是一律写字符串
grep -Fq 'oc_schema_type_for_key()' "$SH_CONFIG" || fail "json_set must resolve value types from a schema table"
grep -Fq 'oc_schema_enum_for_key()' "$SH_CONFIG" || fail "json_set must validate enum values before writing"
if grep -Fq '// 读取值并作为字符串保存' "$SH_CONFIG"; then
fail "json_set must not store every value as a string"
fi
# 关键键的类型判定必须与 schema 基线一致
for key in gateway.port acp.dispatch.enabled; do
want=$(awk -F'\t' -v k="$key" '$1==k {print $2}' "$FIXTURE")
[ -n "$want" ] || fail "schema baseline missing $key"
case "$want" in
integer) want="number" ;;
esac
grep -Fq "$key" "$SH_CONFIG" || fail "$key not handled in oc-config.sh"
# 类型表里必须给出非 string 的判定
awk '/^oc_schema_type_for_key\(\)/,/^}/' "$SH_CONFIG" | grep -Fq "$want" \
|| fail "oc_schema_type_for_key must map $key to $want"
done
# 正确的日志级别键
grep -Fq 'logging.level' "$SH_CONFIG" || fail "shell menu must write logging.level"
grep -Fq 'logging.level' "$JS_CONFIG" || fail "interactive menu must write logging.level"
if awk '/^oc_schema_enum_for_key\(\)/,/^}/' "$SH_CONFIG" | grep -Fq 'gateway.logLevel'; then
fail "gateway.logLevel must not be reintroduced as a writable key"
fi
grep -Fq "jsonSet('gateway.logLevel'" "$JS_CONFIG" && fail "interactive menu must not write gateway.logLevel"
# bind 枚举: 不得再把 all 作为可选项提供
awk '/^oc_schema_enum_for_key\(\)/,/^}/' "$SH_CONFIG" | grep -Fq 'auto lan loopback custom tailnet' \
|| fail "gateway.bind enum must match upstream (auto/lan/loopback/custom/tailnet)"
grep -Fq "value: 'all' }" "$JS_CONFIG" && fail "interactive bind menu must not offer 'all'"
# 写入失败不得报告成功 (避免"界面显示已设置、实际未生效")
grep -Fq 'if json_set "$2" "$3"; then' "$SH_CONFIG" || fail "--set must check json_set exit status"
# ── 行为契约 (需要 node) ──
NODE_BIN=""
for cand in node nodejs /opt/openclaw/node/bin/node; do
if command -v "$cand" >/dev/null 2>&1; then NODE_BIN=$(command -v "$cand"); break; fi
[ -x "$cand" ] && { NODE_BIN="$cand"; break; }
done
if [ -z "$NODE_BIN" ]; then
echo "ok (static only: no node interpreter available)"
exit 0
fi
WORK=$(mktemp -d 2>/dev/null || echo "/tmp/oc-types-$$")
mkdir -p "$WORK"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
CONFIG="$WORK/openclaw.json"
printf '{}\n' > "$CONFIG"
# 直接调用 json_set 的实现: 抽出被测函数, 注入受控的 CONFIG_FILE / NODE_BIN
HARNESS="$WORK/harness.sh"
{
printf 'CONFIG_FILE=%s\n' "$CONFIG"
printf 'NODE_BIN=%s\n' "$NODE_BIN"
printf 'fix_openclaw_state_permissions() { :; }\n'
awk '/^oc_schema_type_for_key\(\)/,/^}/' "$SH_CONFIG"
awk '/^oc_schema_enum_for_key\(\)/,/^}/' "$SH_CONFIG"
awk '/^json_set\(\)/,/^\}$/' "$SH_CONFIG"
cat <<'DRIVER'
case "$1" in
set) shift; json_set "$@" ;;
esac
DRIVER
} > "$HARNESS"
set_val() { sh "$HARNESS" set "$1" "$2" >/dev/null 2>&1; }
# 合法值必须写入成功
for pair in "gateway.port 18789" "acp.dispatch.enabled true" \
"channels.telegram.enabled true" "gateway.bind lan" "logging.level debug"; do
k=${pair%% *}; v=${pair##* }
set_val "$k" "$v" || fail "valid write rejected: $k=$v"
done
# 落盘类型必须匹配 schema
"$NODE_BIN" -e '
const fs=require("fs");
const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
const get=(o,k)=>k.split(".").reduce((a,x)=>a&&a[x],o);
const want={
"gateway.port":"number",
"acp.dispatch.enabled":"boolean",
"channels.telegram.enabled":"boolean",
"gateway.bind":"string",
"logging.level":"string",
};
let bad=[];
for(const k of Object.keys(want)){
const t=typeof get(j,k);
if(t!==want[k]) bad.push(`${k}: want ${want[k]}, got ${t}`);
}
if(bad.length){ console.error(bad.join("; ")); process.exit(1); }
' "$CONFIG" || fail "written values do not match schema types"
# 非法值必须被拒绝, 且不得破坏已有配置
for pair in "gateway.bind all" "gateway.mode cluster" "logging.level verbose" \
"acp.dispatch.enabled maybe" "gateway.port notanumber" "tools.profile turbo"; do
k=${pair%% *}; v=${pair##* }
if set_val "$k" "$v"; then
fail "invalid value accepted: $k=$v"
fi
done
"$NODE_BIN" -e '
const fs=require("fs");
const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
if(j.gateway.bind!=="lan") { console.error("bind clobbered: "+JSON.stringify(j.gateway.bind)); process.exit(1); }
if(j.logging.level!=="debug") { console.error("level clobbered: "+JSON.stringify(j.logging.level)); process.exit(1); }
if(j.acp.dispatch.enabled!==true) { console.error("acp clobbered"); process.exit(1); }
if(j.gateway.port!==18789) { console.error("port clobbered"); process.exit(1); }
' "$CONFIG" || fail "rejected writes must not modify existing config"
# 损坏配置时必须中止写入, 不能用 {} 覆盖
printf '{\n "gateway": { "port": 18789 },\n "models": { "providers": { "p": { "apiKey": "SECRET" } } },\n' > "$CONFIG"
before=$(cat "$CONFIG")
if set_val gateway.port 18790; then
fail "write into corrupt config should have failed"
fi
after=$(cat "$CONFIG")
[ "$before" = "$after" ] || fail "corrupt config was modified by json_set"
grep -Fq SECRET "$CONFIG" || fail "apiKey lost: json_set overwrote a corrupt config"
# 显式类型参数必须生效
printf '{}\n' > "$CONFIG"
set_val meta.customFlag true || fail "explicit string write failed"
sh "$HARNESS" set meta.customNum 42 number >/dev/null 2>&1 || fail "explicit number write failed"
"$NODE_BIN" -e '
const j=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
if(typeof j.meta.customFlag!=="string"){console.error("default should stay string");process.exit(1);}
if(typeof j.meta.customNum!=="number"){console.error("explicit number type ignored");process.exit(1);}
' "$CONFIG" || fail "explicit type argument not honored"
echo "ok"
+184
View File
@@ -0,0 +1,184 @@
#!/bin/sh
# 配置写入安全契约。
#
# 背景: 旧实现的 readConfig() 在 JSON.parse 失败时返回 {},调用方随后
# writeConfig() 会把这个空对象写回磁盘,导致整份配置 (含 models.providers
# 里的 apiKey) 被静默清空。且 writeConfig() 直接覆盖目标文件,无备份、
# 非原子写,中断会留下截断的 JSON。
#
# 本测试用真实 Node 加载 oc-config-interactive.js 的实现,验证:
# 1. 配置损坏时写入被拒绝,原文件字节不变 (fail closed)
# 2. 正常写入是原子的,且留有写前备份
# 3. 备份后缀不占用 OpenClaw 自己的 .bak 轮转链
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
TARGET="$REPO_ROOT/root/usr/share/openclaw/oc-config-interactive.js"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$TARGET" ] || fail "missing $TARGET"
# ── 静态契约: 关键防护不能被回退 ──
grep -Fq 'class ConfigParseError' "$TARGET" || fail "must define ConfigParseError to distinguish corrupt config from missing config"
grep -Fq 'renameSync' "$TARGET" || fail "writeConfig must replace the config atomically via rename"
grep -Fq 'CONFIG_BACKUP_SUFFIX' "$TARGET" || fail "writeConfig must keep a pre-write backup"
grep -Fq "'.luci-pre-write'" "$TARGET" || fail "pre-write backup must not reuse OpenClaw's own .bak rotation names"
grep -Fq 'readConfigForDisplay' "$TARGET" || fail "read-only paths need a non-throwing accessor"
# readConfig() 不得在解析失败时返回 {}
awk '/^function readConfig\(\)/,/^}/' "$TARGET" | grep -Fq 'throw new ConfigParseError' \
|| fail "readConfig must throw on unparsable config instead of returning {}"
# ── 行为契约: 需要 node 才能执行 ──
NODE_BIN=""
for cand in node nodejs /opt/openclaw/node/bin/node; do
if command -v "$cand" >/dev/null 2>&1; then NODE_BIN=$(command -v "$cand"); break; fi
[ -x "$cand" ] && { NODE_BIN="$cand"; break; }
done
if [ -z "$NODE_BIN" ]; then
echo "ok (static only: no node interpreter available)"
exit 0
fi
TMPDIR_T=$(mktemp -d 2>/dev/null || echo "/tmp/oc-write-safety-$$")
mkdir -p "$TMPDIR_T"
cleanup() { rm -rf "$TMPDIR_T"; }
trap cleanup EXIT
# 把 readConfig/writeConfig 从目标文件中抽出来单独求值:
# 直接 require 整个脚本会启动交互菜单,因此这里只提取所需函数体。
HARNESS="$TMPDIR_T/harness.js"
cat > "$HARNESS" <<'HARNESS_EOF'
const fs = require('fs');
const path = require('path');
const target = process.argv[2];
const configFile = process.argv[3];
const src = fs.readFileSync(target, 'utf8');
// 抽取被测函数 (含依赖的错误类与常量),避免执行脚本的交互入口
function extract(startMarker, endMarker) {
const s = src.indexOf(startMarker);
if (s < 0) throw new Error(`marker not found: ${startMarker}`);
const e = src.indexOf(endMarker, s);
if (e < 0) throw new Error(`end marker not found: ${endMarker}`);
return src.slice(s, e);
}
const body = [
extract('class ConfigParseError', 'function configRecoveryHint'),
extract('function readConfig()', 'function readConfigForDisplay'),
extract('function readConfigForDisplay()', '/**\n * 写入 JSON 配置文件'),
extract('function writeConfig(config)', '/**\n * 注册模型'),
].join('\n');
const CONFIG_BACKUP_SUFFIX = '.luci-pre-write';
const C = new Proxy({}, { get: () => '' });
const execSync = () => {};
const fixStatePermissions = () => {};
const CONFIG_FILE = configFile;
const factory = new Function(
'fs', 'path', 'CONFIG_FILE', 'CONFIG_BACKUP_SUFFIX', 'C', 'execSync', 'fixStatePermissions',
`${body}\nreturn { readConfig, readConfigForDisplay, writeConfig, ConfigParseError };`
);
const api = factory(fs, path, CONFIG_FILE, CONFIG_BACKUP_SUFFIX, C, execSync, fixStatePermissions);
const results = [];
function check(name, fn) {
try { fn(); results.push(`PASS ${name}`); }
catch (e) { results.push(`FAIL ${name}: ${e.message}`); }
}
function assert(cond, msg) { if (!cond) throw new Error(msg); }
// 场景 1: 轻微损坏的配置 (尾随逗号, OpenClaw 自身的 JSON5 解析器可容忍)
// 用户只想改一个端口 -> 必须中止, 原文件一字节都不能变
check('corrupt config is not overwritten', () => {
const corrupt = '{\n "gateway": { "port": 18789 },\n "models": { "providers": { "p": { "apiKey": "SECRET" } } },\n';
fs.writeFileSync(CONFIG_FILE, corrupt);
const before = fs.readFileSync(CONFIG_FILE);
let threw = null;
try {
// 精确复刻 jsonSet 的写入路径: 它会自动创建缺失的中间对象,
// 因此不会因为键不存在而抛 TypeError —— 唯一能阻止数据丢失的
// 就是 readConfig() 自己抛错。用真实路径才能守住真实的回归。
const cfg = api.readConfig();
const keys = ['gateway', 'port'];
let obj = cfg;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = 18790;
api.writeConfig(cfg);
} catch (e) { threw = e; }
// 最关键的性质放在最前面: 配置不能丢
const after = fs.readFileSync(CONFIG_FILE);
assert(after.includes('SECRET'), 'apiKey was lost: corrupt config got overwritten');
assert(before.equals(after), 'corrupt config file was modified');
assert(threw !== null, 'readConfig should have thrown on corrupt config');
assert(threw instanceof api.ConfigParseError, `expected ConfigParseError, got ${threw && threw.name}`);
});
// 场景 2: 数组/标量根节点也必须拒绝
check('non-object root is rejected', () => {
fs.writeFileSync(CONFIG_FILE, '[1,2,3]');
let threw = false;
try { api.readConfig(); } catch (e) { threw = e instanceof api.ConfigParseError; }
assert(threw, 'array root should be rejected');
});
// 场景 3: 文件不存在 / 空文件属正常首装情况 -> 返回 {}
check('missing and empty config are treated as empty', () => {
fs.rmSync(CONFIG_FILE, { force: true });
assert(JSON.stringify(api.readConfig()) === '{}', 'missing file should yield {}');
fs.writeFileSync(CONFIG_FILE, ' \n');
assert(JSON.stringify(api.readConfig()) === '{}', 'empty file should yield {}');
});
// 场景 4: 正常写入保留其他字段, 且留下写前备份
check('normal write preserves siblings and leaves a backup', () => {
const good = { gateway: { port: 18789 }, models: { providers: { p: { apiKey: 'KEEPME' } } } };
fs.writeFileSync(CONFIG_FILE, JSON.stringify(good, null, 2));
const cfg = api.readConfig();
cfg.gateway.port = 18790;
api.writeConfig(cfg);
const out = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
assert(out.gateway.port === 18790, 'port not updated');
assert(out.models.providers.p.apiKey === 'KEEPME', 'sibling config lost on normal write');
assert(fs.existsSync(CONFIG_FILE + CONFIG_BACKUP_SUFFIX), 'pre-write backup missing');
const bak = JSON.parse(fs.readFileSync(CONFIG_FILE + CONFIG_BACKUP_SUFFIX, 'utf8'));
assert(bak.gateway.port === 18789, 'backup should hold the pre-write value');
});
// 场景 5: 写入不留临时文件残留
check('no temp file residue after write', () => {
const leftovers = fs.readdirSync(path.dirname(CONFIG_FILE))
.filter((f) => f.includes('.tmp-'));
assert(leftovers.length === 0, `temp files left behind: ${leftovers.join(',')}`);
});
// 场景 6: 拒绝写入非对象
check('writeConfig rejects non-object payloads', () => {
let threw = false;
try { api.writeConfig(null); } catch { threw = true; }
assert(threw, 'null payload should be rejected');
});
console.log(results.join('\n'));
if (results.some((r) => r.startsWith('FAIL'))) process.exit(1);
HARNESS_EOF
OUT=$("$NODE_BIN" "$HARNESS" "$TARGET" "$TMPDIR_T/openclaw.json" 2>&1) || {
echo "$OUT" | sed 's/^/ /' >&2
fail "config write safety behavior checks failed"
}
echo "$OUT" | grep -q '^FAIL' && { echo "$OUT" | sed 's/^/ /' >&2; fail "config write safety behavior checks failed"; }
echo "ok"
+124
View File
@@ -0,0 +1,124 @@
#!/bin/sh
# init.d 启动同步安全契约。
#
# 背景: sync_uci_to_json() 会在每次 start_service 时把 UCI 配置同步进
# openclaw.json。旧实现用 try{...}catch(e){} 吞掉解析错误后让 d 保持 {},
# 然后把这个空对象连同默认值一起写回磁盘。
#
# 实测后果: 一份仅多出尾随逗号的配置(OpenClaw 自身的 JSON5 解析器可容忍),
# 经过一次普通的 /etc/init.d/openclaw start 之后,文件从 177 字节
# (含 models.providers 的 apiKey 与 channels.openclaw-weixin) 变成 221 字节,
# apiKey 与渠道配置全部消失。
#
# 这比手工操作触发的同类缺陷更危险: 它在服务启动路径上自动执行。
# 本测试锁定 fail-closed 行为,并校验同步写入的字段类型符合上游 schema。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
INIT_SCRIPT="$REPO_ROOT/root/etc/init.d/openclaw"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$INIT_SCRIPT" ] || fail "missing $INIT_SCRIPT"
# ── 静态契约 ──
grep -Fq 'sync_uci_to_json' "$INIT_SCRIPT" || fail "sync_uci_to_json missing"
grep -Fq '已跳过 UCI 同步' "$INIT_SCRIPT" \
|| fail "sync_uci_to_json must abort instead of overwriting an unparsable config"
# 不得再出现"解析失败后静默继续"的写法
if awk '/^OC_SYNC_PORT=/,/^" 2>\/dev\/null$/' "$INIT_SCRIPT" \
| grep -Fq "try{d=JSON.parse(fs.readFileSync(f,'utf8'));}catch(e){}"; then
fail "sync_uci_to_json must not swallow JSON parse errors (that wipes user config)"
fi
# ── 行为契约 (需要 node) ──
NODE_BIN=""
for cand in node nodejs /opt/openclaw/node/bin/node; do
if command -v "$cand" >/dev/null 2>&1; then NODE_BIN=$(command -v "$cand"); break; fi
[ -x "$cand" ] && { NODE_BIN="$cand"; break; }
done
if [ -z "$NODE_BIN" ]; then
echo "ok (static only: no node interpreter available)"
exit 0
fi
WORK=$(mktemp -d 2>/dev/null || echo "/tmp/oc-initd-sync-$$")
mkdir -p "$WORK"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
# 抽出内嵌的 node 脚本正文单独执行。
# 边界: 紧跟 '"$NODE_BIN" -e "' 的下一行开始,到单独一行 '" 2>/dev/null' 之前。
EXTRACT="$WORK/extract.js"
cat > "$EXTRACT" <<'EXTRACT_EOF'
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8').split('\n');
let start = -1;
for (let i = 1; i < src.length; i++) {
if (src[i].includes('"$NODE_BIN" -e "') && src[i - 1].startsWith('OC_SYNC_PORT=')) { start = i + 1; break; }
}
if (start < 0) { console.error('START NOT FOUND'); process.exit(1); }
let end = -1;
for (let i = start; i < src.length; i++) {
if (/^"\s*2>\/dev\/null$/.test(src[i])) { end = i; break; }
}
if (end < 0) { console.error('END NOT FOUND'); process.exit(1); }
fs.writeFileSync(process.argv[3], src.slice(start, end).join('\n'));
EXTRACT_EOF
SYNC="$WORK/sync.js"
"$NODE_BIN" "$EXTRACT" "$INIT_SCRIPT" "$SYNC" || fail "cannot extract the embedded sync script"
"$NODE_BIN" --check "$SYNC" >/dev/null 2>&1 || fail "embedded sync script has a syntax error"
run_sync() {
OC_SYNC_PORT="$1" OC_SYNC_BIND="$2" OC_SYNC_TOKEN="$3" OC_SYNC_FILE="$4" \
"$NODE_BIN" "$SYNC" >/dev/null 2>&1
}
# 场景 1: 损坏配置 -> 必须中止且一字节不改
BAD="$WORK/bad.json"
printf '{\n "gateway": { "port": 18789 },\n "models": { "providers": { "p": { "apiKey": "SECRET" } } },\n' > "$BAD"
before=$(cat "$BAD")
if run_sync 19999 lan tok "$BAD"; then
fail "sync should have failed on a corrupt config"
fi
after=$(cat "$BAD")
[ "$before" = "$after" ] || fail "corrupt config was modified by sync_uci_to_json"
grep -Fq SECRET "$BAD" || fail "apiKey lost: sync_uci_to_json overwrote a corrupt config"
# 场景 2: 数组根节点同样必须拒绝
ARR="$WORK/arr.json"
printf '[1,2,3]' > "$ARR"
if run_sync 18789 lan tok "$ARR"; then
fail "sync should reject a non-object config root"
fi
# 场景 3: 正常配置 -> 同步生效且保留其它字段
GOOD="$WORK/good.json"
printf '{ "gateway": { "port": 18789 }, "models": { "providers": { "p": { "apiKey": "KEEPME" } } }, "channels": { "openclaw-weixin": { "enabled": true } } }' > "$GOOD"
run_sync 19999 lan tok2 "$GOOD" || fail "sync failed on a valid config"
"$NODE_BIN" -e '
const fs = require("fs");
const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const errs = [];
if (j.gateway.port !== 19999) errs.push("port not synced from UCI");
if (typeof j.gateway.port !== "number") errs.push("gateway.port must be a number per schema");
if (typeof j.acp.dispatch.enabled !== "boolean") errs.push("acp.dispatch.enabled must be a boolean per schema");
if (!j.models || !j.models.providers || j.models.providers.p.apiKey !== "KEEPME") errs.push("provider apiKey lost during sync");
if (!j.channels || !j.channels["openclaw-weixin"]) errs.push("channel config lost during sync");
if (errs.length) { console.error(errs.join("; ")); process.exit(1); }
' "$GOOD" || fail "sync_uci_to_json damaged a valid config"
# 场景 4: 空文件属首装正常情况
EMPTY="$WORK/empty.json"
printf '' > "$EMPTY"
run_sync 18789 lan t3 "$EMPTY" || fail "sync should treat an empty file as a fresh install"
"$NODE_BIN" -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$EMPTY" \
|| fail "sync produced invalid JSON for a fresh install"
echo "ok"
+101
View File
@@ -0,0 +1,101 @@
#!/bin/sh
# LuCI 端点 CSRF 契约。
#
# 背景: LuCI 的 dispatcher 只对 post() 注册的端点执行 test_post_security()
# (同时要求 REQUEST_METHOD=POST 且表单 token 与会话 authtoken 匹配)。
# call() 注册的端点允许 GET 触发且完全不校验 token。
#
# 原实现里会改状态或返回凭据的端点全都是 call():
# service_ctl 启停/重启/安装 OpenClaw
# uninstall 删除整个运行环境
# plugin_upgrade 下载并执行 .run 安装包
# backup create/restore/delete 备份
# get_token 返回网关 token 与 PTY token
# 这些端点都在 admin/services/openclaw 下(需登录),但 GET 可触发意味着
# 诱导已登录的管理员访问一个链接即可卸载环境或读出凭据。
#
# 本测试锁定: 改状态/返回凭据的端点必须是 post(),只读端点可以是 call()。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
CONTROLLER="$REPO_ROOT/luasrc/controller/openclaw.lua"
BASIC="$REPO_ROOT/luasrc/model/cbi/openclaw/basic.lua"
ADVANCED="$REPO_ROOT/luasrc/view/openclaw/advanced.htm"
CONSOLE="$REPO_ROOT/luasrc/view/openclaw/console.htm"
fail() {
echo "FAIL: $1" >&2
exit 1
}
for f in "$CONTROLLER" "$BASIC" "$ADVANCED" "$CONSOLE"; do
[ -f "$f" ] || fail "missing $f"
done
# 必须用 post() 注册的端点: 会改状态或返回凭据
MUST_POST="service_ctl uninstall plugin_upgrade backup get_token
wechat_install wechat_login wechat_logout wechat_uninstall wechat_upgrade_plugin"
for ep in $MUST_POST; do
line=$(grep -F "\"openclaw\", \"$ep\"}" "$CONTROLLER" | head -1)
[ -n "$line" ] || fail "endpoint $ep not registered"
case "$line" in
*"post(\"action_"*) ;;
*"call(\"action_"*)
fail "endpoint $ep must be registered with post() — it changes state or returns credentials, and call() allows GET without CSRF validation"
;;
*) fail "endpoint $ep: unrecognized registration form" ;;
esac
done
# 前端必须以 POST 方式调用这些端点,并带上 CSRF token。
# basic.lua 里针对 post 端点的调用不得再使用 XHR get。
for u in ctl_url uninstall_url plugin_upgrade_url backup_url; do
if grep -F '(new XHR()).get(' "$BASIC" | grep -Fq ".. $u"; then
fail "basic.lua still calls $u with XHR get (post endpoints reject GET with 405)"
fi
done
# CSRF token 必须被注入前端并随请求提交
grep -Fq 'context.authtoken' "$BASIC" || fail "basic.lua must expose the CSRF token to its scripts"
grep -Fq 'ocCsrfToken' "$BASIC" || fail "basic.lua must pass the CSRF token in requests"
# 允许 {token:ocCsrfToken} 与 {token: ocCsrfToken} 两种写法
grep -Eq 'token:[[:space:]]*ocCsrfToken' "$BASIC" \
|| fail "basic.lua post calls must include the CSRF token"
# 每个 post 调用都必须带 token, 不能漏掉某一处
post_calls=$(grep -c '(new XHR()).post(' "$BASIC" || true)
post_with_token=$(grep '(new XHR()).post(' "$BASIC" | grep -Ec 'token:[[:space:]]*ocCsrfToken' || true)
[ "$post_calls" = "$post_with_token" ] \
|| fail "basic.lua has $post_calls post calls but only $post_with_token carry the CSRF token"
# get_token 的两个调用方也必须改为带 token 的 POST
for f in "$ADVANCED" "$CONSOLE"; do
b=$(basename "$f")
grep -Fq '(new XHR()).get(tokenUrl' "$f" \
&& fail "$b must POST to get_token (it is now a post() endpoint)"
grep -Fq '(new XHR()).post(tokenUrl' "$f" \
|| fail "$b must call get_token via POST"
grep -Eq 'token:[[:space:]]*ocCsrfToken' "$f" || fail "$b must send the CSRF token to get_token"
grep -Fq "var ocCsrfToken = '<%=token%>'" "$f" \
|| fail "$b must read the CSRF token from the LuCI template"
done
# 只读端点保持 call() 即可 —— 确认没有被误改成 post 而导致前端 GET 调用失效
READ_ONLY="status_api setup_log check_update check_system plugin_upgrade_log
wechat_status wechat_install_log wechat_login_status wechat_check_upgrade"
for ep in $READ_ONLY; do
line=$(grep -F "\"openclaw\", \"$ep\"}" "$CONTROLLER" | head -1)
[ -n "$line" ] || fail "endpoint $ep not registered"
case "$line" in
*"post(\"action_"*)
# 若改为 post,前端必须同步改为带 token 的 POST,否则会 405
if grep -F "$ep" "$BASIC" "$ADVANCED" "$CONSOLE" 2>/dev/null | grep -Fq '(new XHR()).get('; then
fail "endpoint $ep was switched to post() but a frontend caller still uses GET"
fi
;;
esac
done
echo "ok"
+118
View File
@@ -0,0 +1,118 @@
#!/bin/sh
# 菜单按键覆盖契约。
#
# 背景: 传统菜单是"echo 打印选项 + case 分支处理"两处独立维护,容易漂移。
# 实测发现两处功能因此完全打不开:
# backup_restore_menu 打印 1..5,case 只有 1) 2) 3) c) d)
# -> 选 4(查看备份列表) / 5(从最新备份恢复) 落到 *) "无效选择",
# 真正的逻辑挂在从不显示的 c / d 上
# reset_to_defaults 打印 1..4,case 只有 1) 2) 3) c)
# -> 选 4(完全恢复出厂) 同样不可达
#
# 本测试对每个传统菜单函数比对"打印出来的按键集合"与"case 覆盖的按键集合",
# 两者必须完全一致,避免再出现"按提示操作却提示无效选择"。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
SH_CONFIG="$REPO_ROOT/root/usr/share/openclaw/oc-config.sh"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$SH_CONFIG" ] || fail "missing $SH_CONFIG"
# 待校验的菜单函数。这些函数都遵循 "echo -e \" ${CYAN}N)${NC} ...\"" +
# "case \$choice in N) ... esac" 的结构。
MENUS="reset_to_defaults backup_restore_menu advanced_menu"
for fn in $MENUS; do
grep -q "^${fn}()" "$SH_CONFIG" || fail "menu function ${fn}() not found"
# 打印出来的按键: 形如 ${CYAN}4)${NC}
printed=$(awk -v f="^${fn}\\\\(\\\\)" '
$0 ~ f { inside = 1 }
inside && /^\}$/ { inside = 0 }
inside && match($0, /\{CYAN\}[0-9a-z]+\)/) {
s = substr($0, RSTART + 6, RLENGTH - 7)
print s
}
' "$SH_CONFIG" | sort -u | tr '\n' ' ')
# case 覆盖的按键。
# 注意各菜单的外层 case 缩进不同 (advanced_menu 的 case 嵌在 while 里,
# 比另外两个深一层),且分支内可能有嵌套 case (如 auto|lan|... / true|false)。
# 因此先定位该函数第一个 "case ... in" 的缩进,只采集与之相同缩进 + 1 个
# 制表符的标签行,避免把嵌套 case 的取值误判成菜单按键。
handled=$(awk -v f="$fn" '
index($0, f "()") == 1 { inside = 1 }
inside && /^\}$/ { inside = 0 }
inside && !depth_found && match($0, /^\t*case .* in$/) {
# 记录外层 case 的制表符数量
ct = 0
while (substr($0, ct + 1, 1) == "\t") ct++
label_prefix = ""
for (i = 0; i <= ct; i++) label_prefix = label_prefix "\t"
depth_found = 1
next
}
inside && depth_found {
# 只接受恰好位于外层 case 分支缩进的标签行
if (index($0, label_prefix) == 1 && substr($0, length(label_prefix) + 1, 1) != "\t") {
line = substr($0, length(label_prefix) + 1)
if (match(line, /^[0-9a-z][0-9a-z|"]*\)/)) {
sub(/\).*$/, "", line)
n = split(line, parts, "|")
for (i = 1; i <= n; i++) {
gsub(/"/, "", parts[i])
if (parts[i] != "") print parts[i]
}
}
}
}
' "$SH_CONFIG" | sort -u | tr '\n' ' ')
[ -n "$printed" ] || fail "${fn}: no printed menu keys detected (parser drift?)"
[ -n "$handled" ] || fail "${fn}: no case labels detected (parser drift?)"
# 打印了但没有分支处理 -> 用户按提示操作会得到"无效选择"
for k in $printed; do
case " $handled " in
*" $k "*) ;;
*) fail "${fn}: menu offers '$k' but no case branch handles it (option is unreachable)" ;;
esac
done
# 有分支但从不显示 -> 隐藏功能,说明键位错位
for k in $handled; do
case " $printed " in
*" $k "*) ;;
*) fail "${fn}: case handles '$k' but the menu never offers it (orphaned branch)" ;;
esac
done
done
# 回归钉子: 这两个功能曾经挂在不显示的 c/d 上。
#
# 注意不要在 grep 模式里写 \t: BusyBox grep 会把它当制表符,
# 而 GNU grep (CI runner) 当作字面字符 t —— 同一个断言在两边结果相反。
# 这里用 awk 判断行首制表符,避免依赖 grep 的转义方言。
has_case_label() {
awk -v fn="$1" -v key="$2" '
index($0, fn "()") == 1 { inside = 1 }
inside && /^\}$/ { inside = 0 }
inside && $0 == "\t\t" key ")" { found = 1 }
END { exit(found ? 0 : 1) }
' "$SH_CONFIG"
}
has_case_label backup_restore_menu 4 \
|| fail "backup menu option 4 (list backups) must be reachable"
has_case_label backup_restore_menu 5 \
|| fail "backup menu option 5 (restore from latest) must be reachable"
has_case_label reset_to_defaults 4 \
|| fail "reset menu option 4 (factory reset) must be reachable"
echo "ok"
+126
View File
@@ -0,0 +1,126 @@
#!/bin/sh
# 模型清单架构契约。
#
# 背景: 两侧实现各自硬编码了一大堆 provider/model ID,且已严重腐坏。
# 实测这些 ID 在 OpenClaw 2026.7.1-2 的 catalog 中已不存在:
# openai/gpt-5.2、gpt-5-mini、gpt-4.1、o3、o4-mini
# anthropic/claude-sonnet-4-20250514、claude-opus-4-20250514、claude-sonnet-4.5
# xai/grok-4、grok-3、deepseek/deepseek-r1、meta-llama/llama-4-maverick
# 01-ai/Yi-1.5-34B-Chat-16K、Qwen/Qwen2.5-*、THUDM/glm-4-9b-chat
#
# 现改为三层架构:
# 1. 精选预设 model-presets.json (shell 与 JS 共读的唯一数据源)
# 2. 动态发现 openclaw models list --provider <id> --plain (带超时, 失败回落)
# 3. 手动输入 (永久保留的兼容出口)
#
# 本测试锁定该架构不被回退成硬编码。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
SH_CONFIG="$REPO_ROOT/root/usr/share/openclaw/oc-config.sh"
JS_CONFIG="$REPO_ROOT/root/usr/share/openclaw/oc-config-interactive.js"
PRESETS="$REPO_ROOT/root/usr/share/openclaw/model-presets.json"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$SH_CONFIG" ] || fail "missing $SH_CONFIG"
[ -f "$JS_CONFIG" ] || fail "missing $JS_CONFIG"
[ -f "$PRESETS" ] || fail "missing shared preset file $PRESETS"
# ── 两侧必须共读同一份预设,而不是各自维护列表 ──
grep -Fq 'model-presets.json' "$SH_CONFIG" || fail "shell side must read the shared preset file"
grep -Fq 'model-presets.json' "$JS_CONFIG" || fail "interactive side must read the shared preset file"
# ── 三层机制的关键函数必须存在 ──
grep -Fq 'oc_preset_models()' "$SH_CONFIG" || fail "shell needs a preset reader"
grep -Fq 'oc_discover_models()' "$SH_CONFIG" || fail "shell needs dynamic discovery"
grep -Fq 'oc_pick_model()' "$SH_CONFIG" || fail "shell needs a unified model picker"
grep -Fq 'function loadModelPresets' "$JS_CONFIG" || fail "interactive needs a preset reader"
grep -Fq 'function discoverProviderModels' "$JS_CONFIG" || fail "interactive needs dynamic discovery"
grep -Fq 'function selectProviderModel' "$JS_CONFIG" || fail "interactive needs a unified model picker"
# 动态发现必须按 provider 查询: 上游的 models list --all 不是各 provider 的超集
grep -Fq -- '--provider' "$SH_CONFIG" || fail "discovery must query per provider"
grep -Fq "'--provider'" "$JS_CONFIG" || fail "discovery must query per provider"
# 动态发现必须带超时: OpenWrt 上不能让菜单卡死
grep -Fq 'OC_MODEL_DISCOVERY_TIMEOUT' "$SH_CONFIG" || fail "shell discovery must be bounded by a timeout"
grep -Fq 'MODEL_DISCOVERY_TIMEOUT_MS' "$JS_CONFIG" || fail "interactive discovery must be bounded by a timeout"
# 手动输入出口必须保留
grep -Fq '手动输入模型 ID' "$SH_CONFIG" || fail "shell must keep a manual model entry"
grep -Fq '__custom__' "$JS_CONFIG" || fail "interactive must keep a manual model entry"
# ── 已确认在上游不存在的 ID 不得再出现在可选菜单里 ──
# 允许出现在注释中 (记录为何废弃),因此这里排除注释行。
STALE_IDS="gpt-5-mini gpt-5-nano claude-sonnet-4-20250514 claude-opus-4-20250514 claude-sonnet-4.5 grok-4-fast grok-3-mini deepseek-r1 llama-4-maverick Yi-1.5-34B-Chat-16K Qwen2.5-72B-Instruct glm-4-9b-chat ernie-3.5-8k ernie-speed-8k"
for id in $STALE_IDS; do
# shell: 排除以 # 开头的注释行
if grep -F -- "$id" "$SH_CONFIG" | grep -v '^[[:space:]]*#' | grep -q .; then
fail "oc-config.sh still offers stale model id: $id"
fi
# js: 排除 // 与 * 开头的注释行
if grep -F -- "$id" "$JS_CONFIG" | grep -v '^[[:space:]]*//' | grep -v '^[[:space:]]*\*' | grep -q .; then
fail "oc-config-interactive.js still offers stale model id: $id"
fi
done
# ── 预设文件自身校验 (需要 node) ──
NODE_BIN=""
for cand in node nodejs /opt/openclaw/node/bin/node; do
if command -v "$cand" >/dev/null 2>&1; then NODE_BIN=$(command -v "$cand"); break; fi
[ -x "$cand" ] && { NODE_BIN="$cand"; break; }
done
if [ -z "$NODE_BIN" ]; then
echo "ok (static only: no node interpreter available)"
exit 0
fi
"$NODE_BIN" -e '
const fs = require("fs");
const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const errs = [];
if (!j.providers || typeof j.providers !== "object") errs.push("missing providers map");
if (!j.verified || !j.verified.date || !j.verified.openclawVersion) {
errs.push("missing verified metadata (date + openclawVersion)");
}
const stale = [
"gpt-5.2", "gpt-5-mini", "gpt-5-nano", "gpt-4.1", "gpt-4o", "o3", "o4-mini",
"claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-sonnet-4.5",
"grok-4", "grok-3", "deepseek-r1", "glm-4.5",
];
for (const [id, p] of Object.entries(j.providers || {})) {
if (!p.label) errs.push(id + ": missing label");
if (!["builtin", "plugin"].includes(p.catalogSource)) {
errs.push(id + ": catalogSource must be builtin or plugin");
}
if (!Array.isArray(p.models) || p.models.length === 0) {
errs.push(id + ": needs at least one preset model");
continue;
}
const seen = new Set();
for (const m of p.models) {
if (!m.model) { errs.push(id + ": preset entry missing model id"); continue; }
// 预设必须是裸模型 ID, 不带 provider 前缀 (openrouter 的上游前缀除外)
if (id !== "openrouter" && m.model.startsWith(id + "/")) {
errs.push(id + "/" + m.model + ": preset must not repeat the provider prefix");
}
if (seen.has(m.model)) errs.push(id + ": duplicate preset " + m.model);
seen.add(m.model);
if (stale.includes(m.model)) {
errs.push(id + ": preset uses a model id confirmed absent upstream: " + m.model);
}
}
}
if (errs.length) { console.error(errs.join("\n")); process.exit(1); }
' "$PRESETS" || fail "model-presets.json failed validation"
echo "ok"
+174
View File
@@ -0,0 +1,174 @@
#!/bin/sh
# 打包清单一致性契约。
#
# 背景: 本项目有三条打包路径,各自独立维护安装清单:
# 1. Makefile — feeds / OpenWrt SDK 编译 (逐个 INSTALL_* 枚举)
# 2. scripts/build_ipk.sh — 本地 .ipk 构建 (大量使用 cp *.js / *.sh 通配)
# 3. scripts/build_run.sh — .run 自解压包构建 (同上)
#
# 实测漂移: Makefile 只安装 oc-config.sh + web-pty.js,漏掉
# oc-config-interactive.js 与 oc-menu-engine.js。Release 的 .ipk/.run 正常,
# 但走 feeds/SDK 编译装出来的包缺这两个文件,此时 oc-config.sh 的
# can_use_interactive() 因 [ -f "$OC_INTERACTIVE" ] 失败而静默回落到传统菜单
# —— 用户看不到任何报错,只会觉得界面和教程不一致。
#
# 本测试对每个应打包的源文件,校验三条路径都会安装它。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
MK="$REPO_ROOT/Makefile"
IPK="$REPO_ROOT/scripts/build_ipk.sh"
RUN="$REPO_ROOT/scripts/build_run.sh"
fail() {
echo "FAIL: $1" >&2
exit 1
}
for f in "$MK" "$IPK" "$RUN"; do
[ -f "$f" ] || fail "missing $f"
done
# 判断某条打包脚本是否会安装指定源文件。
# 命中方式二选一:
# - 显式提到文件名
# - 存在覆盖其目录 + 扩展名的通配 (如 root/usr/share/openclaw/"*.js)
covered() {
_file="$1" # 相对仓库根的路径, 如 root/usr/share/openclaw/oc-menu-engine.js
_script="$2"
_base=$(basename "$_file")
_dir=$(dirname "$_file")
_ext=$(printf '%s' "$_base" | sed -n 's/.*\.\([A-Za-z0-9]\+\)$/\1/p')
# 显式文件名
if grep -Fq "$_base" "$_script"; then
return 0
fi
# 目录通配: 匹配形如 <dir>/"*.ext 或 <dir>/*.ext
if [ -n "$_ext" ] && grep -Fq "${_dir}/" "$_script" && grep -Fq "*.${_ext}" "$_script"; then
# 进一步确认同一行里既有该目录又有该扩展通配
if grep -F "${_dir}/" "$_script" | grep -Fq "*.${_ext}"; then
return 0
fi
fi
return 1
}
# ── 应打包的源文件集合 ──
# 只列运行时必需的文件; tests/ 与 docs/ 由 .gitattributes export-ignore 排除。
FILES=""
add_files() {
for p in "$@"; do
[ -e "$REPO_ROOT/$p" ] || continue
FILES="$FILES $p"
done
}
add_files root/etc/config/openclaw
add_files root/etc/uci-defaults/99-openclaw
add_files root/etc/init.d/openclaw
add_files root/etc/profile.d/openclaw.sh
add_files root/usr/bin/openclaw-env
for f in "$REPO_ROOT"/root/usr/libexec/*.sh; do
[ -f "$f" ] && add_files "root/usr/libexec/$(basename "$f")"
done
add_files luasrc/controller/openclaw.lua
for f in "$REPO_ROOT"/luasrc/openclaw/*.lua; do
[ -f "$f" ] && add_files "luasrc/openclaw/$(basename "$f")"
done
for f in "$REPO_ROOT"/luasrc/model/cbi/openclaw/*.lua; do
[ -f "$f" ] && add_files "luasrc/model/cbi/openclaw/$(basename "$f")"
done
for f in "$REPO_ROOT"/luasrc/view/openclaw/*.htm; do
[ -f "$f" ] && add_files "luasrc/view/openclaw/$(basename "$f")"
done
for f in "$REPO_ROOT"/root/usr/share/rpcd/acl.d/*.json; do
[ -f "$f" ] && add_files "root/usr/share/rpcd/acl.d/$(basename "$f")"
done
# /usr/share/openclaw 下的脚本与前端 (ui/ 目录整体复制, 单独校验)
for f in "$REPO_ROOT"/root/usr/share/openclaw/*.sh "$REPO_ROOT"/root/usr/share/openclaw/*.js; do
[ -f "$f" ] && add_files "root/usr/share/openclaw/$(basename "$f")"
done
[ -n "$FILES" ] || fail "no packageable source files detected (parser drift?)"
# ── 三条路径逐一校验 ──
missing=""
for rel in $FILES; do
covered "$rel" "$MK" || missing="${missing}\n Makefile 缺少 $rel"
covered "$rel" "$IPK" || missing="${missing}\n build_ipk.sh 缺少 $rel"
covered "$rel" "$RUN" || missing="${missing}\n build_run.sh 缺少 $rel"
done
if [ -n "$missing" ]; then
# shellcheck disable=SC2059
printf "打包清单不一致:$missing\n" >&2
fail "packaging paths disagree on which files to install"
fi
# ── ui/ 目录与 VERSION ──
for s in "$MK" "$IPK" "$RUN"; do
grep -Fq 'share/openclaw/ui' "$s" || fail "$(basename "$s") must install the Web PTY ui/ directory"
grep -Fq 'VERSION' "$s" || fail "$(basename "$s") must ship the VERSION file"
done
# ── 回归钉子 ──
# 这两个文件曾只在 build 脚本里、Makefile 漏装
grep -Fq 'oc-config-interactive.js' "$MK" || fail "Makefile must install oc-config-interactive.js (feeds/SDK builds fell back to the legacy menu without it)"
grep -Fq 'oc-menu-engine.js' "$MK" || fail "Makefile must install oc-menu-engine.js"
# 依赖声明三处必须一致, 且不得丢掉 libstdcpp6 (issue #28: 缺失导致 Node 无法运行)
for s in "$MK" "$IPK" "$RUN"; do
grep -Fq 'libstdcpp6' "$s" || fail "$(basename "$s") must keep the libstdcpp6 dependency (issue #28)"
done
# feeds 集成: 不得再依赖 luci.mk 的隐式 Package 生成 (issue #60)
# 只看真正的 include 语句, 注释里提到 luci.mk (说明历史原因) 是允许的。
if grep -E '^[[:space:]]*include[[:space:]].*feeds/luci/luci\.mk' "$MK" >/dev/null 2>&1; then
fail "Makefile must not depend on luci.mk implicit package generation (breaks OpenWrt 25.x feeds, issue #60)"
fi
if grep -E '^[[:space:]]*ifeq[[:space:]]*\(\$\(wildcard[[:space:]].*luci\.mk' "$MK" >/dev/null 2>&1; then
fail "Makefile must not branch on luci.mk availability (issue #60)"
fi
grep -Fq 'define Package/$(PKG_NAME)' "$MK" || fail "Makefile must define Package/... explicitly"
# 新增的共享数据文件必须显式安装: build 脚本只 cp *.js 通配,
# .json 不会被自动带上 (model-presets.json 曾因此漏装)。
for s in "$MK" "$IPK" "$RUN"; do
grep -Fq 'model-presets.json' "$s" \
|| fail "$(basename "$s") must install model-presets.json (the *.js glob does not cover it)"
done
# ── 真实构建产物校验 ──
# 前面的检查都是静态文本比对,抓不到"脚本已改但产物仍缺文件"的情况
# (例如同步失误)。这里实际构建一次 .ipk 并检查解包内容。
# 需要 ar/tar,缺失时跳过而不是误报。
if command -v ar >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then
BUILD_TMP=$(mktemp -d 2>/dev/null || echo "/tmp/oc-parity-build-$$")
mkdir -p "$BUILD_TMP"
cleanup_build() { rm -rf "$BUILD_TMP"; }
trap cleanup_build EXIT
if ( cd "$REPO_ROOT" && sh scripts/build_ipk.sh "$BUILD_TMP" ) >/dev/null 2>&1; then
IPK_FILE=$(ls "$BUILD_TMP"/*.ipk 2>/dev/null | head -1)
if [ -n "$IPK_FILE" ]; then
EXTRACT="$BUILD_TMP/extract"
mkdir -p "$EXTRACT"
( cd "$EXTRACT" && { ar x "$IPK_FILE" 2>/dev/null || tar xzf "$IPK_FILE" 2>/dev/null; } )
if [ -f "$EXTRACT/data.tar.gz" ]; then
mkdir -p "$EXTRACT/data"
tar xzf "$EXTRACT/data.tar.gz" -C "$EXTRACT/data" 2>/dev/null
SHARE="$EXTRACT/data/usr/share/openclaw"
for want in oc-config.sh oc-config-interactive.js oc-menu-engine.js web-pty.js model-presets.json VERSION; do
[ -f "$SHARE/$want" ] \
|| fail "built .ipk is missing /usr/share/openclaw/$want"
done
[ -d "$SHARE/ui" ] || fail "built .ipk is missing /usr/share/openclaw/ui/"
fi
fi
fi
fi
echo "ok"
+78
View File
@@ -0,0 +1,78 @@
#!/bin/sh
# Web PTY 安全契约。
#
# 背景: Web PTY 服务监听 0.0.0.0:18793,原先返回
# Access-Control-Allow-Origin: *
# X-Frame-Options: ALLOWALL
# Content-Security-Policy: default-src * 'unsafe-inline' 'unsafe-eval' ...
# 等于对任意站点开放;且页面把含 PTY token 的 WebSocket URL 写进可见
# 调试文本与 console,token 会残留在截图、录屏与浏览器历史中。
#
# 收紧依据(已逐项核实):
# - 页面资源全为本地 /lib/*.js|css,无外链 -> default-src 'self'
# - 唯一 fetch 是 /health,与 iframe 自身同源 -> 不需要 ACAO
# - 存在内联 <style>/<script> 块 -> 保留 'unsafe-inline'
# - xterm.js 不需要 eval -> 去掉 'unsafe-eval'
# - LuCI(80) 与 PTY(18793) 跨源嵌入 -> 仅需 frame-ancestors
#
# 真正的访问控制始终是 WebSocket 升级时的 token 校验(无/错 token 均 403)。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
PTY="$REPO_ROOT/root/usr/share/openclaw/web-pty.js"
PAGE="$REPO_ROOT/root/usr/share/openclaw/ui/index.html"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$PTY" ] || fail "missing $PTY"
[ -f "$PAGE" ] || fail "missing $PAGE"
# ── WebSocket token 校验是唯一的访问控制,不得移除 ──
grep -Fq "searchParams.get('token')" "$PTY" || fail "WS upgrade must read the token parameter"
grep -Fq 'WS auth failed' "$PTY" || fail "WS upgrade must reject unauthenticated connections"
grep -Fq '403' "$PTY" || fail "WS upgrade must answer 403 on auth failure"
# ── 响应头不得回退成全开放 ──
if grep -Fq "'Access-Control-Allow-Origin': '*'" "$PTY"; then
fail "web-pty.js must not send Access-Control-Allow-Origin: * (no cross-origin fetch is needed)"
fi
if grep -Fq 'ALLOWALL' "$PTY" | grep -qv '^[[:space:]]*//'; then
fail "web-pty.js must not send X-Frame-Options: ALLOWALL"
fi
# CSP 必须存在且不得是 default-src *
grep -Fq 'Content-Security-Policy' "$PTY" || fail "web-pty.js must send a Content-Security-Policy"
if grep -Fq 'default-src *' "$PTY" | grep -qv '^[[:space:]]*//'; then
fail "CSP must not use a wildcard default-src"
fi
grep -Fq "default-src 'self'" "$PTY" || fail "CSP default-src should be 'self'"
grep -Fq 'frame-ancestors' "$PTY" || fail "CSP must keep frame-ancestors so LuCI can embed the terminal"
# 内联块仍在, 因此 unsafe-inline 必须保留; 但不应保留 unsafe-eval
grep -Fq "'unsafe-inline'" "$PTY" || fail "CSP must keep 'unsafe-inline' (the page has inline style/script blocks)"
if grep -F "'unsafe-eval'" "$PTY" | grep -qv '^[[:space:]]*//'; then
fail "CSP should not need 'unsafe-eval'"
fi
# ── 页面不得把含 token 的 URL 写进可见文本或 console ──
grep -Fq 'wsUrlSafe' "$PAGE" || fail "page must use a redacted URL for display/logging"
# 展示位置一律使用脱敏 URL
if grep -F 'loadingDebug.textContent = wsUrl;' "$PAGE" | grep -q .; then
fail "page must not print the token-bearing WebSocket URL into visible text"
fi
if grep -F 'console.log' "$PAGE" | grep -F 'wsUrl' | grep -Fqv 'wsUrlSafe'; then
fail "page must not log the token-bearing WebSocket URL"
fi
# 真实 wsUrl 只应出现在建立连接处
ws_uses=$(grep -c 'new WebSocket(wsUrl)' "$PAGE" || true)
[ "$ws_uses" -ge 1 ] || fail "page must still connect using the real token-bearing URL"
# ── token 读取后必须从地址栏移除 ──
grep -Fq 'history.replaceState' "$PAGE" \
|| fail "page must scrub pty_token from the address bar after reading it"
grep -Fq "searchParams.delete('pty_token')" "$PAGE" \
|| fail "page must remove the pty_token query parameter"
echo "ok"
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
# Telegram 配对语义契约。
#
# 背景 (issue #98 / PR #102):
# 旧实现在「Telegram 配对助手」里调用 `openclaw models auth login-telegram-bot`,
# 该子命令在 OpenClaw 2026.6+ 已不存在,实测报:
# Too many arguments for this command. Try: openclaw models auth --help
#
# 关键在于修法。配对与 Token 配置是两件不同的事:
# - Bot Token 配置 -> channels.telegram.botToken / channels add --bot-token
# - 用户配对 -> openclaw pairing list / pairing approve
# 把「配对助手」改成「再配一次 Token」虽然能消除报错,但等于删掉了配对功能。
# 本测试锁定二者不被混为一谈。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
JS_CONFIG="$REPO_ROOT/root/usr/share/openclaw/oc-config-interactive.js"
SH_CONFIG="$REPO_ROOT/root/usr/share/openclaw/oc-config.sh"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$JS_CONFIG" ] || fail "missing $JS_CONFIG"
[ -f "$SH_CONFIG" ] || fail "missing $SH_CONFIG"
# ── 废弃 CLI 不得复活 ──
for f in "$JS_CONFIG" "$SH_CONFIG"; do
if grep -n 'login-telegram-bot' "$f" | grep -qv '^[0-9]*: *[/*#]'; then
fail "$(basename "$f") still calls the removed models auth login-telegram-bot subcommand"
fi
done
# ── 配对必须走上游 pairing 命令 (两侧实现一致) ──
grep -Fq "'pairing', 'list'" "$JS_CONFIG" || fail "interactive pairing must use openclaw pairing list"
grep -Fq "'pairing', 'approve'" "$JS_CONFIG" || fail "interactive pairing must use openclaw pairing approve"
grep -Fq 'pairing list telegram' "$SH_CONFIG" || fail "shell pairing must use openclaw pairing list"
grep -Fq 'pairing approve telegram' "$SH_CONFIG" || fail "shell pairing must use openclaw pairing approve"
# ── 配对与 Token 配置必须是两个独立入口 ──
grep -Fq "value: 'telegram' }" "$JS_CONFIG" || fail "Telegram token entry must remain in the channel menu"
grep -Fq "value: 'telegram-pairing' }" "$JS_CONFIG" || fail "Telegram pairing entry must remain in the channel menu"
# 配对分支不得把自己变成"再输入一次 Token"
pairing_block=$(awk "/case 'telegram-pairing': \{/,/^ \}$/" "$JS_CONFIG")
[ -n "$pairing_block" ] || fail "cannot locate telegram-pairing branch"
printf '%s' "$pairing_block" | grep -Fq 'pairing' || fail "telegram-pairing branch must invoke pairing commands"
if printf '%s' "$pairing_block" | grep -Eq 'channels\.telegram\.botToken *='; then
fail "telegram-pairing must not overwrite botToken (that is the token config feature, not pairing)"
fi
# 配对前应校验 Token 已存在,而不是引导用户重新录入
printf '%s' "$pairing_block" | grep -Fq "jsonGet('channels.telegram.botToken')" \
|| fail "telegram-pairing should require an existing bot token instead of asking for a new one"
# ── 捕获式 CLI 调用与解析器 ──
grep -Fq 'function ocCmdCapture' "$JS_CONFIG" || fail "need a silent CLI capture helper to parse pairing output"
grep -Fq 'function parsePairingCodes' "$JS_CONFIG" || fail "need a pairing code parser"
# ── 解析器行为验证 (需要 node) ──
NODE_BIN=""
for cand in node nodejs /opt/openclaw/node/bin/node; do
if command -v "$cand" >/dev/null 2>&1; then NODE_BIN=$(command -v "$cand"); break; fi
[ -x "$cand" ] && { NODE_BIN="$cand"; break; }
done
if [ -z "$NODE_BIN" ]; then
echo "ok (static only: no node interpreter available)"
exit 0
fi
"$NODE_BIN" -e '
const fs = require("fs");
const src = fs.readFileSync(process.argv[1], "utf8");
const start = src.indexOf("function parsePairingCodes");
if (start < 0) { console.error("parsePairingCodes not found"); process.exit(1); }
const body = src.slice(start, src.indexOf("\n}", start) + 2);
const parse = new Function(body + "; return parsePairingCodes;")();
const cases = [
// 上游 pairing list --json 的真实形状 (实测): {channel, requests:[...]}
["upstream shape", JSON.stringify({ channel: "telegram", requests: [{ code: "A1" }, { code: "B2" }] }), 2],
["upstream empty", JSON.stringify({ channel: "telegram", requests: [] }), 0],
["plain array", JSON.stringify([{ code: "X1" }]), 1],
["noise around json", "WARN boot\n[{\"code\":\"R1\"}]\ndone", 1],
["dedupe", JSON.stringify([{ code: "D" }, { code: "D" }]), 1],
["empty output", "", 0],
["garbage", "not json at all", 0],
];
let bad = [];
for (const [name, input, want] of cases) {
const got = parse(input);
if (!Array.isArray(got) || got.length !== want) {
bad.push(`${name}: want ${want}, got ${JSON.stringify(got)}`);
}
}
if (bad.length) { console.error(bad.join("; ")); process.exit(1); }
' "$JS_CONFIG" || fail "parsePairingCodes behavior mismatch"
echo "ok"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# 微信插件生命周期契约 (安装 / 登录 / 登出 / 卸载)。
#
# 背景 (实机验证发现):
# action_wechat_uninstall 删除了插件与状态目录,却不重启网关。网关进程仍在
# 内存中持有已加载的微信渠道,会把会话游标 (get_updates_buf, 内含账号标识)
# 写回刚被删除的 .openclaw/openclaw-weixin/accounts/ 目录 —— 于是目录被重建,
# 卸载后仍残留登录态。实测该残留文件 mtime 与卸载时刻相同,是"删除后被重建"。
#
# 对比证据: action_wechat_login 与 action_wechat_logout 本来就会重启网关,
# 只有卸载遗漏了这一步,属实现不一致。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
CONTROLLER="$REPO_ROOT/luasrc/controller/openclaw.lua"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$CONTROLLER" ] || fail "missing $CONTROLLER"
fnbody() {
awk "/^function $1/,/^end\$/" "$CONTROLLER"
}
# ── 卸载必须重启网关,否则登录态会被写回 ──
UNINSTALL=$(fnbody action_wechat_uninstall)
[ -n "$UNINSTALL" ] || fail "cannot locate action_wechat_uninstall"
printf '%s' "$UNINSTALL" | grep -Fq '/etc/init.d/openclaw restart' \
|| fail "uninstall must restart the gateway — otherwise the still-loaded wechat channel rewrites its session state into the deleted directory"
# 重启后需再清理一次状态目录 (重启是异步的)
printf '%s' "$UNINSTALL" | grep -Fq 'wechat_state_dir' \
|| fail "uninstall must remove the wechat state directory"
printf '%s' "$UNINSTALL" | grep -Eq 'sleep [0-9]+; *rm -rf' \
|| fail "uninstall must re-clean the state dir after the async gateway restart"
# ── 登录/登出本来就重启,不得退化 ──
for fn in action_wechat_login action_wechat_logout; do
body=$(fnbody "$fn")
[ -n "$body" ] || fail "cannot locate $fn"
printf '%s' "$body" | grep -Eq '/etc/init.d/openclaw (restart|reload)|restart_gateway' \
|| fail "$fn must reload the gateway so the channel change takes effect"
done
# ── 卸载必须清理配置中的微信痕迹,但不得动无关配置 ──
printf '%s' "$UNINSTALL" | grep -Fq "x !== 'openclaw-weixin' && x !== 'weixin'" \
|| fail "uninstall must drop both the current and legacy wechat plugin ids from plugins.allow"
# 不得整体清空 channels / models / plugins
if printf '%s' "$UNINSTALL" | grep -Eq 'delete d\.channels[^.\[]|d\.channels *= *\{\}|d\.models *= *\{\}'; then
fail "uninstall must not wipe unrelated channels or model config"
fi
# ── 安装/卸载都必须经 POST + CSRF ──
for ep in wechat_install wechat_uninstall wechat_login wechat_logout wechat_upgrade_plugin; do
line=$(grep -F "\"openclaw\", \"$ep\"}" "$CONTROLLER" | head -1)
[ -n "$line" ] || fail "endpoint $ep not registered"
case "$line" in
*'post("action_'*) ;;
*) fail "endpoint $ep must require POST + CSRF (it changes plugin state)" ;;
esac
done
echo "ok"
+66
View File
@@ -0,0 +1,66 @@
#!/bin/sh
# 微信插件升级检测契约。
#
# 背景: action_wechat_check_upgrade 用 `npx view @tencent-weixin/openclaw-weixin version`
# 查询最新版本,但 view 是 npm 的子命令而非 npx 的。npx 会把 view 当作待执行的包
# 去解析并失败:
# npm error could not determine executable to run
# 该错误被 2>/dev/null 吞掉后 latest_version 恒为空,is_newer_version("", x) 恒为
# false,前端又把这种情况落到 else if (d.current_version) 分支显示
# "✅ 当前已是最新版本" —— 于是升级检测从未真正工作过,真有新版也永远不提示。
#
# 本测试锁定: 用 npm 而非 npx;查询失败必须显式报错而不是伪装成"已是最新"。
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
CONTROLLER="$REPO_ROOT/luasrc/controller/openclaw.lua"
VIEW="$REPO_ROOT/luasrc/view/openclaw/wechat.htm"
fail() {
echo "FAIL: $1" >&2
exit 1
}
[ -f "$CONTROLLER" ] || fail "missing $CONTROLLER"
[ -f "$VIEW" ] || fail "missing $VIEW"
# 取出升级检测函数体
BODY=$(awk '/^function action_wechat_check_upgrade/,/^end$/' "$CONTROLLER")
[ -n "$BODY" ] || fail "cannot locate action_wechat_check_upgrade"
# ── 必须用 npm view,不能用 npx view ──
printf '%s' "$BODY" | grep -Fq 'npm_bin' \
|| fail "check_upgrade must resolve the npm binary (view is an npm subcommand, not npx)"
if printf '%s' "$BODY" | grep -E 'npx[a-z_]*_bin|npx.*view' | grep -qv '^[[:space:]]*--'; then
fail "check_upgrade must not call 'npx view' — npx cannot run npm's view subcommand"
fi
printf '%s' "$BODY" | grep -Fq 'view @tencent-weixin/openclaw-weixin version' \
|| fail "check_upgrade must query the wechat plugin version"
# ── 查询失败必须可诊断,不能被 2>/dev/null 吞掉 ──
if printf '%s' "$BODY" | grep -F 'view @tencent-weixin/openclaw-weixin version 2>/dev/null' | grep -q .; then
fail "check_upgrade must not discard stderr — a failed query would look like 'up to date'"
fi
printf '%s' "$BODY" | grep -Fq 'check_err' \
|| fail "check_upgrade must retain the error output for diagnostics"
# ── 响应必须区分"已是最新"与"查不到" ──
printf '%s' "$BODY" | grep -Fq 'status = (latest_version ~= "") and "ok" or "error"' \
|| fail "check_upgrade must report status=error when the version lookup fails"
printf '%s' "$BODY" | grep -Fq 'message' \
|| fail "check_upgrade must return a message explaining a failed lookup"
# ── 前端不得把查询失败显示成"已是最新版本" ──
# 正确顺序: has_upgrade -> 未安装 -> 查询失败 -> 已是最新
printf '%s' "$(cat "$VIEW")" | grep -Fq "d.status !== 'ok' || !d.latest_version" \
|| fail "wechat.htm must handle a failed version lookup before claiming 'up to date'"
printf '%s' "$(cat "$VIEW")" | grep -Fq '无法确认最新版本' \
|| fail "wechat.htm must surface an explicit 'cannot determine latest version' state"
# 旧的错误分支顺序不得复现: else if (d.current_version) 直接说已是最新
if grep -F 'else if (d.current_version) {' "$VIEW" | grep -q .; then
fail "wechat.htm still falls back to 'up to date' whenever current_version exists"
fi
echo "ok"
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.8.26
PKG_RELEASE:=245
PKG_RELEASE:=246
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -40,7 +40,7 @@ function index()
entry({"admin", "services", appname, "settings"}, cbi(appname .. "/client/global"), _("Basic Settings"), 1).dependent = true
entry({"admin", "services", appname, "node_list"}, cbi(appname .. "/client/node_list"), _("Node List"), 2).dependent = true
entry({"admin", "services", appname, "node_subscribe"}, cbi(appname .. "/client/node_subscribe"), _("Node Subscribe"), 3).dependent = true
entry({"admin", "services", appname, "other"}, cbi(appname .. "/client/other", {autoapply = true}), _("Other Settings"), 92).leaf = true
entry({"admin", "services", appname, "other"}, cbi(appname .. "/client/other"), _("Other Settings"), 92).leaf = true
if api.is_finded("haproxy") then
entry({"admin", "services", appname, "haproxy"}, cbi(appname .. "/client/haproxy"), _("Load Balancing"), 93).leaf = true
end
@@ -34,6 +34,7 @@ o = s:option(Flag, "firewall_allow", translate("Firewall Allow"))
o.default = "0"
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -461,6 +461,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -473,6 +473,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -77,6 +77,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -108,6 +108,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -152,6 +152,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
+3 -43
View File
@@ -1439,17 +1439,9 @@ function set_default_cbi()
if not config then config = c_config end
default_init(self, config, ...)
self.api = require "luci.passwall.api"
end
if is_js_luci() == true then
local default_parse = Map.parse
function Map.parse(self, ...)
apply_redirect(self)
local old = self.on_after_save
self.on_after_save = function(self)
if old then old(self) end
self:set("@global[0]", "timestamp", os.time())
end
return default_parse(self, ...)
if is_js_luci() == true then
self.apply_on_parse = false
self.is_js_luci = true
end
end
function Map.foreach(self, stype, func)
@@ -1721,38 +1713,6 @@ function format_go_time(input, default)
return result
end
function apply_redirect(m)
local tmp_uci_file = "/etc/config/" .. c_config .. "_redirect"
if m.redirect and m.redirect ~= "" then
if fs.access(tmp_uci_file) then
local redirect
for line in io.lines(tmp_uci_file) do
redirect = line:match("option%s+url%s+['\"]([^'\"]+)['\"]")
if redirect and redirect ~= "" then break end
end
if redirect and redirect ~= "" then
sys.call("/bin/rm -f " .. tmp_uci_file)
luci.http.redirect(redirect)
end
else
fs.writefile(tmp_uci_file, "config redirect\n")
end
local old = m.on_after_save
m.on_after_save = function(self)
if old then
old(self)
end
local redirect = self.redirect
if redirect and redirect ~= "" then
uci:set(c_config .. "_redirect", "@redirect[0]", "url", redirect)
end
end
else
uci:revert(c_config .. "_redirect")
sys.call("/bin/rm -f " .. tmp_uci_file)
end
end
function match_node_rule(name, rule)
if not name then return false end
if not rule or rule == "" then return true end
@@ -34,28 +34,34 @@ local function ln_run(s, d, command, output)
return string.format("%s >%s 2>&1 &", d .. " " .. command, output)
end
local function remove_firewall_rules(defer_commit)
local to_delete = {}
uci:foreach("firewall", "rule", function(rule)
local name = rule[".name"]
if name and name:find(CONFIG, 1, true) == 1 then
table.insert(to_delete, name)
end
end)
if #to_delete > 0 then
for _, name in ipairs(to_delete) do
uci:delete("firewall", name)
end
if not defer_commit then
api.uci_save(uci, "firewall", true, true)
end
return true
end
return false
end
local function start()
local enabled = tonumber(api.uci_get_s("@global[0]", "enable") or 0)
if enabled == nil or enabled == 0 then
return
end
-- ==================== 启动前:清理所有旧防火墙规则 ====================
local fw_changed = false
local to_delete = {}
uci:foreach("firewall", "rule", function(rule)
if rule[".name"]:find(CONFIG) == 1 then
table.insert(to_delete, rule[".name"])
end
end)
for _, name in ipairs(to_delete) do
cmd("uci delete firewall." .. name)
fw_changed = true
end
if fw_changed then
cmd("uci commit firewall")
end
-- =========================================================================
-- 启动前清理所有旧防火墙规则
local fw_changed = remove_firewall_rules(true)
cmd(string.format("mkdir -p %s %s", CONFIG_PATH, TMP_BIN_PATH))
cmd(string.format("touch %s", LOG_APP_FILE))
@@ -169,44 +175,22 @@ local function start()
if firewall_allow == "1" then
firewall_num = firewall_num + 1
local uid = CONFIG .. "_" .. id
cmd("uci set firewall." .. uid .. "=rule")
cmd("uci set firewall." .. uid .. ".name='".. uid .."'")
cmd("uci set firewall." .. uid .. ".src='" .. (server.firewall_allow_src or "wan") .. "'")
cmd("uci set firewall." .. uid .. ".dest_port='" .. port .. "'")
cmd("uci set firewall." .. uid .. ".target='ACCEPT'")
-- uci:section("firewall", "rule", uid)
-- uci:set("firewall", uid, "name", uid)
-- uci:set("firewall", uid, "src", server.firewall_allow_src or "wan")
-- uci:set("firewall", uid, "dest_port", port)
-- uci:set("firewall", uid, "target", "ACCEPT")
uci:section("firewall", "rule", uid)
uci:set("firewall", uid, "name", uid)
uci:set("firewall", uid, "src", server.firewall_allow_src or "wan")
uci:set("firewall", uid, "dest_port", port)
uci:set("firewall", uid, "target", "ACCEPT")
end
end
end)
if firewall_num > 0 or fw_changed then
-- api.uci_save(uci, "firewall", true, true)
cmd("uci commit firewall")
cmd("/etc/init.d/firewall reload >/dev/null 2>&1")
api.uci_save(uci, "firewall", true, true)
end
end
local function stop()
cmd(string.format("/bin/busybox top -bn1 | grep -v 'grep' | grep '%s/' | awk '{print $1}' | xargs kill -9 >/dev/null 2>&1", CONFIG_PATH))
if true then
local to_delete = {}
uci:foreach("firewall", "rule", function(rule)
if rule[".name"]:find(CONFIG) == 1 then
table.insert(to_delete, rule[".name"])
end
end)
if #to_delete > 0 then
for _, name in ipairs(to_delete) do
cmd("uci delete firewall." .. name)
end
cmd("uci commit firewall")
cmd("/etc/init.d/firewall reload >/dev/null 2>&1")
end
end
remove_firewall_rules()
cmd(string.format("rm -rf %s %s", CONFIG_PATH, LOG_APP_FILE))
end
@@ -18,4 +18,47 @@ local map = self.map
});
}
});
</script>
</script>
<% if luci.http.formvalue("cbi.apply") == "1" and map.is_js_luci then -%>
<script type="text/javascript">
document.addEventListener("luci-loaded", function() {
L.env.apply_display = 2; //Write whatever you want, it's all just a fake progress bar anyway.
L.env.apply_holdoff = 1;
let tt;
let flag = 2;
if (flag == 1) {
L.ui.changes.confirm(false, Date.now() + L.env.apply_rollback * 1000);
} else {
let ts = Date.now();
const deadline = Date.now() + L.env.apply_rollback * 1000;
const tick = () => {
const now = Date.now();
L.ui.changes.displayStatus('notice spinning',
E('p', _('Applying configuration changes… %ds')
.format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
if (now >= deadline)
return;
tt = window.setTimeout(tick, 1000 - (now - ts));
ts = now;
};
tick();
}
window.setTimeout(() => {
<% if not map.redirect then -%>
window.clearTimeout(tt);
L.ui.changes.displayStatus(false);
window.location = window.location.href.split('#')[0];
<%- else %>
window.location.href = "<%=map.redirect%>";
<%- end %>
}, (L.env.apply_display + L.env.apply_holdoff) * 1000);
});
</script>
<%- end %>
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.8.20
PKG_RELEASE:=101
PKG_VERSION:=26.8.27
PKG_RELEASE:=102
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -37,7 +37,7 @@ function index()
entry({"admin", "services", appname, "settings"}, cbi(appname .. "/client/global"), _("Basic Settings"), 1).dependent = true
entry({"admin", "services", appname, "node_list"}, cbi(appname .. "/client/node_list"), _("Node List"), 2).dependent = true
entry({"admin", "services", appname, "node_subscribe"}, cbi(appname .. "/client/node_subscribe"), _("Node Subscribe"), 3).dependent = true
entry({"admin", "services", appname, "other"}, cbi(appname .. "/client/other", {autoapply = true}), _("Other Settings"), 92).leaf = true
entry({"admin", "services", appname, "other"}, cbi(appname .. "/client/other"), _("Other Settings"), 92).leaf = true
if nixio.fs.access("/usr/sbin/haproxy") then
entry({"admin", "services", appname, "haproxy"}, cbi(appname .. "/client/haproxy"), _("Load Balancing"), 93).leaf = true
end
@@ -1,6 +1,5 @@
local api = require "luci.passwall2.api"
local fs = api.fs
local uci = api.uci
local geo_dir = (api.uci_get_c("@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"):match("^(.*)/")
local geosite_path = geo_dir .. "/geosite.dat"
@@ -460,6 +460,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -478,6 +478,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -77,6 +77,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
@@ -108,6 +108,7 @@ o.default = "0"
o:depends({ custom = false })
o = s:option(Value, "firewall_allow_src", translate("Source zone"))
o.rmempty = false
o.nocreate = true
o.allowany = true
o.default = "wan"
+3 -39
View File
@@ -1449,17 +1449,9 @@ function set_default_cbi()
if not config then config = c_config end
default_init(self, config, ...)
self.api = require "luci.passwall2.api"
end
if is_js_luci() == true then
local default_parse = Map.parse
function Map.parse(self, ...)
apply_redirect(self)
local old = self.on_after_save
self.on_after_save = function(self)
if old then old(self) end
self:set("@global[0]", "timestamp", os.time())
end
return default_parse(self, ...)
if is_js_luci() == true then
self.apply_on_parse = false
self.is_js_luci = true
end
end
function Map.foreach(self, stype, func)
@@ -1702,34 +1694,6 @@ function format_go_time(input, default)
return result
end
function apply_redirect(m)
local tmp_uci_file = "/etc/config/" .. c_config .. "_redirect"
if m.redirect and m.redirect ~= "" then
if fs.access(tmp_uci_file) then
local redirect
for line in io.lines(tmp_uci_file) do
redirect = line:match("option%s+url%s+['\"]([^'\"]+)['\"]")
if redirect and redirect ~= "" then break end
end
if redirect and redirect ~= "" then
sys.call("/bin/rm -f " .. tmp_uci_file)
luci.http.redirect(redirect)
end
else
fs.writefile(tmp_uci_file, "config redirect\n")
end
m.on_after_save = function(self)
local redirect = self.redirect
if redirect and redirect ~= "" then
uci:set(c_config .. "_redirect", "@redirect[0]", "url", redirect)
end
end
else
uci:revert(c_config .. "_redirect")
sys.call("/bin/rm -f " .. tmp_uci_file)
end
end
function match_node_rule(name, rule)
if not name then return false end
if not rule or rule == "" then return true end
@@ -18,4 +18,46 @@ local map = self.map
});
}
});
</script>
</script>
<% if luci.http.formvalue("cbi.apply") == "1" and map.is_js_luci then -%>
<script type="text/javascript">
document.addEventListener("luci-loaded", function() {
L.env.apply_display = 2; //Write whatever you want, it's all just a fake progress bar anyway.
L.env.apply_holdoff = 1;
let tt;
let flag = 2;
if (flag == 1) {
L.ui.changes.confirm(false, Date.now() + L.env.apply_rollback * 1000);
} else {
let ts = Date.now();
const deadline = Date.now() + L.env.apply_rollback * 1000;
const tick = () => {
const now = Date.now();
L.ui.changes.displayStatus('notice spinning',
E('p', _('Applying configuration changes… %ds')
.format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
if (now >= deadline)
return;
tt = window.setTimeout(tick, 1000 - (now - ts));
ts = now;
};
tick();
}
window.setTimeout(() => {
<% if not map.redirect then -%>
window.clearTimeout(tt);
L.ui.changes.displayStatus(false);
window.location = window.location.href.split('#')[0];
<%- else %>
window.location.href = "<%=map.redirect%>";
<%- end %>
}, (L.env.apply_display + L.env.apply_holdoff) * 1000);
});
</script>
<%- end %>
@@ -399,7 +399,7 @@ load_acl() {
$ipt_tmp -A PSW2 $(comment "$remarks") ${_ipt_source} -p tcp -j RETURN
[ "$_ipv4" != "1" ] && $ip6t_m -A PSW2 $(comment "$remarks") ${_ipt_source} -p tcp -j RETURN 2>/dev/null
[ -z "$no_tcp_proxy" ] && [ -n "$redir_port" ] && {
[ -z "$no_udp_proxy" ] && [ -n "$redir_port" ] && {
msg2="${msg}$(i18n "Use the %s node [%s]" "UDP" "${node_remarks}")(TPROXY:${redir_port})"
$ipt_m -A PSW2 $(comment "$remarks") -p udp ${_ipt_source} -d $FAKE_IP -j PSW2_RULE
@@ -544,10 +544,10 @@ load_acl() {
$ip6t_m -I OUTPUT $(comment "mangle-OUTPUT-PSW2") -o lo -j RETURN
insert_rule_before "$ip6t_m" "OUTPUT" "mwan3" "$(comment mangle-OUTPUT-PSW2) -m mark --mark ${FWMARK} -j RETURN"
$ipt_m -A PSW2 -p tcp --dport 53 -j ACCEPT
$ipt_m -A PSW2 -p udp --dport 53 -j ACCEPT
$ip6t_m -A PSW2 -p tcp --dport 53 -j ACCEPT
$ip6t_m -A PSW2 -p udp --dport 53 -j ACCEPT
$ipt_m -I PSW2 -p tcp --dport 53 -j ACCEPT
$ipt_m -I PSW2 -p udp --dport 53 -j ACCEPT
$ip6t_m -I PSW2 -p tcp --dport 53 -j ACCEPT
$ip6t_m -I PSW2 -p udp --dport 53 -j ACCEPT
unset msg msg2 comment_l
}
@@ -430,7 +430,7 @@ load_acl() {
nft "add rule $NFTABLE_NAME $nft_prerouting_chain ip protocol tcp ${_ipt_source} counter return comment \"$remarks\""
[ "$_ipv4" != "1" ] && nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto tcp ${_ipt_source} counter return comment \"$remarks\"" 2>/dev/null
[ -z "$no_tcp_proxy" ] && [ -n "$redir_port" ] && {
[ -z "$no_udp_proxy" ] && [ -n "$redir_port" ] && {
msg2="${msg}$(i18n "Use the %s node [%s]" "UDP" "${node_remarks}")(TPROXY:${redir_port})"
nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol udp ${_ipt_source} ip daddr $FAKE_IP counter jump PSW2_RULE comment \"$remarks\""
@@ -572,10 +572,10 @@ load_acl() {
nft "add rule $NFTABLE_NAME mangle_output oif lo counter return comment \"PSW2_OUTPUT_MANGLE\""
nft "add rule $NFTABLE_NAME mangle_output meta mark ${FWMARK} counter return comment \"PSW2_OUTPUT_MANGLE\""
nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol tcp tcp dport 53 counter accept"
nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol udp udp dport 53 counter accept"
nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto tcp tcp dport 53 counter accept"
nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto udp udp dport 53 counter accept"
nft "insert rule $NFTABLE_NAME PSW2_MANGLE ip protocol tcp tcp dport 53 counter accept"
nft "insert rule $NFTABLE_NAME PSW2_MANGLE ip protocol udp udp dport 53 counter accept"
nft "insert rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto tcp tcp dport 53 counter accept"
nft "insert rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto udp udp dport 53 counter accept"
unset msg msg2 comment_l
}
+1 -2
View File
@@ -199,8 +199,7 @@ get_online_version() {
local pkg="$2"
if $USE_APK; then
apk --cache-dir $LISTS_DIR --cache-max-age 0 --repositories-file /dev/null --repository "$index" update >/dev/null 2>&1
local ver=$(apk --cache-dir $LISTS_DIR --cache-max-age 0 --repositories-file /dev/null --repository "$index" query --available --fields version "$pkg" 2>/dev/null | grep -Fm1 'Version: ' | sed 's/^Version: //')
echo "${ver:-0.2.0-r1}"
apk --cache-dir $LISTS_DIR --cache-max-age 0 --repositories-file /dev/null --repository "$index" query --available --fields version "$pkg" 2>/dev/null | grep -Fm1 'Version: ' | sed 's/^Version: //'
else
curl --connect-timeout 2 --max-time 5 -s "$index" | gunzip | grep -FA10 "Package: $pkg" | grep -Fm1 'Version: ' | sed 's/^Version: //'
fi
+13
View File
@@ -0,0 +1,13 @@
# This is free software, licensed under the Apache License, Version 2.0 .
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI shared OpenWrt auth bridge for LinkEase apps
LUCI_PKGARCH:=all
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,171 @@
module("luci.controller.linkease_auth", package.seeall)
function index()
local auth = entry({"admin", "services", "linkease_auth", "auth"}, call("linkease_auth"))
auth.leaf = true
auth.dependent = false
auth.sysauth = "root"
auth.sysauth_authenticator = "htmlauth"
local auth_finish = entry({"admin", "services", "linkease_auth", "auth_finish"}, call("linkease_auth_finish"))
auth_finish.leaf = true
auth_finish.dependent = false
auth_finish.sysauth = "root"
auth_finish.sysauth_authenticator = "htmlauth"
end
local function retrieve_luci_session()
local http = require "luci.http"
local util = require "luci.util"
for _, key in ipairs({"sysauth_https", "sysauth_http", "sysauth"}) do
local sid = http.getcookie(key)
if sid and sid ~= "" then
local sdat = util.ubus("session", "get", { ubus_rpc_session = sid })
if sdat and type(sdat.values) == "table" then
return sid
end
end
end
return nil
end
local function authority_host(authority)
if not authority or authority == "" then
return ""
end
if authority:sub(1, 1) == "[" then
return authority:match("^%[([^%]]+)%]") or ""
end
return authority:match("^([^:]+)") or authority
end
local function valid_apps_return(value)
if not value or value == "" then
return false
end
local function valid_path(path)
if path == "/apps" then
return true
end
local prefix = path:sub(1, 6)
return prefix == "/apps/" or prefix == "/apps?" or prefix == "/apps#"
end
if value:sub(1, 1) == "/" then
return valid_path(value)
end
local scheme, authority, path = value:match("^(https?://)([^/]+)(/.*)$")
if not scheme or not authority or not valid_path(path) then
return false
end
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local request_host = http.getenv("HTTP_HOST") or ""
local lan_host = uci:get("network", "lan", "ipaddr") or ""
local authority_host_value = authority_host(authority)
if authority_host_value ~= "" and authority_host_value == authority_host(request_host) then
return true
end
if authority_host_value ~= "" and authority_host_value == authority_host(lan_host) then
return true
end
-- Example: http://192.168.30.93:8192/apps/dockermanager/
return false
end
local function valid_cookie_value(value)
return value and value:match("^[A-Za-z0-9._%-_]+$") ~= nil
end
local pending_return_cookie = "linkease_openwrt_pending_return"
local bridge_return_cookie = "linkease_openwrt_return"
local pending_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth"
local bridge_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth/auth"
local function cookie_encode(value)
return tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char)
return string.format("%%%02X", char:byte())
end)
end
local function cookie_decode(value)
if not value or value == "" then
return nil
end
return value:gsub("%%(%x%x)", function(hex)
return string.char(tonumber(hex, 16))
end)
end
local function safe_return_target(value)
if valid_apps_return(value) then
return value
end
return "/apps/"
end
local function set_pending_return_cookie(target)
local http = require "luci.http"
http.header("Set-Cookie", pending_return_cookie .. "=" .. cookie_encode(target) .. "; Path=" .. pending_return_cookie_path .. "; Max-Age=300; HttpOnly; SameSite=Lax")
end
local function clear_pending_return_cookie()
local http = require "luci.http"
http.header("Set-Cookie", pending_return_cookie .. "=; Path=" .. pending_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
end
local function clear_bridge_return_cookie()
local http = require "luci.http"
http.header("Set-Cookie", bridge_return_cookie .. "=; Path=" .. bridge_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
end
local function requested_return_target()
local http = require "luci.http"
return safe_return_target(http.formvalue("return") or cookie_decode(http.getcookie(bridge_return_cookie)) or "/apps/")
end
local function pending_return_target()
local http = require "luci.http"
return safe_return_target(cookie_decode(http.getcookie(pending_return_cookie)))
end
local function auth_finish_url()
local dispatcher = require "luci.dispatcher"
return dispatcher.build_url("admin", "services", "linkease_auth", "auth_finish")
end
function linkease_auth()
local http = require "luci.http"
local sid = retrieve_luci_session()
local target = requested_return_target()
if valid_cookie_value(sid) then
clear_bridge_return_cookie()
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
http.redirect(target)
return
end
set_pending_return_cookie(target)
http.redirect(auth_finish_url())
end
function linkease_auth_finish()
local http = require "luci.http"
local sid = retrieve_luci_session()
if not valid_cookie_value(sid) then
http.status(403, "Forbidden")
return
end
local target = pending_return_target()
clear_pending_return_cookie()
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
http.redirect(target)
end
@@ -0,0 +1,5 @@
#!/bin/sh
rm -f /tmp/luci-indexcache*
exit 0
+65 -73
View File
@@ -17,15 +17,15 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=36
PKG_RELEASE:=37
endif
LUCI_TITLE:=Footstrap Theme
# +luci-base is the WHOLE list, and keeping it that way is the constraint. Nothing this package
# ships fetches anything at run time (the self-update script is retired), so there is no second
# name to justify — and the rule that got it here stands: the one convenience tool that ever wanted
# a dep was `curl`, which is NOT in OpenWrt's default set, on an 8 MB device, for one API call. Fall
# back to what the base image has (uclient-fetch, jsonfilter, sha256sum) or do without.
# +luci-base is the WHOLE list, and keeping it that way is the constraint. Nothing this package ships
# fetches anything at run time, so there is no second name to justify — and the rule that got it here
# stands: the one convenience tool that ever wanted a dep was `curl`, which is NOT in OpenWrt's
# default set, on an 8 MB device, for one API call. Fall back to what the base image has
# (uclient-fetch, jsonfilter, sha256sum) or do without.
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
# Else luci.mk defaults these to LuCI's own, and the package claims MAINTAINER "OpenWrt LuCI
@@ -33,60 +33,55 @@ LUCI_PKGARCH:=all
LUCI_MAINTAINER:=VizzleTF <vizzletf47@gmail.com>
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.
# CSS: ship verbatim. luci.mk would run csstidy over it, which is old enough to mangle :has(),
# color-mix() and nested calc() and break the layout. build-css.sh minifies instead.
LUCI_MINIFY_CSS:=0
#
# JS: TWO minify paths, chosen by who is building.
# 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.
# 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.
#
# 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.
# Everyone else (an SDK user, the buildbot — no node) builds the untouched source and keeps
# luci.mk's default jsmin: ~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.
# 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.
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.
# 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.
# No OFL-1.1: the theme ships no Font Software, so declaring it would be a false statement about the
# package's contents.
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE
# /etc/config/footstrap is SHIPPED as an empty stub and WRITTEN AT RUNTIME: Appearance -> "Save as
# default" has rpcd uci-set the router-wide axes into that very file (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.
# /etc/config/footstrap is SHIPPED as an empty stub and WRITTEN AT RUNTIME: Appearance ->
# "Save as default" has rpcd uci-set the router-wide axes into that very file. Without this define
# the package manager owns it as an ordinary file and REPLACES it on upgrade, so the admin's saved
# defaults are wiped by the theme's own one-click Update, silently and reported as success.
# OpenWrt honours the define for BOTH formats (include/package-pack.mk), so one covers 24.10's opkg
# and 25.12's apk alike. Any future root/etc/config/* must be listed here too.
define Package/luci-theme-footstrap/conffiles
/etc/config/footstrap
endef
# RELOAD, NOT RESTART: rpcd holds sessions in memory, so `restart` logs out every LuCI user —
# reload, never 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.
# Verified on a live router: a session survives `reload` and dies across `restart`.
define Package/luci-theme-footstrap/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
@@ -104,12 +99,11 @@ 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 and wiping the theme
# registration here is what flipped every updating 24.10 user back to bootstrap. apk never runs this
# on upgrade (it uses the new package's pre/post-upgrade), so guarding on the arg is correct for both
# managers; real removal passes "remove" (opkg) or no arg and proceeds below.
# opkg runs the OLD package's postrm with arg "upgrade" during a version upgrade and "remove" on a
# real removal. On upgrade this script MUST change nothing: reverting mediaurlbase and wiping the
# theme registration here is what flipped every updating 24.10 user back to bootstrap. apk never
# runs this on upgrade (it uses the new package's pre/post-upgrade), so guarding on the arg is
# correct for both managers.
case "$$1" in *upgrade*) exit 0 ;; esac
[ -n "$${IPKG_INSTROOT}" ] || {
uci -q delete luci.themes.Footstrap
@@ -130,27 +124,22 @@ case "$$1" in *upgrade*) exit 0 ;; esac
}
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.
# Runs right after luci.mk copies luasrc/ucode/htdocs/root/src into PKG_BUILD_DIR, so it rewrites
# the build copy and never the source tree.
#
# 1. cascade.css is generated, not committed — and styles/ is not in luci.mk's copy list, so
# build-css.sh reads it from $(CURDIR) and writes into the build tree. cat/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). Unconditional: with PKG_VERSION empty it falls back to PKG_SRC_VERSION, which luci.mk
# always defines, so only an un-built source checkout keeps the literal '0.0.0-dev'.
# build-css.sh reads it from $(CURDIR) and writes into the build tree. cat and awk only, which
# is why it runs on an OpenWrt buildbot.
# 2. Stamp the git-derived version into fs-version.js. Unconditional: with PKG_VERSION empty it
# falls back to PKG_SRC_VERSION, which luci.mk always defines, so only an un-built source
# checkout keeps the literal '0.0.0-dev'.
#
# THE CATALOGUE IS luci.mk's BUSINESS, and nothing here compiles it. `po/` is the directory
# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-<lang>` 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-update script that resolved
# the theme by name and took `head -1` (issue #6). That script 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.
# 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-<lang>` 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 was `i18n/` while a fielded self-update script mis-picked a multi-asset
# release with `head -1` (issue #6); that script is retired and owfeed builds one theme artifact
# per format regardless.
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
@@ -174,6 +163,11 @@ define Build/Prepare/luci-theme-footstrap
# script for why the ucode-code `/* … */` deliberately stays. -22 KB of 60, git keeps every word,
# and dev-sync.sh does NOT run this: a router you are debugging on keeps its comments.
$(SHELL) $(CURDIR)/strip-templates.sh $(PKG_BUILD_DIR)/ucode
# The gate-only exports (`/* fs:probe */`). A handful of module-private functions are also
# listed in a module's baseclass so a gate in the source repository can call them — nothing on a
# router does. The functions stay; the export line goes, so the surface a package carries is the
# one the theme itself uses.
$(SHELL) $(CURDIR)/strip-probes.sh $(PKG_BUILD_DIR)/htdocs/luci-static/resources
# …and the same for the shell under root/ (71% and 95% comment lines). Whole-line `#` only.
$(SHELL) $(CURDIR)/strip-shell.sh $(PKG_BUILD_DIR)/root
$(SED) "s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$(if $(PKG_VERSION),$(PKG_VERSION),$(PKG_SRC_VERSION))'#" \
@@ -183,18 +177,16 @@ 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.
# The line below is load-bearing and is NOT a signature — do not delete it as boilerplate.
#
# include/scan.mk builds the package list by GREPPING the Makefiles, not by parsing them:
# find -L package -name Makefile | xargs grep -aHE 'call (Build/DefaultTargets|BuildPackage|KernelPackage)'
# A Makefile that does not match is not in the list, so it is never dumped, no
# CONFIG_PACKAGE_* symbol is emitted for it, and `make package/<name>/compile` answers
# "No rule to make target" — with no error naming the package anywhere in the build.
# 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/<name>/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.
# This theme never calls BuildPackage itself; luci.mk does, at the include above, and the grep
# cannot see that. So the only thing that puts this package into the SDK's list is the literal text
# on the next line. tools/scan-marker.sh re-derives the grep from scan.mk and fails the build if it
# goes missing.
# call BuildPackage - OpenWrt buildroot signature
+1 -1
View File
@@ -1,6 +1,6 @@
# luci-theme-footstrap (the package)
A LuCI theme for OpenWrt **23.05 and newer** (ucode templates). Installing and using it:
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`.
+10 -13
View File
@@ -1,24 +1,21 @@
#!/bin/sh
# Build luci-theme-footstrap as an OpenWrt .apk via the SDK.
# The theme is noarch (CSS, JS and templates only — it ships no fonts), so the package installs on any
# router of that release whatever its CPU architecture.
# Build luci-theme-footstrap as an OpenWrt .apk via the SDK. The theme is noarch, so the package
# installs on any router of that release whatever its CPU architecture.
#
# ./build-apk.sh # download SDK if needed, then build
# BUILD_DIR=~/x ./build-apk.sh
#
# THIS IS NOT HOW THE RELEASE IS BUILT 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.
# This is NOT how the release is built — `./tools/stage.sh && owfeed build` is, and it produces both
# formats in seconds without a toolchain. What this exercises is the OTHER path: the Makefile,
# luci.mk, jsmin and the SDK's own packaging, which has to keep working for anyone building the
# theme in an SDK or a feed without owfeed.
set -e
# The point release and the compiler are both baked into the SDK's filename, so this default goes
# stale twice over: downloads.openwrt.org keeps only the CURRENT point release of a branch (older
# ones move to archive.openwrt.org and the fetch 404s), and a toolchain bump changes gcc-14.3.0.
# Kept in step with owfeed.lock's `point:` for the 25.12 branch — that file is what the release
# actually builds against. Override for any other: OPENWRT_RELEASE=24.10.8 sh build-apk.sh, and
# OPENWRT_SDK_FILE=… when the compiler string differs too.
# stale twice over: downloads.openwrt.org keeps only the CURRENT point release of a branch, and a
# toolchain bump changes the gcc string. Kept in step with owfeed.lock's `point:` for the 25.12
# branch, which is what the release builds against. Override with OPENWRT_RELEASE=… and
# OPENWRT_SDK_FILE=… for any other.
REL="${OPENWRT_RELEASE:-25.12.5}"
SDK_BASE="https://downloads.openwrt.org/releases/${REL}/targets/mediatek/filogic"
SDK_FILE="${OPENWRT_SDK_FILE:-openwrt-sdk-${REL}-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst}"
+30 -36
View File
@@ -3,14 +3,14 @@
#
# ./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).
# Directory order is layer order (styles/, base/, theme/, pages/); filename order is source order
# within a directory. A later layer beats an earlier one whatever the specificity, so nothing needs
# !important to override base.
#
# Minified unless --dev: ~468 KB of source -> ~136 KB (-71%). 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.
# Minified unless --dev: ~468 KB of source to ~136 KB. uhttpd serves CSS with NO gzip, so every byte
# is a wire byte. Comments and whitespace go; a selector or a declaration is never rewritten, which
# is why LuCI's csstidy stays off (it mangles :has() and color-mix()). Needs only cat and awk, so an
# OpenWrt buildbot can run it.
set -e
D="$(cd "$(dirname "$0")" && pwd)"
@@ -42,12 +42,12 @@ cat "$D"/styles/*.css \
"$D"/styles/theme/*.css \
"$D"/styles/pages/*.css > "$TMP"
# Strip /* ... */, keep /*! ... */ (the licence banner), drop indentation/blank lines.
# Strip /* … */, keep /*! … */ (the licence banner), drop indentation and 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.
# String-aware: a scanner that just hunts for the next "/*" lets `content: "/*"` open a comment and
# eat every rule up to the next "*/", with only the brace count below as a guard — and two such
# literals balance each other, so rules can vanish in silence. Quoted data-URIs run through here on
# every build.
strip_comments() {
awk '
BEGIN { inc = 0; q = "" }
@@ -82,23 +82,20 @@ strip_comments() {
# 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.
# Removed (~9.5 KB): the space after `:`, the spaces around `{ } ; ,`, the last `;` of a block, the
# newline after every declaration. 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.
# Left alone, each for a reason:
# - the single space between selectors: `.a .b` is a DESCENDANT combinator, `.a.b` is not;
# - spaces inside calc(): required around `*`, `/` and the `-` of `calc(100% - 8px)`;
# - the LINE BREAK inside a declaration, which is whitespace too — joining lines with nothing
# between them turns a wrapped calc() into `…))- .004 …`, and a `-` with no space before it is
# a parse error, so the declaration drops and the token it defined goes undefined, silently —
# --fs-bg then became invalid at computed-value time and the canvas fell back to white
# (export-tier.mjs caught it: contrast 1.5:1);
# - `>` `+` `~` 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 '
@@ -175,14 +172,11 @@ squeeze() {
# 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 <style> inside an SVG icon carries braces by construction). It fails closed, so
# nothing was ever corrupted; what it costs is an afternoon spent looking for an imbalance that is
# not there, on a message that names the file as broken. Nothing in the tree contains such a byte
# today, which is exactly how this waited for whoever adds the first one — the same sentence
# already written above the comment stripper, which was fixed and left this one behind.
# String-aware, for the same reason the comment stripper is: a brace inside a CSS STRING is not a
# block, so a counter that gsub()s over the raw line makes a perfectly valid rule fail the build.
# Measured, all three shapes: `content: ";}"`, `content: "{"`, and a data-URI carrying `;}`. It
# fails closed, so nothing is ever corrupted; what it costs is a hunt for an imbalance that is not
# there.
brace_count() {
awk '
BEGIN { q = "" }
+26 -32
View File
@@ -43,25 +43,22 @@ tar -C "$D/htdocs/luci-static/resources" -cf - . | ssh "$R" "mkdir -p /www/luci-
FS_V="$(git -C "$D" describe --tags --always 2>/dev/null | sed 's/^v//')"
# if-form, not `[ -n ] && ssh`: under `set -e` a bare `&&`-list whose LAST command fails is what
# aborts, so `[ -n "$FS_V" ] && ssh …` would take the sync down whenever git describe yielded
# nothing (a copied tree without .git). An `&&` used to guard an ASSIGNMENT is a different shape and
# is safe — stage.sh:41 and update-po.sh:39 rely on that. expr refuses a tag with sed/shell-special
# characters rather than interpolating it.
# nothing (a copied tree without .git). An `&&` guarding an ASSIGNMENT is a different shape and is
# safe. expr refuses a tag with sed- or shell-special characters rather than interpolating it.
if [ -n "$FS_V" ] && expr "$FS_V" : '[0-9A-Za-z._-]*$' >/dev/null; then
ssh "$R" "sed -i \"s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$FS_V'#\" /www/luci-static/resources/fs-version.js"
fi
# The catalogue. Nothing in Build/Prepare compiles it, and the RELEASE does not come from luci.mk
# either: owfeed bundles the catalogues inside the theme package (owfeed.yml), so a dev router gets
# them from nowhere unless this sync makes them. po2lmo is a luci-base host tool, absent from a dev
# machine by default, so this is best-effort: without it the strings stay English and the sync still
# The catalogue. Nothing in Build/Prepare compiles it, and the release does not come from luci.mk
# either — owfeed bundles the catalogues inside the theme package — so a dev router gets them from
# nowhere unless this sync makes them. po2lmo is a luci-base host tool, absent from a dev machine
# by default, so this is best-effort: without it the strings stay English and the sync still
# succeeds. (Build it once from the pinned luci checkout: make -C modules/luci-base/src po2lmo —
# plural_formula.c is GENERATED by contrib/lemon, so a bare cc over the .c files cannot work.)
#
# THE BASENAME IS `footstrap-theme`, matching owfeed.yml and NOT the po file's own name: lmo_load_
# catalog globs *.<lang>.lmo so any basename loads, and a v0.8.4 router still owns footstrap.ru.lmo
# through the old luci-i18n package, where the same path would be a file conflict. Sync under a
# different name and the dev router carries two archives for the same msgids, with glob order
# deciding which one the page shows.
# The basename is `footstrap-theme`, matching owfeed.yml and NOT the po file's own name:
# lmo_load_catalog globs *.<lang>.lmo so any basename loads, and a v0.8.4 router still owns
# footstrap.ru.lmo through the old luci-i18n package, where the same path is a file conflict.
if command -v po2lmo >/dev/null 2>&1; then
ssh "$R" "mkdir -p /usr/lib/lua/luci/i18n"
for po in "$D"/po/*/*.po; do
@@ -106,22 +103,21 @@ done
ssh "$R" "/etc/init.d/rpcd reload 2>/dev/null; rm -f /tmp/luci-indexcache*"
ssh "$R" "
# Sweep every pre-consolidation name, INCLUDING $N-top: the top bar is not a theme any more,
# so a stale mediaurlbase pointing at its media dir would still render a theme LuCI no longer
# lists.
# Sweep every pre-consolidation name, including $N-top: the top bar is not a theme any more, so a
# stale mediaurlbase pointing at its media dir would still render a theme LuCI no longer lists.
#
# rm -rf, not rm -f: these are DIRECTORIES (and $N-top a SYMLINK to one, which rm -rf removes
# as the link, not its target). \`rm -f\` refuses a directory, 2>/dev/null swallowed the error
# and this block runs without set -e — so the sweep silently did nothing. Every path is a
# literal built from \$N: no glob, no user input.
# rm -rf, not rm -f: these are DIRECTORIES (and $N-top a symlink to one, which rm -rf removes as the
# link rather than its target). \`rm -f\` refuses a directory, 2>/dev/null swallows the error, and
# this block runs without set -e — so the sweep silently does nothing. Every path is a literal built
# from \$N: no glob, no user input.
cd /www/luci-static
rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light
cd /usr/share/ucode/luci/template/themes
rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light
# The overview layout used to install into luci-mod-status's GLOBAL include dir, where LuCI loads
# every *.js under any theme. It is a chrome module (fs-overview.js) now. A package upgrade drops
# the old file; dev-sync copies rather than installs, so it has to be swept by name — otherwise the
# dev router keeps running BOTH copies and the grid is arranged twice.
# The overview layout must not be left in luci-mod-status's global include dir, where LuCI loads
# every *.js under any theme: it is a chrome module now. A package upgrade drops the old file, but
# dev-sync copies rather than installs, so it has to be swept by name — otherwise the dev router
# runs both copies and the grid is arranged twice.
rm -f /www/luci-static/resources/view/status/include/05_footstrap_overview_layout.js
for db in /lib/apk/db/installed /usr/lib/opkg/status; do [ -f \"\$db\" ] && touch \"\$db\"; done
rm -f /tmp/luci-indexcache*"
@@ -133,18 +129,16 @@ scp -q "$D"/root/etc/uci-defaults/30_luci-theme-footstrap "$R":/tmp/30_luci-them
ssh "$R" "PKG_UPGRADE=1 sh /tmp/30_luci-theme-footstrap; rm -f /tmp/30_luci-theme-footstrap /tmp/luci-indexcache*"
# ---- BUST THE BROWSER CACHE, or a dev deploy is invisible to the device you are testing on ----
# ---- bust the browser cache, or a dev deploy is invisible to the device you are testing on ----
#
# head.ut versions every asset with `?v={{ pkgs_update_time }}`, and luci-base derives that from the
# mtime of the PACKAGE DATABASE (runtime.uc: /lib/apk/db/installed, falling back to
# /usr/lib/opkg/status). Installing a package moves it; scp'ing files over an existing install does
# not — so a dev deploy leaves the URL identical while the bytes underneath change, and a browser is
# entitled to keep serving what it already has. On a phone that is not a theory: a whole afternoon of
# "still shakes" reports were taken against a stylesheet several deploys old.
# mtime of the PACKAGE DATABASE. Installing a package moves it; scp'ing files over an existing
# install does not — so a dev deploy leaves the URL identical while the bytes change, and a browser
# is entitled to keep serving what it has. On a phone that is not a theory: an afternoon of "still
# shakes" reports were taken against a stylesheet several deploys old.
#
# Touching the database file is what an install would have done to it, so the next page carries a new
# `?v=` for every asset and nothing can be stale. Router-only, dev-only: nothing in the package does
# this.
# Touching the database file is what an install would have done to it, so the next page carries a
# new `?v=` for every asset. Router-only, dev-only: nothing in the package does this.
ssh "$R" 'touch /lib/apk/db/installed 2>/dev/null || touch /usr/lib/opkg/status 2>/dev/null || true'
echo "synced to $R (single Footstrap theme registered, active theme unchanged)"
@@ -6,91 +6,61 @@
'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.
/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js
* holds the axes and fs-version.js the version string; this file is the form they are shown in.
*
* 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 — twenty-one 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 a tab on System -> System, beside General Settings / Logging / Time Synchronization /
* Language and Style. Twenty-one axes, nine of them with a colour field, a swatch and a contrast
* readout, do not fit a floating popover that has to trap Tab and stay inside a 320px column, and
* keeping both containers would render every axis twice.
*
* 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 form is appended by a MutationObserver rather than by a route of its own, the same boundary
* fs-overview.js sits on: a theme may not own a dispatcher node, because the node outlives the
* theme that registered it and the menu would keep an entry whose view is gone. So the theme owns
* no menu.d and no view — it watches for the stock page, adds one section and removes nothing.
*
* 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. */
* The version line makes no request and must not grow one: which version is INSTALLED is what this
* page answers, and which is available is the package manager's question. */
/* 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. */
* Everything applies immediately and there is nothing to save: every axis is this browser's, in
* localStorage, and the page repaints under the control as it moves. Only "Save as default" writes
* anything, pushing the current look to the ROUTER for other browsers. That distinction is the
* model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own. */
function render() {
/* A promise, and the build runs INSIDE it rather than as its argument: `Promise.resolve(build())`
* evaluates build() synchronously, so a throw in it unwound out of render() before mount() could
* attach .catch/.finally — leaving mount()'s _building flag set for the life of the document. The
* tab then never built again on that page and nothing was logged, which is both channels silent
* at once. Deferred like this, the same throw lands in the .catch that exists for it. */
/* build() runs inside the promise, not as its argument: `Promise.resolve(build())` evaluates it
* synchronously, so a throw unwinds out of render() before mount() can attach .catch/.finally
* and leaves _building set for the life of the document — the tab then never builds again and
* nothing is logged. */
return Promise.resolve().then(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). */
/* every saved axis re-checks the Save button after applying, so it greys the moment this
* browser matches the saved default and un-greys when it diverges. Wrapped around the appliers
* because the controls call them directly and have no other seam back here. */
const bump = (fn) => (v) => { fn(v); refreshSave(); };
/* Every colour control mirrors something it does not own — the PALETTE's colour, while its own
* axis is off, and the contrast that colour lands at. A palette switch, 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. */
/* Every colour control mirrors something it does not own: the palette's colour while its own
* axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves
* all of it under controls nobody touched, so they refresh together. */
const colourCtls = [];
const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh());
/* wrap an applier so the colour readouts follow it: mode and palette change what every axis is
* measured against, and a preset changes the axes themselves */
* measured against */
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.
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
* `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and
* theme/60-inputs.css already lay them out — so this page inherits every future fix to the form
* layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2,
* where an app may win on specificity), which is right for a page inside #view.
*
* 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. */
* `make` is handed the same label string the caption renders, because every control needs it a
* second time as its aria-label; stating it twice is how the visible caption and what a screen
* reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks
* the rows CSS has to single out. */
const group = (label, make, opts) => {
const o = opts || {};
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
@@ -99,24 +69,18 @@ function build() {
]);
};
/* ---- the CONTROLS are LuCI's own, not this theme's ------------------------------------------
/* ---- the controls are LuCI's own ----
*
* 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.
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other
* tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css,
* `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports
* — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further
* back.
*
* 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 `<select>` inside it read as a hole in the card. On a page there is no such argument, and
* a control LuCI maintains is one this theme cannot get wrong on its own.
*
* THE EVENT IS `widget-change`, dispatched by UIElement, not a listener on the inner element —
* that is the seam the widget publishes, and reaching past it to the `<select>` 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. */
* Listen for `widget-change` from UIElement, not for an event on the inner element: that is the
* seam the widget publishes, and reaching past it ties this to how the widget happens to be
* built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes
* the page under the drag; the appliers are idempotent, so wiring both costs nothing. */
const selectCtl = (current, choices, apply, label) => {
const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) });
const node = w.render();
@@ -124,52 +88,11 @@ function build() {
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
/* THE ONE WIDGET THIS THEME CANNOT ASSUME, and the reason 23.05 is a supported release again.
*
* `ui.RangeSlider` arrived in 24.10. On 23.05 it is simply not there, and because this whole
* panel is built inside one try/catch the miss took the ENTIRE Appearance tab with it — a
* console line and an empty tab, reported from the field rather than by a gate, because no gate
* had ever opened a 23.05 router.
*
* So the widget is used where it exists and reproduced where it does not: same DOM, same class
* names (`.cbi-range-slider` and its value/units children are what theme/60-inputs.css dresses),
* same two events, and the same base class — `ui.AbstractElement` is the name LuCI exports on
* BOTH 23.05 and master, which is what makes `setUpdateEvents`/`setChangeEvents` available to
* the copy. Nothing below this line knows which of the two it got.
*
* Kept deliberately smaller than upstream's: `calculate` is not reproduced, because no axis on
* this page uses it. If one ever does, use the real widget's shape rather than growing this. */
const RangeSlider = ui.RangeSlider || ui.AbstractElement.extend({
__init__(value, options) {
this.value = value;
this.options = Object.assign({ min: 0, max: 100, step: 1, calcunits: null }, options);
},
render() {
this.sliderEl = E('input', {
type: 'range', min: this.options.min, max: this.options.max,
step: this.options.step || 'any', value: this.value
});
this.valueEl = E('output', { class: 'cbi-range-slider-value' }, String(this.value));
const node = E('div', { class: 'cbi-range-slider' }, [
this.sliderEl,
this.valueEl,
this.options.calcunits ? E('span', { class: 'cbi-range-slider-calc-units' }, this.options.calcunits) : null
].filter(Boolean));
this.node = node;
this.setUpdateEvents(this.sliderEl, 'input', 'blur');
this.setChangeEvents(this.sliderEl, 'change');
this.sliderEl.addEventListener('input', () => { this.valueEl.textContent = this.sliderEl.value; });
dom.bindClassInstance(node, this);
return node;
},
getValue() { return this.sliderEl.value; },
setValue(value) { this.sliderEl.value = value; this.valueEl.textContent = value; }
});
const sliderCtl = (current, min, max, apply, label, opts) => {
const o = opts || {};
const w = new RangeSlider(String(current), {
min: min, max: max, step: o.step || 1, calcunits: o.unit || null
const w = new ui.RangeSlider(String(current), {
min: min, max: max, step: o.step || 1
});
const node = w.render();
node.setAttribute('aria-label', label);
@@ -179,8 +102,8 @@ function build() {
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. */
/* one colour axis: `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,
@@ -192,16 +115,14 @@ function build() {
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 *.<lang>.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. */
/* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves
* one merged catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
* has no catalogue for. */
/* ---- section 1: the shell ---- */
const shell = [
@@ -226,8 +147,6 @@ function build() {
'2020': 'OpenWrt 2020'
}, 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'),
@@ -235,14 +154,12 @@ function build() {
}, bump(prefs.applyDensity), label)),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label, { unit: 'px' })),
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label)),
/* 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. */
/* The top layout has no accordion, so this switch is meaningless there: always built,
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
* `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze
* the control to the layout the page loaded in while CSS morphs the chrome live. */
group(_('Submenus', 'footstrap'), (label) => selectCtl(
prefs.currentAutoCollapse() ? 'on' : 'off', {
off: _('Keep open', 'footstrap'),
@@ -253,32 +170,31 @@ function build() {
/* ---- 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. */
/* 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 */
/* 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 select 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, combo box" and "Density, slider" with nothing to tell them
* apart. */
/* The strength half of the Tint, meaningful only in hue mode: a hex canvas is the colour
* asked for, with no chroma of ours to scale. CSS hides it in the other two states.
*
* Not called "Density": that is the select above, and this string is both the caption and
* the aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
step: 5,
unit: '%'
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
/* 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. */
/* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas.
* Measured as text on a card, the use that fails first: as a fill it carries derived ink,
* as a link or status label it carries only itself. It is also what answers #20 ("sometimes
* you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here
* are not coming back. */
colourGroup(_('Accent', 'footstrap'), {
current: prefs.currentAccent, apply: prefs.applyAccent
}, 'var(--fs-accent)', {
@@ -304,17 +220,14 @@ function build() {
})
];
/* ---- 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 surfaces: the sheet the UI is drawn on ----
* Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on
* each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the
* palette's, and moving it would recolour the very thing being measured against.
*
* The hairline 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. */
* The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label
* — and below that it is decoration, which a hairline is entitled to be, so the readout states
* the number and leaves the call to the admin. */
const surfaces = [
colourGroup(_('Cards', 'footstrap'), {
current: prefs.currentCard, apply: prefs.applyCard
@@ -342,28 +255,23 @@ function build() {
})
];
/* ---- section 3: the wallpaper and everything that depends on which one is picked ----
/* ---- section 3: the wallpaper and the rows each value brings ----
*
* Wallpaper is THREE-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
* uploaded photo). 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.
* Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
* uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of
* its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column
* inside the first, and those controls started 216px right of every other one on the page,
* measured on the router. Flat rows hidden as a group is what the stock pages do with a
* dependent field.
*
* 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. */
* The select is the per-browser switch deciding whether to paint an image, so it is what keeps
* the Save button honest; Choose/Remove only swap the picture and never touch the axis. The
* native file inputs stay hidden — the styled buttons trigger them. */
const wallpaper = (() => {
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
/* display:none, not the `hidden` attribute — a bare `hidden=""` still rendered the native
* "Choose File / No file chosen" control; only the styled button below should be visible. */
/* display:none, not `hidden`: a bare `hidden=""` still renders the native
* "Choose File / No file chosen" control */
const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' });
const chooseLabel = _('Choose image', 'footstrap');
const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]);
@@ -376,30 +284,27 @@ function build() {
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. */
/* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS
* and snapshotAxes(), so it moves this browser toward or away from the router default and
* must be bump()-ed like every other saved axis, or the Save button misreports its own
* status. Separate from the Tint strength above. */
const dimLabel = _('Dim', 'footstrap');
const scaleLabel = _('Scale', 'footstrap');
const strengthLabel = _('Strength', 'footstrap');
const inkLabel = _('Colours', 'footstrap');
/* The rows the PATTERN brings. Scale and Strength are live: the appliers write a custom
* property, so the tile 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. */
/* The rows the pattern brings. Scale and Strength are live: the appliers write a custom
* property, so the tile resizes and fades under the drag. Colours decides whether the
* file's own palette is kept or replaced by the theme's — a mask uses the alpha only,
* which is right for line art and wrong for artwork that carries its own colours. */
const patRows = [
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600,
bump(prefs.applyPatternSize), lbl, { step: 20, unit: 'px' })),
bump(prefs.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100,
bump(prefs.applyPatternStrength), lbl, { step: 5, unit: '%' })),
bump(prefs.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
@@ -411,7 +316,7 @@ function build() {
() => 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: '%' }))
bump(prefs.applyPhotoDim), lbl, { step: 5 }))
];
function reflect(tok) {
@@ -422,11 +327,10 @@ function build() {
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. */
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
* `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not
* removed: each row holds a live control, so rebuilding on every switch would freeze it to
* the state it was constructed in. */
function togglePanel(v) {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
@@ -447,12 +351,11 @@ function build() {
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. */
/* uploadPattern already switched this browser onto the pattern, so the control
* must catch up or the page paints the tile while the dropdown reads Off.
* `dom.callClassMethod` is how LuCI moves its own widgets from outside;
* setWallpaper is then called directly, because a programmatic setValue emits
* no `widget-change`. */
dom.callClassMethod(seg, 'setValue', 'pattern');
setWallpaper('pattern');
})
@@ -500,47 +403,37 @@ function build() {
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).
/* ---- section 4: the router default and the version ----
*
* 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(). */
* Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over
* the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the
* saved default only shows on a fresh browser. The two Reset buttons below are the escape
* hatches, and they do not land in the same place. */
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. */
/* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to
* saved" clears them and lets every axis fall back to whatever the router holds, while "Reset
* to default" writes the theme's built-ins explicitly — the only way to say "as the theme
* ships" on a router with 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. */
/* the stock destructive class, so the button discarding every local tweak is the red one
* (theme/55-buttons.css). "Reset to saved" stays neutral: it steps back to the shared state
* rather than discarding. */
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to 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.) */
/* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired
* session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set
* in the session and commit then no-ops without writing the file, returning success (measured
* on the router). The package owns that file and the read side falls back to built-in
* defaults. */
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
/* the Save button IS the status: match -> disabled "Saved as default", diverged -> enabled
* "Save as default". Called after every axis change (via bump).
/* The Save button is the status: matching disables it, diverging enables it. Called after every
* axis change (via bump).
*
* Unless this browser refuses storage, in which case the status would be a lie in both halves:
* nothing was written, so every current*() reads the ROUTER default back and the comparison is
* true however far the page has been dragged from it. Say what is actually true instead — the
* axes apply and are forgotten on reload — and leave the button enabled, because pushing this
* browser's look to the router is the one thing that still works here. */
* Unless the browser refuses storage, where the comparison would always be true — nothing was
* written, so every current*() reads the router default back however far the page has been
* dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to
* the router is the one thing that still works. */
function refreshSave() {
if (prefs.storageBroken()) {
saveBtn.disabled = false;
@@ -558,9 +451,9 @@ function build() {
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. */
/* on failure refreshSave re-enables the button so the user can retry; the usual cause
* is a stale session, which a reload fixes. The raw rpc error stays in a title
* tooltip. */
.catch((e) => {
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
saveErr.title = String((e && e.message) || e);
@@ -568,13 +461,9 @@ function build() {
})
.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. */
/* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is
* banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a
* click meant for its neighbour. Each reload lands back on this tab — see armReturn(). */
const armed = new Map();
function disarm(btn, label) {
armed.delete(btn);
@@ -598,21 +487,20 @@ function build() {
}
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 */
refreshSave(); /* correct label and enabled state before the first paint */
const versionLink = E('a', {
'class': 'fs-ap-version',
'href': ver.REPO_URL,
'target': '_blank',
/* `noreferrer` alone: it implies noopener wherever it is honoured at all, and the theme's
* other outward links — footer.ut's two and the footer's own — spell it that way. Two
* spellings of one rule is how they drift. */
/* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it
* that way */
'rel': '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 */
/* the one row whose control is a pair of buttons, each named by its own text, so `make`
* ignores the caption rather than re-using it as an aria-label */
group(_('Router default', 'footstrap'),
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, resetSavedBtn, resetBtn ]),
{ extra: saveErr })
@@ -623,35 +511,27 @@ function build() {
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. */
/* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put
* their rows straight into the pane. These are grouping headings within one pane, styled by
* pages/80-appearance.css. */
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ])
].concat(rows));
/* ---- the folded groups ------------------------------------------------------------------
* 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.
/* ---- the folded groups ----
* Nine colour fields and an uploader are the widest rows on the page and most admins never
* touch them, so each group is a disclosure, closed by default.
*
* A disclosure and not a switch, 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.
* A disclosure and not a switch: a switch answers "is this feature on", and turning it off
* would either revert nine colours or change nothing at all. Opening or closing a fold applies,
* un-applies and disables nothing.
*
* It is the W3C APG disclosure pattern, the same one the menu's sections use: a <button> owning
* the region it shows, `aria-expanded` on the button and `aria-controls` pointing at the panel.
* `hidden` on the panel rather than a class, so a closed group is out of the tab order and out
* of the accessibility tree for free.
* W3C APG disclosure pattern, as the menu's sections use: a <button> owning the region,
* `aria-expanded` on it and `aria-controls` pointing at the panel. `hidden` on the panel rather
* than a class, so a closed group leaves the tab order and the accessibility tree for free.
*
* The open/closed state is remembered per browser but is NOT an axis: it changes nothing about
* how the page looks, so it is absent from AXIS_KEYS, from snapshotAxes() and from the
* pre-paint. Closed is the default, including on a router that already has colours set — the
* fold says where things are, not whether they are in use. */
* The open/closed state is remembered per browser but is not an axis — it changes nothing about
* how the page looks — so it is absent from AXIS_KEYS, snapshotAxes() and the pre-paint. */
let foldSeq = 0;
function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq);
@@ -661,13 +541,10 @@ function build() {
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
}, [
E('h4', {}, [ title ]),
/* The chevron is the affordance, and it is the SAME one the overview's card toggles
* draw: an empty box whose ::after is two borders rotated 45° (styles/pages/
* 20-overview.css). Not an <svg> — this theme has one chevron for "this panel opens",
* and a second drawing of it would be a second thing to keep looking like the first.
* Empty and aria-hidden: the STATE is on the button's aria-expanded, which is also what
* CSS rotates it off, so what a screen reader is told and what the eye sees cannot
* disagree. */
/* the same chevron the overview's card toggles draw: an empty box whose ::after is two
* borders rotated 45° (pages/20-overview.css), not a second <svg> to keep in step.
* Empty and aria-hidden — the state is the button's aria-expanded, which is also what
* CSS rotates it off. */
E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' })
]);
const paint = () => {
@@ -678,9 +555,8 @@ function build() {
open = !open;
prefs.lsSet(key, open ? 'on' : 'off');
paint();
/* Refreshed on OPEN because the axes below it may have moved while it was collapsed —
* a palette switch or a preset changes what every readout says. Cheap and skipped
* while closed: nothing in that fold is on screen to be wrong. */
/* refreshed on open because the axes below may have moved while it was collapsed;
* skipped while closed, where nothing is on screen to be wrong */
if (open) refreshColours();
});
paint();
@@ -689,9 +565,8 @@ function build() {
]);
}
/* Colours and Surfaces are ONE fold: they are the same job — "make this router a different
* colour" — split into two headings only because a figure and the sheet it sits on are read
* differently. Two folds for one decision would be two things to open. */
/* Colours and Surfaces are one fold: the same job, split into two headings only because a
* figure and the sheet it sits on are read differently */
const page = E('div', { 'class': 'fs-ap' }, [
section(_('Interface', 'footstrap'), shell),
foldable(_('Colours', 'footstrap'),
@@ -701,26 +576,22 @@ function build() {
section(_('Defaults', 'footstrap'), defaults)
]);
/* The first fill, deferred one microtask so the tree above is finished being assembled. It does
* NOT wait for the form to be in the document, and does not need to: every value a readout shows
* comes back through widgets.probeColor(), which keeps its own hidden probe attached to <body>
* and resolves the cascade there — so this runs while the form is still detached (mount() appends
* it a microtask later) and still reads the live palette. */
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for
* the form to be in the document: every readout resolves through widgets.probeColor(), whose
* hidden probe is attached to <body>, so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
}
/* ---- mounting it on the stock System page ---------------------------------------------------
/* ---- mounting it on the stock System page ----
*
* The same shape as fs-overview.js's, and for the same reason: a chrome module is instantiated once
* per PAGE LOAD, so it has to notice SPA navigation itself. `body[data-page]` is the signal — both
* the server template and fs-router stamp it with the dispatch path — so one attribute observer
* covers arriving at System, leaving it, and coming back. */
* The same shape as fs-overview.js's: a chrome module is instantiated once per page load, so it
* notices SPA navigation itself through `body[data-page]`, which the server template and fs-router
* both stamp with the dispatch path. */
const PAGE = 'admin-system-system';
/* A reset reloads the page, and a reload opens the stock page on the tab LuCI remembers — which is
* never this one, because ui.tabs only knows the tabs it built itself. So the reset says where it
* came from and mount() puts the user back. sessionStorage and not a URL fragment: the fragment is
* the stock page's own business, and a stale one would keep re-opening this tab on every later
/* A reset reloads the page, and a reload opens the tab LuCI remembers — never this one, since
* ui.tabs only knows the tabs it built itself. sessionStorage rather than a URL fragment: the
* fragment is the stock page's business, and a stale one would re-open this tab on every later
* visit. The key is read once and removed, so it survives exactly one reload. */
const RETURN_KEY = 'fs-ap-return';
function armReturn() { try { sessionStorage.setItem(RETURN_KEY, '1'); } catch (e) {} }
@@ -731,7 +602,7 @@ function takeReturn() {
return true;
} catch (e) { return false; }
}
const MARK = 'fs-ap'; /* the built form's own class; also how mount() knows it is already there */
const MARK = 'fs-ap'; /* the built form's class, and how mount() knows it is already there */
/* how long the stock view gets to render its tabs before a missing group counts as a failure */
const TAB_DEADLINE = 5000;
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
@@ -746,33 +617,22 @@ function stopWatch() {
_observedView = null;
}
/* Append the form once the stock view has rendered. LuCI's system.js resolves its own promises
* before it puts anything in #view, so there is nothing to hook but the DOM — hence the observer,
* which also covers the view being re-rendered under us (a Save & Apply redraws the map).
*
* Idempotent through the marker: an observer fires for every mutation, and the form's own
* construction is a mutation. Without the check it would append itself for as long as it kept
* noticing itself. */
/* The stock tab GROUP: the element whose children are the panes, which ui.tabs marks
* data-initialized when it builds the menu — and the menu it inserted is that element's previous
* sibling. Both are read from the DOM rather than assumed, because a group that is not initialised
* yet is a page still rendering, not a page without tabs.
* data-initialized when it builds the menu; the menu it inserted is that element's previous
* sibling. Both are read from the DOM, because a group that is not initialised yet is a page still
* rendering, not a page without tabs.
*
* The flag and the sibling are the whole test, deliberately: the panes themselves are NOT required
* to be found here, and a check for them by CLASS is what failed. A modern pane carries no class at
* all — form.js gives it `data-tab` and `data-tab-title` and nothing else, and `.cbi-tabcontainer`
* is luci-compat vocabulary from the Lua CBI — so `:scope > .cbi-tabcontainer` matched nothing on a
* page that plainly has tabs, and the tab was never added, silently. ui.tabs itself marks
* `panes[0].parentNode`, so the panes ARE this element's children; they are simply not identifiable
* that way. */
/* Tab groups that belong to the page we just LEFT. The router stamps body[data-page] before the
* incoming view renders, and on a warm route #view still holds the outgoing page's DOM at that
* moment — so mount() found ITS tab strip and appended the whole Appearance form, plus a live,
* clickable "Footstrap" <li>, to another page's tabs. Measured arriving at System -> System from
* Network -> DHCP: two builds for one arrival, and for 66 ms on localhost (an RTT or more on a real
* router, since the window is the incoming view's load()) the tab sat on the DHCP strip and opened
* all 24 Appearance rows when clicked. Every group present at the moment of the stamp is therefore
* disqualified; the incoming view's own group is a fresh element and is not in this set. */
* The flag and the sibling are the whole test. The panes are deliberately not looked for by class:
* a modern pane carries none — form.js gives it `data-tab` and `data-tab-title`, and
* `.cbi-tabcontainer` is luci-compat vocabulary — so matching on that silently finds nothing on a
* page that plainly has tabs.
*
* Groups belonging to the page just left are disqualified: the router stamps body[data-page]
* before the incoming view renders, and #view still holds the outgoing page's DOM at that moment,
* so mount() would append the form and a clickable "Footstrap" <li> to another page's tab strip:
* arriving at System -> System from Network -> DHCP that was two builds for one arrival, and the
* tab sat on the DHCP strip for 66 ms on localhost (an RTT or more on a real router).
* The incoming view's own group is a fresh element and is not in this set. */
const _staleGroups = new WeakSet();
function disqualifyCurrentGroups() {
const view = document.getElementById('view');
@@ -793,42 +653,51 @@ function tabGroup(view) {
/* Append the pane and its tab once the stock view has rendered. LuCI's system.js resolves its own
* promises before it puts anything in #view, so there is nothing to hook but the DOM — hence the
* observer, which also covers the view being re-rendered under us (a Save & Apply redraws the map).
* observer, which also covers the view being re-rendered (a Save & Apply redraws the map).
*
* The tab is added BY HAND rather than by calling ui.tabs.initTabGroup again: that function returns
* immediately when the group carries data-initialized, and clearing the flag to re-run it would
* build a SECOND menu beside the first (it inserts one unconditionally) and drop the stock tabs'
* own click bindings. One <li>, the same click handler ui.tabs binds to every other tab, and the
* pane the handler expects to find.
* The tab is added by hand rather than by calling ui.tabs.initTabGroup again: that returns
* immediately on a group carrying data-initialized, and clearing the flag to re-run it builds a
* second menu beside the first and drops the stock tabs' click bindings. One <li>, the same click
* handler ui.tabs binds to every other tab, and the pane that handler expects.
*
* Idempotent through the marker: an observer fires for every mutation, and the form's own
* construction is a mutation. Without the check it would append itself for as long as it kept
* noticing itself. */
* Idempotent through the marker, since the form's own construction is a mutation the observer sees.
*
* A map redraw (Save without Apply) rebuilds the group and `ui.tabs` stamps `data-initialized` as
* an attribute change that can land after the last childList change, so the observer watches that
* attribute too and a miss schedules retries on a widening delay — otherwise the tab is missing
* until the next navigation (openwrt/luci#8903). */
const RETRIES = [ 0, 60, 150, 300, 600, 1200 ];
let _retryTimer = 0, _retryAt = 0;
function retryMount() {
if (_retryTimer) return;
if (_retryAt >= RETRIES.length) return;
const delay = RETRIES[_retryAt++];
_retryTimer = window.setTimeout(() => { _retryTimer = 0; mount(); }, delay);
}
function mount() {
const view = document.getElementById('view');
if (!view || view.querySelector('.' + MARK) || _building) return;
if (!view || !onPage()) return;
if (view.querySelector('.' + MARK)) { _retryAt = 0; return; }
if (_building) return;
const tabs = tabGroup(view);
if (!tabs) return;
if (!tabs) { retryMount(); return; }
_building = true;
render()
.then((form) => {
/* re-check: render() resolves on a microtask and the view can have been replaced, or
* navigated away from, in the meantime */
/* re-check: render() resolves on a microtask, and the view may have been replaced or
* navigated away from meanwhile */
const v = document.getElementById('view');
if (!onPage() || !v || v.querySelector('.' + MARK)) return;
const t = tabGroup(v);
if (!t) return;
/* The tab is named after the THEME, not after what it does: it sits between four stock
* tabs that are all "what this page configures" (General Settings, Logging, …), and a
* fifth called Appearance reads as another facet of the router rather than as one
* package's settings. "Footstrap" says whose these are — and it is a proper noun, so it
* is deliberately NOT translated, like the palette name in the form below. */
/* Named after the theme, not after what it does: beside four stock tabs that are all
* "what this page configures", a fifth called Appearance would read as another facet
* of the router. A proper noun, so deliberately untranslated. */
const title = 'Footstrap';
/* data-tab-active is deliberately absent: the stock page opens on whichever tab it
* opened on before, and a theme has no business taking that over. */
/* The same shape a stock pane has, which is `data-tab` + `data-tab-title` and no class:
* ui.tabs.switchTab reads the attributes, and the `cbi-tabcontainer` class this used to
* carry is luci-compat's, styled by no rule this theme ships. */
* opened on before, and a theme has no business taking that over. The pane's shape is
* a stock pane's — `data-tab` + `data-tab-title`, no class. */
t.group.appendChild(E('div', {
'data-tab': TAB,
'data-tab-title': title
@@ -836,9 +705,8 @@ function mount() {
const link = E('a', { 'href': '#' }, [ title ]);
link.addEventListener('click', ui.tabs.switchTab.bind(ui.tabs));
t.menu.appendChild(E('li', { 'class': 'cbi-tab-disabled', 'data-tab': TAB }, [ link ]));
/* …and if this load is the one a reset asked for, open on it. Clicking the link we just
* built goes through ui.tabs' own switchTab, so the stock panes are hidden exactly the
* way they are for any other tab — nothing here reimplements the switch. */
/* if this load is the one a reset asked for, open on it: clicking the link goes through
* ui.tabs' own switchTab, so nothing here reimplements the switch */
if (takeReturn()) link.click();
})
.catch((e) => console.error('footstrap: the Appearance tab failed to build', e))
@@ -851,31 +719,40 @@ function watch() {
if (_viewObserver || !view || !onPage()) return;
_observedView = view;
_viewObserver = new MutationObserver(mount);
_viewObserver.observe(view, { childList: true, subtree: true });
/* `data-initialized` is the moment the group becomes usable, and it does not always come with
* a childList change (see retryMount above) */
_viewObserver.observe(view, {
childList: true, subtree: true,
attributes: true, attributeFilter: [ 'data-initialized' ],
});
mount();
/* A DEADLINE on the one failure that is otherwise perfectly silent. tabGroup() reads three
* private ui.tabs facts — the `data-initialized` marker, the `cbi-tabmenu` class on the menu it
* inserts, and `cbi-tab-disabled` on the items — and one of those has already moved between
* 24.10 and 25.12 (`data-tab-group` was dropped with no announcement). If any of the three we do
* read goes the same way, mount() simply returns early on every mutation: the stock page renders
* perfectly, nothing throws, and every Appearance axis becomes unreachable. Say it once, after
* the page has had time to render. */
/* A deadline on an otherwise silent failure. tabGroup() reads two private ui.tabs facts — the
* `data-initialized` marker and the `cbi-tabmenu` class on the menu — and mount() writes a
* third, `cbi-tab-disabled` on the item it appends; one such fact has already moved between
* 24.10 and 25.12 (`data-tab-group` was dropped unannounced). If another does, mount() returns
* early on every mutation: the stock page renders, nothing throws, and every Appearance axis
* is unreachable. */
window.setTimeout(() => {
const v = document.getElementById('view');
if (!onPage() || !v || v.querySelector('.' + MARK) || _building) return;
/* one last attempt: the complaint below claims ui.tabs changed shape, which is only true
* if a fresh look still finds no group */
_retryAt = 0;
mount();
if (v.querySelector('.' + MARK) || _building || tabGroup(v)) return;
console.error('footstrap: the Appearance tab could not be attached — this page has tabs, but '
+ 'ui.tabs no longer marks them the way fs-appearance.js looks for. Every Appearance axis '
+ 'is unreachable until that is updated.');
}, TAB_DEADLINE);
}
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the data-page
* observer inside. */
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the data-page
* observer inside */
function wire() {
if (_routeObserver || !document.body) return;
_routeObserver = new MutationObserver(() => {
/* BEFORE deciding anything: whatever is in #view at the moment data-page changes belongs to
* the page being left (see _staleGroups). */
/* before deciding anything: whatever is in #view when data-page changes belongs to the
* page being left (see _staleGroups) */
disqualifyCurrentGroups();
return onPage() ? watch() : stopWatch();
});
@@ -5,10 +5,10 @@
'require fs-prefs as prefs';
'require fs-menutree as tree';
/* The chrome AROUND the content: the mode menu, the section tabs, the rail toggle, and the
* measurements that decide how much room any of it gets. The MAIN menu is not here — it is injected
* by menu-footstrap.js as a callback (renderMainMenu), because LuCI instantiates every required
* module into a singleton and so a renderer cannot be a subclass of the chrome (docs/conventions.md). */
/* The chrome around the content: the mode menu, the section tabs, the rail toggle and the
* measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as
* a callback (renderMainMenu), since a required LuCI module is a singleton and a renderer cannot
* subclass the chrome (docs/conventions.md). */
/* the injected main-menu renderer; handed over once by the theme's init() */
let _renderMain = null;
@@ -29,8 +29,8 @@ function renderTabMenu(node, url, level) {
children.forEach((child) => {
const isActive = (L.env.dispatchpath[3 + (level || 0)] === child.name);
/* aria-current="page", not just the `active` class: the class is paint, which a screen
* reader cannot see. E() drops a null attribute value, so inactive tabs carry nothing. */
/* aria-current, not just the `active` class, which is paint a screen reader cannot see.
* E() drops a null attribute value, so inactive tabs carry nothing. */
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s %s'.format(child.name, isActive ? 'active' : '') }, [
E('a', { 'href': L.url(url, child.name), 'aria-current': isActive ? 'page' : null }, [ _(child.title) ])
]));
@@ -52,31 +52,31 @@ function renderTabMenu(node, url, level) {
/* ---- tab-strip auto-fit ----
* A tab strip (#tabmenu, or a view's own .cbi-tabmenu) can carry ~11 pills (luci-app-justclash)
* that overflow one row. Rather than wrap, shrink: two density classes (styles/theme/40-tabs.css)
* trim padding, then gap+font. Floored so a pill never gets tighter than its label — past the
* floor the strip is allowed to wrap. */
* that overflow one row. Rather than wrap, shrink: two density classes (theme/40-tabs.css) trim
* padding, then gap+font. Floored so a pill never gets tighter than its label; past the floor the
* strip may wrap. */
function stripFitsOneRow(ul) {
/* Only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
* read as "one row" while the strip had in fact wrapped, and the density fit never fired. */
/* only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
* reads as "one row" on a strip that has wrapped */
const items = [...ul.children].filter((el) => el.getClientRects().length > 0);
const first = items[0], last = items[items.length - 1];
/* one row iff first and last item share a top edge */
return !first || !last || first.offsetTop === last.offsetTop;
}
function fitTabStrips() {
/* `.fs-sidebar > ul.nav` is the main menu in EVERY layout — the same list — so the
* flexDirection check below is what tells a bar (row) from a vertical sidebar (column),
* where a one-row measure is meaningless. */
/* `.fs-sidebar > ul.nav` is the main menu in every layout, so the flexDirection check below
* is what tells a bar (row) from a vertical sidebar (column), where a one-row measure is
* meaningless */
document.querySelectorAll('.tabs, .cbi-tabmenu, .fs-sidebar > ul.nav').forEach((ul) => {
if (ul.children.length < 2) return;
if (ul.matches('.fs-sidebar > ul.nav') && getComputedStyle(ul).flexDirection !== 'row') {
/* vertical list: the measure would floor it at fs-dense2 forever. Clear and skip. */
/* vertical list: the measure would floor it at fs-dense2 forever */
if (ul.classList.contains('fs-dense1') || ul.classList.contains('fs-dense2'))
ul.classList.remove('fs-dense1', 'fs-dense2');
return;
}
/* steady state (poll tick on an already-fitting strip): one measure, no class writes —
* the write-measure-write dance below forces a reflow per strip, every second. */
* the write-measure-write below forces a reflow per strip, on every tick */
if (!ul.classList.contains('fs-dense1') && !ul.classList.contains('fs-dense2') && stripFitsOneRow(ul))
return;
ul.classList.remove('fs-dense1', 'fs-dense2');
@@ -87,45 +87,30 @@ function fitTabStrips() {
ul.classList.add('fs-dense2'); /* floor: leave wrapped if it still overflows */
});
}
/* ---- does the CONTENT column still have room, once the sidebar has taken its cut? ----
/* ---- does the content column still have room, once the sidebar has taken its cut? ----
*
* The sidebar gives way to the bar when what is LEFT for the content would be too narrow to read.
* A viewport breakpoint (`@media (max-width: 767px)`) cannot say that: the cut is not a constant —
* 224px expanded, 68px collapsed to the rail — so one breakpoint gave both states the same answer,
* and the rail folded away at the same width as the full sidebar, the ~156px it had just freed
* buying the user nothing. Do NOT measure the RENDERED sidebar either: the answer would depend on
* the state it is deciding (once it is a bar there is no cut, so the content "fits", so it
* un-narrows, so it cuts again) — oscillation.
* The sidebar gives way to the bar when what is left for the content would be too narrow to read.
* A viewport breakpoint cannot say that: the cut is 224px expanded and 68px as a rail, so one
* breakpoint gives both states the same answer and the rail folds away at the same width as the
* full sidebar. Do not measure the RENDERED sidebar either — the answer would depend on the state
* it is deciding (as a bar there is no cut, so the content fits, so it un-narrows) and oscillate.
*
* The widths come from the STYLESHEET (02-tokens.css), which is what lays the sidebar out; never
* restate them here, or narrowing the rail in CSS leaves this subtracting the old width with no gate
* able to see it. Memoised because fitShell runs on every resize and mutation and getComputedStyle
* forces a style recalc; the fallbacks stop an empty custom property making the measurement NaN
* (`NaN < NaN` is false, so the sidebar would simply never yield). */
/* A CUSTOM PROPERTY IS UNTYPED, so its computed value is a token stream and not a length.
* `parseFloat(getComputedStyle(root).getPropertyValue('--fs-sidebar-w'))` therefore reads
* `calc(224px * 1)` — a string starting with `c` — and returns NaN, which the fallbacks below turn
* straight back into the literals this function exists to stop restating. It read correctly until
* the Density axis wrapped three of the four tokens in `calc(… * var(--fs-density-box))`; only
* `--fs-content-min`, still a bare `500px`, kept working, which is why the failure was one-sided
* and silent. Measured on the router: content-min 500, and sidebar/rail/pad all NaN.
* The widths come from the stylesheet (02-tokens.css) and are never restated here, or narrowing
* the rail in CSS leaves this subtracting the old width with no gate able to see it.
*
* Resolve them the way the platform actually offers without registering the property: assign the
* token to a REAL length property on a throwaway element and read the used value back. */
* A custom property is untyped, so `parseFloat(getComputedStyle(root).getPropertyValue(…))` reads
* `calc(224px * 1)` and returns NaN — silently, since `NaN < NaN` is false and the sidebar simply
* never yields. Assign the token to a real length property on a throwaway element and read the
* used value back instead. */
let _probe = null;
/* THE PROBE IS A PLAIN <div> IN THE SHARED DOCUMENT, so a third-party app's CSS can style it, and
* every one of its declarations is !important for that reason alone. It carries no chrome mark, so
* `fs-sheets`'s fence deliberately does not spare it (the fence protects the chrome's own elements),
* and even a fenced sheet still matches an unmarked div. Issue #19: an app whose stylesheet carried
* `div { min-width: 500px !important }` won every read — all four tokens came back 500 — so the cut
* the sidebar was said to take became 500 + 2x500 and `fitShell` folded the sidebar into a bar on a
* 1857px desktop. The threshold that produces is exactly 500 + 500 + 1000 = 2000 CSS px, which is
* why it was reported as a ZOOM bug: Chrome at 90% gives 2063 CSS px and passed, 100% gives 1857 and
* failed. Inline !important is what answers it — a style-attribute declaration outranks any author
* rule at the same importance, so there is nothing left for the app to out-rank.
* box-sizing is stated for the same reason: getComputedStyle().width is the CONTENT box, so a
* foreign `border-box` plus padding would shave the reading (the padding/border resets below are
* important, but only a stated box-sizing makes them provably irrelevant). */
/* The probe is a plain <div> in the shared document, so a third-party app's CSS can style it —
* it carries no chrome mark and the fence deliberately does not spare it. Every declaration is
* therefore !important, which a style-attribute wins outright: an app carrying
* `div { min-width: 500px !important }` otherwise wins every read and the sidebar folds into a bar
* on a 1857px desktop (issue #19) — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
* was reported as a zoom bug: Chrome at 90% gives 2063 CSS px and passes, 100% gives 1857 and
* fails. box-sizing is stated for the same reason — getComputedStyle().width is
* the content box, so a foreign `border-box` plus padding would shave the reading. */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
@@ -142,19 +127,18 @@ function resolveLen(token, dflt) {
return Number.isFinite(v) ? v : dflt;
}
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc —
* but keyed on the DENSITY, because that is the one thing that changes these widths at runtime
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() precisely so they
* are re-measured). Reading one attribute is free; the memo without the key meant a density change
* re-measured against the widths of the density before it. */
/* The last resort, stated ONCE so the fallbacks and the sanity net below cannot restate the
* stylesheet's widths in two different places. Reaching for these means the measurement failed. */
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc,
* but keyed on the density: that is the one thing changing these widths at runtime
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() for exactly that).
*
* The defaults are stated once so the fallbacks and the sanity net below cannot restate the
* stylesheet's widths in two places. Reaching for them means the measurement failed. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56, contentMax: 1280 };
let _geom = null, _geomDensity = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
/* the gutter is re-asked even on the memo hit: it moves with the WIDTH, not with the density */
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
if (_geom && _geomDensity === density) return _geom;
_geomDensity = density;
const px = (name, dflt) => resolveLen(name, dflt);
@@ -162,27 +146,22 @@ function shellGeometry() {
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: px('--fs-rail-w', GEOM_DFLT.railW),
/* the token is ONE side's padding; the column loses it twice. It is also only the FALLBACK:
* measureShell() overwrites this field with the gutter the column actually got, and until it
* has (the login page has no `.fs-content`, and nothing has measured before the first
* fitter) the token is the honest answer. */
/* the token is one side's padding; the column loses it twice. It is only the fallback —
* measureShell() overwrites this with the gutter the column actually got, which nothing
* has measured before the first fitter (and the login page has no `.fs-content`). */
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
/* the cap the column stops growing at, so the model knows where the surplus becomes margin
* rather than gutter — see columnWidth() */
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
contentMax: px('--fs-content-max', GEOM_DFLT.contentMax)
};
/* Plausibility, and it costs one comparison: the rail IS the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known ways this measurement fails destroy
* that — a hijacked probe reports ONE foreign width for all four (issue #19), a renamed or
* absent token reports 0 for all four (an abs-positioned empty div with `width:auto` shrinks to
* 0, and 0 is finite, so the per-read fallback above never fires). Neither can be seen in the
* numbers one at a time; the RELATION between them is what gives it away. */
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe
* reports one foreign width for all four (issue #19), a renamed or absent token reports 0 for
* all four (an abs-positioned empty div shrinks to 0, which is finite, so the per-read fallback
* never fires). Only the relation between the numbers gives either away. */
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
/* SAY SO when it fires. The fallback keeps the chrome laid out, which is right — but it lays it
* out on the literals CLAUDE.md forbids copying into JS, so from there on the sidebar folds at a
* width nobody chose while the page still LOOKS correct. Silent, that is how a renamed token
* ships green; one console line is the difference between a mystery and a grep. Once per
* document — this runs on every resize and every mutation. */
/* The fallback keeps the chrome laid out, but on built-in literals, so the sidebar folds at a
* width nobody chose while the page still looks correct. Say so, or a renamed token ships
* green. Once per document — this runs on every resize and mutation. */
if (!sane && !_geomWarned) {
_geomWarned = true;
console.error('footstrap: the chrome widths did not read back from the stylesheet (got '
@@ -190,60 +169,43 @@ function shellGeometry() {
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
}
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
/* the token is only the fallback; the gutter that counts is the one measureShell() read */
if (_shellPad != null) _geom.contentPad = _shellPad;
return _geom;
}
/* THE GUTTER IS MEASURED WHERE IT IS APPLIED, not read off the token that usually supplies it, and
* SO IS THE WINDOW — but both are read HERE, from a fitter, and nowhere else.
/* The window's width and the column's gutter are read here, from a fitter, and nowhere else.
*
* `--fs-content-pad` is what `.fs-content` uses at desktop widths, and `theme/20-shell.css` gives
* the same element `padding: var(--fs-space-4)` below 767px: the token says 28px a side while the
* column's gutter is 16px there. That is a 24px error in a model this file exists to keep honest,
* and `live-audit` found it on every page at 320, 390 and 568 — the same trap the alert-message rule
* under that media query carries a paragraph about, repeated in the JS. The breakpoint itself may
* NOT come here: a width literal copied into JS is exactly what these reads exist to avoid, and a
* media query has no `data-*` to key on either. Asking the element what it actually got is the only
* form that cannot drift.
* The gutter is measured where it is applied rather than read off `--fs-content-pad`: below 767px
* `theme/20-shell.css` re-pads `.fs-content` to `var(--fs-space-4)`, so the token says 28px a side
* while the real gutter is 16px. The breakpoint may not be restated here — a width literal in JS is
* what these reads exist to avoid — so the element is asked what it actually got.
*
* WHY IT IS A SEPARATE FUNCTION AND WHY ONLY A FITTER CALLS IT. `clientWidth` is a LAYOUT read and
* `getComputedStyle` resolves style; `contentWidth()` below is called mid-scroll, by the one pass in
* this theme that must answer without reading either (fs-select's, for a table the poll brought in
* under the reader's thumb). So the numbers are taken when a fitter runs — which is on every resize
* and every content mutation, and never during a flick, because `fitChrome()` defers exactly like
* every other measuring pass — and everything after that reads what was stored. Nothing they
* describe can change without a resize, and a resize schedules a fit; a resize that lands DURING a
* scroll is deferred with the rest, so a mid-scroll answer is the geometry as of the last still
* moment, which is the trade this whole file is built on.
* Only a fitter calls this because `clientWidth` is a layout read and `getComputedStyle` resolves
* style, while `contentWidth()` below must answer mid-scroll without either (fs-select's pass, for
* a table the poll brought in under the reader's thumb). A fitter runs on every resize and content
* mutation and defers during a flick, so a mid-scroll answer is the geometry as of the last still
* moment.
*
* Unlike the token probe above, a hostile app's declaration is not a threat here: `.fs-content`
* carries no chrome mark, so an app CAN restyle it, and if it did then that padding IS the column's
* real gutter. What the probe must not report is a foreign answer for OUR layout; this reports the
* layout as it stands. Before any fitter has run — the login page has no `.fs-content` at all — the
* token stands in. */
* A hostile declaration is no threat here: `.fs-content` carries no chrome mark, so if an app
* re-pads it then that padding IS the column's gutter. Before any fitter has run — the login page
* has no `.fs-content` — the token stands in. */
let _shellOuter = 0, _shellPad = null, _padAt = null;
function measureShell() {
/* the window's own width, every time: fitShell read it before this function existed and the
* value is what everything downstream is measured against */
/* the window's own width, every time: everything downstream is measured against it */
_shellOuter = document.documentElement.clientWidth;
/* The GUTTER, though, is resolved style, and this runs on every mutation batch — once a second
* on any polled page. Three things move it: the width (a media query re-paddings the column
* below 767px), the density (the token is `calc(28px * var(--fs-density-space))`) and the PAGE.
* The third is the one the paragraph above already names and this key used to miss: `.fs-content`
* carries no chrome mark, so a foreign sheet may re-pad it — and `sheets.scopeToCurrentPage()`
* enables and disables those sheets on every client navigation, with no width and no density
* change to notice it by. Navigating off a page whose sheet re-padded the column would otherwise
* leave this pinned at that app's gutter for as long as the width held. `body[data-page]` is the
* one attribute a navigation always restamps, so it is the third term; an unchanged page still
* resolves nothing. Same trade as the token memo above, for the same reason. */
/* The gutter is resolved style and this runs on every mutation batch, so it is memoised on the
* three things that move it: the width (a media query re-pads the column below 767px), the
* density (the token is a calc over it) and the page. The page is the third term because a
* foreign sheet may re-pad `.fs-content`, and `sheets.scopeToCurrentPage()` enables and
* disables those sheets per navigation with no width or density change to notice it by;
* `body[data-page]` is the one attribute a navigation always restamps. */
const key = (document.documentElement.getAttribute('data-density') || '') + '|' + _shellOuter +
'|' + (document.body ? document.body.getAttribute('data-page') || '' : '');
if (_padAt === key && _shellPad != null) return;
const host = document.querySelector('.fs-content');
const cs = host ? getComputedStyle(host) : null;
const v = cs ? parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) : NaN;
/* a failed read leaves the key unset, so the next pass tries again rather than caching a miss */
/* a failed read leaves the key unset, so the next pass retries rather than caching a miss */
if (Number.isFinite(v) && v >= 0) {
_shellPad = v;
_padAt = key;
@@ -254,44 +216,37 @@ function measureShell() {
function columnWidth(g, state) {
/* narrow OR top: in both the chrome is above the content, not beside it */
const cut = (state.narrow || state.top) ? 0 : (state.rail ? g.railW : g.sidebarW);
/* AND THE COLUMN STOPS GROWING. `.fs-content` is `max-width: var(--fs-content-max); margin: 0
* auto`, so past roughly a 1500px window the surplus becomes margin and not column: without the
* cap this answered ~2280 on a 2560px sidebar layout for a column that is 1224 wide. No caller
* can reach that today — both ask a LOWER bound (`< --fs-content-min` here, `< CRAMPED` in
* fs-select) and the cap only binds where both are clear by a factor of two — but this is the
* exported answer to "how wide is the content column", and an answer that is only true below
* 1500px is the kind of thing the next caller inherits without being told. */
/* The column stops growing: `.fs-content` is `max-width: var(--fs-content-max); margin: 0
* auto`, so past ~1500px the surplus becomes margin, and without the cap this answers ~2280 on
* a 2560px window for a column that is 1224 wide. No caller can reach that today (both ask a
* lower bound), but this is the exported answer to "how wide is the content column". */
const room = Math.min(state.outerW - cut, g.contentMax);
return Math.max(0, room - g.contentPad);
}
function fitShell() {
const root = document.documentElement;
/* the one place the window and the gutter are read — see measureShell(). It runs in BOTH
* branches: the bar layout decides nothing here, but `contentWidth()` still answers in it. */
/* runs in both branches: the bar layout decides nothing here, but `contentWidth()` still
* answers in it */
measureShell();
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
root.removeAttribute('data-narrow');
return;
}
const g = shellGeometry();
/* asked UNCOLLAPSED — this is the measurement that decides `data-narrow`, so it may not read it */
/* asked uncollapsed: this measurement decides `data-narrow`, so it may not read it */
const content = columnWidth(g, { outerW: _shellOuter, rail: prefs.currentRail() });
/* toggleAttribute, NOT setAttribute: a same-value setAttribute still QUEUES a mutation record
* (measured in Chromium: 5 identical setAttribute('data-narrow','') -> 5 records; toggleAttribute
* on an already-present attribute -> 0). fitShell runs from fitChrome, which fs-fit calls on every
* mutation batch inside #view — i.e. once a second on any polled page. menu-footstrap observes
* data-narrow and treats each record as a mode CHANGE, so on a phone (390 - 224 - 56 = 110 < 500,
* so the attribute is permanently set) every poll tick re-fired closeFlyouts() and the section the
* user had just tapped open snapped shut, forever. The bug was one-sided and therefore invisible
* on a desktop: the else-branch removeAttribute on an absent attribute already fires 0 records. */
/* toggleAttribute, not setAttribute: a same-value setAttribute still queues a mutation record,
* while toggleAttribute on an already-present attribute queues none. fitShell runs on every
* mutation batch inside #view, and menu-footstrap treats each data-narrow record as a mode
* change, so on a phone (where the attribute is permanently set) every poll tick would re-fire
* closeFlyouts() and snap shut the section the user just opened. */
root.toggleAttribute('data-narrow', content < g.contentMin);
}
function fitChrome() {
/* Nothing this function asks can change while the reader scrolls — the bar's width, the menu's
* width, the room beside the brand — and every one of those questions is a layout read landing
* in the middle of a flick. Put off until the scrolling stops; see fs-fit.js. */
/* nothing asked here — bar width, menu width, room beside the brand — can change while the
* reader scrolls, and each is a layout read landing mid-flick: defer (fs-fit.js) */
if (fit.scrolling()) {
fit.deferMeasurement();
return;
@@ -301,31 +256,28 @@ function fitChrome() {
const bar = document.querySelector('.fs-sidebar');
const menu = document.getElementById('topmenu');
/* The top bar is MEASURED at every width — no 768 floor. It used to bail below 768 and hand
* the job to a phone-bar media query, which left the sub-768 bar pinning its dropdowns to the
* left edge and never collapsing "Refreshing"; the shrink/compact/stack escalation below now
* runs at any width for the top layout. (The SIDEBAR layout still has its own phone bar,
* decided by fitShell's data-narrow, and is untouched here.) */
/* The top bar is measured at every width, with no breakpoint floor: the shrink/compact/stack
* escalation below runs at any width. (The sidebar layout has its own phone bar, decided by
* fitShell's data-narrow, and is untouched here.) */
const topBar = !!bar && !!menu && prefs.isTopLayout();
if (bar) bar.classList.remove('fs-bar-stack', 'fs-ind-compact', 'fs-bar-actrow');
fitTabStrips();
/* ---- does the main menu fit on the brand's row? ----
* Whether it fits depends on how many sections THIS router has (stock 5, a loaded box 11), not
* on the viewport — so it is measured, not a breakpoint. `@media (max-width: 1199px)` stacked
* it on every laptop: a stock bar's contents come to ~683px, i.e. one row fits down to ~723px.
* Measured UNSTACKED (the remove above): a stacked menu owns a whole row and would "fit",
* flipping straight back — oscillation.
* It depends on how many sections THIS router has (stock 5, a loaded box 11), not on the
* viewport, so it is measured — a `max-width: 1199px` breakpoint stacked it on every laptop.
* Measured unstacked (the remove above), because a stacked menu owns a whole row, would "fit"
* and flip straight back.
*
* The menu's own pills wrapping IS the "does not fit" signal, but only because the unstacked
* top bar is flex-wrap: nowrap (50-toplayout.css); otherwise the BAR wraps, hands the menu
* a whole row, and it always "fits". Do NOT measure the bar's children by offsetTop instead:
* the bar is align-items:center with children of differing heights, so their offsetTop differs
* even on one row (that read as "wrapped" for a 5-section menu). */
* The menu's own pills wrapping is the "does not fit" signal, and only works because the
* unstacked top bar is flex-wrap: nowrap (50-toplayout.css) — otherwise the bar wraps, hands
* the menu a row and it always fits. Do not measure the bar's children by offsetTop instead:
* the bar is align-items:center over children of differing heights, so offsetTop differs even
* on one row. */
if (topBar && !stripFitsOneRow(menu)) {
/* First step before stacking: collapse the poll pill ("Refreshing", ~90px) to an icon
* square and re-measure — that width alone is often enough to keep the menu on the
* brand's row and skip the second row entirely (styles/theme/50-toplayout.css). */
/* first step before stacking: collapse the poll pill (~90px) to an icon square and
* re-measure — often enough to keep the menu on the brand's row
* (theme/50-toplayout.css) */
bar.classList.add('fs-ind-compact');
fitTabStrips();
if (!stripFitsOneRow(menu)) {
@@ -334,25 +286,23 @@ function fitChrome() {
}
}
/* The cluster's own escalation, for EVERY bar — the top layout at any width, and the sidebar
* layout once fitShell has stamped data-narrow. It runs after the menu's, so that when the menu
* did NOT fit, .fs-bar-stack has already given it a row of its own. */
/* the cluster's own escalation, for every bar; after the menu's, so a menu that did not fit
* has already been given a row of its own by .fs-bar-stack */
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
publishBarHeight(bar);
}
/* ---- HOW TALL THE BAR ACTUALLY IS, for whoever has to stick underneath it ----
/* ---- how tall the bar actually is, for whoever sticks underneath it ----
*
* A data table's header row sticks while its rows scroll past (theme/30-tables.css). Where the
* DOCUMENT scrolls — the top layout, and the sidebar layout once it has become a bar — the sticky
* bar is what the header has to clear, and `--fs-bar-h` is only its DESIGNED height: the bar grows
* when the brand wraps, when the menu takes a row of its own (.fs-bar-stack) or when the cluster
* does (.fs-bar-actrow), and every one of those is decided a few lines above by measurement.
* So the measurement is published, and the CSS falls back to the token when it is missing.
* A data table's header row sticks while its rows scroll past (theme/30-tables.css), and where the
* document scrolls it has to clear the sticky bar. `--fs-bar-h` is only the bar's DESIGNED height:
* it grows when the brand wraps, when the menu takes a row (.fs-bar-stack) or when the cluster does
* (.fs-bar-actrow), each decided by measurement above. So the measurement is published and the CSS
* falls back to the token.
*
* Written only on a CHANGE and rounded to the pixel: this runs on every fit pass, and a custom
* Written only on a change and rounded to the pixel: this runs on every fit pass, and a custom
* property write on :root invalidates style for the whole document. */
let _barH = 0;
function publishBarHeight(bar) {
@@ -366,16 +316,13 @@ function publishBarHeight(bar) {
/* ---- does the right-hand cluster still fit beside the brand? ----
*
* The same question as the menu's, one row up, and it cannot be asked the same way. The cluster is
* four SIBLINGS — the indicators, Search, Appearance, Log out — so flexbox wraps them ONE AT A
* TIME: measured on a 380px phone bar, Log out alone dropped onto a second row and sat at its LEFT
* edge under the hostname, while the other three stayed up top. Either the whole cluster shares the
* brand's row or it takes a row of its own, right-aligned; there is no useful state in between.
* The cluster is four siblings (indicators, Search, Appearance, Log out), which flexbox wraps one
* at a time: on a narrow bar Log out alone drops to a second row, left-aligned under the hostname.
* Either the whole cluster shares the brand's row or it takes a row of its own, right-aligned.
*
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose on a bar showing
* both), and only if that still overflows give the cluster a row. .fs-ind-compact may already be
* set by the menu's escalation above — adding it twice is free, and it must NOT be cleared here:
* whoever asked for it still needs it. */
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose), then give the
* cluster a row. .fs-ind-compact may already be set by the menu's escalation above and must not be
* cleared here — whoever asked for it still needs it. */
function fitCluster(bar, menu) {
bar.classList.remove('fs-bar-actrow');
if (clusterFitsBrandRow(bar, menu))
@@ -388,14 +335,10 @@ function fitCluster(bar, menu) {
bar.classList.add('fs-bar-actrow');
}
/* Add the widths up rather than read positions: the bar is align-items:center over children of
* differing heights, so offsetTop differs even on ONE row — the same trap the menu measurement
* documents above. The menu is excluded either way. Where it wraps to a row of its own it plainly
* is not competing (`ul.nav { flex: 1 1 100% }` — the sidebar-layout bar, and the top bar once
* .fs-bar-stack is set); where it does share the brand's row, un-stacked at `flex: 1 1 auto`
* (theme/50-toplayout.css), it is the one child that SHRINKS, so counting its current width would
* report the cluster as not fitting whenever the menu happened to be wide. What the cluster has to
* fit beside is the brand and the other actions. */
/* Add the widths up rather than read positions, for the offsetTop reason above. The menu is
* excluded either way: on a row of its own (`ul.nav { flex: 1 1 100% }`) it is not competing, and
* where it shares the brand's row it is the child that shrinks (`flex: 1 1 auto`), so counting its
* current width would report the cluster as not fitting whenever the menu happened to be wide. */
function clusterFitsBrandRow(bar, menu) {
const cs = getComputedStyle(bar);
const gap = parseFloat(cs.columnGap) || 0;
@@ -403,9 +346,7 @@ function clusterFitsBrandRow(bar, menu) {
let need = -gap;
for (const el of bar.children) {
/* offsetParent is null for a display:none child, which is most of them in a bar:
* .fs-navlabel, .fs-spacer and #modemenu never show there, and #indicators is empty
* (and so hidden) until the first poll pill lands. */
/* offsetParent is null for a display:none child, which is most of them in a bar */
if (el === menu || el.offsetParent === null)
continue;
need += el.offsetWidth + gap;
@@ -413,12 +354,10 @@ function clusterFitsBrandRow(bar, menu) {
return need <= room;
}
/* No observer and no resize listener of our own: fs-fit owns both, and this file used to grow the
* second one docs/conventions.md warns against. A view renders its .cbi-tabmenu into #view, which fs-fit's
* MutationObserver already watches — and it re-fits SYNCHRONOUSLY (rule 2), where the copy here
* deferred through fit.schedule(), i.e. the duplicate was strictly the slower path into the same
* work. #tabmenu is a sibling of #view rather than inside it, but nothing writes it except
* renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view. */
/* No observer and no resize listener of our own — fs-fit owns both (docs/conventions.md). A view
* renders its .cbi-tabmenu into #view, which fs-fit's MutationObserver already watches and re-fits
* synchronously. #tabmenu is a sibling of #view, but nothing writes it except renderChrome(),
* which schedules a fit itself. */
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
@@ -430,8 +369,7 @@ function renderModeMenu(node, renderMainMenu) {
? child.name === L.env.requestpath[0]
: index === 0;
/* the main menu must render even if a template has no #modemenu — only the mode
* list itself is skippable chrome */
/* the main menu must render even where a template has no #modemenu */
if (ul)
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
@@ -449,7 +387,7 @@ function renderModeMenu(node, renderMainMenu) {
ul.style.display = '';
}
/* rebuild mode menu + main menu + section tabs from the current L.env; on first load and after
/* rebuild mode menu + main menu + section tabs from the current L.env, on first load and after
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
function renderChrome() {
const root = tree.tree();
@@ -465,11 +403,9 @@ function renderChrome() {
if (L.env.dispatchpath.length >= 3) {
let node = root, url = '';
/* `node.children &&`, exactly as fs-menutree's nodeForSegs() walks it: a node without
* children is an ordinary leaf, and reading `.children[…]` off one is a TypeError that
* escapes renderChrome() — i.e. it takes out the mode menu, the tabs and, on the init path,
* everything menu-footstrap-common wires after it. The walk already tests `node` on each
* step; testing only half of what it dereferences is what left the hole. */
/* `node.children &&`, as fs-menutree's nodeForSegs() walks it: a childless node is an
* ordinary leaf, and reading `.children[…]` off one throws out of renderChrome(), taking
* the mode menu, the tabs and everything init wires after it */
for (let i = 0; i < 3 && node; i++) {
node = node.children && node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
@@ -482,8 +418,8 @@ function renderChrome() {
}
/* Sidebar rail toggle: collapse the sidebar to an icon-only strip. The state lives on
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else —
* flyout submenus, hidden labels — is CSS keyed off that attribute. */
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else is
* CSS keyed off that attribute. */
function wireRail() {
const btn = document.getElementById('fs-rail-toggle');
if (!btn) return;
@@ -499,26 +435,23 @@ function wireRail() {
btn.addEventListener('click', () => {
prefs.applyRail(!prefs.currentRail());
sync();
/* the sidebar's cut just changed by ~156px, so the content column may now clear (or fall
* below) --fs-content-min: re-measure rather than wait for a resize that is not coming */
/* the sidebar's cut just changed by ~156px, so the column may now clear or fall below
* --fs-content-min: re-measure rather than wait for a resize that is not coming */
fit.schedule();
});
sync();
}
/* An indicator pill carries its whole meaning as prose — LuCI writes "Unsaved Changes: 2" — and the
* collapsed rail is 68px wide. Measured on the router: the pill wants 86px, wraps onto three lines
* and hangs 34px past the rail's edge over the content (issue #14). The rail is an icon strip, so
* CSS squares the pill there and draws this attribute instead of the label; a text node cannot be
* reached by a selector, which is why the badge has to be lifted into one here.
/* An indicator pill carries its meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge (issue #14). CSS
* squares the pill there and draws this attribute instead of the label — a text node cannot be
* reached by a selector, so the badge is lifted into an attribute here.
*
* The COUNT is what it shows — the only part that changes and the only part worth reading at that
* size. A pill with no trailing number (a third-party app's "Backup pending") falls back to a
* neutral dot: clipping the prose was tried and rendered "up pen", because a centred pill gives an
* ellipsis no start to anchor to. Choosing between the two is a decision, so it lives here rather
* than as a second CSS rule. The full prose stays in the label either way — a screen reader still
* reads it, and `title` keeps it reachable by pointer. */
* The count is the only part that changes and the only part readable at that size. A pill with no
* trailing number falls back to a neutral dot; clipping the prose instead renders as garbage
* ("up pen"), because a centred pill gives an ellipsis no start to anchor to. The full prose
* stays in the label for screen readers, and in `title` for the pointer. */
const IND_DOT = '•';
function wireIndicatorCounts() {
@@ -535,10 +468,9 @@ function wireIndicatorCounts() {
});
}
/* ui.showIndicator REPLACES the label's text node on an update ("Unsaved Changes: 1" -> ": 2")
* and appends the span on the first change of the session, so both matter — and characterData
* too, for the in-place rewrite. Our own attribute writes do not re-enter: attributes are not
* observed. */
/* ui.showIndicator replaces the label's text node on an update and appends the span on the
* first change of the session, so childList, subtree and characterData all matter. Our own
* attribute writes do not re-enter: attributes are not observed. */
new MutationObserver(stamp).observe(box, { childList: true, subtree: true, characterData: true });
stamp();
}
@@ -547,24 +479,20 @@ return baseclass.extend({
setRenderMain,
renderChrome,
wireIndicatorCounts,
/* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
* measurement rides the same engine as the data tables' */
/* registered with fs-fit by the theme's init(): the bar's fit rides the same engine as the
* data tables' */
fitChrome,
/* The width a page's content column has, WITHOUT reading layout. The sidebar (or the rail) eats
* a known amount of the window and the shell adds a known padding, and all three are already
* memoised against the density attribute for `fitShell()`. It is exported because a pass that
* must answer mid-scroll — fs-select's, for a table the poll has just brought in — otherwise has
* only the window's own width, which in the sidebar layout is the wrong number by exactly the
* sidebar: at an 800px window the column is 520px, so the cheap judgement said "plenty of room"
* for a table that then overflowed and was clipped.
/* The width a page's content column has, without reading layout: the sidebar or rail eats a
* known amount of the window and the shell adds a known padding, all memoised for fitShell().
* Exported because a pass answering mid-scroll (fs-select's, for a table the poll just brought
* in) otherwise has only the window width, which in the sidebar layout is wrong by exactly the
* sidebar — at 800px the column is 520px, so a table judged to have room overflows.
*
* The arithmetic itself is columnWidth()'s, shared with fitShell — see there. All this adds is
* the page's current state, which fitShell is in the middle of deciding and this one reads. */
* The arithmetic is columnWidth()'s; this only adds the page's current state. */
contentWidth() {
/* NO LAYOUT READ, and that is the whole point of this export: the window's width and the
* column's gutter are whatever the last fitter measured (measureShell), and the three
* attributes below are style, not layout. The bootstrap read is for a caller that arrives
/* no layout read: the window width and gutter are whatever the last fitter measured, and
* the three attributes below are style. The bootstrap read serves a caller arriving
* before any fitter has run, which cannot happen mid-scroll. */
if (!_shellOuter) measureShell();
const root = document.documentElement;
@@ -576,10 +504,9 @@ return baseclass.extend({
});
},
/* exported for the unit suite in the theme's own repository (tests/chrome-geometry.test.mjs — no
* tests ship in the package): the cut is pure arithmetic over a handful of measured numbers, and
* driving it directly is the only way to hold every combination of layout, rail and width
/* exported for tests/chrome-geometry.test.mjs (no tests ship in the package): driving the
* arithmetic directly is the only way to cover every combination of layout, rail and width
* without a browser */
columnWidth,
columnWidth, /* fs:probe */
wireRail
});
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,9 @@
'use strict';
'require baseclass';
/* The menu tree as a ROUTING TABLE: path <-> node, with LuCI's alias/firstchild resolution.
*
* Pure lookup — it renders nothing and touches no DOM. Both the chrome (which walks the tree to
* draw the mode menu and the section tabs) and the SPA router (which asks "what view does this URL
* open?") need it, and a module they both require is the only way to give them one copy without a
* cycle: LuCI's require() raises DependencyError on a dependency loop, so the shared half has to
* come OUT rather than be reached across. */
/* The menu tree as a routing table: path <-> node, with LuCI's alias/firstchild resolution. Pure
* lookup — no DOM. The chrome and the SPA router both need it, and require() raises
* DependencyError on a loop, so the shared half is its own module rather than reached across. */
/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
let _tree = null;
@@ -17,10 +13,10 @@ function setTree(tree) {
}
/* /cgi-bin/luci/admin/status/overview -> ['admin','status','overview'].
* The bare base (what build_url() emits for the brand wordmark) yields an EMPTY seg list, NOT null:
* the dispatcher's root node is itself a `firstchild`, so resolveSegs([]) walks to the overview
* exactly as the server does — returning null made the wordmark un-routable and full-reload. null
* stays reserved for a path outside LuCI's scriptname. */
* The bare base (what build_url() emits for the brand wordmark) yields an empty seg list, not
* null: the dispatcher's root node is itself a `firstchild`, so resolveSegs([]) walks to the
* overview as the server does — returning null made the wordmark full-reload instead. null is
* reserved for a path outside LuCI's scriptname. */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && pathname.indexOf(base) !== 0)
@@ -41,18 +37,15 @@ function nodeForSegs(segs) {
/* ---- alias / firstchild resolution ----
*
* 7 of the 27 menu links are redirects, not pages: 4 `alias` (Firewall, System Log, Realtime
* Graphs) and 3 `firstchild` (Administration, Terminal, Attended Sysupgrade) — i.e. the
* most-clicked entries were the ones still doing a full load.
* 7 of the 27 menu links are redirects rather than pages (4 `alias`, 3 `firstchild`). The server
* does not redirect them: a full GET answers 200 at the requested URL and stamps the resolved leaf
* into requestpath/dispatchpath/nodespec. The client must therefore resolve exactly as
* dispatcher.uc does, or a click and an F5 on the same URL open different pages — nodeWeight() and
* firstChildOf() are ports, not approximations. Only the ACL check is skipped, the tree from
* /admin/menu already being ACL-filtered for this session.
*
* The server does not redirect them: a full GET of /admin/status/logs answers 200 at that URL and
* stamps the RESOLVED leaf into requestpath/dispatchpath/nodespec, keeping `pathinfo` as requested.
* The client must resolve EXACTLY as dispatcher.uc does, or a click and an F5 on the same URL would
* open different pages — nodeWeight() and firstChildOf() are ports, not approximations. Only the
* ACL check is skipped: the tree from /admin/menu is already ACL-filtered for this session.
*
* `rewrite` is deliberately NOT followed: the tree has none, and a wrong guess at its splice
* semantics would silently open the WRONG page — worse than the full load it falls back to. */
* `rewrite` is deliberately not followed: the tree has none, and a wrong guess at its splice
* semantics would open the wrong page — worse than the full load it falls back to. */
/* node_weight() from dispatcher.uc: lower wins; a login node sorts last. */
function nodeWeight(node) {
@@ -106,42 +99,23 @@ function resolveSegs(segs) {
/* ---- readonly is a property of the PATH, not of the leaf ----
*
* The dispatcher decides it twice over, from two different inputs. apply_tree_acls()
* (dispatcher.uc:442) walks the menu JSON handed to the client and marks a NODE readonly when its
* OWN `depends.acl` resolves to read-without-write. The request path instead accumulates every
* ancestor's acls into ctx.acls and stamps the leaf from the accumulation
* (`resolved.node.readonly = !perm`, :1003). So a leaf that declares no acl of its own still comes
* back readonly from a real GET, while its node in the tree carries nothing at all.
* apply_tree_acls() (dispatcher.uc:442) marks a NODE readonly from its own `depends.acl`, but a
* request accumulates every ancestor's acls into ctx.acls and stamps the leaf from the
* accumulation (`resolved.node.readonly = !perm`, :1003). Reading the leaf alone therefore
* disagrees with a full load: luci.js derives hasViewPermission() from
* `!env.nodespec.readonly`, which views and the Save/Apply footer key their disabled state off, so
* every page whose SECTION is read-only would gain controls the server refuses.
*
* Reading the leaf alone therefore loses it, and did: measured on the stand, a full load of
* admin/status/logs/syslog reports nodespec.readonly true — the flag sits on `logs`, two levels up —
* against false on an SPA arrival, and the same for dmesg and all four realtime graphs. luci.js
* implements hasViewPermission() as `!env.nodespec.readonly`, which is what views and luci.js's own
* Save/Apply footer key their disabled state off, so on a session with narrower ACLs than root's
* this is any page whose SECTION is read-only.
* The operator is AND down the path, not OR: check_acl_depends() (:312) is handed the whole
* concatenated list (:457-470) and grants write as soon as ANY group in it does, so a page is
* read-only only when every acl-bearing node on the path is. A leaf with a writable acl of its own
* re-opens the whole path.
*
* AND down the path, not OR — the operator is the whole of this function and it was the wrong one.
* check_acl_depends() (dispatcher.uc:312) is handed ONE list, the concatenation ctx_append() built
* from every node's `depends.acl` along the path (:457-470), and it answers `writable = true` as
* soon as ANY group in that list grants write. So a page is read-only exactly when NO group on the
* path is writable — and since apply_tree_acls() (:436-446) marks a node `readonly` when that
* node's own list yields no write, "no group on the path is writable" is "EVERY acl-bearing node on
* the path is readonly". One read-only ancestor is not enough: a leaf that declares a writable acl
* of its own re-opens the whole path.
* A node with no `depends.acl` contributes nothing to ctx.acls and is skipped. That is also why
* the test reads `depends.acl` rather than the `readonly` flag alone: an acl-bearing node the
* session may write carries no flag and would otherwise look ungated.
*
* Measured, because a source reading is not a verdict: on the 25.12 stand, `admin/status/logs`
* carries the read-only `luci-mod-status-logs` and its child `syslog` was given a writable acl of
* its own; a full load then reported `nodespec.readonly` FALSE while this function said true — the
* same class of disagreement between a click and an F5 that the leaf-only reading used to produce,
* only in the opposite direction, and this time it TAKES AWAY a Save/Apply the server allows.
*
* A node with no `depends.acl` is not evidence either way and is skipped: the dispatcher puts
* nothing into ctx.acls for it. That is also why the answer needs `depends.acl` at all rather than
* the `readonly` flag alone — an acl-bearing node the session may write carries no flag, and is
* therefore indistinguishable from an ungated node without looking at its acl list. /admin/menu
* serves both fields (66 of 243 nodes carry an acl on the stand).
*
* Feed it the RESOLVED segments, since that is the path the dispatcher accumulates over. */
* Feed it the RESOLVED segments — that is the path the dispatcher accumulates over. */
function readonlyForSegs(segs) {
let node = _tree;
let gated = 0, locked = 0;
@@ -163,10 +137,10 @@ function readonlyForSegs(segs) {
return gated > 0 && gated === locked;
}
/* The view class a menu node instantiates, or null if the node isn't SPA-able. The Status→Overview
* `template` node maps to view.status.index (its server template just instantiates that — the
* globals that template also defines are the chrome bootstrap's, see ensureOverviewHelpers in
* menu-footstrap-common.js). Shared by navigate() and the hover prefetch. */
/* The view class a menu node instantiates, or null when the node is not SPA-able. The
* Status -> Overview `template` node maps to view.status.index, its server template doing nothing
* else; the globals that template also defines come from ensureOverviewHelpers() in
* menu-footstrap-common.js. Shared by navigate() and the hover prefetch. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
@@ -177,7 +151,7 @@ function viewClassFor(node) {
return null;
}
/* The node the CURRENT full-load landed on, i.e. what L.env.dispatchpath points at. */
/* The node the current full load landed on, i.e. what L.env.dispatchpath points at. */
function currentNode() {
return nodeForSegs(L.env.dispatchpath || []);
}
@@ -4,44 +4,31 @@
'require network';
'require fs-fit as fit';
/* Footstrap overview LAYOUT-only module: renders NOTHING of its own, only re-arranges the STOCK
* sections — wrapping System / Memory / Storage in a grid so Memory and Storage sit in a right
* column beside System. Content, data and styling stay luci-mod-status's. (Do not go back to the
* old 05_footstrap_dashboard.js: re-rendering a custom tree every poll flickered and reset mobile
* scroll.) The stock poll updates each section IN PLACE via dom.content() and never rebuilds the
* .cbi-section wrapper, so once moved into our grid the wrappers stay put across polls.
/* 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
* custom tree every poll instead (the old 05_footstrap_dashboard.js) flickered and reset mobile
* scroll. The stock poll fills each section in place via dom.content() and never rebuilds the
* .cbi-section wrapper, so the wrappers stay inside our grid across polls.
*
* IT USED TO LIVE IN LuCI'S GLOBAL INCLUDE DIR (view/status/include/05_footstrap_overview_layout.js)
* and that was a real defect, not a filing preference: luci-mod-status loads EVERY *.js in that
* directory, so this file was fetched, parsed and evaluated on the overview of every router running
* a DIFFERENT theme. Measured with a headless browser against the dev container with `bootstrap`
* active: the request is right there beside 10_system.js and 20_memory.js. The `L.env.media` gate
* silenced it, but only after it had already been downloaded and run — a theme package reaching
* into another module's namespace, which is exactly what this project refuses to do to third-party
* apps (docs/conventions.md, the three zones). It is a chrome module now, loaded by the chrome, so a router on
* another theme never sees it at all — which is why nothing below re-checks `L.env.media`: the file
* cannot be reached except through this theme's own footer partial, and a gate for that is a gate
* for a state that cannot occur.
*
* WHAT THAT COST, and why the code below looks the way it does: the old location bought two timing
* guarantees for free, because LuCI evaluated the file INSIDE index.load(). Both had to be paid for
* explicitly — see patchOverview() below and ensureOverviewHelpers() in menu-footstrap-common.js. */
/* section title -> grid role. _() with NO msgctxt on purpose: these must resolve to exactly what
* luci-mod-status resolves to, or the titles stop matching. Built once — it used to cost an
* allocation plus three _() lookups per poll tick. */
* This must NOT be filed in LuCI's global include dir (view/status/include/): luci-mod-status
* evaluates every *.js there, so the file would be fetched and run on routers using another theme,
* with an `L.env.media` gate only silencing it after the fact. As a chrome module it is
* unreachable except through this theme's footer partial, which is why nothing below re-checks
* `L.env.media`. That location also supplied two timing guarantees for free, by evaluating inside
* index.load(); both are paid for explicitly here — patchOverview() below, and
* ensureOverviewHelpers() in menu-footstrap-common.js. */
/* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what
* luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
function sectionTitle(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 the wrapped one meant the
* grid never applied on 24.10 at all — measured on the dev container: every section tagged
* `none`, `.fs-ovl` never built, silently. */
/* 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');
if (!h) return '';
/* The first non-empty TEXT node, not `firstChild`. That heading is not text-only any more: 25.12
* appends a hide/show <span> inside the same <h3>, and `firstChild` lands on the title today only
* because upstream happened to put the words first. One reordering there and every section reads
* as an empty title — the grid silently stops being built, with the page still rendering fine. */
/* 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 */
for (const n of h.childNodes) {
if (n.nodeType !== 3) continue;
const t = String(n.nodeValue || '').trim();
@@ -53,37 +40,29 @@ function sectionTitle(sec) {
/* the wrapper we built, so the poll-tick fast path costs one property read */
let _wrapEl = null;
/* A PORT NAME THE CARD HAD TO CUT IS STILL READABLE ON HOVER.
/* A port name the card had to cut stays readable on hover: styles/pages/20-overview.css ellipses
* it at one line so every card takes the width the row can spare, and the name is the one thing on
* a card that cannot be guessed from the rest.
*
* styles/pages/20-overview.css clamps a port card's name to two lines with an ellipsis — that is
* what lets every card be 99px wide instead of as wide as the longest interface name on the device.
* The name is the one thing on the card that cannot be guessed from the rest of it, so the element
* carries its own full text as a native tooltip.
* The tooltip is set unconditionally: testing `scrollWidth` against `clientWidth` per card would
* force a synchronous layout on every poll tick, since 29_ports.js rebuilds these tiles each time.
*
* NOTHING HERE READS LAYOUT. Asking "was this one actually truncated?" means `scrollHeight` against
* `clientHeight` per card, i.e. a forced synchronous layout inside the path a poll tick lands on
* every five seconds — and 29_ports.js REBUILDS these tiles on each of those ticks, so the question
* would be asked again every time. A title on a name that fits costs a tooltip repeating what is
* already on screen; a layout read here costs the page.
*
* It runs BEFORE arrange()'s fast path, and that is the point: the fast path returns as soon as the
* grid is intact, while the tiles under it are new elements with no attribute on them. */
* Runs BEFORE arrange()'s fast path, which returns as soon as the grid is intact while the tiles
* under it are new elements. */
function nameTooltips(view) {
for (const icon of view.querySelectorAll('img[src*="/port_"]')) {
const head = icon.closest('.ifacebox')?.firstElementChild;
const name = head ? head.textContent.trim() : '';
/* `!==` so an unchanged name is not written back on every tick — a title write is cheap,
* but a mutation of a node inside the tree we are observing is not. */
/* `!==`: a mutation inside the tree we observe is not cheap, even when the write is */
if (name && head.title !== name)
head.title = name;
}
}
function arrange() {
/* the SPA nav can leave this _observer wired while another page renders into #view — detach as
* soon as the route stops being the overview. Both the server template and the SPA router
* stamp body[data-page] with the DISPATCH path, so /admin/status (firstchild -> overview)
* matches too. */
/* an SPA nav can leave the observer wired while another page renders into #view: detach as soon
* as the route stops being the overview. body[data-page] carries the DISPATCH path from both
* the server template and the router, so /admin/status (firstchild -> overview) matches. */
if ((document.body.getAttribute('data-page') || '') !== 'admin-status-overview') {
stopWatch();
return;
@@ -93,11 +72,10 @@ function arrange() {
nameTooltips(view);
/* Fast path — the poll lands here once a second, forever. The stock poll never rebuilds the
* .cbi-section wrappers, so the grid survives and there is nothing to do; proving that used
* to cost a querySelectorAll over #view plus a sectionTitle() dig per section, every tick.
* Deliberately NOT a disconnect(): if a future luci-mod-status ever DOES rebuild a section,
* the wrapper loses its children and the slow path below rebuilds the grid — self-healing. */
/* 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
* future luci-mod-status does rebuild a section, the wrapper loses its children and the slow
* path below rebuilds the grid. */
if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
return;
@@ -122,11 +100,10 @@ function arrange() {
_wrapEl = wrap;
}
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange()
* (coalesced, ONE _observer per #view node — a per-poll _observer leak would slow the page down).
* The SPA router may REPLACE the #view element between visits, so re-attach when the node we
* observed is no longer the current one: a singleton bound to the first #view would silently
* watch a detached tree and the grid would never apply on a later SPA visit. */
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange(),
* coalesced and one observer per #view node. The SPA router may replace #view between visits, so
* re-attach when the observed node is no longer the current one — a singleton bound to the first
* #view would watch a detached tree and the grid would never apply again. */
let _observer = null, _observedView = null, _routeObserver = null;
function stopWatch() {
if (_observer) _observer.disconnect();
@@ -139,10 +116,8 @@ function watch() {
if (_observer && _observedView !== view)
stopWatch();
arrange();
/* The route check is what the stock include's render() used to provide implicitly — it only ran
* when luci-mod-status rendered THIS page. A chrome module is alive on every page, so without
* this an observer would be attached to #view on, say, the firewall page and re-run arrange()
* for every mutation of a table it has no business watching. */
/* a chrome module is alive on every page, so without the route check an observer would attach
* to #view on, say, the firewall page and re-run arrange() for every table mutation */
if (_observer || !view ||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
return;
@@ -153,14 +128,9 @@ function watch() {
_observer.observe(view, { childList: true, subtree: true });
}
/* WHAT REPLACED render(). The include was instantiated by luci-mod-status once per overview render,
* which is how it knew a visit had happened; a chrome module is instantiated once per PAGE LOAD and
* then has to notice SPA navigation itself. `body[data-page]` is the signal — both the server
* template and fs-router stamp it with the dispatch path — so one attribute observer covers arriving
* at the overview, leaving it, and coming back.
*
* The empty `.cbi-section` wrapper stock used to build around this include is gone with it, and so
* is the `.fs-ovl-marker` element that existed only to let CSS hide that wrapper. */
/* A chrome module is instantiated once per page load, so it has to notice SPA navigation itself.
* `body[data-page]` is the signal — the server template and fs-router both stamp it with the
* dispatch path — so one attribute observer covers arriving, leaving and coming back. */
function wire() {
if (_routeObserver || !document.body)
return;
@@ -175,34 +145,29 @@ function wire() {
onOverview();
}
/* Everything that must happen on ARRIVAL at the overview, from either direction: a full page load
* that lands here, or an SPA navigation that restamps data-page. patchOverview() is idempotent
* (the __fsProgressive flag), so the two paths cannot double-patch. */
/* Arrival at the overview, from a full page load or an SPA navigation. patchOverview() is
* idempotent (the __fsProgressive flag), so the two paths cannot double-patch. */
function onOverview() {
patchOverview();
watch();
}
/* ---- progressive paint -----------------------------------------------------
/* ---- progressive paint ----
*
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over EVERY
* include's load(), and render() does not return the tree until it resolves — so #view stays
* EMPTY for as long as the slowest include takes. Measured on the dev router (warm SPA nav):
* 182 ms of blank page, of which System/CPU/Memory/Storage/DHCP/Network were ready at 88 ms and
* were simply waiting on 29_ports and 60_wifi (180 ms each).
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over every
* include's load(), and render() withholds the tree until it resolves — #view stays empty for as
* long as the slowest include takes (measured: 182 ms, of which most sections were ready at 88 ms
* and waiting on 29_ports and 60_wifi).
*
* Replacing poll_status does two things:
* 1. Each section paints when ITS OWN data lands: first content halves, 182 -> ~90 ms. Nothing
* jumps — the frames are already in the DOM (built before poll_status is called), a section
* just goes hidden -> filled, exactly as on a stock poll tick.
* 2. Kills the redundant re-fetch: stock adds the poller only after the first load completes
* and Poll.add() steps at once, so the overview re-fetched EVERYTHING (~250 ms of ubus)
* right after the first paint. The in-flight guard joins that to the run already going.
* 1. each section paints when its own data lands (182 -> ~90 ms). Nothing jumps: the frames are
* already in the DOM, a section goes hidden -> filled as on any poll tick;
* 2. drops the redundant re-fetch — stock adds the poller after the first load and Poll.add()
* steps at once, re-fetching everything (~250 ms of ubus) right after the first paint.
*
* NOT a re-implementation — frames, toggles, includes and their render() stay upstream's.
* fillSection() transcribes stock's own loop in the same order so it can be diffed against
* index.js when luci-mod-status changes; if that shape is gone, the patch is skipped and the
* page runs stock. */
* Not a re-implementation: frames, toggles, includes and their render() stay upstream's.
* fillSection() transcribes stock's loop in the same order so it can be diffed against index.js;
* if that shape is gone, the patch is skipped and the page runs stock. */
function fillSection(inc, container, res) {
if (inc.failed)
return;
@@ -224,20 +189,15 @@ function fillSection(inc, container, res) {
}
let _inflight = null;
/* WHICH containers the in-flight run is filling. The guard below is module-level because the
* duplicate load it kills is module-level, but the frames are per RENDER — so joining a run blindly
* joined one that fills SOMEBODY ELSE'S frames. Reproduced: double-click Status → Overview 100 ms
* apart, or leave and return inside the first-load window, and the second arrival's sections stayed
* at their birth `display:none` for a full poll interval (measured 5.9 s against 0.4 s on a single
* arrival) — it had "joined" a run that was filling the frames the content swap had just detached.
* Stock LuCI has no such guard and each render fetches its own data, so the blank page is ours. */
/* Which containers the in-flight run is filling. The guard is module-level because the duplicate
* load it kills is, but frames are per render: joining a run blindly joins one filling somebody
* else's frames, and the second arrival's sections then stay at `display:none` for a full poll
* interval (5.9 s against 0.4 s). */
let _inflightFor = null;
function pollProgressive(includes, containers, first_load) {
/* A run is already fetching exactly this data, FOR THESE FRAMES — join it instead of starting a
* second stampede of the same RPCs. This is what kills the duplicate load. A run for older
* frames is left to finish into the detached nodes it owns, which costs nothing and is simpler
* than cancelling RPCs that are already in flight. */
/* a run already fetching this data for THESE frames is joined rather than duplicated; a run for
* older frames is left to finish into the detached nodes it owns */
if (_inflight && _inflightFor === containers)
return first_load ? Promise.resolve() : _inflight;
@@ -248,8 +208,8 @@ function pollProgressive(includes, containers, first_load) {
const loaded = (typeof inc.load === 'function')
? Promise.resolve(inc.load()).catch(() => { inc.failed = true; })
: Promise.resolve(null);
/* the point of the patch: fill THIS section the moment ITS data is here,
* not at the end of a Promise.all over all of them */
/* the point of the patch: fill this section when its own data lands, not at the
* end of a Promise.all over all of them */
return loaded.then((res) => {
try { fillSection(inc, containers[i], res); }
catch (e) { console.error('footstrap: overview section failed', e); }
@@ -261,58 +221,38 @@ function pollProgressive(includes, containers, first_load) {
});
_inflight = run.finally(() => {
/* only if it is still OURS: a newer render may have replaced it while this one was running */
/* only if still ours: a newer render may have replaced it mid-run */
if (_inflightFor === containers) { _inflight = null; _inflightFor = null; }
});
_inflightFor = containers;
/* NOBODY AWAITS THIS ON THE FIRST LOAD — the line below hands the caller a fresh
* Promise.resolve() so index.render() can return at once — so a rejection here has no handler
* and surfaces as an unhandled rejection in the console. `run` rejects for one ordinary reason:
* network.flushCache() failing on an expired session, i.e. exactly when the user is already
* being redirected to the login page and the noise is least useful. The sections' own failures
* cannot reach it (fillSection is called inside a try/catch and inc.load() has its own .catch),
* so there is nothing to report that the page has not reported already. */
/* Nobody awaits this on the first load (the caller gets a fresh Promise.resolve()), so a
* rejection would surface as an unhandled one. `run` rejects for one ordinary reason:
* flushCache() on an expired session, when the user is already being redirected to login.
* Section failures cannot reach it — fillSection runs in a try/catch and inc.load() has its
* own .catch. */
_inflight.catch(() => {});
/* First load: resolve NOW so index.render() returns its tree and the frames reach #view
* immediately; the sections fill themselves. A poll tick resolves when the data is in —
* that is what the poller expects. */
/* first load resolves now, so index.render() returns its tree and the frames reach #view while
* the sections fill themselves; a poll tick resolves when its data is in, as the poller
* expects */
return first_load ? Promise.resolve() : _inflight;
}
/* Patch the stock overview view: replace poll_status so each section paints when its own data lands.
/* Patch the stock overview view: replace poll_status so each section paints when its own data
* lands.
*
* TIMING, AND WHAT MOVING THE FILE COST. As an include this ran at module eval — which LuCI performs
* inside index.load(), i.e. after the view instance exists and before render() calls poll_status:
* the exact window the patch needs, for free, on both a full load and an SPA nav.
*
* A chrome module evaluates much earlier, so that window has to be aimed at rather than inherited,
* and it is called from the ROUTE (see wire()) instead of at eval. Two reasons it is not called at
* module eval any more: `L.require('view.status.index')` would pull the whole stock overview view
* into memory on every page, including pages that are not the overview; and on a full page load the
* require would race index.load() — which is why the patch is idempotent, guarded by the
* `__fsProgressive` flag on the prototype, and why failing to land is harmless. If it misses, the
* page simply renders the stock way: one Promise.all, ~90 ms later. Never broken, sometimes slower.
*/
* Called from the route (wire()), not at module eval: requiring 'view.status.index' at eval would
* pull the whole stock view into memory on every page, and on a full load it would race
* index.load(). Hence the `__fsProgressive` guard and the fact that missing the window is
* harmless — the page then renders the stock way, one Promise.all, ~90 ms later. */
function patchOverview() {
/* `window.L`, NEVER the bare `L` this factory was handed — and the reason is NOT this module's
* own convenience, it is what the STOCK view ends up holding. `require()` passes the object it
* was called on into the loaded module's factory (`const L = this` in luci.js), and
* `view/status/index.js` then loads its own includes with that same `L` inside `load()`
* (`L.require('view.status.include.' + …)`), so whichever `L` reaches index.js reaches
* 30_network.js too — which calls `L.itemlist(...)` directly. `ui` hangs itemlist/showModal/…
* on the RUNTIME INSTANCE that the dispatcher builds (`window.L = new LuCI()`), and a chrome
* module like this one is loaded as a dependency, i.e. through `LuCI.prototype.require`, so our
* `L` is the PROTOTYPE and has none of them.
*
* `require()` caches by class name, so the FIRST caller decides this for everybody: patching
* through the bare `L` cached view.status.index bound to the prototype, and the overview then
* died mid-render on "L.itemlist is not a function" with the page stuck on "Loading view…"
* (issue #22 follow-up). It only bit on an SPA arrival — a full page load has the dispatcher
* require the view through `window.L` before this observer ever fires — and only when this
* patch won the race against fs-router's own `RT.require`, which is exactly the "sometimes,
* coming from another page" the report described. Same trap as the itemlist calls below
* (docs/spa-router.md), reached from the other side. */
/* `window.L`, never the bare `L` this factory was handed. require() passes the object it was
* called on into the loaded module's factory, and index.js loads its own includes with that
* same `L` — 30_network.js then calls `L.itemlist(...)`, which lives on the runtime instance
* (`window.L = new LuCI()`), not on the prototype a chrome module receives. require() caches
* by class name, so the first caller decides this for everybody: through the bare `L` the
* overview dies mid-render on "L.itemlist is not a function", stuck on "Loading view…" (issue
* #22 follow-up). docs/spa-router.md. */
window.L.require('view.status.index').then((idx) => {
const proto = idx ? Object.getPrototypeOf(idx) : null;
if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
@@ -324,17 +264,12 @@ function patchOverview() {
}).catch((e) => console.error('footstrap: overview progressive paint not applied', e));
}
/* THE THREE TEMPLATE GLOBALS ARE NOT HERE, and where they went is the point. `progressbar`,
* `renderBox` and `renderBadge` come from `admin_status/index.ut`'s inline script, which an SPA
* arrival never runs, so the theme defines them — but a stock include calls them bare from its own
* `render()`, so they must exist before the view class does. While this file was in the chrome's
* directive prologue that was free: it evaluated at chrome init. As a page module it is required
* during the navigation that needs it, racing the router's require of the view class, so the
* definitions moved to `menu-footstrap-common.js`, which every page evaluates before the router
* exists. This module keeps only what the Overview itself needs. */
/* `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. */
return baseclass.extend({
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the
* data-page observer inside. */
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
* data-page observer inside */
wire,
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -5,46 +5,37 @@
'require fs-router as router';
'require fs-widgets as widgets';
/* Find a page by typing its name, instead of remembering which section owns it.
/* Find a page by typing its name instead of knowing which section owns it. A loaded router
* carries ~200 menu nodes across 11 sections, and a tab such as Firewall -> Port Forwards appears
* in no menu list until you are already there; this indexes every node the dispatcher would
* render, tabs included.
*
* A loaded router carries ~200 reachable menu nodes across 11 sections, and the only way to reach
* one was to know its parent — "Attended Sysupgrade" is under System, "Crontab" under System too,
* "Port Forwards" is a TAB of Network -> Firewall and appears in no menu list at all until you are
* already there. This indexes every node the dispatcher would render, tabs included.
* It costs no request: the tree is the same ACL-filtered /admin/menu blob the chrome loaded
* (fs-menutree), so the palette lists exactly the pages this session may open. The index is built
* on the first open, not at init — a user who never searches pays nothing, and only a full load
* can change the tree.
*
* It costs no request: the tree is the SAME ACL-filtered /admin/menu blob the chrome already
* loaded (fs-menutree), so the palette knows exactly the pages this session is allowed to open —
* nothing to leak, nothing to 403 on. The index is built on the FIRST open, not at init: a user
* who never searches pays nothing, and a full load is the only thing that can change the tree.
*
* Navigation is deliberately NOT a call into the router: every result is a real <a href>, so a
* click bubbles to the router's own document-level handler and takes the SPA path (or falls back
* to a full load when the node is not SPA-able) with no second copy of that decision here. Enter
* synthesises the same click. */
* Navigation is deliberately not a call into the router: every result is a real <a href>, so a
* click bubbles to the router's own document-level handler and no copy of that decision lives
* here. Enter synthesises the same click. */
/* ---- the index ---------------------------------------------------------- */
/* How deep below the MODE the walk goes. `depth` counts recursion levels and the walk starts at 1
* on a node that already has two segments, so a node at depth N carries N+1 of them: 4 admits
* admin/<section>/<page>/<tab>/<subtab>. That is one more than LuCI's own dispatcher renders, and
* deliberately so — a third-party node nested deeper is worth finding, and the depth term in the
* score below already ranks it last. */
/* How deep below the mode the walk goes. `depth` counts recursion levels starting at 1 on a node
* that already has two segments, so 4 admits admin/<section>/<page>/<tab>/<subtab> — one level
* more than LuCI renders, so a deeper third-party node is still findable and the depth term in
* search() ranks it last. */
const MAX_DEPTH = 4;
let _index = null;
/* The node's own children, ACL- and title-filtered exactly as ui.menu.getChildren() filters them
* — but NOT through getChildren() itself, and that is the whole reason this function exists.
/* The node's own children, ACL- and title-filtered as ui.menu.getChildren() filters them, but not
* through getChildren(): on an alias node it returns the alias TARGET's children, which is right
* for drawing a menu and wrong for indexing — Network -> Firewall aliases onto the `firewall/zones`
* leaf, so all five of its tabs vanish from the index (78 nodes instead of 238).
*
* On an ALIAS node getChildren() returns a copy whose `children` are the alias TARGET's, which is
* right for drawing a menu and wrong for indexing: Network -> Firewall is an alias onto the
* `firewall/zones` VIEW, a leaf, so its five tabs (Port Forwards, Traffic Rules, NAT Rules, IP
* Sets, Custom Rules) came back as an empty list and "port" found nothing on a router that plainly
* has a Port Forwards page. Measured on the dev router: 78 indexed nodes through getChildren(),
* with every tab of every aliased page missing.
*
* Order does not matter here (search ranks by score, not by menu weight), so getChildren()'s sort
* is not reimplemented — only its two filters, which are what "this session may open it" means. */
* Order does not matter (search ranks by score), so only the two filters are reimplemented, not
* the sort. */
function childrenOf(node) {
const kids = node.children || {};
const out = [];
@@ -59,8 +50,8 @@ function childrenOf(node) {
function walk(node, segs, trail, out, depth) {
childrenOf(node).forEach((entry) => {
const child = entry.node;
/* the chrome carries its own Logout (partials/logout.ut) — searching for it would open a
* confirmation the user did not ask for */
/* the chrome carries its own Logout (partials/logout.ut); indexing it would let a search
* open a confirmation the user did not ask for */
if (depth === 1 && entry.name === 'logout')
return;
@@ -73,17 +64,13 @@ function walk(node, segs, trail, out, depth) {
title: title,
trail: trail,
depth: depth,
/* Three haystacks, searched in this order of preference (see score()). `name` is the
* ENGLISH node name and is indexed on purpose: a Russian UI translates "Firewall" to
* "Межсетевой экран", and an admin who knows OpenWrt by its English docs types
* "firewall". Both find it. */
/* Three haystacks, in this order of preference (see tokenScore()). The English
* node name is indexed on purpose: a translated UI otherwise hides the page from
* an admin who knows OpenWrt by its English docs. */
t: title.toLowerCase(),
p: trail.join(' ').toLowerCase(),
/* …minus the ROOT segment, which is `admin` on every single node. Indexed, it made
* every substring of "admin" — a, ad, adm, dmi, min, in — a hit on all ~200 pages, so
* `min` returned "Terminal", "Administration", "Attended Sysupgrade" and stopped at the
* 20-result cap sorted only by depth (measured). It carries no information precisely
* because it is shared by everything. */
/* …minus the root segment, `admin`, which every node shares: indexed, every
* substring of it ("ad", "min", …) hits all ~200 pages and fills the result cap */
n: csegs.slice(1).join(' ').toLowerCase()
});
@@ -103,13 +90,10 @@ function buildIndex() {
return out;
}
/* Built once per DOCUMENT, and deliberately not re-derived per open: it is a projection of the
* client menu tree, which `ui.menu.load()` itself caches for the life of the document. So the
* palette is exactly as fresh as the sidebar beside it — a package installed or removed without a
* reload is missing from, or still listed in, BOTH. Invalidating only this half would make the two
* disagree, which is worse than either being stale; the reload the package manager already prompts
* for is what refreshes them together. (Cheap enough to rebuild if that ever changes: 238 nodes on
* the dev router.) */
/* Built once per document: it projects the client menu tree, which `ui.menu.load()` caches for
* the life of the document, so the palette is exactly as fresh as the sidebar beside it.
* Invalidating only this half would let the two disagree, which is worse than both being stale;
* the reload a package install prompts for refreshes them together. */
function index() {
if (!_index) _index = buildIndex();
return _index;
@@ -117,12 +101,9 @@ function index() {
/* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit SOMETHING, so "fire port" finds Firewall -> Port
* Forwards while "fire xyz" finds nothing — an AND is what a user typing a second word means.
*
* Deliberately not a fuzzy subsequence match (the "fzf" kind): on a two-letter query it matches
* almost every entry and the ranking then decides everything, which reads as random. A substring
* ladder is predictable — what you typed is visibly in what you got. */
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
* a fuzzy subsequence match — on a two-letter query that matches nearly every entry and leaves the
* ranking to decide everything, which reads as random. */
const HIT_NONE = 99;
function tokenScore(e, tok) {
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
@@ -145,7 +126,7 @@ function search(q, limit) {
sum += s;
}
if (sum !== HIT_NONE)
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: the shallower page first */
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: shallower page first */
}
hits.sort((a, b) => a.score - b.score);
return hits.slice(0, limit).map((h) => h.e);
@@ -153,17 +134,17 @@ function search(q, limit) {
/* ---- recently visited --------------------------------------------------- */
/* What the palette shows before a single character is typed. A router admin lives in three or four
* pages, so the empty state is the most-used view of this thing — an empty box would waste it.
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view.
*
* Only the PATH is stored, never the title: the title is resolved back through the index on every
* render, so it follows the UI language and a page that disappeared with its package simply drops
* out instead of lingering as a dead row. */
* Only the path is stored, never the title — the title is resolved through the index on every
* render, so it follows the UI language and a page removed with its package drops out instead of
* lingering as a dead row. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the
* "these are paths" filter is this module's business. */
* "these are paths" filter belongs here */
function loadRecent() {
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
}
@@ -184,15 +165,14 @@ function recentEntries() {
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, a tap or a focus first, so the FIRST visit of a
* session to a page still pays for its module chain. The recents list is the best predictor of that
* page available anywhere in the theme — an admin lives in three or four of them — and it is already
* on disk. Walking the whole menu instead would pull every view module on the box, which is the cost
* docs/spa-router.md warns about; five recents is a handful of files.
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to
* a page still pays for its module chain. The recents list is the best predictor available and is
* already on disk; warming the whole menu instead would pull every view module on the box
* (docs/spa-router.md).
*
* The current page is skipped: wire() has just remembered it, so it heads the list, and it is loaded
* by definition. saveData is the user saying "not over this link", and speculation is exactly what
* has to go then — the per-link prefetch stays, because that one follows a deliberate hover or tap. */
* The current page is skipped — wire() has just remembered it and it is loaded by definition.
* Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
* deliberate hover or tap. */
const RECENT_WARM = 5;
function warmRecent() {
@@ -200,10 +180,9 @@ function warmRecent() {
const here = (L.env.dispatchpath || []).join('/');
const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
/* Nothing waits on this, so it belongs after the page has settled — at idle, with a timeout for a
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout: nothing
* on screen waits for this, so it competes with the view's own module fetches and RPCs and must
* lose that race on purpose. */
/* Nothing waits on this, so it runs at idle, with a timeout for a page that never goes idle (a
* busy poll). The fallback delay is long on purpose: this competes with the view's own module
* fetches and RPCs and must lose that race. */
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
@@ -219,13 +198,13 @@ function wire() {
const btn = document.getElementById('fs-search-btn');
if (!btn) return;
/* Remember the page this full load landed on. The SPA path is covered by onNavigate below;
* this covers an F5, a non-SPA-able node and the very first page of a session. */
/* remember the page this full load landed on: onNavigate below covers the SPA path, this
* covers an F5, a non-SPA-able node and the first page of a session */
remember(L.env.dispatchpath || []);
/* The callback is handed the RESOLVED segments of the incoming page: reading L.env here would
* give the OUTGOING one, since the router fires its callbacks before it re-points L.env. */
/* the callback is handed the resolved segments of the INCOMING page; L.env still points at
* the outgoing one when the router fires its callbacks */
router.onNavigate(remember);
/* after remember(), so the page we are standing on is the one head of the list that gets skipped */
/* after remember(), so the page we stand on heads the list and is the one skipped */
warmRecent();
const input = E('input', {
@@ -247,15 +226,14 @@ function wire() {
ico.innerHTML = widgets.svgIcon('<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>');
const box = E('div', { 'class': 'fs-search-box' }, [
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
/* the note is a CAPTION for the rows below it ("Recently visited") and the box's EMPTY
* STATE when there are none, so it belongs above the list in both readings — it sat after
* the list at first, which put the caption under the rows it captions */
/* the note captions the rows below it ("Recently visited") and doubles as the empty
* state, so it belongs above the list in both readings */
note,
list
]);
/* data-fs-chrome marks a Zone 1 ROOT (docs/third-party-apps.md): this is a `position: fixed` overlay parented
* to <body>, i.e. outside the <nav> that carries the mark in header.ut — exactly the shape that
* once left the Appearance popover unfenced while every test said the chrome was defended. */
/* data-fs-chrome marks a zone-1 root (docs/third-party-apps.md): this overlay is parented to
* <body>, outside the <nav> that carries the mark in header.ut, so without it the fence does
* not cover the palette — the shape that once left the Appearance popover unfenced */
const ov = E('div', {
'id': 'fs-search-ov',
'class': 'fs-search-ov',
@@ -269,12 +247,11 @@ function wire() {
let opts = [], ents = [], at = -1;
/* Warm the highlighted page's module chain, DEBOUNCED. render() re-runs setActive(0) on every
* keystroke, so warming immediately would pull the top result of "w", "wi", "wir"… and only the
* last of those is a page anyone asked for. The delay also matches how the list is used: the
* highlight settles, then Enter. Only the ARROW KEYS and typing need this — the rows are real
* anchors in the document, so a mouse moving over one already reaches the router's own pointerover
* listener. warmClass() dedupes per class, so a row revisited costs nothing. */
/* Warm the highlighted page's module chain, debounced: render() re-runs setActive(0) on every
* keystroke, so warming at once would pull the top result of "w", "wi", "wir"… Only arrow keys
* and typing need this — the rows are real anchors, so a mouse over one already reaches the
* router's pointerover listener. fs-router's warmClass() dedupes, so a row revisited costs
* nothing. */
let warmT = null;
function warmActive() {
if (warmT) window.clearTimeout(warmT);
@@ -302,9 +279,9 @@ function wire() {
ents = entries;
list.innerHTML = '';
opts = entries.map((e, i) => {
/* role="option" on the <a> rather than a <div> wrapping one: an option may not contain
* an interactive element, and the anchor has to stay a real link — it is what carries
* the click to the router, and what keeps middle-click and "copy link" working. */
/* role="option" on the <a> itself: an option may not contain an interactive element,
* and the anchor must stay a real link — it carries the click to the router and keeps
* middle-click and "copy link" working */
const a = E('a', {
'class': 'fs-search-opt',
'role': 'option',
@@ -315,8 +292,8 @@ function wire() {
E('span', { 'class': 'fs-search-opt-title' }, [ e.title ]),
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
]);
/* close BEFORE the click reaches the router (which re-renders the chrome underneath),
* and without taking focus back — the user is going somewhere else */
/* close before the click reaches the router, which re-renders the chrome underneath;
* no focus return, the user is going elsewhere */
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
@@ -347,8 +324,8 @@ function wire() {
input.addEventListener('input', () => render(input.value.trim()));
/* Keys are handled on the OVERLAY, not the input: the scrim is part of the dialog and a click
* on it moves focus to the overlay itself, where an Escape must still close. */
/* keys are handled on the overlay, not the input: a click on the scrim moves focus to the
* overlay itself, where Escape must still close */
ov.addEventListener('keydown', (ev) => {
switch (ev.key) {
case 'Escape':
@@ -368,14 +345,14 @@ function wire() {
case 'Enter':
if (at < 0 || !opts[at]) return;
ev.preventDefault();
/* a synthetic click carries detail 0, which is exactly what the router reads as a
* KEYBOARD activation — so the focus lands where a keyboard navigation puts it */
/* a synthetic click carries detail 0, which the router reads as a keyboard
* activation, so focus lands where a keyboard navigation puts it */
opts[at].click();
return;
case 'Tab':
/* aria-modal="true" is a promise that Tab cannot walk out into the page behind, and
* the input is the dialog's only tabbable element, so the trap is: stay. Escape (or a
* click outside) is the way out, and both hand focus back to the trigger. */
/* aria-modal="true" promises Tab cannot walk out into the page behind, and the input
* is the dialog's only tabbable element. Escape or an outside click is the way out,
* both handing focus back to the trigger. */
ev.preventDefault();
input.focus();
return;
@@ -387,16 +364,14 @@ function wire() {
btn.addEventListener('click', () => { ov.hidden ? open() : close(); });
/* Back and Forward are navigations no listener here can see: every close above is a user act on
* the document (Escape, the scrim, the trigger, picking a result). An open palette therefore rode
* a popstate onto the next page, aria-modal and Tab-trapped, while the router moved focus behind
* it. returnFocus=false because the router places focus itself on a navigation. */
/* Back and Forward are navigations no listener above can see — every other close is a user act
* on the document — so without this an open palette rides a popstate onto the next page,
* aria-modal and Tab-trapped. returnFocus=false: the router places focus itself. */
router.onNavigate(() => close(false));
/* Ctrl/Cmd+K is the shortcut every command palette has taught users, and `/` is the one every
* search field on the web has. `/` only when the user is not already typing somewhere: an
* <input>, a contenteditable, or a .cbi-dropdown (fs-select.js gives those their own typeahead,
* where a `/` is a search character, not a shortcut). */
/* Ctrl/Cmd+K and `/`, the two shortcuts users already have. `/` only when the user is not
* typing somewhere — an <input>, a contenteditable, or a .cbi-dropdown, where fs-select.js's
* typeahead reads it as a search character. */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,43 +1,28 @@
'use strict';
'require baseclass';
/* ---- theme identity: the version footstrap SHIPS, and the repo it came from ----
/* The installed version, shown on the Appearance tab with no network call; which version is
* available is the package manager's question.
*
* Shown at the foot of the Appearance tab with NO network call: it is what is installed, not what
* is available. Which version is available is the package manager's question — the installer adds
* the feed, and `apk upgrade` answers it.
*
* FS_VERSION is stamped at build/deploy: the theme Makefile (Build/Prepare) and dev-sync.sh rewrite
* the '0.0.0-dev' literal below — BY FILE NAME, so this constant cannot move to another file without
* changing both seds. An unstamped source checkout stays 'dev'. */
* The Makefile (Build/Prepare), dev-sync.sh and tools/stage.sh sed the literal below BY FILE
* NAME, so it cannot move to another file without changing all three. An unstamped checkout
* stays 'dev'. */
const FS_VERSION = '0.0.0-dev';
/* The parentheses around the regex are load-bearing — do not "tidy" them away. luci.mk minifies
* this file with jsmin, whose regex-vs-division test is a ONE-character lookback against a fixed
* allow-list. `n` (the last letter of `return`) is not on it, so `return /re/` is read as a
* division and the regex's `//` swallows the rest of the file — exiting 0 (openwrt/luci#8299).
/* The parentheses are load-bearing: jsmin's regex-vs-division test is a one-character lookback
* against a fixed allow-list, and `n` (the last letter of `return`) is not on it, so `return /re/`
* is read as a division and swallows the rest of the file, exiting 0 (openwrt/luci#8299).
*
* The shape of the version is the WHOLE test — do NOT re-add a `FS_VERSION !== '0.0.0-dev'`
* comparison. CI runs terser over this file BEFORE the Makefile stamps the version, so at that
* moment both sides of that comparison are the same literal: terser folded it to `&& !1` and
* every released build reported itself as '(dev)' forever after. An SDK/buildbot build has no
* terser step, so it worked locally and only ever broke in a release. A regex test is not constant-folded —
* proven by the same minified output, which kept this very `.test()` call — so the sentinel is
* excluded by its SHAPE instead. `-dev$` and not `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps
* `git describe` ('0.9.4-12-gabc1234') and that must keep counting as a real version. */
* The sentinel is matched by shape, never by `FS_VERSION !== '0.0.0-dev'`: terser runs before the
* Makefile stamps the version, so that comparison folds to a constant and every release reports
* '(dev)' — an SDK build has no terser step, so it broke only in releases. A regex test is not
* folded. `-dev$` rather than `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps `git describe`
* ('0.9.4-12-gabc1234'), which must count as a real version. */
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
/* Two exports, and the two that were dropped are worth a line each so they are not re-added out of
* habit: `VERSION` (the bare string) and `isReal` had no caller anywhere in the theme or the
* templates — `label()` is what every reader actually wants, and it is the one place that decides
* how a dev build names itself. An export nobody imports is not free: it is a promise to keep the
* shape stable. */
/* Do not re-add `VERSION` or `isReal` as exports: neither had a caller, and an export nobody
* imports is a promise to keep the shape stable. */
return baseclass.extend({
/* the project's page: the link at the foot of the Appearance tab, and its only reader since
* the footer line was withdrawn. It was assembled from a separate FS_REPO constant that the
* retired self-update script needed to build API paths from, and nothing has needed the bare
* `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
/* what the Appearance tab's version row prints */
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -1,24 +1,14 @@
'use strict';
'require baseclass';
/* The theme's own UI primitives: the one inline-SVG wrapper, the disclosure pair the menu is built
* on, and the colour control the Appearance tab's nine colour axes are built on. Nothing here knows
* what it is being used FOR — that is the point, and it is why the menu and the Appearance tab can
* each take what they need without either requiring the other.
*
* A popup PLACEMENT helper lived here too, shared by the menu's dropdown clamp and the Appearance
* panel when that was a floating popover on <body>. The panel is a tab on System -> System now
* (fs-appearance.js) and places nothing, so the helper had one caller left and went with it; the
* edge gap it clamped against belongs to the menu, which is the only thing still clamping. */
/* The theme's UI primitives: the inline-SVG wrapper, the disclosure pair the menu is built on, and
* the colour control behind the Appearance tab's colour axes. Nothing here knows what it is used
* for, so the menu and the Appearance tab share it without requiring each other. */
/* The chrome's ONE inline-SVG wrapper. Every icon this theme draws is the same 24x24 stroked
* outline and differs only in its path data, but the wrapper was written out per call site — in the
* menu, in the search box, and again in four .ut partials — so `stroke-width` and the two linecap
* attributes were free to drift between icons that are meant to look like one set. Body in, markup
* out; the caller supplies only the shape.
*
* aria-hidden: every icon here sits beside its own label (or inside a control that has one), and an
* unlabelled <svg> is otherwise announced as a graphic in its own right. */
/* The one inline-SVG wrapper: every theme icon is a 24x24 stroked outline differing only in path
* data, so stroke width and linecaps are stated once and cannot drift between call sites.
* aria-hidden, because each icon sits beside its own label — an unlabelled <svg> is otherwise
* announced as a graphic in its own right. */
function svgIcon(body, cls) {
return '<svg class="' + (cls || 'fs-ico') + '" aria-hidden="true" viewBox="0 0 24 24" fill="none" '
+ 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">'
@@ -26,20 +16,19 @@ function svgIcon(body, cls) {
}
/* ---- disclosure primitives, shared by the menu ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows and
* hides. These lived once per menu file back when there were two, and the copies had already drifted
* (only one Escape handler learnt to check flyout mode). The trigger SELECTOR stays a parameter. */
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. The trigger selector stays a parameter. */
/* `.open` and aria-expanded must never disagree — `.open` alone told a sighted user everything and
* a screen-reader user nothing — so every open and close goes through this one function.
* `linkSel` is the layout's trigger (the menu's `:scope > a`). */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
* alone tells a sighted user everything and a screen-reader user nothing. `linkSel` is the
* layout's trigger (the menu's `:scope > a`). */
function setOpen(li, on, linkSel) {
li.classList.toggle('open', on);
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
}
/* An <a role="button"> is given Enter by the browser but NOT Space, and a
* disclosure control has to answer both. */
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
* to answer both. */
function wireSpaceKey(link) {
link.addEventListener('keydown', (ev) => {
if (ev.key !== ' ' && ev.key !== 'Spacebar') return;
@@ -48,20 +37,16 @@ function wireSpaceKey(link) {
});
}
/* Dismissal both ways: a click outside closes; and WCAG 2.2 SC 1.4.13 (Content on Hover or Focus)
* requires a hover/focus panel to be dismissible from the KEYBOARD, with focus handed back to the
* trigger. `when` restricts both to flyout mode, where `.open` means "popup panel" — closing an
* unfolded ACCORDION because the user clicked elsewhere on the page would be wrong. */
/* A click outside closes; WCAG 2.2 SC 1.4.13 also requires a hover/focus panel to be dismissible
* from the keyboard, with focus handed back to the trigger. `when` restricts both to flyout mode,
* where `.open` means "popup panel" — an unfolded accordion must not close on an outside click. */
function wireDismiss(opts) {
const active = () => (opts.when ? opts.when() : true);
document.addEventListener('click', (ev) => {
/* `closest?.` — a document-level listener sees whatever anyone dispatches, and a click
* whose target is not an Element (document itself, a text node from a synthetic dispatch)
* has no closest(). The throw would come out of THIS listener, i.e. the menu would stop
* closing its flyouts for the rest of the session. Every other document-level handler in
* the theme already guards it (fs-router's link router, fs-search's shortcuts,
* fs-select's typeahead); this one was the odd one out. */
/* `closest?.`: a document-level listener sees any dispatched click, including one whose
* target is not an Element and has no closest(). The throw would kill this listener for
* the rest of the session. */
if (active() && !ev.target.closest?.(opts.inside))
opts.close();
});
@@ -76,70 +61,53 @@ function wireDismiss(opts) {
});
}
/* THE SEGMENTED CONTROL AND THE RANGE WRAPPER WERE HERE, and they are not coming back: the
* Appearance page draws its enums with `ui.Select` and its numbers with `ui.RangeSlider` — LuCI's
* own widgets, present on every release this theme supports and already dressed by this theme's
* stylesheet (`select` in base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css).
*
* They were written for the floating popover this page used to be, where a native <select> read as
* a hole in the card. On a page there is no such argument, and a control LuCI maintains is one this
* theme cannot get wrong on its own — the roving tabindex here was itself a bug fix for a
* radiogroup that announced one control and offered N tab stops. */
/* The Appearance page draws enums with `ui.Select` and numbers with `ui.RangeSlider`: LuCI widgets
* are present on every supported release, already dressed by this stylesheet (`select` in
* base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css), and cannot be got wrong here.
* Do not re-add a segmented control or range wrapper of our own. */
/* ---- colour: reading what the page is ACTUALLY painted, and what that costs in contrast --------
/* ---- colour: reading what the page is actually painted ----
*
* The Appearance page has to answer two questions no stored value can: what colour is a role right
* now (the palette's own, when the axis is off — and there is no copy of the palette in JS, on
* purpose), and what contrast does the user's colour land at. Both are questions about the computed
* cascade, so both are asked of the browser.
* Two questions no stored value answers: what colour a role is right now (the palette's own while
* the axis is off — there is deliberately no copy of the palette in JS), and what contrast the
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
*
* `getComputedStyle(root).getPropertyValue('--fs-accent')` cannot answer either: a custom property's
* computed value is the token stream after var() substitution, so a palette hex comes back as a hex
* but `oklch(from … l c H)` comes back as that function, unevaluated. Setting the expression as a
* real `color` on a real element and reading it back gets the browser to resolve it — which is the
* whole point, since relative colour, color-mix() and the tint's calc() are exactly the values the
* theme is made of. One hidden probe, reused: an element per query would be a layout thrash on
* every drag of the hue slider. */
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
* resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of.
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
let _probe = null;
function probeColor(expr) {
if (!_probe) {
/* off-screen rather than display:none — a display:none element still computes `color`, but
* keeping it laid out avoids depending on that being true of every engine. It carries no
* text and no size, so it paints nothing. */
/* EVERY DECLARATION IMPORTANT, and the reason is the same one fs-chrome.js's length probe
* carries (issue #19): this is an unmarked plain element in a document shared with
* `luci-app-*`, the sheet fence deliberately does not spare it, and an app's unlayered
* `span { color: … !important }` or `* { color: … !important }` outranks a layer and a plain
* inline style alike. A probe that loses its own colour reports the app's instead — and this
* probe's answer is written into the hex field, painted into the swatch and graded for
* contrast, so a stolen reading becomes the admin's saved axis on the next confirm. */
/* Off-screen rather than display:none, so the reading does not depend on a display:none
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the PREVIOUS colour standing, which
* would report a stale answer as a fresh one. */
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
* would report a stale answer as a fresh one */
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
/* A computed colour -> [r,g,b] 0..255, or null. RASTERISED, never parsed out of the string, and that
* is not a preference: a computed `color` keeps the SPACE it was authored in, so an axis in hue mode
* comes back as `oklch(0.54 0.19 300)`. Read as numbers that is [0.54, 0.19, 300] — three values in
* the wrong units, clamped into a colour nobody chose — and every consumer here believed it: the hex
* field, the `<input type="color">` swatch beside it and the WCAG readout. Measured on the stand with
* `tint=120, accent=300`: a near-white green canvas whose real text contrast is ~16:1 reported #010078 and "Too
* faint to read", and confirming the swatch would have written that navy over the admin's own hue.
*
* Painting one pixel and reading it back asks the engine to do the conversion instead. It is the
* same method tools/export-tier.mjs uses, and for the same reason its header gives: "a color-mix()
* computes to whatever space it was written in, and `oklch(L C H)` has three numbers that parse
* perfectly well — silently and wrongly — as an rgb() triple". The parse survives only as the
* fallback for an engine with no 2D context, where the legacy `rgb()`/`color(srgb …)` forms are all
* that can appear anyway. */
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
@@ -154,8 +122,8 @@ function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle KEEPS the last value it could parse, so a colour this engine rejects would report
* the previous one as a fresh reading — the same trap probeColor() clears its own style for. */
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
@@ -170,10 +138,9 @@ function parseColor(s) {
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to REPORT: the theme never
* corrects a colour behind the user's back, it says what the colour costs and leaves the choice
* with them (03-palettes.css derives the ink over a fill, which is a different question — that one
* has a right answer). */
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
* derives the ink over a fill, which is a different question). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
@@ -188,36 +155,30 @@ function contrastRatio(fgExpr, bgExpr) {
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
/* #rrggbb from whatever probeColor returned, because <input type="color"> accepts nothing else —
* not a name, not an rgb() string, not a short hex. An unparseable colour becomes black rather than
* throwing: the swatch is a convenience beside the text field, and the text field is authoritative. */
/* #rrggbb, because <input type="color"> accepts nothing else. An unparseable colour becomes black
* rather than throwing: the text field beside the swatch is the authoritative one. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One COLOUR axis: a native swatch, the hex field beside it, and a button back to the palette's own
* colour. It reports through onPick as a hex STRING, or 0 for "back to the palette" — the caller
* hands either straight to fs-prefs.js's colorAxis.
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
* caller hands straight to fs-prefs.js's colorAxis.
*
* THERE IS NO HUE SLIDER, and it was here. The axis still stores a hue (1–360) and the stylesheet
* still rotates the palette by one — a value saved before this, or a router default written then,
* goes on working — but nothing in the UI produces one any more. Two controls for one value read as
* two settings however carefully they mirrored each other, and the slider was the half that could
* not do the job the axes exist for: rotating a hue keeps the palette's chroma, so no angle of it
* reaches a grey. A field that takes the colour you want is the whole control.
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
* still rotates the palette by one, so a saved value goes on working.
*
* `opts.probe` is the live token to read the effective colour back from (so the field shows the
* PALETTE's colour while the axis is off, with no copy of the palette in JS), and `opts.contrast`
* is the pair whose ratio is reported under the row. */
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color is the one control here that is NOT ours: the browser draws the picker, which is
* the right call — it is the picker the user already knows, it is keyboard- and
* screen-reader-accessible without us reimplementing a colour wheel, and on a phone it is the
* native one. The text field beside it is what makes a value shareable (an admin pastes a hex
* from a brand guide), and what a browser without a real picker falls back to. */
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
* fallback where the browser draws no picker. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
@@ -226,42 +187,33 @@ function colorControl(current, onPick, label, opts) {
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds RIGHT NOW — the control keeps no copy of its own, because the page it
* lives on can change the axis behind it (a preset, Reset to default) and a private copy would
* then be the stale one. `current` is only the value at build time. */
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that MIRRORS the axis rather than sets it. Called after every edit — and
* after a preset, a palette switch or a dark-mode flip, through the returned refresh() — because
* all three change what "the palette's own colour" is while this axis stays off. */
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
* own colour is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* Do not fight the user mid-edit: `#0` is a legal thing to have typed so far, and
* overwriting the field on every keystroke made the input impossible to type into. */
/* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on
* every keystroke made the input impossible to type into */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette is also the axis's STATE readout: enabled means this axis
* is holding a colour of its own, disabled means what the field shows is the palette's. */
/* the button back to the palette doubles as the axis state readout: enabled means the axis
* holds a colour of its own, disabled means the field shows the palette's */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* SAY WHAT IT MEANS, NOT WHAT IT MEASURES. This used to read "On a card 5.4:1 · AA", which is
* three pieces of jargon for one plain fact: whether the colour you just picked can be read.
* The ratio and the WCAG level are what a designer checks, and an admin recolouring a router
* is not one — the number stays, in the title, where it costs nothing and is there for
* anyone who does want it.
/* The readout states what the ratio means; the number itself stays in the title.
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
* border is a legitimate choice.
*
* The thresholds are still WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI
* SHAPE, which is why a hairline is graded on the second one (`kind: 'shape'`). A border
* below 3:1 is not a failure the way unreadable text is — a faint hairline is a legitimate
* thing to want — so it says "faint" and warns rather than "not enough" and alarms.
*
* The class names are written out WHOLE rather than built from a suffix: the theme's own
* namespace is swept for dead CSS by matching fs-* tokens in the source (tools/
* fs-orphans.mjs), and a name assembled by concatenation is invisible to that sweep — the
* three rules would read as dead CSS and the fragment as an unstyled class. */
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
@@ -274,7 +226,6 @@ function colorControl(current, onPick, label, opts) {
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
/* the measurement itself, for whoever wants it: the ratio and the threshold it was judged by */
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
@@ -282,10 +233,9 @@ function colorControl(current, onPick, label, opts) {
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and on Enter, NOT on every keystroke: a half-typed `#0096` is a valid prefix of
* two different colours and applying it would repaint the page under the cursor. An unparseable
* value on blur snaps back to what the axis actually holds, so the field can never claim a
* colour the page is not painted in. */
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
* cannot claim a colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
@@ -298,8 +248,8 @@ function colorControl(current, onPick, label, opts) {
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller owns when this runs again; it cannot be done here, because probeColor() needs the
* document and this control is not in it yet */
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
@@ -9,25 +9,16 @@
'require fs-sheets as sheets';
'require fs-search as search';
/* PAGE MODULES: TWO OF THESE MODULES ARE FOR ONE PAGE EACH, AND USED TO SHIP WITH EVERY PAGE.
/* Page modules: `fs-appearance` (System -> System) and `fs-overview` (Status -> Overview) each
* serve one page and are required only on it. A `require` pragma would make them a hard
* dependency — luci.js fetches and evaluates before this factory runs — costing 11.5 KB and 3.8 KB
* after terser on every admin page that has neither.
*
* `fs-appearance` draws the Appearance controls on System -> System and `fs-overview` reshapes
* Status -> Overview; each watches `body[data-page]` and does nothing anywhere else, which is the
* right design — a theme may not register a dispatcher node, so it cannot own a route. But they
* were `require`d in the directive prologue, and a pragma is a hard dependency: luci.js fetches and
* evaluates them before this file's factory runs, on EVERY admin page. Measured after terser: 11.5
* KB for the Appearance panel and 3.8 KB for the Overview one, on every cold visit to a page that
* has neither.
* Each module keeps its own `body[data-page]` observer; `wire()` re-checks the page
* synchronously, so a module arriving after the stamp still starts watching.
*
* So the pragma is replaced by the same observation they were doing for themselves, once, here: on
* the page they belong to — and only there — the module is required and wired. What each of them
* then does is unchanged, including its own observer: `wire()` re-checks the page synchronously, so
* a module that arrives after the stamp still starts watching. The dependency EDGE is what moved,
* not the behaviour.
*
* The map duplicates a page name that also lives inside each module, and `npm run page-modules`
* derives both sides and fails if they drift — the same trade as the Appearance axes, which are
* implemented twice on purpose and held by a gate. */
* The map duplicates a page name that also lives inside each module; `npm run page-modules`
* derives both sides and fails on drift. */
const PAGE_MODULES = {
'admin-system-system': 'fs-appearance',
'admin-status-overview': 'fs-overview'
@@ -40,8 +31,8 @@ function wirePageModules() {
const name = PAGE_MODULES[document.body.getAttribute('data-page') || ''];
if (!name || _pageModules.has(name)) return;
_pageModules.set(name, RT.require(name).then((m) => m.wire()).catch((e) => {
/* a page module that will not load costs its own page's extras and nothing else, so it
* is dropped from the map and retried the next time that page comes up */
/* a failed page module costs only its own page's extras: drop it and retry the next
* time that page comes up */
_pageModules.delete(name);
console.error('footstrap: ' + name + ' did not load', e);
}));
@@ -51,31 +42,26 @@ function wirePageModules() {
load();
}
/* THE THREE TEMPLATE GLOBALS STATUS→OVERVIEW NEEDS, DEFINED WHERE ORDER IS GUARANTEED.
/* 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
* and then instantiates `view.status.index`; the stock includes (18_cpu, 30_network, 60_wifi…) call
* them bare from their own `render()`. An SPA arrival never runs that inline script, so this theme
* is their only definition — and a definition that arrives late is a `ReferenceError` thrown from a
* stock include on a page already committed to the document.
* the stock includes (18_cpu, 30_network, 60_wifi…) call bare from their own `render()`. An SPA
* arrival never runs that script, so the theme is their only definition — and a late definition is
* a `ReferenceError` from a stock include on a page already committed to the document.
*
* THEY LIVE HERE RATHER THAN IN `fs-overview.js` FOR EXACTLY ONE REASON: ordering. While that module
* was in the directive prologue it evaluated at chrome init, i.e. before any navigation could
* happen, and the guarantee was free. It is a page module now (PAGE_MODULES above) — required
* DURING the navigation that needs it, in a chain that races the router's own require of the view
* class. fs-overview's chain is the shorter of the two and should win, but nothing orders them, and
* losing costs the page. This file is required by the footer on every page and evaluates before the
* router exists, so moving the ~40 dependency-free lines here restores the guarantee at a cost of
* their own bytes; the 3.8 KB of Overview layout code stays on the Overview.
* They live here rather than in `fs-overview.js` because a page module is required DURING the
* navigation that needs it, racing the router's own require of the view class with nothing
* 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 L.itemlist → window.L.itemlist (the two-L trap,
* docs/spa-router.md), and the typeof guards make every one a no-op on a full page load, where the
* template's own copies win the race — any theme, as before. */
* Bodies are verbatim from upstream except L.itemlist -> window.L.itemlist (the two-L trap,
* docs/spa-router.md). The typeof guards make each a no-op on a full page load, where the
* template's own copies win the race. */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copied VERBATIM from LuCI's
/* eslint-disable no-var -- these three bodies are verbatim copies of LuCI's
admin_status/index.ut so they can be diffed against upstream when it changes.
Modernising the `var`s would silently break that property, which is the whole
reason the copies are safe to carry. */
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),
@@ -109,11 +95,10 @@ function ensureOverviewHelpers() {
/* eslint-enable no-var */
}
/* Unconditional: the typeof guards make it a no-op wherever the real definitions already exist. */
ensureOverviewHelpers();
/* The chrome BOOTSTRAP: load the menu tree once, hand it to the parts that need it, and wire them
* in the right order. It renders nothing itself — every piece lives in its own module:
/* Chrome bootstrap: load the menu tree once, hand it to the parts that need it and wire them in
* order. It renders nothing itself — every piece lives in its own module:
*
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
* fs-prefs the Appearance axes and their localStorage
@@ -123,28 +108,23 @@ ensureOverviewHelpers();
* fs-sheets the guard against a view's injected CSS repainting every later page
* fs-search the page-search palette (indexes the same tree, on first open)
* fs-appearance the Appearance controls, appended to the stock System page
* fs-overview the overview grid — a THEME module, not a luci-mod-status include
* fs-version the shipped version string (shown in the Appearance section, no network)
* fs-overview the overview grid — a theme module, not a luci-mod-status include
* fs-version the shipped version string
*
*
* They compose by CALLING each other, never by inheriting: LuCI instantiates every required module
* into a singleton, so `base.extend` across modules throws and a module cannot subclass another
* (docs/conventions.md — proven, not assumed). The same constraint is why the MAIN menu arrives as a callback:
* menu-footstrap.js is the one renderer, and it injects renderMainMenu here rather than overriding
* a method. LuCI raises DependencyError on a require() cycle, so the graph above is a DAG by
* construction — the shared halves (fs-menutree, fs-prefs) were pulled out precisely so that no two
* modules have to reach across into each other. */
* They compose by calling, never by inheriting: LuCI makes every required module a singleton, so
* `base.extend` across modules throws (docs/conventions.md). Hence the main menu arriving as a
* callback — menu-footstrap.js injects renderMainMenu rather than overriding a method. A
* require() cycle raises DependencyError, so the graph is a DAG by construction and the shared
* halves (fs-menutree, fs-prefs) are separate modules. */
return baseclass.extend({
/* entry point: load the menu tree, render the mode menu (which drives the injected
* renderMainMenu) and the section tabs, and wire the chrome. */
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 }`). Nothing below depends on
* it, and hanging it off ui.menu.load() only made the broken frame last a round-trip
* longer — or forever, since the .catch() below swallows a menu failure into console. */
/* 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
* ui.menu.load() extends the broken frame by a round trip, or forever when the .catch()
* below swallows a menu failure. */
sheets.watchViewSheets();
prefs.guardDarkStamp(); /* a third party stamping :root — same shape, different vector */
prefs.guardDarkStamp(); /* same, for a third party stamping :root */
ui.menu.load().then((menu) => {
tree.setTree(menu);
@@ -154,27 +134,22 @@ return baseclass.extend({
router.seed();
/* the bar's "does the menu fit beside the brand" measurement joins the engine the
* tables use: it re-runs on every #view resize (a rail collapse and a layout toggle
* produce one) and on content mutations */
* tables use: re-run on every #view resize and on content mutations */
fit.add(chrome.fitChrome);
chrome.renderChrome();
/* after setTree(): the palette indexes that tree — lazily, on its first open, but its
* recent-pages list is recorded from the first navigation onwards */
/* after setTree(): the palette indexes that tree on first open, and records recent
* pages from the first navigation onwards */
search.wire();
chrome.wireRail();
chrome.wireIndicatorCounts();
/* BEFORE router.wire(): the router restamps body[data-page] on every SPA navigation,
* and that attribute is what the page modules key off. Wiring the observer after the
* router's would still work today (the first stamp is the server's, already in the
* DOM), but it would make a route change racy against a listener that is not attached
* yet — cheap to order correctly, expensive to debug. */
/* before router.wire(): the router restamps body[data-page] on every SPA navigation,
* and that attribute is what the page modules key off */
wirePageModules();
router.wire();
router.wireVisibility();
/* fs-chrome's renderTabMenu warns about exactly this, and the root chain was left bare: a
* throw anywhere in the calls above took out the menu, the router and the Appearance tab
* together, silently. It still fails — there is no sane partial recovery — but loudly. */
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
* tab together — so this fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
});
@@ -6,20 +6,17 @@
'require fs-widgets as widgets';
'require menu-footstrap-common as common';
/* The theme's ONE menu renderer: a vertical #topmenu the CSS also turns into the top bar and the
* rail flyouts — same markup, no second renderer (docs/chrome.md). The disclosure primitives it builds
* sections on come from fs-widgets and the auto-collapse preference from fs-prefs; the rest of the
* chrome (mode menu, tabs, rail, router, Appearance tab) is bootstrapped by menu-footstrap-common, which
* this file composes with by injecting renderMainMenu into common.init — a callback, not an
* override, because a required LuCI module is a singleton and cannot be subclassed (docs/conventions.md).
* Spec: docs/chrome.md */
/* The theme's one menu renderer: a vertical #topmenu that the CSS also turns into the top bar and
* the rail flyouts — same markup, no second renderer. Disclosure primitives come from fs-widgets,
* the auto-collapse preference from fs-prefs; the rest of the chrome is bootstrapped by
* menu-footstrap-common, which this file composes with by injecting renderMainMenu into
* common.init — a callback, not an override, since a required LuCI module is a singleton and
* cannot be subclassed. Spec: docs/chrome.md */
/* A NULL PROTOTYPE, because `ICONS[key]` is a lookup by a MENU NODE NAME — a string a third-party
* package chooses in its menu.d, not one this theme knows. A plain object literal answers
* `constructor` and `__proto__` out of Object.prototype, so a node with either name resolved to a
* truthy non-string, skipped the `|| ICONS._default` fallback, and iconSvg() concatenated it into
* link.innerHTML below — `function Object() { [native code] }` and `[object Object]` respectively
* (measured). The dotted reads (ICONS.vpn, ICONS._default) are unaffected. */
/* Null prototype: `ICONS[key]` is keyed by a menu node name, which a third-party package picks in
* its own menu.d. On a plain literal a node called `constructor` or `__proto__` resolves out of
* Object.prototype to a truthy non-string ('[object Object]' for the latter), skips the
* `|| ICONS._default` fallback and is concatenated into link.innerHTML. */
const ICONS = Object.assign(Object.create(null), {
status: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
system: '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
@@ -42,17 +39,15 @@ function iconSvg(name) {
return widgets.svgIcon(body);
}
/* `.open` means two things: in the expanded sidebar, an unfolded accordion section (several may
* be open, the active one starts open); in the rail or the bar, a popup panel (exactly one open,
* hover drives it, a tap toggles it, cleared on outside click and once a real mouse enters). */
/* Is a section's panel a POPUP (flyout / bar dropdown) rather than an unfolded accordion? Must
* read the same input the STYLESHEET does: `data-narrow` (fitShell() in fs-chrome.js
* stamps it before the menu renders). NEVER a viewport breakpoint: it used to be
* matchMedia('(max-width: 767px)') while the CSS turns the sidebar into a bar as soon as the
* content column drops below --fs-content-min — 780px with the sidebar expanded. At 770-775px on
* the router the chrome was a bar while the menu still believed it was an accordion: Escape and
* click-outside did not close a panel (widgets.wireDismiss gates on this) and clampDropdown
* refused to place it. */
/* `.open` means two things: in the expanded sidebar an unfolded accordion section (several may be
* open, the active one starts open); in the rail or the bar a popup panel (exactly one open, hover
* drives it, a tap toggles it, cleared on outside click and once a real mouse enters). */
/* Is a section's panel a popup (flyout / bar dropdown) rather than an unfolded accordion? Reads
* the same input as the stylesheet: `data-narrow`, stamped by fitShell() in fs-chrome.js before
* the menu renders. Never a viewport breakpoint — the CSS turns the sidebar into a bar when the
* content column drops below --fs-content-min, so a media query leaves a band of widths where the
* chrome is a bar and the menu still believes it is an accordion — a `max-width: 767px` media
* query left exactly that band at 770-775px. */
function flyoutMode() {
return prefs.currentRail() || prefs.isTopLayout() ||
document.documentElement.hasAttribute('data-narrow');
@@ -62,19 +57,16 @@ function flyoutMode() {
const TRIGGER = ':scope > a';
const OPEN_LI = '#topmenu > li.open';
/* ---- dropdown edge-clamp (the BAR, every width and every bar state) ------
* A bar panel hangs off its OWN item (li position:relative, ul left:0 — theme/20-shell.css), so an
* item near the right edge would push its panel past the viewport. Nudge it back inside. Runs at
* ANY width now that the top bar is measured (no 768 floor); the rail flies panels out sideways
* and needs a different placement, so it is excluded below. */
/* ---- dropdown edge-clamp (the bar, at every width) ----
* A bar panel hangs off its own item (li position:relative, ul left:0 — theme/20-shell.css), so an
* item near the right edge would push its panel past the viewport. The rail flies panels out
* sideways and needs a different placement, so it is excluded below. */
/* how close the panel may come to the viewport edge before it is nudged back in */
const EDGE_GAP = 8;
/* Is this panel a BAR dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Ask what the STYLESHEET asks, exactly as flyoutMode() does: `data-narrow` is what
* turns the sidebar layout into a bar, and it also disables the rail (the rail's rules are all
* scoped `:not([data-narrow])`), so a narrow window is a bar even with the rail on. Gating this on
* isTopLayout() alone was issue #19's second half: the panel was anchored per item by the CSS but
* the clamp declined to place it, so an item near the right edge overflowed the viewport. */
/* Is this panel a bar dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Same input as the stylesheet: `data-narrow` turns the sidebar into a bar and also
* disables the rail (its rules are scoped `:not([data-narrow])`), so a narrow window is a bar even
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped (issue #19). */
function barDropdown() {
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
}
@@ -83,9 +75,8 @@ function clampDropdown(li) {
const menu = li.querySelector(':scope > ul');
if (!menu) return;
/* One pending measure per item: sweeping the pointer across the bar otherwise queues a
* frame per item crossed, each a write-then-read of layout, none cancelled once the
* pointer has moved on. */
/* one pending measure per item: sweeping the pointer across the bar otherwise queues a
* write-then-read of layout per item crossed, none cancelled once the pointer moves on */
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
li._fsClampRaf = window.requestAnimationFrame(() => {
li._fsClampRaf = 0;
@@ -93,15 +84,15 @@ function clampDropdown(li) {
const r = menu.getBoundingClientRect();
if (!r.width) return; /* still hidden — nothing to place */
/* measured after a frame: on pointerenter the :hover rule that reveals the panel
* has not applied yet, so it would still measure 0x0 */
/* measured after a frame: on pointerenter the :hover rule revealing the panel has not
* applied yet, so it still measures 0x0 */
const overflowRight = r.right - (window.innerWidth - EDGE_GAP);
if (overflowRight > 0)
menu.style.left = -Math.min(overflowRight, r.left - EDGE_GAP) + 'px';
});
}
/* a nudge computed for the old width, or for a bar we have since left, is wrong — drop them
* and let the next hover/tap recompute. */
/* a nudge computed for the old width, or for a bar we have since left, is wrong: drop it and let
* the next hover/tap recompute */
function clearClamps() {
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
}
@@ -116,11 +107,10 @@ function closeFlyouts(except) {
});
}
/* Restore the accordion after LEAVING flyout mode (rail expanded, window grew). closeFlyouts()
* alone used to run on both transitions — right going IN (a stuck popup is worse than a folded
* section), wrong coming OUT: it stripped `.open` from everything, and since the markup is not
* rebuilt on a rail toggle the remembered set was never re-applied, so expanding the rail folded
* every section and "Keep open" quietly meant nothing. */
/* Restore the accordion after leaving flyout mode (rail expanded, window grew). closeFlyouts() is
* right going in — a stuck popup is worse than a folded section — but wrong coming out: the markup
* is not rebuilt on a rail toggle, so nothing would re-apply the remembered set and "Keep open"
* would mean nothing. */
function restoreAccordion() {
const auto = prefs.currentAutoCollapse();
document.querySelectorAll('#topmenu > li.has-sub').forEach((li) => {
@@ -129,15 +119,13 @@ function restoreAccordion() {
});
}
/* Which top-level sections are unfolded, by node name. renderChrome() rebuilds #topmenu on every
* SPA nav, so without this a section the user opened (Keep open) would refold on every tab
* switch. Only consulted in the expanded sidebar with auto-collapse off; flyouts and
* auto-collapse only ever re-open the active one. Persisted in localStorage because a
* module-level Set does not survive a full page load, and plenty of LuCI pages are not SPA-able
* (non-`view` nodes, any F5). */
/* Which top-level sections are unfolded, by node name: renderChrome() rebuilds #topmenu on every
* SPA nav, so a section the user opened would otherwise refold on every tab switch. Only consulted
* in the expanded sidebar with auto-collapse off. Persisted in localStorage because a module-level
* Set does not survive a full page load, and many LuCI pages are not SPA-able. */
const OPEN_KEY = 'fs-menu-open';
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; this list is a SET
* (membership is the only question asked of it), which is the one part that differs. */
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; a Set is used because
* membership is the only question asked of it */
function loadOpenSections() {
return new Set(prefs.lsGetArr(OPEN_KEY));
}
@@ -159,8 +147,8 @@ function renderMainMenu(tree, url, level) {
const idx = (level || 0) + 1;
children.forEach((child) => {
/* the chrome carries its own Logout entry (partials/logout.ut, in every layout), so
* drop the tree's top-level admin/logout node or it shows up twice */
/* the chrome carries its own Logout entry (partials/logout.ut), so the tree's
* top-level admin/logout node would show up twice */
if (!level && child.name === 'logout')
return;
@@ -181,9 +169,9 @@ function renderMainMenu(tree, url, level) {
? '<svg class="fs-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'
: '';
/* `active` is a CLASS: it paints the item and says nothing to a screen reader, so the
* menu had no "you are here" at all. aria-current="page" belongs on the LEAF only — a
* section header is a disclosure button, not a link to the current page. */
/* `active` only paints the item; aria-current is what a screen reader reads, and it
* belongs on the LEAF alone — a section header is a disclosure button, not a link to
* the current page */
const link = E('a', {
'href': hasSub ? '#' : L.url(url, child.name),
'class': (isActive && !hasSub) ? 'active' : '',
@@ -209,21 +197,18 @@ function renderMainMenu(tree, url, level) {
startOpen ? 'open' : ''
].join(' ').trim()
}, [ link, submenu ]);
/* for restoreAccordion(): the markup is not rebuilt on a rail toggle, so the
* remembered set has to be matched back to live <li>s by name */
/* for restoreAccordion(): the remembered set is matched back to live <li>s by name */
if (!level) li.dataset.name = child.name;
if (hasSub) {
/* W3C APG disclosure-navigation: a section header is a BUTTON owning a panel, not
/* W3C APG disclosure navigation: a section header is a button owning a panel, not
* a link to "#". Not role="menu" — APG is explicit that site navigation must not
* take on the menubar pattern's arrow-key semantics. */
/* THE ID MUST BE INJECTIVE, because a menu node name is a string a third-party
* package picks in its own menu.d and two of them may differ only in punctuation.
* `[^a-z0-9]+` -> '-' folded `foo.bar` and `foo-bar` onto ONE id, so two sections at
* the same level emitted the same `id` and both triggers' aria-controls resolved to
* whichever panel stands first in the document — a screen reader is then told the
* closed section owns the open one's contents. Escaping to the code point keeps the
* id readable for the ordinary all-alphanumeric case and cannot collide. */
* take on the menubar pattern's arrow-key semantics.
*
* The id must be injective: node names come from third-party menu.d files and may
* differ only in punctuation, and folding them (`[^a-z0-9]+` -> '-') makes two
* triggers' aria-controls resolve to the same panel. Escaping to the code point
* keeps the ordinary all-alphanumeric case readable and cannot collide. */
const subId = 'fs-sub-' +
String(child.name).replace(/[^a-z0-9]/gi, (c) => '_' + c.charCodeAt(0).toString(16)) +
'-' + idx;
@@ -235,16 +220,16 @@ function renderMainMenu(tree, url, level) {
link.addEventListener('click', (ev) => {
ev.preventDefault();
const open = li.classList.contains('open');
/* flyout panels are exclusive, but must NOT touch the remembered set: it
* mirrors the desktop "Keep open" state, and wiping it here meant one tap
* in the phone flyout lost the user's open sections back on desktop */
/* flyout panels are exclusive, but must not touch the remembered set: it
* mirrors the desktop "Keep open" state, which one tap on a phone would
* otherwise wipe */
if (flyoutMode()) {
closeFlyouts();
setOpen(li, !open);
if (!open) clampDropdown(li); /* tap-opened panel must fit too */
return;
}
/* the expanded-sidebar accordion folds the others back only when asked
/* the sidebar accordion folds the others back only when asked
* (Appearance -> Submenus) */
if (prefs.currentAutoCollapse()) { closeFlyouts(); _openSections.clear(); }
setOpen(li, !open);
@@ -256,9 +241,9 @@ function renderMainMenu(tree, url, level) {
widgets.wireSpaceKey(link);
/* hybrid devices: once a real MOUSE enters, drop the tap-opened panel so hover is
* authoritative and two panels never stack. Guarded on pointerType — a touch tap
* fires pointerenter ('touch') BEFORE the click, and clearing there would break
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is
* authoritative and two panels never stack. Guarded on pointerType, since a touch
* tap fires pointerenter ('touch') before the click and clearing there would break
* tap-to-close. */
li.addEventListener('pointerenter', (ev) => {
if (ev.pointerType === 'mouse' && flyoutMode())
@@ -279,8 +264,8 @@ return baseclass.extend({
__init__() {
common.init(renderMainMenu);
/* Click-outside and Escape close an open flyout. Gated on flyoutMode(): outside it
* `.open` means "unfolded accordion", which must not fold on a click elsewhere. */
/* click-outside and Escape close an open flyout, gated on flyoutMode(): outside it
* `.open` means unfolded accordion, which must not fold on a click elsewhere */
widgets.wireDismiss({
when: flyoutMode,
inside: '#topmenu > li.has-sub',
@@ -289,34 +274,31 @@ return baseclass.extend({
close: () => closeFlyouts()
});
/* Entering flyout mode: fold everything, or a section left open as an accordion
* reappears as a popup panel stuck on screen. Leaving it: restoreAccordion().
/* Entering flyout mode folds everything, or a section left open as an accordion
* reappears as a popup stuck on screen; leaving it restores the accordion.
*
* Watch the ATTRIBUTE, not the rail button: fs-chrome's wireRail() registers its click
* handler from inside the ui.menu.load() promise, i.e. AFTER this runs, so a listener
* Watch the attribute, not the rail button: fs-chrome's wireRail() registers its click
* handler from inside the ui.menu.load() promise, i.e. after this runs, so a listener
* added here would fire first and read the old data-rail. data-layout rides along —
* toggling the layout live is the same transition, so it needs no menu re-render. */
* toggling the layout live is the same transition and needs no menu re-render. */
const modeChanged = () => {
clearClamps();
flyoutMode() ? closeFlyouts() : restoreAccordion();
};
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it). It was
* missing here, so dragging a window 800 -> 770px turned the sidebar into a bar with
* the accordion still unfolded inside it, and no transition handler ran. */
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it): without
* it, narrowing the window turns the sidebar into a bar with no transition handler
* running and the accordion still unfolded inside it */
new MutationObserver(modeChanged).observe(document.documentElement, {
attributes: true, attributeFilter: [ 'data-rail', 'data-layout', 'data-narrow' ]
});
/* No 768 media-query listener any more: the top bar is measured at every width
* (fitChrome, no floor), so crossing 768 no longer flips the chrome — nothing to
* transition that the attribute observer and the resize clamp-clear below don't cover. */
/* No media-query listener: the top bar is measured at every width (fitChrome), so no
* breakpoint flips the chrome — the attribute observer and the resize clamp-clear
* below cover every transition. */
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame
* (the shared coalescer) because resize fires dozens of times a second while a window
* is dragged.
*
* WIDTH ONLY, for the reason fs-fit's observer states at length: every browser on iOS
* fires `resize` continuously while the URL bar slides away, and a clamp computed for a
* width that did not change is work done on a page the user is scrolling. */
* because resize fires dozens of times a second while a window is dragged. Width only:
* iOS fires `resize` continuously while the URL bar slides away, and re-clamping for an
* unchanged width is work done on a page the user is scrolling. */
let lastWidth = window.innerWidth;
const reclamp = fit.frame(clearClamps);
window.addEventListener('resize', () => {
@@ -325,14 +307,11 @@ return baseclass.extend({
reclamp();
});
/* Appearance -> Submenus -> auto-collapse. fs-prefs.js owns the stored value and says only
* that it changed; applying it is entirely ours, because every piece of the state is —
* the remembered set, the `.open` class and the aria-expanded that setOpen keeps in step.
*
* restoreAccordion() already IS "apply the current auto-collapse setting": with the set
* cleared and auto ON it computes open = the active section alone, and with auto OFF it
* restores what "Keep open" remembered. Skipped in flyout mode — a section there is a
* popup, and force-opening the active one would leave it stuck on screen. */
/* Appearance -> Submenus -> auto-collapse: fs-prefs.js owns the stored value and only
* announces the change; the remembered set, `.open` and aria-expanded are all ours.
* restoreAccordion() is already "apply the current setting" — with the set cleared and
* auto on it opens the active section alone. Skipped in flyout mode, where force-opening
* the active section would leave a popup stuck on screen. */
document.addEventListener('fs-autocollapse', (ev) => {
if (ev.detail && ev.detail.on) {
_openSections.clear();
+18 -24
View File
@@ -3,36 +3,30 @@
#
# ./mangle-tokens.sh <cascade.css> <reserved-source-dir>...
#
# Run from the package Makefile over $(PKG_BUILD_DIR) only — NEVER over a dev build. The names are
# 16.6% of the sheet (22586 B over 1824 occurrences, 137 distinct) and mean nothing to a browser;
# this is the same trade terser already makes for the JS, where top-level identifiers are mangled
# because a LuCI resource file is function-scoped. Measured: 135655 -> 123935, i.e. -11.7 KB, and
# uhttpd serves /www with no compression, so those are wire bytes on every cold load as well as
# flash bytes.
# Run from the package Makefile over $(PKG_BUILD_DIR) only, never over a dev build. The names are
# 16.6% of the sheet and mean nothing to a browser; this is the same trade terser makes for the JS.
# Measured: 135655 -> 123935 bytes, and uhttpd serves /www with no compression, so those are wire
# bytes on every cold load as well as flash bytes.
#
# WHY IT IS SAFE, and each clause is a thing that was checked rather than assumed:
# Why it is safe, each clause checked rather than assumed:
# * `--fs-*` is the PRIVATE tier. The outbound contract with third-party apps is the `--*-color-*`
# export tier, a different prefix, and it is not touched. Verified on the router: no installed
# luci-app reads a `--fs-` name.
# * The RESERVED set is DERIVED, not listed: every `--fs-` name that appears in the theme's JS or
# in a .ut template crosses a seam (fs-prefs.js writes `--fs-tint`, head.ut's pre-paint writes
# `--fs-radius-base`, fs-chrome.js reads `--fs-sidebar-w` …) and keeps its name. 35 of 137 today.
# A new one cannot be forgotten, because nothing here names them.
# * Point the dirs at the SOURCE tree, not at $(PKG_BUILD_DIR). In CI the build tree's JS has
# already been through terser and its comments are gone, so five names that only appear in a
# comment stopped being reserved — the same source produced a different sheet depending on who
# built it (measured at the time: 10 reserved via CI against 15 via a plain SDK build, of a
# smaller reserved set than today's 35). Reading the source over-reserves by
# about a kilobyte, and over-reserving is the direction that cannot break anything.
# * The scan is STRING-AWARE and reads the WHOLE identifier before deciding. A prefix match would
# be a silent corruption: `--fs-space-2` is a prefix of `--fs-space-2-5`.
# * The short names are `--a`…`--z`, `--A`…`--Z`, then `--aa`… . The shortest custom property
# otherwise present in this sheet is 5 characters (`--box`), so a collision is impossible;
# the script re-checks that instead of trusting it.
# * The RESERVED set is DERIVED, not listed: every `--fs-` name appearing in the theme's JS or in
# a .ut template crosses a seam and keeps its name, so a new one cannot be forgotten.
# * Point the dirs at the SOURCE tree, not at $(PKG_BUILD_DIR): in CI the build tree's JS has
# already been through terser and its comments are gone, so a name that only appears in a
# comment stops being reserved and the same source produces a different sheet depending on who
# built it. Reading the source over-reserves by about a kilobyte, which is the direction that
# cannot break anything.
# * The scan is string-aware and reads the WHOLE identifier before deciding: a prefix match would
# be a silent corruption, `--fs-space-2` being a prefix of `--fs-space-2-5`.
# * The short names are `--a`…`--z`, `--A`…`--Z`, then `--aa`…. The shortest custom property
# otherwise present is 5 characters, so a collision is impossible; the script re-checks that
# rather than trusting it.
#
# What it costs: the SHIPPED sheet is unreadable when debugging on a router. Everything that reads
# the theme's own names — dev-sync.sh, galdiff.py, the a11y gate, docs/gallery.html (4 references)
# and docs/devkit (29) — runs against an unmangled build, which is why this is a package-build step
# the theme's own names runs against an unmangled build, which is why this is a package-build step
# and not part of build-css.sh. Verify a change here with cssdiff.py, mangled against plain.
set -e
@@ -1,8 +1,7 @@
#!/bin/sh
# Registration, and the three symlinks that expose what the admin uploads. Nothing here migrates a
# router off an older footstrap: OpenWrt expects a sysupgrade rather than a package upgrade, so
# leftovers go with the image, and the cleanup that is worth doing is config rather than files.
# Registration, plus the three symlinks that expose what the admin uploads. Nothing here migrates a
# router off an older footstrap: OpenWrt expects a sysupgrade rather than a package upgrade.
changed=0
@@ -15,15 +14,15 @@ set_opt() {
set_opt themes.Footstrap /luci-static/footstrap
# A fresh install may pick the theme; an upgrade must never move a router off the theme it is on.
# `changed` is what tells the two apart — on an upgrade the entry is already there — and it carries
# the decision on its own, because apk exports no PKG_UPGRADE.
# `changed` is what tells the two apart — on an upgrade the entry is already there — and it has to
# carry the decision alone, because apk exports no PKG_UPGRADE.
if [ "${PKG_UPGRADE:-}" != 1 ] && [ "$changed" = 1 ]; then
set_opt main.mediaurlbase /luci-static/footstrap
fi
[ "$changed" = 1 ] && uci commit luci
# uhttpd serves /www only, so the admin's own uploads live in /etc — where a sysupgrade keeps them
# uhttpd serves /www only, so the admin's uploads live in /etc — where a sysupgrade keeps them
# (lib/upgrade/keep.d) — and are reached through symlinks. The pattern keeps its .svg name: uhttpd
# types a response by extension, and an SVG sent as octet-stream paints nowhere.
mkdir -p /etc/footstrap
@@ -1,13 +1,11 @@
# Preserve what the ADMIN put on this router across sysupgrade: the login/page background
# (Appearance -> Wallpaper -> File), the wallpaper pattern (Appearance -> Wallpaper -> Pattern) and
# the webfonts the admin put in /etc/footstrap/fonts. All three are written at runtime — an upload
# or the admin, never the package — so all three survive a package upgrade on their own; a firmware
# sysupgrade, however, only keeps paths listed here (or in a conffile / /etc/sysupgrade.conf).
# NOT conffiles: they live outside root/etc/config, and tools/conffiles.mjs would reject a
# Preserve what the ADMIN put on this router across sysupgrade: the login/page background, the
# wallpaper pattern and the webfonts in /etc/footstrap/fonts. All three are written at runtime, so
# they survive a package upgrade on their own; a firmware sysupgrade keeps only paths listed here
# (or in a conffile / /etc/sysupgrade.conf).
# Not conffiles: they live outside root/etc/config, and tools/conffiles.mjs rejects a
# conffiles-define entry the package does not ship.
/etc/footstrap/login-bg
/etc/footstrap/pattern.svg
# A directory, and the whole of it: the faces plus the fonts.css generated beside them. Losing the
# sheet while keeping the faces would leave footstrap.settings.fonts pointing at nothing, which is
# the one state that costs a 404 on every page.
# The whole directory: the faces plus the fonts.css generated beside them. Keeping the faces
# without the sheet leaves footstrap.settings.fonts pointing at nothing — a 404 on every page.
/etc/footstrap/fonts
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
# Drop the `/* fs:probe */` exports from the JS IN A BUILD TREE.
#
# ./strip-probes.sh <dir>
#
# A handful of module-private functions are also listed in the module's `baseclass.extend({…})` for
# one reason: a gate in this repository calls them. Nothing on a router does — the functions
# themselves are used inside their own module, and only the export line exists for the tests.
#
# So the line is marked and removed on the way into a package: the checkout keeps the seam the gates
# need and the router gets a module surface that is only what the theme calls. Same trade as
# strip-templates.sh and strip-shell.sh.
#
# Only a line that ends with the marker, and the line must be a complete export entry — anything
# else is left in place and reported, a half-removed object literal being a module that does not
# parse and a theme that does not load.
set -e
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-probes.sh <dir>" >&2; exit 1; }
LIST=$(mktemp)
CUR=""
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT INT TERM
find "$DIR" -name '*.js' -type f | sort > "$LIST"
found=0
files=0
while IFS= read -r f; do
grep -q '/\* fs:probe \*/' "$f" || continue
files=$((files + 1))
CUR="$f.tmp$$"
awk '
/\/\* fs:probe \*\/$/ {
line = $0
sub(/[ \t]*\/\* fs:probe \*\/$/, "", line)
# a complete entry, in either form the modules use: `name,` or `name: expression,`
if (line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*,[ \t]*$/ ||
line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*:.*,[ \t]*$/) { dropped++; next }
print "strip-probes: not a whole export entry, left in place: " line | "cat 1>&2"
}
{ print }
END { printf "%d", dropped > "/dev/stderr" }
' "$f" 2> "$CUR.n" > "$CUR"
n=$(cat "$CUR.n"); rm -f "$CUR.n"
found=$((found + ${n:-0}))
mv "$CUR" "$f"
CUR=""
done < "$LIST"
[ "$files" -gt 0 ] || { echo "strip-probes: no marked export in $DIR" >&2; exit 1; }
echo "strip-probes: $found export(s) dropped from $files file(s)"
+16 -22
View File
@@ -4,50 +4,44 @@
# ./strip-shell.sh <dir>
#
# Same trade as strip-templates.sh and build-css.sh: the reader gets the comments from git, the
# router gets the bytes. `root/etc/uci-defaults/30_luci-theme-footstrap` is 51% comment lines and
# `root/lib/upgrade/keep.d/luci-theme-footstrap` is 77% (ten lines of reasoning above three paths).
# router gets the bytes (uci-defaults is 51% comment lines, keep.d 77%).
#
# ONLY a line whose first non-blank character is `#`, and never the shebang. That rule is safe here
# and the reason is checked, not assumed: sh has no block-comment syntax, so the only way a `#` can
# be something other than a comment is inside a string or a heredoc — a trailing `# …` after code is
# left alone because the line does not START with it, and there is no heredoc anywhere under root/
# (grepped). If one is ever added, this script has to learn about it: a `# …` line inside a heredoc
# is DATA.
# ONLY a line whose first non-blank character is `#`, and never the shebang. sh has no block-comment
# syntax, so the only way a `#` can be something else is inside a string or a heredoc; a trailing
# `# …` after code is left alone because the line does not START with it, and there is no heredoc
# anywhere under root/ (grepped, and refused below in the files this loop rewrites).
#
# Files are matched by their shebang or by being under a directory whose contents are shell
# (uci-defaults, keep.d), never by extension — none of them have one.
# Files are matched by their shebang or by living under a shell directory (uci-defaults, keep.d),
# never by extension — none of them have one.
set -e
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-shell.sh <dir>" >&2; exit 1; }
# ONE trap, covering the file list and whatever temp the loop is holding when it dies — the same
# shape build-css.sh, mangle-tokens.sh and update-po.sh already use. Under `set -e` an awk failure
# used to leave `<file>.tmp<pid>` behind, and stage.sh runs this over the staged payload, so the
# leftover would be copied into the package: a `30_luci-theme-footstrap.tmp1234` under
# /etc/uci-defaults is a file the ROUTER executes and deletes on its next boot.
# ONE trap, covering the file list and whatever temp the loop holds when it dies. Under `set -e` an
# awk failure leaves `<file>.tmp<pid>` behind, and stage.sh runs this over the staged payload, so the
# leftover is copied into the package: a `30_luci-theme-footstrap.tmp1234` under /etc/uci-defaults is
# a file the ROUTER executes and deletes on its next boot.
LIST=$(mktemp)
CUR=""
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT INT TERM
# The list goes through a file rather than `for f in $(find …)`: that splits on whitespace, so a
# checkout under a path with a space in it fed this loop two halves of a directory name and the
# script reported "no shell file" while pointing at the tree instead of at the path.
# checkout under a path with a space fed the loop two halves of a directory name.
find "$DIR" -type f | sort > "$LIST"
before=0
after=0
found=0
while IFS= read -r f; do
# shell only: a shebang, or the one comment-only manifest this package ships (keep.d). The other
# two files under root/ — the uci config and the rpcd ACL — are deliberately left alone.
# shell only: a shebang, or the one comment-only manifest this package ships (keep.d). The uci
# config and the rpcd ACL are deliberately left alone.
case "$(head -c 2 "$f")" in
'#!') ;;
*) case "$f" in */keep.d/*) ;; *) continue ;; esac ;;
esac
# The heredoc refusal, over the files this loop actually REWRITES. It used to run over the whole
# tree, so a `<<` inside the rpcd ACL or the uci config — neither of which is ever touched here —
# aborted the package build for a hazard that cannot apply to them.
# The heredoc refusal, over the files this loop actually REWRITES. Run over the whole tree it
# aborted the build on a `<<` in the ACL or the uci config, neither of which is touched here.
if grep -q '<<' "$f"; then
echo "strip-shell: a heredoc in $f — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1
+19 -28
View File
@@ -3,41 +3,32 @@
#
# ./strip-templates.sh <dir>
#
# Run from the package Makefile (Build/Prepare) over $(PKG_BUILD_DIR), never over the source tree:
# git keeps every word, the router does not need any of them. Same trade this project already makes
# for JS (jsmin/terser) and CSS (build-css.sh) — templates were simply never included in it, and
# they are mostly comments: `{# … #}` is 22633 of 60227 bytes (38%).
# Run from the package Makefile over $(PKG_BUILD_DIR), never over the source tree: git keeps every
# word and the router needs none of them. Same trade this project makes for JS and CSS, and
# templates are mostly comments — `{# … #}` is 38% of their bytes.
#
# sh + awk only, like build-css.sh: an OpenWrt buildbot has no node and this must not become the
# reason a build needs one.
# sh and awk only, like build-css.sh: an OpenWrt buildbot has no node.
#
# WHAT IS AND IS NOT TOUCHED, and the distinction is the whole safety argument:
# What is and is not touched, which is the whole safety argument:
# * `{# … #}` — a TEMPLATE comment. ucode treats `{#` as a comment opener everywhere outside a
# `{% … %}` code block, so a `{#` that survives in this tree is a comment BY DEFINITION; if one
# ever sat inside a <script> string the template would already be broken. Verified before
# writing this: 0 of them appear inside a code block, and every opener has exactly one closer.
# * `/* … */` STANDING ON ITS OWN LINES — a code comment, wherever it sits: ucode inside
# `{% … %}`, JavaScript inside an inline <script>, CSS inside an inline <style>. 18362 bytes
# across this tree, 8 KB of them in the shipped package after gzip, which is 11% of it.
# `{% … %}` code block, so a `{#` that survives in this tree is a comment by definition.
# Verified: 0 of them appear inside a code block, and every opener has exactly one closer.
# * `/* … */` STANDING ON ITS OWN LINES — a code comment, wherever it sits: ucode, JavaScript in
# an inline <script>, CSS in an inline <style>.
#
# This used to be left alone with the argument that stripping it "needs a lexer that knows
# ucode strings" — and that argument is right about the general case and wrong about this one.
# The rule here is not "remove /* … */"; it is "remove a comment that OWNS its lines": `/*` is
# the first non-blank thing on its line and `*/` is the last non-blank thing on its (possibly
# later) line. For that to eat live code, a string literal would have to span lines AND contain
# a line that is nothing but a comment — measured across every .ut in this tree: 18362 of 18362
# comment bytes are whole-line, zero are inline, and no multi-line template literal contains a
# line-leading `/*`. Anything that does not fit the rule is LEFT IN PLACE and counted, so the
# day one appears the build says so instead of quietly changing the meaning of a template.
# The rule is not "remove /* … */" but "remove a comment that OWNS its lines": `/*` is the first
# non-blank thing on its line and `*/` the last non-blank thing on its. For that to eat live
# code a string literal would have to span lines AND contain a line that is nothing but a
# comment — measured across every .ut here: 18362 of 18362 comment bytes are whole-line.
# Anything that does not fit the rule is left in place and counted.
#
# What this deliberately does NOT do is minify: no joining of lines, no touching of anything
# that is not a comment from column one. The output still reads like the source, minus the prose.
# It deliberately does not minify: no joining of lines, no touching of anything that is not a
# comment from column one.
#
# Whitespace control is EMULATED, not ignored: `{#- …` also eats the whitespace before the comment
# and `… -#}` the whitespace after it, which is how ucode itself renders them. Nearly every .ut here
# opens with a licence block closing `-#}` (footer.ut is one `{% include %}` line and has none);
# head.ut's is the one that matters, swallowing the newline before its <!DOCTYPE html>, and dropping
# the comment without the trim would put that newline back.
# and `… -#}` the whitespace after it, which is how ucode itself renders them. head.ut's licence
# block is the one that matters — its `-#}` swallows the newline before <!DOCTYPE html>, and
# dropping the comment without the trim would put that newline back.
set -e
DIR="${1:-}"
+6 -7
View File
@@ -10,17 +10,16 @@
* Copyright 2011 Twitter, Inc
*/
/* Layer order — declared here and nowhere else. A later layer beats an earlier one
* regardless of specificity, so a theme rule never needs !important to outrank base;
* one that needs it to beat another footstrap rule belongs in a later layer instead.
* Unlayered rules beat every layer — that slot is deliberately empty, it is the
* escape hatch.
/* Layer order, declared here and nowhere else. A later layer beats an earlier one regardless of
* specificity, so a theme rule never needs !important to outrank base; one that needs it to beat
* another footstrap rule belongs in a later layer. Unlayered rules beat every layer, and that slot
* is deliberately empty.
*
* tokens design tokens + the outbound bridge to LuCI's --*-color-* names styles/0*
* base widget defaults every LuCI view assumes exist styles/base/*
* theme footstrap components and layouts styles/theme/*
* page per-page corrections styles/pages/*
*
* build-css.sh concatenates those four dirs into cascade.css — generated and
* gitignored, never edit it. */
* build-css.sh concatenates those four directories into cascade.css, which is generated and
* gitignored — never edit it. */
@layer tokens, base, theme, page;
+244 -357
View File
@@ -1,110 +1,90 @@
@layer tokens {
/* ============================================================
* Design tokens. TWO TIERS, and the split is load-bearing:
/* Design tokens, in two tiers, and the split is load-bearing:
*
* PRIVATE --fs-* every rule in this theme reads ONLY these.
* EXPORT --*-color-* LuCI's conventional names, defined FROM the private tier and
* read by NOBODY here — they exist so a third-party luci-app-*
* keeps working and keeps following the palette and dark mode.
* PRIVATE --fs-* every rule in this theme reads only these
* EXPORT --*-color-* LuCI's conventional names, defined FROM the private tier and read
* by nobody here — they exist so a third-party luci-app-* keeps
* working and keeps following the palette and dark mode
*
* :root is a SHARED scope and every luci-app-* drops CSS into the same document,
* UNLAYERED — which outranks every cascade layer. An app declaring --accent, --radius,
* --text, --border — or, likelier, the CONVENTIONAL --text-color-high, which base itself
* once read 158 times — silently repainted this theme: 312 of 336 elements on
* docs/gallery.html with a hostile :root, 0 after the split. The bridge is OUTWARD-only;
* audit.py fails on any read of an export name inside styles/.
* :root is a shared scope and every luci-app-* drops CSS into the same document, unlayered,
* which outranks every cascade layer. An app declaring --accent or the conventional
* --text-color-high, which base itself once read 158 times, repaints the theme: 312 of 336
* gallery elements with a hostile :root before the split, 0 after. The bridge is outward-only,
* and tools/audit.py fails on any read of an export name inside styles/.
*
* docs/design-system.md. Light = :root; dark = :root[data-darkmode="true"].
* ============================================================ */
* docs/design-system.md. Light = :root; dark = :root[data-darkmode="true"]. */
:root {
/* Palette colours are in 03-palettes.css, one swappable block per colourway x mode;
* everything here is palette-independent or var()-derived. The inks (--fs-on-accent and
* friends) live in those blocks too, beside the fills they must be legible against: a
* dark palette has light fills and needs a dark ink, and one global `#fff` failed WCAG AA
* on seven of the eight dark-palette fills (down to 1.69:1). */
--card-bg-color: var(--fs-panel) !important;
--border-color: var(--fs-border) !important;
--app-container_title-color: var(--fs-text) !important;
--item-label_key-span-color: var(--app-container_title-color) !important;
--app-container_status-label_block: var(--app-container_title-color) !important;
/* Palette COLOURS are in 03-palettes.css (one swappable block per colourway × mode);
* everything here is palette-INDEPENDENT or var()-derived, so it follows the active
* palette. The INKS (--fs-on-accent &c.) are in the palette blocks too, beside the
* fills they must be legible against: a dark palette has LIGHT fills and needs a DARK
* ink, and the single global `--fs-on-accent: #fff` that used to sit here failed WCAG
* AA on seven of the eight dark-palette fills (down to 1.69:1). */
--fs-shadow: 0 1px 3px rgba(15,23,37,.08);
/* pop surfaces (menus, dropdown lists, Appearance popover, select picker): one value
* for the .28/.30/.32 literals that had drifted apart across them. */
/* pop surfaces (menus, dropdown lists, the select picker): one value rather than a
* literal per site */
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30);
/* the top bar's edge: a drop below + a 1px inset seam, both a quiet 25% of the border
* colour. The one bar/card shadow that was written inline (50-toplayout.css) — an
* unnamed derived level, the drift shape the ladder exists to prevent. */
/* the top bar's edge: a drop below plus a 1px inset seam, both a quiet 25% of the border
* colour */
--fs-shadow-bar: 0 1px 3px color-mix(in srgb, var(--fs-border) 25%, transparent),
inset 0 -1px 0 color-mix(in srgb, var(--fs-border) 25%, transparent);
/* ---- TWO FOCUS RINGS, and WHICH ONE A CONTROL TAKES IS THE CONTRACT ------------
* -soft is a TINT (10-15% of the accent), so composited on the surface it lands on it
* measures 1.15 / 1.27 / 1.17 / 1.29 : 1 across palette x mode. That is a GLOW, not an
* indicator: WCAG 1.4.11 wants 3:1 for the thing that tells you where focus is. It is
* legitimate only BESIDE a second channel — every field here also flips border-color to
* --fs-accent, and that change measures 4.57-7.04:1 across the three palettes x both modes
* x the three surfaces, so the border is the indicator and the tint is the halo around it.
/* ---- two focus rings, and which one a control takes is the contract ----
* -soft is a TINT (10-15% of the accent), measuring 1.15-1.29:1 on the surface it lands
* on: a glow, not an indicator, where WCAG 1.4.11 wants 3:1. It is legitimate only beside
* a second channel — every field taking it also flips border-color to --fs-accent, which
* measures 4.57-7.04:1 across palette x mode x surface, so the border is the indicator and
* the tint is the halo.
*
* -solo is for a control where the ring is the WHOLE indicator: nothing else about it
* changes on focus. Three groups were relying on the tint alone and therefore showed
* effectively nothing under the keyboard — every sidebar menu link and the chrome's icon
* buttons (10-chrome.css), the section tabs (40-tabs.css) and both range sliders. The
* 2px of surface is what lets it read against a FILLED control too (an accent-filled
* button or the active tab pill would otherwise take an accent ring on an accent box);
* solid accent then measures 5.19 / 5.39 / 5.22 / 7.04 / 5.81 / 5.24 on panel across the six
* palette x mode blocks, and 4.61-7.55 on the canvas.
* It had been written out as a literal twice (the filled buttons, the active pill) before
* it had a name — the drift shape the ladder exists to prevent.
* -solo is for a control where the ring is the WHOLE indicator and nothing else changes on
* focus: the sidebar menu links and chrome icon buttons, the section tabs, both range
* sliders. Its 2px of surface is what lets it read against a FILLED control, where an
* accent ring would otherwise sit on an accent box; solid accent then measures 5.19-7.04
* on panel and 4.61-7.55 on the canvas.
*
* The invalid twin takes -fill (18%), not -soft: a red ring must read as an ALARM, not
* as a hover tint. It is paired with a red BORDER, so the tint rule above applies. */
* The invalid twin takes -fill (18%), not -soft: a red ring must read as an alarm rather
* than a hover tint, and it is paired with a red border. */
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* THE CHOSEN-ROW RAIL, the same ladder one rung along: a chosen row in a list says so with an
* opaque surface and an inset bar, never with a tint of the accent under accent text (that
* measured 4.21:1 in dark — see theme/65-dropdown.css). Named because it is written in three
* places that cannot be one rule: the open dropdown's row, a native `select option:checked`,
* and the ring the FOCUSED row wears on top of it. The first two are pinned to each other by
* tools/mirror.mjs; the third restated the literal until it had a name. */
/* The chosen-row rail: a chosen row says so with an opaque surface and an inset bar, never
* with a tint of the accent under accent text (4.21:1 in dark — theme/65-dropdown.css).
* Named because it is written in three places that cannot be one rule: the open dropdown's
* row, a native `select option:checked`, and the ring the focused row wears on top. The
* first two are pinned to each other by tools/mirror.mjs. */
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
/* THE HAIRLINE, named because it is a decision and not an accident. Every separator in
* this theme is the same one-pixel line in the same border colour — 69 declarations said
* so in longhand, which is 69 places to miss when the answer changes (a 2px line for a
* high-contrast mode, a softer colour, a logical-property rewrite). The shorthand is the
* token, so `border`, `border-top` and friends all take it, and the private tier means it
* costs two characters in the shipped sheet rather than twenty-six. */
/* The hairline: every separator in this theme is the same one-pixel line in the same
* border colour, so it is stated once rather than in 69 longhand declarations that would
* each have to change for a 2px high-contrast line or a softer colour. The shorthand is
* the token, so `border`, `border-top` and friends all take it. */
--fs-hairline: 1px solid var(--fs-border);
/* A FIELD REACTING TO FOCUS: the ring arrives and the edge changes colour together, and
* they must stay together — a transition list that drifts apart shows the border moving
* before the ring. Eleven controls ride this. */
/* a field reacting to focus: the ring arrives and the edge changes colour together, and a
* transition list that drifts apart shows the border moving before the ring */
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
/* the theme's ONE colour literal, deliberately: black at .7 is the absence of light
* behind a dialog, not a tint of any token — a palette-tinted scrim over a dark page
* dims nothing. A token so the RULES stay literal-free. */
/* the theme's one colour literal, deliberately: black at .7 is the absence of light behind
* a dialog, not a tint of any token — a palette-tinted scrim over a dark page dims
* nothing. A token, so the rules stay literal-free. */
--fs-scrim: rgba(0, 0, 0, .7);
/* ---- ROLE TINT LADDER (derived) ---------------------------------------
* A tint OF a role, mixed FROM it, so it follows the palette with nothing
* hand-maintained (the trap the *-hsl/*-rgb bridges fell into — foot of this file).
* What these mixes lacked was NAMES, and an unnamed level drifts in silence: the same
* outline border was 40% in a table and 45% in the action bar, the same diff block
* 30% in base and 18% in theme, the same hover fill 12% here and 18% there — four
* strengths where the design has two.
/* ---- role tint ladder (derived) ----
* A tint OF a role, mixed FROM it, so it follows the palette with nothing hand-maintained.
* Unnamed, these levels drift: the same outline border was 40% in a table and 45% in the
* action bar, the same hover fill 12% in one file and 18% in another.
*
* -soft 12% a quiet FILL — outline-button :hover
* -fill 18% a stronger FILL — callout/diff surface, the invalid ring
* -line 40% a HAIRLINE — outline-button border, diff border
* -line-hi 55% that hairline on :hover
*
* The role × step matrix is COMPLETE on purpose, read or not: a gap is how the drift
* started (--fs-accent-soft existed, good/warn/danger had no sibling, so every rule
* wanting one invented a percentage). --fs-accent-soft is the one member NOT here —
* its strength is the only one that differs per MODE (10% light / 15% dark;
* hicontrast 10/14), so it sits in the palette blocks.
* ----------------------------------------------------------------------- */
* The role x step matrix is complete on purpose, read or not: a gap is how the drift
* starts, since a rule wanting a level that does not exist invents a percentage.
* --fs-accent-soft is the one member not here — its strength differs per MODE (10% light,
* 15% dark; hicontrast 10/14) — so it sits in the palette blocks. */
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
--fs-warn-soft: color-mix(in srgb, var(--fs-warn) 12%, transparent);
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
@@ -123,117 +103,88 @@
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
--fs-warn-line-hi: color-mix(in srgb, var(--fs-warn) 55%, transparent);
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
/* THE WARN ROW HAS NO READER TODAY, and it is kept on purpose — do not "clean it up".
* `good` and `danger` are consumed by the button variants (.cbi-button-positive /
* -negative / -reset, theme/55-buttons.css) and `warn` only by -fill, because LuCI emits
* no warning BUTTON. The row is what makes the next component that needs one reach for a
* token instead of mixing its own percentage, which is the whole reason these four
* strengths are stated once here rather than at each call site. A grep that finds no
* `var(--fs-warn-line)` is therefore expected, not a finding. */
/* The warn row has no reader today and is kept on purpose: LuCI emits no warning BUTTON,
* so only -fill is consumed. The row is what makes the next component reach for a token
* instead of mixing its own percentage. A grep finding no `var(--fs-warn-line)` is
* expected. */
/* frosted pop surface (top-nav dropdown, rail flyout). NOT merged into --fs-bar-bg
* (88%): a bar is a thin strip the page scrolls under and seeing content ghost
* through it is the point; a dropdown carries a whole menu and must stay readable. */
/* frosted pop surface (top-nav dropdown, rail flyout), deliberately not merged into
* --fs-bar-bg (88%): a bar is a thin strip the page ghosts through on purpose, while a
* dropdown carries a whole menu and must stay readable */
--fs-glass: color-mix(in srgb, var(--fs-panel) 96%, transparent);
/* one blur radius for every frosted surface — the bar said 10px and the pops 12px,
* a difference nobody chose. */
/* one blur radius for every frosted surface — the bar said 10px and the pops 12px */
--fs-blur: blur(12px);
/* the circular-arrows refresh glyph, as a mask so its colour follows the palette
* (--fs-good active / --fs-dim paused). ONE copy: the poll-status pill collapses to it both
* in the collapsed rail and in the bar's compact cluster (20-shell.css owns both rules), and
* inlining the SVG twice is exactly the drift css-dup exists to catch. STATIC glyph
* on purpose — see the rail note; a spinner makes an idle poll look busy and moves the
* click target that pauses it. */
/* OURS. It was derived from Lucide's `refresh-cw` (ISC) — `M21 3v5h-5` was byte-identical,
* and the r=9 arc and `L21 8` terminus came with it — which is a second outside obligation
* this theme never declared, found while auditing the first. Redrawn as two OPEN
* arcs with solid triangular heads: a different construction, not a nudge of the same one
* (Lucide caps a continuous stroke with an L-shaped hook). Solid heads and not chevrons
* because it renders at 18px — measured, chevrons dissolve at that size. It is a MASK, so
* fill and stroke both give alpha. */
/* The circular-arrows refresh glyph, as a mask so its colour follows the palette
* (--fs-good active, --fs-dim paused). One copy: the poll pill collapses to it both in the
* rail and in the bar's compact cluster. A static glyph on purpose — a spinner makes an
* idle poll look busy and moves the click target that pauses it.
*
* OURS, and redrawn to keep it so. It was derived from Lucide's `refresh-cw` (ISC) —
* `M21 3v5h-5` was byte-identical — which is a second outside licence obligation this theme
* never declared. Redrawn as two OPEN arcs with solid triangular heads: a different
* construction, not a nudge of the same one (Lucide caps a continuous stroke with an
* L-shaped hook). Solid heads rather than chevrons because chevrons dissolve at the 18px
* this renders at. */
--fs-icon-refresh: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");
/* ...and the size it is drawn at. De-duplicating the SVG left its GEOMETRY to drift anyway:
* the rail said 19px and the top bar 18px for the same glyph, which css-dup cannot see —
* the declaration bodies differ, so it goes quiet. The two sites legitimately differ in
* TECHNIQUE (the rail masks the element and drops the border; the bar keeps the bordered
* square to match Appearance/Log out); the size was nobody's decision. */
/* …and the size it is drawn at, which css-dup cannot police for a glyph inlined once and
* sized at two call sites. The two sites legitimately differ in technique — the rail masks
* the element and drops the border, the bar keeps the bordered square — but not in size. */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* the 1px raised edge on a plate carrying text, plus its text shadow. .ifacebox
* mixed --fs-dim and .ifacebadge --fs-panel for the SAME edge at the same 5%. */
/* the 1px raised edge on a plate carrying text, plus its text shadow */
--fs-emboss: inset 0 1px 0 color-mix(in srgb, var(--fs-panel) 5%, transparent);
--fs-text-emboss: 0 1px 1px color-mix(in srgb, var(--fs-panel) 75%, transparent);
/* Hover cue for a SOLID control — the one cue that cannot be a colour, since the fill
* it shifts is whatever the role token says (the three sites had drifted to
* 1.06/1.08/1.12). PER MODE, AND THE DIRECTION FLIPS, measured from rendered pixels:
* `filter` recolours the TEXT too, and a light-mode solid button is a saturated fill
* with WHITE ink, which cannot get brighter — on --fs-accent (#0969da) white is
* 5.19:1 at rest, brightness(1.08) drops it to 4.59:1 and a visible 1.15 to 4.08:1,
* an AA failure caused by hovering. So light DARKENS: .90 moves fill and ink together
* and lands at 4.94:1 — a hair under the resting 5.19 rather than the 6.16 you get by
* darkening the fill alone, and still clear of the 4.5 floor that brightening breaks.
* Dark, whose fill is light and ink dark, brightens instead (1.15). Both move the button
* away from the page it sits on, which is what the cue has to do. */
/* Hover cue for a solid control — the one cue that cannot be a colour, the fill it shifts
* being whatever the role token says. Per mode, and the DIRECTION FLIPS: `filter`
* recolours the text too, and a light-mode solid button is a saturated fill with white
* ink, which cannot get brighter — brightening drops white on --fs-accent from 5.19:1 to
* 4.59:1 at 1.08 and to 4.08:1 at a visible 1.15, an AA failure caused by hovering. So
* light darkens (.90 lands at 4.94:1, still clear of the 4.5 floor) and dark, whose fill
* is light and ink dark, brightens (1.15). */
--fs-hover-lift: brightness(.90);
/* ---- MOTION — four durations, because the UI does four things. There used to be
* seven (.12/.125/.15/.2/.22/.25/.4) and four curves (ease, ease-in, linear, one
* cubic-bezier), none of it chosen — two rules even declared the SAME three
* properties at the same duration in a different ORDER, so a grep could not see they
* were the same rule. There is deliberately NO easing token: every transition and
* animation omits the timing function and takes the CSS default (ease) — one curve,
* nothing to keep in sync, fewer bytes than naming it. `ease` IS the initial value of
* animation-timing-function, so writing it in the shorthand changes nothing; four
* animations said it and one did not, which is the evidence nobody chose it. A rule
* needing another curve is making a design decision and must justify it in a comment.
/* ---- motion: four durations, because the UI does four things ----
* There is deliberately no easing token: every transition and animation omits the timing
* function and takes the CSS default (ease), which is one curve, nothing to keep in sync
* and fewer bytes than naming it. A rule needing another curve is making a design decision
* and must justify it in a comment.
*
* The scale governs `animation` too — that half was missed the first time. Only the
* transitions were converted, so `fs-fade` (ONE keyframe, one gesture) was ridden at
* .14s, .16s and .3s: the same drift as the refresh glyph's 19px/18px, and invisible to
* css-dup for the same reason — the declaration bodies differ, so it goes quiet.
* 95-a11y-media.css kills all of this under prefers-reduced-motion.
* ----------------------------------------------------------------------- */
* The scale governs `animation` too, not only `transition`: one gesture ridden at three
* durations is drift css-dup cannot see, the declaration bodies differing.
* 95-a11y-media.css kills all of this under prefers-reduced-motion. */
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
--fs-dur-fill: .4s; /* a progress bar growing to its value */
--fs-content-max: 1280px; /* content column cap */
/* content column side padding — read by .fs-content, the top bar's centering calc
* and the notification width calc, so they cannot drift apart. */
/* content column side padding, read by .fs-content, the top bar's centering calc and the
* notification width calc, so the three cannot drift */
--fs-content-pad: calc(28px * var(--fs-density-space));
/* ------------------------------------------------------------------------
* THE SHELL'S GEOMETRY — tokens, not literals, because the JS reads them back with
* getPropertyValue(): the CSS lays the sidebar out, and fs-chrome.js
* (fitShell()) subtracts that cut from the viewport to decide whether what is LEFT
* is still readable. The JS used to keep its own copies (SIDEBAR_W = 224, RAIL_W =
* 68, CONTENT_MIN = 500) against bare literals in the CSS: narrow the rail here and
* the measurement silently went on subtracting the old width.
* ---------------------------------------------------------------------- */
/* The shell's geometry: tokens, not literals, because the JS reads them back — the CSS
* lays the sidebar out and fs-chrome.js's fitShell() subtracts that cut from the viewport
* to decide whether what is left is readable. With copies in JS, narrowing the rail here
* leaves the measurement subtracting the old width. */
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
--fs-content-min: 500px; /* below this a content column stops being readable */
/* translucent blurred bar surface — the top-nav bar and the sidebar's phone bar are
* one component in two markups; one token keeps their glass tint identical. */
/* translucent blurred bar surface: the top-nav bar and the sidebar's phone bar are one
* component in two markups, so one token keeps their glass tint identical */
--fs-bar-bg: color-mix(in srgb, var(--fs-panel) 88%, transparent);
/* height of that bar, and the scroll-margin every anchor target needs to clear it
* (base/10-reset.css) — if the two drift, an in-page jump lands under the bar. */
* (base/10-reset.css): if the two drift, an in-page jump lands under the bar */
--fs-bar-h: calc(46px * var(--fs-density-box));
/* the square icon-button in the bar/rail: the logo box, Appearance (.fs-themerow),
* Log out (.fs-logout) and the collapsed poll pill are all one size so the right
* cluster reads as a row of equal squares. Was a bare 34px in three files with the
* logo left at 30 — the odd one out. */
/* the square icon button in the bar and rail: the logo box, the .fs-themerow controls,
* Log out and the collapsed poll pill are one size, so the right cluster reads as a row
* of equal squares */
--fs-btn-size: calc(34px * var(--fs-density-box));
/* Corner-radius scale. One user-tunable base (Appearance → Rounding, 0–20px,
* localStorage `fs-radius`, pre-painted inline on :root by partials/head.ut before
* first paint); the three semantic radii scale from it proportionally so every
* surface rounds in step, and pills stay fully round. At base 12: card 12, control
* 10, chip 8. Every border-radius in theme/pages reads one of these — the only bare px left
* are 0, 50%, and three that are not a card's corner at all: the checkbox tick box's 2px
* (theme/60-inputs.css) and the 1px on each of the two chevron glyphs (pages/20-overview.css,
* pages/80-appearance.css), which are 7px squares rotated 45°. */
/* Corner-radius scale: one user-tunable base (Appearance -> Rounding, 0-20px, pre-painted
* on :root by partials/head.ut) with three semantic radii scaling from it proportionally,
* so every surface rounds in step and pills stay round. At base 12: card 12, control 10,
* chip 8. Every border-radius in theme/ and pages/ reads one of these; the only bare px
* left are 0, 50% and three that are not a card's corner at all. */
--fs-radius-base: 12px;
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
@@ -241,107 +192,84 @@
--fs-radius-pill: 999px; /* pills, toggles, badges — always round */
/* A card's inner gutter (.cbi-section). A token because it is read back to UNDO it:
* theme/45-misc.css bleeds the realtime graphs out to the card's border edge, and the
* offset it needs is this padding plus the card's 1px border. Written as a literal in
* both places, narrowing the gutter here would silently re-clip every graph — the same
* trap --fs-sidebar-w exists to close for fitShell(). */
* theme/45-misc.css bleeds the realtime graphs out to the card's border edge by this
* padding plus the 1px border, so a literal in both places would re-clip every graph when
* the gutter narrows. */
--fs-card-pad: calc(16px * var(--fs-density-space));
/* The vertical rhythm BETWEEN cards (.cbi-section, tab groups, alerts, action bars).
* It was the same 16px hand-written in seven files — 45-misc.css even carried a comment
* apologising for it ("Same 16px every .cbi-section already uses") — which is an
* unnamed level, and an unnamed level drifts (see the role-tint ladder above). */
/* the vertical rhythm between cards (.cbi-section, tab groups, alerts, action bars):
* an unnamed level drifts, so it is stated once */
--fs-card-gap: calc(16px * var(--fs-density-space));
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* ---- TYPE SCALE — footstrap's own, and the reason it exists is not tidiness ----
/* ---- type scale ----
*
* styles/base carried a 13/18 rhythm with 9px and 8.5px halves of it. That is
* luci-theme-bootstrap's scale, i.e. Twitter's 2011 choice, and it arrived here with the
* fork rather than by anyone deciding it fits Manrope. Naming those same numbers as tokens
* would have changed nothing that matters — the numbers ARE the borrowed expression — so
* the scale is re-derived here and base reads it instead of restating literals.
* 13px is the base deliberately: a router UI is dense tables of addresses and counters, and
* the size was chosen for that. The rhythm around it is a unitless 1.5 rather than a hard
* 18px — `line-height: 18px` is inherited as 18px into a 20px heading and clips it, while
* 1.5 is re-resolved against each element's own size.
*
* 13px stays the base DELIBERATELY, and it is not the same 13 as upstream's: a router UI is
* dense tables of addresses and counters, and the size was re-chosen for that, not
* inherited. What changes is the RHYTHM around it — a unitless 1.5 instead of a hard 18px.
* Unitless matters: `line-height: 18px` is inherited as 18px into a 20px heading and clips
* it, while 1.5 is re-resolved against each element's own size.
* Every step is a whole px; the line box 1.5 computes is not (13 -> 19.5), which is what
* unitless leading gives on an odd size. What must stay whole is a BOX: never size an
* element `--fs-type * --fs-leading` — take the nearer --fs-space step.
*
* Every STEP is a whole px; the line box 1.5 computes is not (13 -> 19.5), and that is fine
* — a fractional line box is what unitless leading gives on any odd size, and the baseline
* still snaps. What must stay whole is a BOX: never size an element `--fs-type * --fs-leading`
* (a half-pixel border is upstream's 8.5px bug wearing a calc) — take the nearer --fs-space
* step, which is what a line of text at this scale rounds to anyway. */
/* FIVE steps, and the count is the point. The scale had grown to eight — 10, 11, 12, 13, 14,
* 16, 20, 26 — where four of the gaps were a single pixel, which is below the threshold at
* which anyone reads a size as *different*: it does not build hierarchy, it just makes two
* rules that look unrelated in a grep. The 1px neighbours were folded together (10 into 11,
* 12 into 13) and the 14 was split by what it was actually doing — chrome and menu items are
* body text (13), a section title is a title (16) — which is what finally puts real distance
* between a heading and the text under it, where before there was one pixel.
* Ratio is roughly 1.25 per step. Anything that needs a size not on this list is making a
* design decision and should say so in a comment. */
/* ---- DENSITY: one axis, three multipliers, and it only works because of the scales ----
* Five steps, and the count is the point: a scale with 1px gaps builds no hierarchy, it
* just makes two rules look unrelated in a grep. Ratio is roughly 1.25 per step, and
* anything needing a size off this list is making a design decision and should say so. */
/* ---- density: one axis, three multipliers ----
*
* Compact / Normal / Large is a CLIENT preference (Appearance), stamped as
* :root[data-density] — absent means Normal, so the default costs no attribute and no
* override, exactly like every other axis here.
* Compact / Normal / Large is a client preference stamped as :root[data-density]; absent
* means Normal, so the default costs no attribute and no override.
*
* THREE multipliers, because three different kinds of size want different treatment and one
* knob gets all three wrong:
* Three multipliers, because one knob gets all three kinds of size wrong:
*
* --fs-density-type TEXT, and it moves LEAST: .9 at Compact, 1.15 at Large. "Compact" is
* a request for less air, not for smaller writing, so the type gives up
* a tenth where the air gives up a third — at .9 the base is 12px and
* the eyebrow 10px, which is the floor this file is willing to go to,
* and only because the user asked for the mode by name.
* --fs-density-space pure AIR — padding, margin, gap. This is what actually buys the room,
* so it moves the most (.65 at Compact). It does NOT grow at Large: the
* point there is bigger text, and inflating the air with it just reads
* as zoom, which is the browser's job.
* --fs-density-box boxes that must HOLD text or an icon: the sidebar column, the rail,
* the bar height, the square icon buttons, the control-height floors.
* These cannot follow the air: the type only gives up a tenth, so a
* sidebar cut by 35% would no longer fit the menu labels it exists to
* show. They move gently (.85 / 1.15).
* --fs-density-type TEXT, moving least (.9 / 1.15). Compact is a request for less air,
* not for smaller writing; .9 puts the base at 12px, which is the
* floor.
* --fs-density-space pure AIR — padding, margin, gap — which is what buys the room, so
* it moves most (.65 at Compact). It does not grow at Large, where
* inflating the air just reads as zoom.
* --fs-density-box boxes that must HOLD text or an icon: the sidebar, the rail, the
* bar height, the icon buttons, the control-height floors. They
* cannot follow the air — the type gives up only a tenth, so a
* sidebar cut by 35% no longer fits the labels it exists to show.
*
* This is only expressible because every size in the theme now reads one of these tokens:
* before that, a density axis would have meant hunting ~240 literals and would have gone
* stale the first time someone wrote a number. Add a size OUTSIDE these ladders and it
* silently stops responding to the axis — that is the cost, and the reason the ladders are
* the contract.
* This is only expressible because every size in the theme reads one of these tokens: a
* size written outside the ladders silently stops responding to the axis.
*
* Type is rounded to whole pixels (a fractional font size renders soft); space is not, since
* a sub-pixel padding is invisible and rounding it would collide steps together. */
* Type is rounded to whole pixels, a fractional font size rendering soft; space is not,
* since a sub-pixel padding is invisible and rounding it would collide steps together. */
--fs-density-type: 1;
--fs-density-space: 1;
--fs-density-box: 1;
/* ---- CONTROL HEIGHTS: three steps, and they follow the density too -----------
* A minimum height is what stops a button collapsing onto its text, so it is part of how
* dense the UI feels — leave it literal and Compact shrinks the writing while the buttons
* stay put, which is the opposite of the setting. These had drifted to EIGHT values
* (28/30/32/36/38/40/42/44) for what the design has three of; each was moved by at most 2px
* onto the nearest step.
* Not to be confused with --fs-control-h below: that is a FIELD's height, derived from the
* text it must hold, and it already follows the type ladder. These are the floors a chrome
* control gets when its content is not text. */
/* ---- control heights: three steps, following the density ----
* A minimum height stops a button collapsing onto its text, so it is part of how dense the
* UI feels: left literal, Compact shrinks the writing while the buttons stay put.
* Not --fs-control-h below, which is a FIELD's height derived from the text it must hold;
* these are the floors a chrome control gets when its content is not text. */
--fs-ctl-h-sm: calc(30px * var(--fs-density-box)); /* dense/small: .btn.small, table-row buttons */
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
/* UNROUNDED HERE, ROUNDED IN THE @supports BELOW, and the split is not style: it is the
* difference between a soft failure and no page. `round()` is the YOUNGEST thing this theme
* depends on (Chromium 125, Firefox 118, Safari 15.4 — years after :has(), color-mix() and
* @layer), and a custom property holds any token stream, so an engine without it PARSES these
* fine and fails at SUBSTITUTION: `font-size: var(--fs-type)` is then invalid at
* computed-value time and computes to `unset`. Measured by serving a sheet with `round(`
* renamed: 5881 of 5912 elements on Overview change, body type goes 13px -> 16px, 5880
* font-size and 5733 line-height declarations are lost, and --fs-control-h takes every
* field's min-height with it because it reads --fs-type. Declared this way such an engine
/* Unrounded here, rounded in the @supports below, and the split is the difference between
* a soft failure and no page: `round()` is the youngest thing this theme depends on
* (Chromium 125, Firefox 118, Safari 15.4), and a custom property holds any token stream,
* so an engine without it parses these fine and fails at SUBSTITUTION — `var(--fs-type)`
* is then invalid at computed-value time and computes to `unset`, taking every font-size,
* line-height and field min-height with it. Measured by serving a sheet with `round(`
* renamed: 5881 of 5912 elements on Overview change, body type goes 13px -> 16px, and 5880
* font-size and 5733 line-height declarations are lost. Declared this way, such an engine
* keeps the whole ladder and pays a fractional pixel at the two non-default densities. */
/* One step BELOW the ladder's smallest, and it exists for one measured reason: the Port
* status card's traffic figures. They are the widest thing a card must fit whole, so their
* size is what sets how narrow a card may be — at --fs-type-xs the floor is 106px, at 10px
* it is 94px, which is what turns ten cards plus a lonely eleventh into one row of eleven.
* A figure read as a pair of numbers beside a label tolerates the step that body text would
* not. Declared here rather than as a literal in the page rule so the density axis stays in
* one place. */
--fs-type-2xs: calc(10px * var(--fs-density-type));
--fs-type-xs: calc(11px * var(--fs-density-type)); /* eyebrow, badges, tooltips, dense metadata */
--fs-type: calc(13px * var(--fs-density-type)); /* body, tables, fields, menu items — the base */
--fs-type-lg: calc(16px * var(--fs-density-type)); /* section titles (h3/legend), alert headings */
@@ -420,40 +348,30 @@
--fs-space-8: calc(32px * var(--fs-density-space));
--fs-space-10: calc(40px * var(--fs-density-space));
/* The spinner glyph's box (base/95-luci.css `.spinning::before`, its width AND height — one
* number, one name). It became a token because a SECOND file used to encode the same size:
* the glyph was absolutely positioned, so theme/55-buttons.css had to clear it with a
* `padding-left` derived from `left` + `width` + a gap, and the two files drifted with
* nothing to notice. That padding is gone — the glyph sits in the flow now (issues #15 and
* #22) — so this is once again read in one place, which is where a resize belongs. */
/* the spinner glyph's box (base/95-luci.css `.spinning::before`, width and height): one
* number, one name, read in one place */
--fs-spin-size: var(--fs-space-5);
/* One control height for every field that is a single line — input, select, .cbi-select,
* .cbi-dropdown, and the checkbox/radio row that must line up beside them. DERIVED, not
* measured by hand: the text box (--fs-type x --fs-leading = 19.5px) plus the vertical
* inset those fields carry (2 x --fs-space-1) plus both 1px borders = 29.5 -> ceil 32.
* It was a bare 30px in four rules, which was upstream's same arithmetic over upstream's
* 18px leading — so re-scaling the type left every control the wrong height for its own
* text, silently. Stated once here so the two cannot drift again. */
/* One control height for every single-line field — input, select, .cbi-select,
* .cbi-dropdown and the checkbox/radio row beside them. Derived rather than measured by
* hand: the text box (--fs-type x --fs-leading = 19.5px) plus the vertical inset those
* fields carry (2 x --fs-space-1) plus both 1px borders = 29.5 -> ceil 32 at the default
* density. A literal here is arithmetic over one particular leading, so
* re-scaling the type silently leaves every control the wrong height for its own text. */
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
/* HOW WIDE AN ELASTIC CONTROL GETS IN A FORM ROW. A `.cbi-value-field` is `flex: 1`, so
* anything asking for 100% takes the whole card — and a form whose every control ends at a
* different x reads as broken even when each width is defensible on its own. Fixed-width
* controls (a select, a number box) stay at their own 210px; this is the ceiling for the
* ones that stretch: the text/url/search inputs, the dynlist and the range slider.
*
* 440px is not on the 4px step scale and does not want to be — it is a COLUMN WIDTH, the
* same kind of number as the 180px label column beside it, and it was already the figure
* three rules had reached independently. Named here so a fourth one cannot pick 460. */
/* How wide an elastic control gets in a form row. A `.cbi-value-field` is `flex: 1`, so
* anything asking for 100% takes the whole card, and a form whose controls each end at a
* different x reads as broken. Fixed-width controls keep their own 210px; this is the
* ceiling for the ones that stretch. It is a COLUMN width, like the label column beside
* it, so it is deliberately not on the 4px step scale. */
--fs-field-max: 440px;
/* ---- Stacking scale ----------------------------------------------------
* Every z-index in the theme comes from here. There used to be nine bare numbers
* (2, 40, 50, 60, 70, 800, 900, 1000, 1100) across seven files, with nothing
* recording what should sit above what — so the Appearance popover (1000) painted
* OVER an open modal (#modal_overlay, 900). As an ordered list, that bug is obvious.
/* ---- stacking scale ----
* Every z-index in the theme comes from here. Bare numbers across seven files record
* nothing about what should sit above what, which is how a popover came to paint over an
* open modal; as an ordered list that is obvious.
*
* Bottom to top:
* raise in-flow child that must clear its siblings (zonebadge tooltip)
@@ -474,75 +392,59 @@
--fs-z-tooltip: 1000;
--fs-z-dropdown: 1100;
/* ---- EXPORT TIER — OUTBOUND ONLY ---------------------------------------
* Nothing in styles/ may read these (audit.py fails the build if it starts to). They
* exist so a third-party luci-app-* addressing them still gets our palette and dark
* mode, and so an app overwriting one restyles only its own widgets.
/* ---- export tier: outbound only ----
* Nothing in styles/ may read these (tools/audit.py fails the build if it does). They
* exist so a third-party luci-app-* addressing them still gets our palette and dark mode,
* and so an app overwriting one restyles only its own widgets.
*
* high/medium/low is a PROMINENCE scale, not decoration. It was once three aliases of
* ONE token — a flat colour where the consumer asked for a gradation — and apps DO
* ask: luci-app-podkop paints its "no data" latency with --primary-color-low and got
* the same vivid accent as a live value; luci-app-justclash reads eleven of these
* names; stock firewall.js and status/cpu.js read them too.
* high/medium/low is a PROMINENCE scale, not decoration: apps do ask for the gradation —
* one paints its "no data" latency with --primary-color-low, another reads eleven of these
* names, and stock firewall.js and status/cpu.js read them too — so three aliases of one
* token hand back a flat colour.
*
* Direction is bootstrap's, which every app was calibrated against: `high` = the most
* pronounced value of the role (--text-color-high the readable text, -low the faint
* one; --border-color-high the visible rule, -low the hairline) — except
* --background-color-*, which is an ELEVATION axis (high = raised, low = recessed).
* The MECHANISM differs per family, by measurement — see each block.
* Direction is bootstrap's, which every app was calibrated against: `high` is the most
* pronounced value of the role, except --background-color-*, which is an ELEVATION axis
* (high = raised). The mechanism differs per family, by measurement — see each block.
*
* Binding constraint: apps read a level as `color:` about as often as they read it as
* `background:` (counted across bootstrap's cascade, firewall.js, cpu.js), so EVERY
* level must clear WCAG AA as text on --fs-bg/--fs-panel/--fs-panel2 AND carry a
* legible --on-*-color as a fill.
* ---------------------------------------------------------------------- */
* Binding constraint: apps read a level as `color:` about as often as as `background:`, so
* every level must clear WCAG AA as text on --fs-bg/--fs-panel/--fs-panel2 and carry a
* legible --on-*-color as a fill. */
--background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
--background-color-low: var(--fs-panel2);
/* -low used to be a DUPLICATE of -medium, both --fs-dim, on the grounds that a fainter
* step would be illegible: --fs-dim sits on the AA floor in dark (4.56:1 on --fs-panel2)
* and --fs-faint is the fainter one. That held while the two were a hair apart. Light's
* secondary tier has since moved to 11.8:1 (see 03-palettes.css), so the duplicate stopped
* being a rounding decision and started flattening the export: `spread(high, low)` fell to
* 0.082 in the default palette and 0.094 in hicontrast, under the 0.10 the ramp promises —
* an app asking for a gradation would have got one colour twice.
*
* So -low is --fs-faint, the theme's own third ink, and the illegibility that argument
* feared is what the gate below measures rather than assumes: every level is held to AA as
* text on all three surfaces AND as a fill under its --on-* ink, in both modes, across the
* whole palette x tint matrix. --fs-faint clears it everywhere. */
/* -low is --fs-faint, the theme's own third ink, and not a duplicate of -medium: making the
* two the same token flattens the export (`spread(high, low)` falls to 0.082 in the
* default palette, under the 0.10 the ramp promises) and hands an app asking for a
* gradation one colour twice. Legibility is measured rather than assumed — every level is
* held to AA as text on all three surfaces and as a fill under its --on-* ink, in both
* modes, across the whole palette x tint matrix. */
--text-color-highest: var(--fs-text);
--text-color-high: var(--fs-text);
--text-color-medium: var(--fs-dim);
--text-color-low: var(--fs-faint);
/* borders carry no text, so here -low MAY fade toward the surface: a hairline
* is supposed to disappear into it. */
/* borders carry no text, so -low may fade toward the surface: a hairline is supposed to
* disappear into it */
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
--border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
/* ACCENT RAMPS: the axis is CHROMA at constant lightness — `high` is the pure palette
/* Accent ramps: the axis is CHROMA at constant lightness — `high` is the pure palette
* colour and each step mixes it toward --fs-dim in oklch, which interpolates lightness
* linearly and so drains saturation while leaving luminance put. Contrast barely moves
* across the ramp, so every level stays printable as TEXT. Both alternatives were
* measured and rejected:
* linearly and so drains saturation while leaving luminance put, keeping every level
* printable as text. Both alternatives were measured and rejected:
*
* fade `low` toward the surface (the intuitive "muted") spends contrast we do not
* have: on --fs-panel2 in dark every accent already sits at 4.56–4.59:1, +0.06 over
* AA, and an 8% fade took --warn-color-low to 4.18:1 and its ink ON it to 4.45:1.
* fading `low` toward the surface spends contrast we do not have — in dark every accent
* already sits at 4.56-4.59:1, and an 8% fade took one level to 4.18:1;
*
* push `high` toward --fs-text (free in contrast terms) collapses the ramp in DARK
* mode, where --fs-text is near-white: the strong end comes back washed out, 0.055
* from the weak end, i.e. flat. Lightness is not an axis in both modes at once.
* pushing `high` toward --fs-text collapses the ramp in dark, where --fs-text is
* near-white: the strong end comes back 0.055 from the weak end, i.e. flat.
*
* Bootstrap's own ramp walks lightness (its --primary-color-low is its DARKEST blue),
* which is why it reads inverted in dark mode and lands at 3.6:1 — below AA. Keep its
* direction, drop its mechanism. Every step is mixed FROM the token it grades: no
* second, hand-kept copy of a colour (foot of this file). tools/export-tier.mjs proves
* all of it across {footstrap,hicontrast} × {light,dark} — AA as text on all three
* surfaces, a legible --on-*-color as a fill, and that the ramp is not secretly flat. */
* Bootstrap's own ramp walks lightness, which is why it reads inverted in dark mode and
* lands at 3.6:1. Keep its direction, drop its mechanism. Every step is mixed FROM the
* token it grades, so there is no hand-kept second copy of a colour, and
* tools/export-tier.mjs proves the whole tier across palette x mode. */
--primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
@@ -567,19 +469,19 @@
color-scheme: light;
}
/* ---- the rounded half of the type ladder, for engines that can round -------------
/* ---- the rounded half of the type ladder, for engines that can round ----
*
* A fractional font size renders soft, so every size here is meant to land on a whole pixel —
* but `round()` in calc() is younger than everything else this theme needs, and a token that
* uses it on an engine that does not is not a fallback, it is a DELETED token that takes every
* rule reading it (the note by the ladder above has the measurement). So the ladder is declared
* plainly up there and rounded here, where the at-rule can be asked first. --fs-control-h is
* derived from --fs-type and rounds UP to the 4px control ladder for the same reason.
* A fractional font size renders soft, so every size is meant to land on a whole pixel — but a
* token using `round()` on an engine without it is not a fallback, it is a deleted token that
* takes every rule reading it (see the ladder above). So the ladder is declared plainly there
* and rounded here, where the at-rule can be asked first. --fs-control-h is derived from
* --fs-type and rounds UP to the 4px control ladder for the same reason.
*
* `@supports (width: round(1px, 1px))` and not a `calc()` probe: the function has to be tested
* where a length is expected, and `width` is the plainest property that takes one. */
* `@supports (width: round(1px, 1px))` rather than a `calc()` probe: the function has to be
* tested where a length is expected. */
@supports (width: round(1px, 1px)) {
:root {
--fs-type-2xs: calc(round(10px * var(--fs-density-type), 1px));
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px));
--fs-type: calc(round(13px * var(--fs-density-type), 1px));
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px));
@@ -589,34 +491,24 @@
}
}
/* ---- the DENSITY axis, and it is exactly two declarations per step ----------------
/* ---- the density axis ----
*
* Normal is a bare :root — no attribute, no override — so the default path costs nothing and a
* browser that has never touched the control renders identically to before this existed.
* Normal is a bare :root, so the default path costs no attribute and no override.
*
* Compact moves the air furthest (space .65 against type .9), because that is what the setting is
* actually for: less AIR, not smaller writing — matching the type to the air would put the body
* at 8.5px, far under the floor the rest of this file defends. Large is the mirror image and not
* the symmetric one: type and box grow to 1.15 while the air stays at 1, so the mode reads as
* bigger text rather than as zoom. At Compact the base is 12px and at Large 15px; the eyebrow
* never drops below 10px.
*
* These multiply the LADDERS, not the rules, so a size that is on a ladder follows the axis for
* free and one that is not silently ignores it. That is the whole reason the ladders came first. */
* These multiply the LADDERS, not the rules: a size on a ladder follows the axis for free and
* one that is not silently ignores it. */
:root[data-density="compact"] {
/* Type moves LEAST (.9 against .65 for the air), and that ordering is the point: padding is
* where the room actually is, so leaning on the text instead would cost legibility to save
* very little. At .9 the base is 12px and the eyebrow 10px — the floor this file is willing
* to go to, and only because the user asked for this mode by name. */
/* type moves least (.9 against .65 for the air): padding is where the room is, so leaning
* on the text would cost legibility to save very little. At .9 the base is 12px and the
* eyebrow 10px, which is the floor. */
--fs-density-type: .9;
--fs-density-box: .85;
--fs-density-space: .65;
}
:root[data-density="large"] {
/* Large is about the TEXT, so the air does not grow with it: padding stays on the Normal
* rhythm and only the type — and the boxes that have to hold it — get bigger. Inflating the
* spacing as well just pushed content off the screen and made the setting read as "zoom",
* which is the browser's job, not the theme's. */
/* Large is about the TEXT, so the air does not grow with it: only the type and the boxes
* that hold it get bigger. Inflating the spacing too pushes content off the screen and
* makes the setting read as zoom, which is the browser's job. */
--fs-density-type: 1.15;
--fs-density-box: 1.15;
--fs-density-space: 1;
@@ -627,24 +519,19 @@
* tokens live here */
--fs-shadow: 0 1px 2px rgba(0,0,0,.4);
--fs-disabled-opacity: .45;
/* the hover cue flips direction here — see --fs-hover-lift above. A dark-mode solid
* button is a LIGHT fill with DARK ink, so brightening moves it away from the page
* and away from its own ink; in light mode that same move erases its white ink's
* contrast. */
/* the hover cue flips direction here (see --fs-hover-lift): a dark-mode solid button is a
* light fill with dark ink, so brightening moves it away from both the page and its own
* ink */
--fs-hover-lift: brightness(1.15);
color-scheme: dark;
}
/* BOTH component bridges are GONE: the HSL one (--*-hsl triples, --*-h/s/l parts) and the
* RGB one that outlived it (--accent-rgb, --error-color-high-rgb, --success-color-high-rgb,
* read as `rgba(var(--x), .3)`). Same mistake in two notations — a hand-kept second copy of
* a colour that already exists as a token: it goes stale in silence when a palette is
* recoloured, and if the triple goes missing the declaration is invalid at computed-value
* time, so the tint vanishes with NO error (audit.py cannot catch that — the var IS defined,
* only elsewhere). Every rule that read them takes color-mix() over the palette token now,
* for identical output:
/* No component bridge in any notation (--*-hsl triples, --*-rgb read as `rgba(var(--x), .3)`):
* a hand-kept second copy of a colour goes stale in silence when a palette is recoloured, and
* a missing triple makes the declaration invalid at computed-value time, so the tint vanishes
* with no error tools/audit.py can catch — the var IS defined, only elsewhere. A tint of X is
* mixed FROM X, and a component copy must not be reintroduced in any notation:
* rgba(var(--error-color-high-rgb), .3) -> color-mix(in srgb, var(--fs-danger) 30%, transparent)
* A tint of X is mixed FROM X. Do not reintroduce a component copy of a colour in any
* notation. The one surviving --*-rgb is --zone-color-rgb, and it is not ours:
* luci-mod-network writes it inline on a zone badge (base/95-luci.css). */
* The one surviving --*-rgb is --zone-color-rgb, which is not ours: luci-mod-network writes it
* inline on a zone badge (base/95-luci.css). */
}
+170 -247
View File
@@ -1,10 +1,9 @@
@layer tokens {
/* ============================================================
* COLOURWAYS (palettes)
/* ---- colourways (palettes) ----
*
* Every colour is a variable; every palette is one self-contained block per mode. Three ship
* — footstrap (GitHub Primer, the default), hicontrast and bootstrap (the stock LuCI theme's
* colours) — but the structure is the point: a new colourway is copy-one-pair-and-recolour.
* Every colour is a variable and every palette is one self-contained block per mode, so a new
* colourway is copy-one-pair-and-recolour. Four ship: footstrap (GitHub Primer, the default),
* hicontrast, bootstrap (the stock LuCI theme's colours) and 2020.
*
* TO ADD A COLOURWAY "sunset":
* 1. Copy the two hicontrast blocks below, rename data-palette to "sunset".
@@ -21,187 +20,139 @@
* unnamed palette is missing from the picker, not broken), and matrix() in
* tools/lib/gallery.mjs — a palette absent there is one export-tier.mjs and
* a11y-gallery.mjs never measure, so it ships ungated.
* Nothing else — layout, components and the --*-color-* export bridge (02-tokens) key off
* these tokens, and every tint/shadow/hairline is a color-mix() OVER them.
* Nothing else: layout, components and the --*-color-* export bridge key off these tokens, and
* every tint, shadow and hairline is a color-mix() over them.
*
* THE INKS ARE PER PALETTE AND PER MODE, and that is not a style choice. A single global
* `--fs-on-accent: #fff` used to sit on every accent/good/warn/danger fill (solid buttons,
* the active tab pill, the toggle knob, .fs-seg, the login submit). White holds up on the
* DARK fills of a LIGHT palette and nowhere else: on footstrap dark it measured 2.77–2.80:1
* and on hicontrast dark --fs-good 1.69:1, where WCAG AA wants 4.5:1 for text and 3:1 for a
* UI shape like the toggle knob. A dark palette has LIGHT fills, so its ink must be DARK.
* Check a new colourway's inks against all four of its own fills: every pair below clears
* 4.5:1.
* The inks are per palette and per mode. A single global `--fs-on-accent: #fff` holds up on
* the dark fills of a LIGHT palette and nowhere else — on a dark palette's light fills it
* measures 1.69-2.80:1, where AA wants 4.5:1 for text and 3:1 for a UI shape. Check a new
* colourway's inks against all four of its own fills; every pair below clears 4.5:1.
*
* A tint of X is mixed FROM X — do not reintroduce a component copy of a colour in any
* notation (both bridges, --*-hsl then --*-rgb, are gone; foot of 02-tokens.css).
* notation (foot of 02-tokens.css).
*
* Default: footstrap fills bare :root (no data-palette), so a no-JS load still has
* colours; every other colourway is opt-in via its own data-palette value. Specificity:
* :root[data-palette=…][data-darkmode=…] (0,3,0) beats the bare/default blocks, and each
* block sets a COMPLETE token set so the winner fully applies.
* ============================================================ */
* Default: footstrap fills bare :root, so a no-JS load still has colours; every other
* colourway is opt-in. :root[data-palette=…][data-darkmode=…] (0,3,0) beats the bare blocks,
* and each block sets a COMPLETE token set so the winner fully applies. */
/* Native <select> closed-control chevron (theme/60-inputs.css). A data-URI cannot
* reference var() or currentColor, so each palette block bakes its own --fs-dim into
* the stroke — the only way the glyph can follow palette and mode. */
/* ============================================================
* BACKGROUND TINT — "which router am I on?"
/* ---- background tint: "which router am I on?" ----
*
* One hue (Appearance → Tint, 0–360°, localStorage `fs-tint`, pre-painted by
* partials/head.ut) washed into the CANVAS — --fs-bg, the surface the cards float on — so
* a whole LuCI install reads as green / violet / amber at a glance. localStorage is keyed
* by ORIGIN, so the cue is per router with nothing server-side: the same browser shows the
* main router green and the AP violet, and a screenshot pasted into a ticket says which
* box it came from. Off (no data-tint) = the palette exactly as it was.
* One hue (Appearance -> Tint, 0-360°, localStorage `fs-tint`, pre-painted on :root by
* partials/head.ut) washed into the CANVAS — --fs-bg, the surface the cards float on — so a
* whole install reads as green, violet or amber at a glance. localStorage is keyed
* by origin, so the cue is per router with nothing server-side. Off (no data-tint) is the
* palette exactly as it was.
*
* NOTHING ELSE MOVES: the cards, the chrome and the semantic colours keep the palette's
* own values. An identity cue has no business restating itself on every table row, and
* re-hueing the status colours would make it lie about status.
* Nothing else moves: the cards, the chrome and the semantic colours keep the palette's own
* values, and re-hueing a status colour would make it lie about status.
*
* WHY --fs-bg-base EXISTS: a custom property cannot be defined from itself — `--fs-bg:
* color-mix(…, var(--fs-bg), …)` is a cycle, invalid at computed-value time, and would
* drop the colour silently. So a palette names the RAW canvas, this block owns the one
* derivation, and every rule goes on reading --fs-bg.
* --fs-bg-base exists because a custom property cannot be defined from itself:
* `--fs-bg: color-mix(…, var(--fs-bg), …)` is a cycle, invalid at computed-value time, and
* drops the colour silently. A palette names the raw canvas, this block owns the derivation,
* and every rule goes on reading --fs-bg.
*
* WHY IT SETS CHROMA + HUE INSTEAD OF MIXING A COLOUR IN. The obvious `color-mix(in oklch,
* var(--fs-bg-base) 96%, <tint>)` is a trap: in a POLAR space the result's HUE lands on
* the tint's almost immediately, so the percentage never controlled the strength. Measured
* on the dark canvas (28,33,40), a "2%" mix at hue 165 gave 25,36,31 — fully green — and
* "6%" gave 23,36,31, the same colour: turning the knob down changed nothing, exactly as
* users reported. Light mode failed the other way — the mix left sRGB and clipped, so the
* tint was invisible on blue hues and obvious on green ones, i.e. the cue's strength
* depended on WHICH colour you picked. So instead: give the canvas the user's hue at a
* chroma we choose, leave its LIGHTNESS alone (one relative-colour declaration).
* ONE HONEST KNOB --fs-tint-c is the strength, in chroma, and in DARK mode it behaves the
* same on every hue: no gamut clipping at these values, no hue surprise.
* Light mode has less headroom and does clip at the top of the wheel
* (measured: footstrap light hue 0 wants 1.011/0.955/0.970 of sRGB and is
* clamped to 255,244,247), which costs a few units of the cue's strength but
* not its direction — every hue still moves the canvas its own way. The one
* surface where that stops being true is a canvas of pure white: on the
* bootstrap colourway's #ffffff, 180° and 258° land 1/255 apart, so the cue
* is nearly hue-blind there. That is the price of copying a stock theme's
* surfaces exactly, and it is why the tint is designed against the default
* palette's off-white rather than against white.
* CONTRAST IS FREE `l` is copied through, so AA margins barely move. The mix dragged
* lightness toward the anchor: an early attempt cost
* --primary-color-high on --fs-bg its whole margin in hicontrast
* light, 4.61:1 down to 4.40:1 — under AA.
* It sets chroma and hue rather than mixing a colour in, because in a POLAR space the mix's
* hue lands on the tint's almost immediately and the percentage never controls the strength:
* measured on the dark canvas, "2%" and "6%" at hue 165 gave the same green. Light mode failed
* the other way, the mix leaving sRGB and clipping, so the cue's strength depended on which
* hue was picked. So the canvas takes the user's hue at a chroma we choose and keeps its own
* LIGHTNESS, in one relative-colour declaration:
* one honest knob --fs-tint-c is the strength, in chroma. Light mode has less headroom and
* does clip at the top of the wheel, which costs a few units of strength
* but not the direction. On a canvas of pure WHITE the cue is nearly
* hue-blind — on bootstrap's #ffffff, 180° and 258° land 1/255 apart —
* which is the price of copying a stock theme's surfaces.
* contrast is free `l` is copied through, so AA margins barely move; the mix dragged
* lightness toward the anchor and cost one export level 4.61 -> 4.40:1.
*
* Proven, not assumed: tools/export-tier.mjs runs the AA matrix over six hues × both modes
* × every palette (it caught that 4.40:1) and tools/a11y-gallery.mjs runs axe over the
* tinted gallery at two extreme hues. Change --fs-tint-c and re-run both.
* tools/export-tier.mjs runs the AA matrix over six hues x both modes x every palette and
* tools/a11y-gallery.mjs runs axe over the tinted gallery at two extreme hues. Change
* --fs-tint-c and re-run both.
*
* The shipped chroma is deliberately tiny — the dark floor (.011) is below the chroma the
* dark canvas already carries (.0153). This is an IDENTITY cue, not a colourway: a large
* flat field is where the eye is most sensitive to a cast, so a tint this faint still
* tells two routers apart while the page still looks like the theme you chose. Loud is a
* different feature and belongs in a palette, where the cards and chrome can move too.
* ============================================================ */
* The shipped chroma is deliberately tiny — the dark floor is below the chroma the dark canvas
* already carries. This is an identity cue, not a colourway: a large flat field is where the
* eye is most sensitive to a cast. Loud belongs in a palette, where the cards and chrome can
* move too. */
:root {
--fs-bg: var(--fs-bg-base);
/* The UI accent, derived from the palette's own -base hex — so the accent-hue axis
* (below) can rotate it without a second copy to keep in sync. Absent an accent hue,
* --fs-accent IS the palette's -base value.
*
* There used to be a second pair here, --fs-accent-lt / --fs-accent-lt-base, described
* as "the logo gradient's light end" and rotated beside the accent. The brand mark has
* no gradient: partials/brand.ut draws the OpenWrt arcs with a baked #00b5e2 and one
* mode-flipped ring, so nothing read either token — the pair was defined in both
* palettes, rotated through an oklch(from …) on every accent hue, and listed as a step
* in "TO ADD A COLOURWAY" above, all to paint nothing. Removed rather than left as a
* reserved name, because a comment promising that the accent hue moves the logo is
* worse than no comment: it describes behaviour a reader can verify is absent. */
/* the UI accent, derived from the palette's own -base hex so the accent-hue axis below can
* rotate it with no second copy to keep in sync. Absent an accent hue, --fs-accent IS the
* palette's -base value. */
--fs-accent: var(--fs-accent-base);
/* The three STATUS colours, derived from the palette's -base hex for exactly the reason
* the accent is: Appearance → Colours gives each one its own axis, and an axis that
* rotated a token the palettes declared directly would need a second copy of every value
* to rotate FROM. Absent an axis, each --fs-good IS the palette's -base value, so a
* palette that sets only the -base half paints identically to before the axes existed. */
/* The SURFACES, split the same way and for the same reason as the accent: Appearance →
* Surfaces rewrites the -base half, and a custom property defined through itself is a cycle
* that drops the colour silently. A palette declares the raw value; every rule reads the
* derived one.
/* the three status colours, derived from the palette's -base hex for the reason the accent
* is: an axis rotating a token the palettes declared directly would need a second copy of
* every value to rotate from */
/* The surfaces, split the same way and for the same reason as the accent. A palette
* declares the raw value; every rule reads the derived one.
*
* A Transparency slider lived here briefly, mixing each of these with `transparent` so the
* wallpaper showed through the cards. It is gone: thinning the surface thins the TEXT's
* background out from under it, so every card became a legibility problem at exactly the
* setting that looked interesting, and the contrast readouts on the page could not even
* report it — a ratio against a semi-transparent backdrop is a ratio against whatever
* happens to be behind that pixel. The wallpaper axis is the honest way to have a picture. */
* Do not add a transparency axis over these: thinning the surface thins the TEXT's
* background out from under it, and the contrast readouts cannot even report it — a ratio
* against a semi-transparent backdrop is a ratio against whatever is behind that pixel.
* The wallpaper axis is the honest way to have a picture. */
--fs-panel: var(--fs-panel-base);
--fs-panel2: var(--fs-panel2-base);
--fs-border: var(--fs-border-base);
--fs-good: var(--fs-good-base);
--fs-warn: var(--fs-warn-base);
--fs-danger: var(--fs-danger-base);
/* The hues the user picked, in degrees (Appearance → Colours). Declared here, not only
* inline by head.ut/fs-prefs.js, so the stylesheet stands on its own: a var() with no
* definition anywhere is invalid at computed-value time and takes the whole declaration
* down SILENTLY. Their values are irrelevant while the matching data-* is absent. */
/* The hues the user picked, in degrees. Declared here and not only inline by
* head.ut/fs-prefs.js, so the stylesheet stands on its own: a var() with no definition
* anywhere is invalid at computed-value time and takes the whole declaration down. */
--fs-accent-h: 0;
--fs-tint-h: 0;
--fs-good-h: 0;
--fs-warn-h: 0;
--fs-danger-h: 0;
/* Tint strength (Appearance → Tint strength — NOT the Density select, which is the UI-density
* axis and owns that word), a multiplier on the tint chroma below: 1 = the designed strength,
* up to 2. fs-prefs.js/head.ut set it inline (value/100) when the user drags the slider;
* paired with the hue, it is the second half of the router-identity Tint.
/* Tint strength (Appearance -> Tint strength — NOT the Density select, which is the
* UI-density axis and owns that word): a multiplier on the tint chroma below, 1 being the
* designed strength and 2 the cap. Set inline (value/100) by head.ut and fs-prefs.js.
*
* 0 IS NOT "the palette back". The rule below is a relative colour that REPLACES chroma, so a
* multiplier of 0 also strips the cast the canvas already had — every palette's canvas is a
* blue-grey (hue 248-264, chroma .0153 dark / .0034 light, measured below), and at 0 it goes
* neutral at the same lightness: footstrap dark rgb(28,33,40) paints as rgb(33,33,33). Small,
* but it is not the untinted page, and it cannot be made into one here — a conditional would
* have to sit on :root itself, which is the one element a style query cannot reach. The true
* off is clearing the Tint hue, which drops data-tint and this rule with it. */
* 0 is not "the palette back": the rule below is a relative colour that REPLACES chroma, so
* a multiplier of 0 also strips the cast the canvas already had (every palette's canvas is
* a blue-grey) and leaves it neutral at the same lightness — footstrap dark rgb(28,33,40)
* paints as rgb(33,33,33). It cannot be made into the
* untinted page here — a conditional would have to sit on :root, which a style query cannot
* reach. The true off is clearing the Tint hue, which drops data-tint and this rule. */
--fs-tint-strength: 1;
/* The uploaded login/page background (Appearance → Wallpaper → File). --fs-login-bg-url is
* the url() head.ut/fs-prefs.js stamp inline; `none` here is the same stand-on-its-own
* default the hues carry above (a var() with no definition is invalid at computed-value
* time). --fs-photo-scrim is the wash laid OVER the photo so on-canvas text stays legible on
* an arbitrary image: derived from --fs-bg, so it follows the palette, the mode AND the tint,
* and its opacity bounds the effective canvas contrast regardless of what the photo is. Both
* are inert until data-login-bg is present (16-login-bg.css). */
/* The uploaded login/page background. --fs-login-bg-url is the url() head.ut and fs-prefs.js
* stamp inline; `none` here is the stand-on-its-own default the hues carry above.
* --fs-photo-scrim is the wash laid over the photo so on-canvas text stays legible on an
* arbitrary image — derived from --fs-bg, so it follows palette, mode and tint, and its
* opacity bounds the effective canvas contrast whatever the photo is. Both are inert until
* data-login-bg is present (theme/16-login-bg.css). */
--fs-login-bg-url: none;
/* --fs-photo-dim is the scrim OPACITY over a File photo (Wallpaper → File → Dim), a
* per-browser axis like the rest; head.ut/fs-prefs.js override it inline. The scrim is mixed from --fs-bg-BASE,
* NOT --fs-bg, so it is always NEUTRAL — the router-identity Tint colours --fs-bg and must
* never leak onto a photo; the Tint's Density is a separate control. */
/* --fs-photo-dim is the scrim opacity over a File photo, a per-browser axis like the rest.
* The scrim is mixed from --fs-bg-BASE, not --fs-bg, so it stays neutral: the tint colours
* --fs-bg and must never leak onto a photo. */
--fs-photo-dim: 74%;
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
/* The uploaded PATTERN (Appearance → Wallpaper → Pattern), tiled behind the content by
* 15-wallpaper.css. --fs-pattern-url is the url() head.ut/fs-prefs.js stamp inline and is
* `none` here for the same reason --fs-login-bg-url is: nothing is uploaded yet, and a var()
* with no definition at all is invalid at computed-value time. The other two are ordinary
* per-browser axes the same two places override inline — the tile's edge length and the
* layer's opacity. .2 is where a neutral line reads as a drawing rather than as texture:
* measured on a tinted canvas, .15 was swallowed by the hue. */
/* The uploaded pattern, tiled behind the content by 15-wallpaper.css. --fs-pattern-url is
* stamped inline and is `none` here for the same reason --fs-login-bg-url is. The other two
* are ordinary per-browser axes: the tile's edge length and the layer's opacity, where .2
* is where a neutral line reads as a drawing rather than texture (.15 is swallowed by a
* tinted canvas). */
--fs-pattern-url: none;
--fs-pattern-size: 440px;
--fs-pattern-strength: .2;
/* HOW STRONG THE CUE IS — a chroma, and a function of the HUE, because the canvas is
* not neutral: every palette's canvas is a blue-grey (measured: oklch hue 248–264)
* and the dark one carries chroma .0153 of it. A flat chroma made the blue/violet end
* of the wheel INVISIBLE — asking for blue at .011 painted the canvas *less* blue
* than the palette already was. The cue is read against the untinted page, not against
* grey, so it must out-chroma the canvas's own cast where the two agree. Hence a floor
* plus two cos() terms, each peaking on one hue and gone 90° away:
/* How strong the cue is: a chroma, and a function of the HUE, because the canvas is not
* neutral — every palette's canvas is a blue-grey (oklch hue 248-264) and the dark one
* carries chroma .0153 of it. A flat chroma leaves the blue/violet end of the wheel
* invisible, painting the canvas less blue than the palette already was. The cue is read
* against the untinted page, so it must out-chroma the canvas's own cast where the two
* agree: a floor plus two cos() terms, each peaking on one hue and gone 90° away.
*
* +BOOST at 258° the canvas's own hue; without it blue and violet did nothing.
* -DAMP at 55° red-through-yellow, which the eye picks up first: at a shared
* floor the warm half shouted while the cold half whispered, and a
* cue is only usable if every hue lands with about the same weight.
* (Dark only — on a near-white canvas warm is the quietest hue there
* is.)
* +boost at 258° the canvas's own hue; without it blue and violet do nothing
* -damp at 55° red-through-yellow, which the eye picks up first, so at a shared floor
* the warm half shouts while the cold half whispers (dark only — on a
* near-white canvas warm is the quietest hue there is)
*
* One expression per mode, no per-hue table to keep in sync with the palettes. LIGHT
* runs the HIGHER floor, which sounds backwards until you look at the surface:
* near-white has almost no chroma of its own (.0034), so the tint is the only colour
* there and 8 thousandths of it simply did not read. */
* One expression per mode, with no per-hue table to keep in sync. Light runs the HIGHER
* floor: near-white has almost no chroma of its own (.0034), so the tint is the only
* colour there. */
--fs-tint-c: calc((.016 + .012 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))) * var(--fs-tint-strength));
}
:root[data-darkmode="true"] {
@@ -210,8 +161,7 @@
}
:root[data-tint="hue"] { --fs-bg: oklch(from var(--fs-bg-base) l var(--fs-tint-c) var(--fs-tint-h)); }
/* ============================================================
* THE COLOUR AXES — five colours, two ways to set each
/* ---- the colour axes: five colours, two ways to set each ----
*
* Accent, Tint, Good, Warn and Danger are one axis pointed at five tokens (fs-prefs.js's
* colorAxis). Each is OFF (no attribute = the palette exactly as it shipped) or set one of
@@ -224,13 +174,11 @@
* head.ut/fs-prefs.js. An inline custom property outranks every layer, so
* the declaration is not repeated here; what IS here is the consequence.
*
* WHY THE INK IS DERIVED, AND ONLY IN HEX MODE. --fs-on-accent is the ink ON the accent
* fill — a solid button's label, the toggle knob, the active tab pill. The palettes pick it
* per mode against fills they were measured against (white over footstrap light's #0969da is
* 5.19:1; over the dark palette's light fills white collapses to 2.79:1, hence #10151c
* there). A user-entered colour is measured against nothing, so keeping the palette's ink
* would put white on #ffd400 — 1.43:1, an unreadable button, on the one control whose whole
* job is to be pressed.
* The ink is derived, and only in hex mode. --fs-on-accent is the ink ON the accent fill — a
* solid button's label, the toggle knob, the active tab pill — and the palettes pick it per
* mode against fills they were measured against. A user-entered colour is measured against
* nothing, so keeping the palette's ink puts white on #ffd400: 1.43:1, on the one control
* whose whole job is to be pressed.
*
* So in hex mode the ink follows the LIGHTNESS of whatever was entered:
* `oklch(from <fill> clamp(0, (l - .62) * -100, 1) 0 0)` is black above L .62 and white
@@ -245,22 +193,18 @@
* could live in.
*
* Tint has no ink row: it colours the CANVAS, whose text is --fs-text, a palette token this
* axis must not move. That is also why the Appearance page reports the contrast a custom
* canvas lands at instead of quietly correcting it.
* ============================================================ */
* axis must not move — which is also why the Appearance page reports the contrast a custom
* canvas lands at instead of correcting it. */
:root[data-accent="hue"] { --fs-accent: oklch(from var(--fs-accent-base) l c var(--fs-accent-h)); }
:root[data-good="hue"] { --fs-good: oklch(from var(--fs-good-base) l c var(--fs-good-h)); }
:root[data-warn="hue"] { --fs-warn: oklch(from var(--fs-warn-base) l c var(--fs-warn-h)); }
:root[data-danger="hue"] { --fs-danger: oklch(from var(--fs-danger-base) l c var(--fs-danger-h)); }
/* The attribute is REPEATED to win on specificity, and that is load-bearing rather than a tic.
* A palette's dark block is `:root[data-darkmode="true"]` — also (0,2,0) — and it declares the
* ink too; at equal specificity the later rule wins, and the palettes are later in this file. So
* the single-attribute form silently lost in DARK MODE ONLY: measured on the router, a grey
* #4b5563 accent kept the dark palette's near-black ink (rgb(16,21,28) on rgb(75,85,99), 1.9:1)
* while light mode correctly derived white. Repeating the attribute makes it (0,3,0) and the
* rule wins on specificity in both modes — never on source order, which is the house rule
* precisely because moving a block down the file is a fix that the next edit undoes. */
/* The attribute is repeated to win on specificity, which is load-bearing: a palette's dark
* block is `:root[data-darkmode="true"]`, also (0,2,0), and it declares the ink too — so at
* equal specificity the later rule wins and the single-attribute form loses in dark mode only
* (a grey accent kept the dark palette's near-black ink at 1.9:1 while light derived white).
* Repeating the attribute makes it (0,3,0) and wins in both modes, never on source order. */
:root[data-accent="hex"][data-accent] { --fs-on-accent: oklch(from var(--fs-accent) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-good="hex"][data-good] { --fs-on-good: oklch(from var(--fs-good) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-warn="hex"][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
@@ -275,19 +219,16 @@
--fs-panel2-base: #f6f8fa;
--fs-border-base: #d0d7de;
--fs-text: #1f2328;
/* The SECONDARY tier carries LuCI's page descriptions and every field title, and at the
* GitHub-light greys it started from (#656d76/#68727e, 5.2:1 and 4.9:1 on the card) that is
* help text a reader has to lean into — reported on the upstream PR by someone reading the
* light theme on a real router. LuCI's pages are technical and their descriptions are the
* part you read once and act on, so this tier is now 11.8:1 and 10.0:1: still a tier below
* the 15.8:1 body ink, which is what keeps a label a label, but no longer a whisper. */
/* The secondary tier carries LuCI's page descriptions and every field title, which at the
* GitHub-light greys (5.2:1 and 4.9:1 on the card) is help text a reader has to lean into.
* Now 11.8:1 and 10.0:1, still a tier below the 15.8:1 body ink so a label stays a
* label. */
--fs-dim: #33383d;
--fs-faint: #3d4348;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%2333383d" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #0969da;
/* a tint OF --fs-accent, so mixed FROM it. It used to restate the accent's RGB as a
* literal, which goes stale silently the moment the accent is recoloured (or the
* accent-hue axis rotates it) — the failure that killed the *-rgb bridge. */
/* a tint OF --fs-accent, mixed FROM it: a restated literal goes stale the moment the accent
* is recoloured or the accent-hue axis rotates it */
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
--fs-track: #eaeef2;
--fs-good-base: #197c36;
@@ -327,22 +268,19 @@
/* ---- hicontrast — deeper, more saturated (opt-in) ---- */
:root[data-palette="hicontrast"] {
/* LIGHT
* The accent/good/warn/danger below used to be BRIGHTER than the default palette's,
* which made the palette named "hicontrast" the lower-contrast one: --fs-good
* #17b978 as a label on --fs-panel measured 2.55:1 against the default's 5.08:1.
* These four serve both as text on --fs-panel (status labels, outline buttons, port
* speeds) and as fills under white ink, and both uses want the same thing — ≥4.5:1
* against white — so darkening fixes both at once. Now 5.22/5.35/5.93/5.80, i.e.
* The four accents below are DARKER than the default palette's, not brighter: brighter
* made the palette named "hicontrast" the lower-contrast one — --fs-good #17b978 as a
* label on --fs-panel measured 2.55:1 against the default's 5.08:1. They serve both as
* text on --fs-panel (status labels, outline buttons, port speeds) and as fills under
* white ink, and both uses want >=4.5:1 against white. At 5.22/5.35/5.93/5.80 they are
* genuinely above the default palette, which is what the name promises. */
--fs-bg-base: #eef1f6;
--fs-panel-base: #ffffff;
--fs-panel2-base: #f6f8fb;
--fs-border-base: #e4e9f1;
--fs-text: #101725;
/* …and this palette's secondary tier moves with it, because the promise in the comment above
* is RELATIVE: 5.8:1 and 4.9:1 were above the default when the default was 5.2:1 and 4.9:1,
* and below it the moment that changed. 13.5:1 and 12.0:1 against the default's 11.8 and
* 10.0. */
/* …and the secondary tier moves with it, the promise above being relative: 13.5:1 and
* 12.0:1 against the default palette's 11.8 and 10.0 */
--fs-dim: #272f3d;
--fs-faint: #2e3745;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%23272f3d" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
@@ -374,8 +312,8 @@
--fs-good-base: #43e0a0;
--fs-warn-base: #ffb454;
--fs-danger-base: #ff6b6b;
/* dark ink: white on these neons was 2.57/1.69/1.76/2.78 — a white label on --fs-good
* was unreadable. #0a0d13 (= --fs-bg-base) gives 7.55/11.49/11.03/7.01. */
/* dark ink: white on these neons measures 2.57/1.69/1.76/2.78, while #0a0d13 (= --fs-bg-base)
* gives 7.55/11.49/11.03/7.01 */
--fs-on-accent: #0a0d13;
--fs-on-good: #0a0d13;
--fs-on-warn: #0a0d13;
@@ -384,45 +322,40 @@
/* ---- bootstrap — the colours of luci-theme-bootstrap, the LuCI default (opt-in) ----
*
* The one palette named after another PACKAGE, because that is what it is: the surfaces,
* greys and semantic colours of the stock theme, so an admin who wants the look they already
* know keeps it and still gets the chrome, the client navigation and the Appearance axes.
* The surfaces, greys and semantic colours of the stock theme, so an admin who wants the look
* they already know keeps it and still gets the chrome, the client navigation and the axes.
*
* Bootstrap computes everything from HSL axes; these are those axes evaluated:
* --background-color high/medium/low #ffffff / #f9f9f9 / #f5f5f5 (dark #222 / #282828 / #2c2c2c)
* --text-color high/medium #404040 / #808080 (dark #bfbfbf / #7f7f7f)
* --border-color high/medium #cccccc / #dddddd (dark #555555 / #444444)
* The canvas and the card are BOTH --background-color-high: bootstrap is flat and lets the
* border carry the structure, so copying its surfaces means copying that too. panel2 is its
* --background-color-low, the surface it stripes tables and hovers rows with.
* The canvas and the card are both --background-color-high: bootstrap is flat and lets the
* border carry the structure. panel2 is its --background-color-low, the surface it stripes
* tables and hovers rows with.
*
* WHERE THIS DEVIATES, AND WHY IT HAS TO. Bootstrap's semantic colours do not clear AA on
* bootstrap's own surfaces, and tools/export-tier.mjs holds every palette to 4.5:1 on all
* three — the export tier is what other people's apps print text in. Measured on --fs-panel2:
* success #00ac59 2.73:1, warn #efbd0b 1.61:1, error #f62b12 3.65:1, primary #1976d2 4.22:1;
* dark error #d15653 3.44:1, success #00a66c 4.44:1. So each one is taken from the STEP OF
* BOOTSTRAP'S OWN RAMP that clears it where such a step exists — primary/success/error light
* are its -medium/-low values verbatim — and where none does, the hue and saturation are kept
* and only the value is moved until the colour clears 4.95:1. Warn is the only large move:
* no yellow readable as text on white is still that yellow, which is the same trade the
* default palette made (#946300).
* Where this deviates: bootstrap's semantic colours do not clear AA on bootstrap's own
* surfaces (on --fs-panel2, success 2.73:1, warn 1.61:1, error 3.65:1, primary 4.22:1; in dark
* error 3.44:1 and success 4.44:1), and
* every palette here is held to 4.5:1 on all three because the export tier is what other
* people's apps print text in. So each is taken from the step of bootstrap's own ramp that
* clears it where one exists, and otherwise keeps hue and saturation while the value moves
* until it clears 4.95:1. Warn is the only large move: no yellow readable as text on white is
* still that yellow.
*
* Bootstrap's dark HEADER GRADIENT (#333 → #222, in both modes) is not here: the chrome reads
* --fs-panel, so a palette cannot give the bar its own colour. That would need a chrome token
* pair, which is a change to the chrome, not a colourway. */
* Bootstrap's dark header gradient is not here: the chrome reads --fs-panel, so a palette
* cannot give the bar its own colour — that would need a chrome token pair, which is a change
* to the chrome, not a colourway. */
:root[data-palette="bootstrap"] {
/* LIGHT */
--fs-bg-base: #ffffff;
--fs-panel-base: #ffffff;
--fs-panel2-base: #f5f5f5;
--fs-border-base: #cccccc;
/* THE INK IS THE ONE PLACE THIS COLOURWAY LEAVES BOOTSTRAP'S OWN VALUES BEHIND, and it is
* the same decision the default palette made: bootstrap's #404040 body and #6a6a6a muted
* tier measure 10.4:1 and 5.4:1 on white, and the muted one carries LuCI's page
* descriptions and every field title — help text you have to lean into. Matched to the
* default palette's light ramp instead, ratio for ratio: 15.7 / 11.7 / 10.0 against its
* 15.8 / 11.8 / 10.0. Neutral greys, so the colourway still reads as bootstrap's — what is
* copied is its hue, not its readability. */
/* The ink is the one place this colourway leaves bootstrap's own values behind, the same
* decision the default palette made: its #404040 body and #6a6a6a muted tier measure
* 10.4:1 and 5.4:1 on white, and the muted one carries every field title. Matched to the
* default palette's light ramp ratio for ratio (15.7 / 11.7 / 10.0), in neutral greys, so
* what is copied is the hue and not the readability. */
--fs-text: #232323;
--fs-dim: #383838;
--fs-faint: #424242;
@@ -444,24 +377,20 @@
/* DARK */
--fs-bg-base: #222222;
--fs-panel-base: #282828;
/* bootstrap's --background-color-low in dark is #2c2c2c, and that is the one surface value
* copied rather than reproduced: 0.016 from --fs-panel, under the 0.02 export-tier.mjs
* wants, so an app asking --background-color-high/-low for a gradation got one colour
* twice. Bootstrap's LIGHT pair is 0.039 apart and its dark pair is not — the same ramp
* check catches it in the theme this palette is named after. #303030 is 0.031. */
/* bootstrap's own dark --background-color-low (#2c2c2c) sits 0.016 from --fs-panel, under
* the 0.02 the ramp check wants, so an app asking high/-low for a gradation gets one colour
* twice; #303030 is 0.031. Its light pair is 0.039 apart, so the same check catches this in
* the theme this palette is named after. */
--fs-panel2-base: #303030;
--fs-border-base: #444444;
--fs-text: #bfbfbf;
/* Bootstrap's dark has no third ink — #bfbfbf body, #7f7f7f muted — so the two quiet tiers
* are this palette's own, and AA decides where the fainter one can sit: the export tier's
* -low follows --fs-faint (02-tokens.css), and on a tinted --fs-panel2 the #969696 this
* used to carry measures 4.46:1, under the floor for a name apps print text in. #9a9a9a is
* the first step that clears it, so the FLOOR pins --fs-faint and the step between the
* tiers has to come from above: --fs-dim moves up by the same distance the hicontrast dark
* ramp puts between its own two (12 channel steps), leaving 191 / 166 / 154. Bootstrap's
* muted grey is darker than either, so both are already a deviation; one step more of it
* is cheaper than collapsing --text-color-medium and -low onto one colour, which is the
* thing this revision separates everywhere else. */
/* Bootstrap's dark has no third ink, so the two quiet tiers are this palette's own and AA
* decides where the fainter one sits: the export tier's -low follows --fs-faint, and on a
* tinted --fs-panel2 #969696 measures 4.46:1, under the floor for a name apps print text
* in. #9a9a9a is the first step that clears it, so the floor pins --fs-faint and the step
* between the tiers comes from above (--fs-dim moves up by the 12 channel steps the
* hicontrast dark ramp uses). Both are already a deviation, and one step more is cheaper
* than collapsing --text-color-medium and -low onto one colour. */
--fs-dim: #a6a6a6;
--fs-faint: #9a9a9a;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%23a6a6a6" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
@@ -471,35 +400,29 @@
--fs-good-base: #1aaf7b;
--fs-warn-base: #aa9968;
--fs-danger-base: #dd807e;
/* dark ink: these fills are LIGHT, and bootstrap's own --on-*-color in dark is likewise
* its --background-color-high. #222222 gives 5.65/5.65/5.66/5.65. */
/* dark ink: these fills are light, and bootstrap's own --on-*-color in dark is likewise its
* --background-color-high. #222222 gives 5.65/5.65/5.66/5.65. */
--fs-on-accent: #222222;
--fs-on-good: #222222;
--fs-on-warn: #222222;
--fs-on-danger: #222222;
}
/* ============================================================
* 2020 — the OpenWrt 2020 theme's colourway
/* ---- 2020 — the OpenWrt 2020 theme's colourway ----
*
* `luci-theme-openwrt-2020` is one colour scheme and no dark mode: the OpenWrt CI cyan
* (#00B5E2) on the deep navy (#002B49), white content, and three semantic colours
* (#5CB85C / #CC8800 / #CC1111). This palette is that identity carried onto footstrap's
* surfaces — proposed in review as the way its look survives the theme itself.
* (#00B5E2) on the deep navy (#002B49), white content, and three semantic colours. This
* palette carries that identity onto footstrap's surfaces.
*
* WHAT MOVED, AND WHY EACH ONE HAD TO. The cyan is the whole identity and it is unreadable as
* text on white: #00B5E2 measures 2.09:1, where the export tier every app prints text in wants
* 4.5:1. So the LIGHT mode keeps the hue and darkens it, while the DARK mode — whose surfaces
* are the navy the cyan was designed against — carries it near enough its own value. That
* split is the point of the pair: 2020's own scheme IS the dark one, and the light mode is
* what footstrap needs to show the same identity on a white page.
* The cyan is the whole identity and is unreadable as text on white (2.09:1 against the 4.5:1
* the export tier wants), so the LIGHT mode keeps the hue and darkens it while the DARK mode,
* whose surfaces are the navy the cyan was designed against, carries it near its own value.
* That split is the point of the pair.
*
* The three semantic colours get the same treatment for the same reason: on white, 2020's
* green is 2.28:1, its amber 3.42:1 and its red 5.25:1 — only the red clears AA, and it is the
* only one kept close to its original.
* The three semantic colours get the same treatment: on white, 2020's green is 2.28:1 and its
* amber 3.42:1, while its red clears AA at 5.25:1 and is the one kept close to the original.
*
* The navy is not the chrome here. In 2020 it is the header and the sidebar; a palette cannot
* give the chrome its own colour (that reads --fs-panel), so in dark mode the navy is the
* CANVAS and the chrome sits on it, which is the nearest thing this theme's structure has. */
* The navy is not the chrome here. In 2020 it is the header and the sidebar, but the chrome
* reads --fs-panel, so in dark mode the navy is the CANVAS and the chrome sits on it. */
:root[data-palette="2020"] {
/* LIGHT — white content, the CI cyan darkened until it can be read on it */
--fs-bg-base: #ffffff;
+25 -18
View File
@@ -1,9 +1,8 @@
@layer base {
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
/* scroll-margin-top: an anchor jump must clear the sticky bar or it lands underneath
* it (WCAG 2.2 SC 2.4.11). Token, never a literal — the bar is 46px, not the 40 this
* used to guess. */
/* scroll-margin-top: an anchor jump must clear the sticky bar or it lands underneath it
* (WCAG 2.2 SC 2.4.11). The token, never a literal — the bar is 46px. */
*, *::before, *::after {
scroll-margin-top: var(--fs-bar-h);
margin: 0;
@@ -27,16 +26,26 @@
list-style: none;
}
/* `hidden` is how a VIEW says an element is not there, and the only way available to code that
* ships no stylesheet. The UA gives it `display: none` at the weakest possible strength, so any
* `display` a theme sets on a class beats it and the element paints anyway: measured here on
* `.tr` (table-row), `.td` (table-cell), `ul.nav > li` (block), `.cbi-page-actions` (flex) and
* `.ifacebox` (inline-flex).
*
* The flag is the point, not a workaround for a layer mistake: this must outrank every display
* in tokens/base/theme/page at once, including the id-specific ones in the page layer, and it
* has to keep doing so for rules not written yet. `until-found` is excluded
* because there `display: none` would break the find-in-page reveal the value exists for. */
[hidden]:not([hidden="until-found"]) { display: none !important; }
html {
font-size: 100%;
height: 100%;
}
/* :focus-visible, not :focus — the ring is for the keyboard; a mouse click focuses too and
* the UA heuristic keeps the ring off it. Replaces the 2001-era `thin dotted` (plus its
* `a:hover, a:active { outline: 0 }` companion, needless once the ring is keyboard-only):
* dotted 1px was the only focus indicator generic links had, and it is near-invisible on
* a dark panel. */
/* :focus-visible, not :focus — the ring is for the keyboard, and the UA heuristic keeps it off a
* mouse click. A 1px dotted outline is near-invisible on a dark panel, and it was the only focus
* indicator generic links had. */
a:focus-visible {
outline: 2px solid var(--fs-accent);
outline-offset: 2px;
@@ -81,8 +90,8 @@
input[type="search"] {
appearance: textfield;
/* NO box-sizing: content-box. It contradicts the universal border-box above;
* the theme sizes fields by padding, so a search box came out ~22px too wide. */
/* no box-sizing: content-box — it contradicts the universal border-box above, and the
* theme sizes fields by padding, so a search box comes out ~22px too wide */
}
input[type="search"]::-webkit-search-decoration {
@@ -95,16 +104,14 @@
font-family: var(--fs-font-mono);
}
/* LuCI's .control-group is bootstrap's JOINED input-group: the trailing button was pulled back
* over the input so their 1px borders coincided, and its left corners were squared to match.
* This theme does not join controls — theme/55-buttons.css gives every button the full token
* radius, and a LAYER beats base unconditionally, so the squared corners never applied while
* the pull-back still did: the button's ROUNDED left corner sat on top of the input's rounded
* right corner (issue #7 — package-manager's Filter/Clear and "Download and install"/OK).
* Separate controls with a real gap instead.
/* LuCI's .control-group is bootstrap's JOINED input-group: the trailing button pulled back over
* the input so their borders coincided, with its left corners squared to match. This theme does
* not join controls — every button takes the full token radius from a later layer, so the
* squared corners never apply while the pull-back still does and the button's rounded corner
* sits on the input's (issue #7). Separate controls with a real gap instead.
*
* The password reveal is the one group that IS joined, and theme/60-inputs.css builds that seam
* itself (one border around the group, `gap: 0`) — it does not need the pull-back either. */
* itself (one border around the group, `gap: 0`). */
.control-group {
display: inline-flex;
gap: var(--fs-space-2);
@@ -1,22 +1,19 @@
@layer base {
/* The body box, links, and the type scale a view inherits from plain markup.
* body background/padding/font-family: theme/20-shell.css — absorbed.
*
* Every size and gutter here reads the scale in 02-tokens.css. It used to state literals —
* 13/18 with 9px and 8.5px halves, a 30/24/18/16/14/13 heading ramp, a 25px list indent — which
* was luci-theme-bootstrap's scale rather than a decision anyone made about Manrope. */
* Every size and gutter here reads the scale in 02-tokens.css rather than stating a literal. */
body {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
/* unitless: it inherits as a FACTOR, so a 20px heading gets 30px of leading instead of the
* flat 18px a length would push down onto it and clip. */
* flat length that would clip it */
line-height: var(--fs-leading);
color: var(--fs-text);
min-height: 100%;
display: flex;
flex-direction: column;
/* gutter, not `overflow-y: scroll`: same no-reflow guarantee, without a dead
* scrollbar down every short page. */
/* gutter, not `overflow-y: scroll`: the same no-reflow guarantee without a dead scrollbar
* down every short page */
scrollbar-gutter: stable;
}
@@ -45,11 +42,9 @@
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
margin-bottom: var(--fs-space-2);
/* prose, so no orphans: LuCI's descriptions are one or two sentences of help text and a
* single trailing word on its own line reads as a rendering fault. `pretty`, not
* `balance` — balance is capped at a few lines and re-flows the WHOLE block, which is for
* headings; pretty only reworks the tail. Unsupported browsers ignore it and wrap as
* before, so there is nothing to guard. */
/* prose, so no orphans: a single trailing word on its own line reads as a rendering fault.
* `pretty`, not `balance` — balance is capped at a few lines and re-flows the whole block,
* which is for headings. An unsupporting browser ignores it. */
text-wrap: pretty;
}
@@ -61,8 +56,7 @@
/* The heading ramp lives in theme/45-misc.css — absorbed whole (weights, sizes, ink);
* theme/30-tables.css owns `legend`. Only the `<h_ small>` treatment remains here. */
/* One rule for every `<h_ small>`: relative, so it tracks whatever step its heading sits on
* instead of the four hand-set sizes (18/14/14/12) this used to carry. */
/* one rule for every `<h_ small>`, relative so it tracks whatever step its heading sits on */
h1 small,
h2 small,
h3 small,
@@ -141,8 +135,8 @@
}
code {
/* panel2 (6.45:1), not a border-level surface: --fs-text on the latter is 4.40:1
* in dark — under AA. Still reads as a chip. */
/* panel2 (6.45:1), not a border-level surface: --fs-text on the latter is 4.40:1 in dark,
* under AA */
background-color: var(--fs-panel2);
color: var(--fs-text);
padding: 1px var(--fs-space-1);
+36 -44
View File
@@ -14,12 +14,11 @@
padding-top: var(--fs-space-5);
}
/* Control typography STAYS IN BASE, deliberately — the one absorption that was tried and
* reverted (measured): as a bare-element rule in the theme layer it out-ranked every CLASSED
* base refinement regardless of specificity — `.zonebadge-empty { font-size: smaller }` lost
* to it and the firewall's zone badges grew from 10.8 to 13px. In base, classed rules beat it
* on specificity, which is the contract they were written against. `select` is not a member:
* its face/size/leading live in its own theme rule (60-inputs.css). */
/* Control typography stays in BASE: moved to the theme layer, a bare-element rule out-ranks
* every classed base refinement regardless of specificity, and the firewall's zone badges grew
* from 10.8 to 13px. In base, classed rules beat it on specificity, which is the contract they
* were written against. `select` is not a member — its face, size and leading live in
* 60-inputs.css. */
label,
input,
button {
@@ -37,32 +36,29 @@
}
.cbi-value-field {
/* logical, not margin-left: this is the gap AFTER the label column, and LuCI ships four
* RTL languages (ar/fa/he/ur). Identical to margin-left while :root carries no dir=rtl —
* which nothing in openwrt/luci sets today — so this buys nothing yet and costs nothing;
* it is here so the geometry is already correct if dir is ever stamped. */
/* logical, not margin-left: this is the gap AFTER the label column, and LuCI ships four RTL
* languages (ar/fa/he/ur). Identical to margin-left while nothing in openwrt/luci stamps
* dir=rtl, so it costs nothing and is already correct if one ever does. */
margin-inline-start: var(--fs-space-5);
flex: 1;
/* a flex item won't shrink below min-content by default, so a nested table or one
* long unbreakable value pushed the whole row out of the card */
/* a flex item will not shrink below min-content by default, so a nested table or one long
* unbreakable value pushes the whole row out of the card */
min-width: 0;
}
/* size + ink: theme/60-inputs.css (`.cbi-value-title`) — absorbed. Left: the label
* column's geometry, which theme does not touch. */
.cbi-value label.cbi-value-title {
/* the nudge that lands the label's first baseline on the field's. Retuned for the 1.5
* leading in 20-typography.css — it was 6px against a hard 18px line box.
* line-height: theme/60-inputs.css (.cbi-value-title) — absorbed with the control rule
* that made it necessary. */
/* the nudge that lands the label's first baseline on the field's, tuned for the unitless
* leading in 20-typography.css */
padding-top: var(--fs-space-1);
/* 180px is a column WIDTH, not a rhythm: it is what fits LuCI's longest option labels
* beside a field, so it is not on the 4px step scale and does not want to be. */
/* 180px is a column WIDTH, not a rhythm — what fits LuCI's longest option labels beside a
* field — so it is deliberately not on the 4px step scale */
flex: 0 0 180px;
/* `end`, not `right`: the label is aligned TOWARDS its field, which is a logical intent.
* NOT the same as base/95-luci.css's `.left/.right/.center` — those are LuCI's forcing
* utilities (form.js hands them out at four call sites, eight across luci-base), and a
* class named `right` means right in any direction. Never sweep those onto start/end. */
* Not the same as base/95-luci.css's `.left/.right/.center`, which are LuCI's forcing
* utilities — a class named `right` means right in any direction. Never sweep those onto
* start/end. */
text-align: end;
}
@@ -83,9 +79,9 @@
/* The shared field box (the 210px x control-height default, #localtime's widening, and the
* per-widget escapes: file, button types, checkbox/radio, .cbi-select, readonly, the
* cell-filling widths): theme/60-inputs.css — absorbed WHOLE. The escapes had to move with
* the box: each beat it here in-layer, and left behind they would lose to a theme rule by
* layer no matter their specificity. */
* cell-filling widths) lives in theme/60-inputs.css — absorbed whole. The escapes had to move
* with the box: each beat it here in-layer, and left behind they would lose to a theme rule by
* layer whatever their specificity. */
.cbi-dynlist {
height: auto;
@@ -170,9 +166,9 @@
gap: var(--fs-space-1);
}
/* .cbi-select (the shell, its inner <select>, the ▾ plate): theme/60-inputs.css — absorbed
* whole. Do not re-add an option rule here either: layer beats specificity, so theme's bare
* `option` outranks even `.cbi-select select option:checked`. */
/* .cbi-select lives in theme/60-inputs.css — absorbed whole. Do not re-add an option rule here
* either: layer beats specificity, so theme's bare `option` outranks even
* `.cbi-select select option:checked`. */
/* Checkbox/radio drawing: theme/60-inputs.css — absorbed whole (it undoes the generic input
* box, which lives there now). */
@@ -181,10 +177,9 @@
color: var(--fs-dim);
}
/* Transitions and the focus/hover ring: absorbed onto the theme's one ring token
* (--fs-focus-ring / --fs-focus-ring-invalid) in theme/55-buttons.css, 60-inputs.css and
* 65-dropdown.css. The old generic glow here rang every clicked button and every hovered
* widget in a second, unnamed focus style — the absorption was a redesign, not a move. */
/* Transitions and the focus/hover ring are the theme's one ring token (--fs-focus-ring /
* --fs-focus-ring-invalid). A generic glow here rings every clicked button and every hovered
* widget in a second, unnamed focus style. */
input[disabled],
button[disabled],
@@ -207,9 +202,8 @@
padding: 0 0 var(--fs-space-3) var(--fs-space-3);
}
/* `gap`, not the -3px/+3px negative-margin pair this carried: that trick predates flex gap and
* it leaks — the outer -3px pulls the row 3px into whatever sits beside it, which is why the
* children had to pay it back. One gap says the same thing and stays inside the box. */
/* `gap`, not a negative-margin pair: that trick predates flex gap and leaks, the outer -3px
* pulling the row into whatever sits beside it */
.cbi-section-create {
display: inline-flex;
align-items: center;
@@ -222,25 +216,23 @@
/* the input's cell-filling width: theme/60-inputs.css — absorbed with the generic box */
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. The legacy `.actions`
* plate has no theme override, so it stays (coverage: some luci-app-* still emits it). */
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. The legacy `.actions` plate has no
* theme override, so it stays: some luci-app-* still emits it. */
.actions {
background: var(--fs-panel2);
margin-bottom: var(--fs-space-4);
/* was 17px 20px 18px 17px — an asymmetric inset nobody chose; it is a plate, so it takes
* the card's own gutter on every side. */
/* a plate, so it takes the card's own gutter on every side */
padding: var(--fs-card-pad);
border-top: var(--fs-hairline);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
text-align: end;
}
/* PHYSICAL, mirrored by hand — see the note on `.close` in base/90-widgets.css for why `float`
* does not take a logical keyword here. This one contradicted its OWN container: `.actions`
* above sets `text-align: end`, which follows the writing direction, while the float did not,
* so in RTL the plate aligned its content to the left while the secondary action stayed on the
* right — the two halves of one row pulling opposite ways. Measured in a live document at
* 1400px: 582px from the reading edge in LTR, 10px in RTL. */
/* Physical, mirrored by hand — see `.close` in base/90-widgets.css for why `float` takes no
* logical keyword. Unmirrored it contradicts its own container: `.actions` above sets
* `text-align: end`, which follows the writing direction, so in RTL the plate aligns its
* content one way and the secondary action the other. Measured in a live document at 1400px:
* 582px from the reading edge in LTR, 10px in RTL. */
.actions .secondary-action,
.cbi-page-actions .secondary-action{
float: right;
+49 -86
View File
@@ -15,101 +15,66 @@
position: relative;
}
/* NO per-cell border-top, and that is a fix, not an omission. The theme draws a table's
* separators on the ROW (theme/30-tables.css: header `border-bottom`, then every body row but
* the last), and it reset the cell border only inside `.cbi-section`. A `.table` that LuCI
* renders straight into `#view` — the apk package list, Status → Firewall's nftables — is
* outside that reset, so it kept base's cell border on top of the theme's row border: every
* separator drawn twice, and, because that cell line is straight and runs to the table's very
* edge while the theme's frame is rounded, a square corner cutting across the radius (issue #7,
* "лишняя граница, не имеющая скругления"). */
/* No per-cell border-top, and that is a fix rather than an omission: the theme draws a table's
* separators on the ROW (theme/30-tables.css), so a cell border here is a second copy of every
* separator — and because the cell line is straight and runs to the table's very edge while the
* frame is rounded, a square corner cuts across the radius (issue #7). */
.table .th, .table .td {
display: table-cell;
/* middle is deliberate: a cell's content is a mix of text, badges and 36px buttons, and
* top-aligning mixed-height rows reads as a layout fault. (The `Fixme` that sat here was
* upstream bootstrap's own stale marker, imported with the rule.) */
/* middle is deliberate: a cell's content is a mix of text, badges and buttons, and
* top-aligning mixed-height rows reads as a layout fault */
vertical-align: middle;
/* was `10px 10px 9px` — a bottom 1px shy of the top for no reason anyone recorded, which
* is what an inherited number looks like. Symmetric, on the grid. */
padding: var(--fs-space-2) var(--fs-space-3);
text-align: start;
}
/* AN UNBREAKABLE VALUE MUST NOT LEAVE THE CARD, in ANY .table. Stock has always had this
* (`overflow-wrap: break-word` on every cell); the theme replaced it with a rule that first
* asked what KIND of table it is — it broke long values only for `#view .table[id] .td,
* table.fs-dt .td`, i.e. a table carrying an id or a `.tr.table-titles` header row. That
* narrowing is gone (theme/30-tables.css records its removal) and this rule is the only owner
* now. A third-party status page rendering a key/value `<div class="table">` with neither
* matched nothing, so its cells kept
* `overflow-wrap: normal` and one unbroken token — an ICCID, a firmware string, an APN, a
* base64 key, a DUID — held the column open and pushed the table outside its card.
* Measured against luci-app-3ginfo-lite's own markup (`admin/modem`, three such tables).
/* An unbreakable value must not leave the card, in ANY .table. Asking what KIND of table it is
* first leaves a third-party key/value `<div class="table">` matching nothing, so one unbroken
* token — an ICCID, a base64 key, a DUID — holds the column open and pushes the table outside
* its card (measured on 3ginfo-lite).
*
* NOT scoped to the phone tier, and that was measured rather than assumed. Scoping it to
* `max-width: 767px` looked like the cautious choice and left a hole exactly where the
* column is narrow but the WINDOW is not: with a 130-character token the table escaped its
* card by 147px at 900px, 247px at 800px and 279px at 768px, and was contained at every
* width with this rule unscoped. That is the same argument the fit engine is built on —
* whether something fits is a property of the CONTENT and its column, not of the viewport.
*
* The cost is nil, which is the other half of the decision: across 8 router pages × 4
* widths × 849 text cells, turning this on changes NOTHING — same cell overflows (0), same
* tables past their column (0), same multi-line cells, same mid-word breaks, same total
* line count. It bites only on the unbreakable token it exists for.
* Not scoped to the phone tier: that leaves a hole where the column is narrow but the WINDOW is
* not (with a 130-character token the table escaped its card by 147px at 900px and 279px at
* 768px), which is the argument the fit engine is built on — whether something fits is a
* property of the content and its column, not of the viewport. The cost is nil: across 8 pages
* x 4 widths x 849 text cells nothing else changes.
*
* `anywhere`, not `break-word`: `break-word` permits the break without reducing the cell's
* min-content contribution, so a `display: table` box still cannot shrink below its widest
* token and the column stays open — tried, and the table stayed 355px wide in a 346px
* column. The narrower `anywhere` rules in theme/ keep winning where they apply.
* token and the column stays open — tried, and the table stayed 355px wide in a 346px column.
*
* THAT IS A CONTAINMENT DEFAULT, and it is right for every tier that has nowhere to put an
* overflow: a key/value include inside a card, the config table (which is never measured), the
* Overview meter rows, the realtime legend. It is exactly wrong for the one tier that IS
* measured — a data table can card, break one column or scroll, so it wants the truth instead,
* and `theme/30-tables.css` gives it `break-word` for as long as it is still a table. The split
* is the whole design: honest floors where a measurement reads them, containment everywhere
* else. Do not "unify" these two by moving either one.
* That is a CONTAINMENT default, right for every tier with nowhere to put an overflow — a
* key/value include, the config table, the Overview meter rows. It is wrong for the one tier
* that IS measured: a data table can card, break one column or scroll, so it wants the truth
* instead, and theme/30-tables.css gives it `break-word` for as long as it is a table. Do not
* unify the two by moving either.
*
* SET PER ROLE, and that is the whole of the second rule: a VALUE may break anywhere, a LABEL
* breaks at words. `.th` and `.td` never match the same element, so neither line depends on the
* other's source order, and the pair rule above keeps only what both cells share.
* Set per ROLE: a value may break anywhere, a label breaks at words. `anywhere` costs a
* min-content of ONE CHARACTER, which is the point on a value — the column may collapse rather
* than push the table out — and the bug it looks like on a header, since an auto-layout table
* hands the surplus to whichever column asks loudest and the header row pays: measured on
* Processes, `PID` came out as "PI/D" and the header row stood 104px tall, against 54px with
* the floor at its longest word (issue #32).
*
* `anywhere` costs a min-content of ONE CHARACTER. On a value that is the point — the column
* may collapse to nothing rather than push the table out of its card. On a header it is the bug
* it looks like: an auto-layout table hands the surplus to whichever column asks loudest, and
* with every text column's floor at one character the header row is what pays. Measured on
* Status → Processes at 1228px against a Russian catalogue, where the actions column is
* `nowrap` and 477px wide: `PID` came out as "PI/D", `Владелец` as "ВЛ/АД/ЕЛЕ/Ц", `root` in the
* body as "roo/t", and the header row stood 104px tall. With the header floor back at its
* longest WORD: 54px, every label and every owner cell on one line, the value columns 47/94/152
* against 39/49/90, and the table still inside its card to the pixel (issue #32).
*
* `break-word` for the label, not `normal`: a header that IS one long token — a third-party
* table naming a column after a key — must still break rather than overflow. It just may not
* shrink the column below that word, which is what a header row is for. */
* `break-word` for the label, not `normal`: a header that IS one long token must still break
* rather than overflow, it just may not shrink the column below that word. */
.table .td { overflow-wrap: anywhere; }
.table .th { overflow-wrap: break-word; }
/* …AND THE FIRST COLUMN IS A LABEL TOO, whichever element it happens to be made of.
/* …and the first column is a label too, whichever element it is made of.
*
* `anywhere` costs a min-content of one character (above), and auto table layout hands each
* column its share by MAX-content — so a two-column key/value table whose value is one enormous
* token starves the key column down to that single character, and the header row above it with
* it. Measured against luci-app-dockerman's Environment table (issue #36), which prints
* `JSON.stringify()` of every field `docker info` returns: at 8 000 characters of value the key
* column fell from 95px to 55px and "Driver" started wrapping; on the reporter's own host it
* reached about one glyph, so the column header read "E N T R Y" straight down the page, on
* Firefox 153 and on Blink alike.
* `anywhere` costs a min-content of one character, and auto table layout hands each column its
* share by MAX-content, so a two-column key/value table whose value is one enormous token
* starves the key column down to that character — measured at 8000 characters of value, the key
* column fell from 95px to 55px, and on the reporter's host the header read one glyph per line
* (issue #36, on Firefox and Blink alike).
*
* `break-word` puts the floor back at the longest WORD — the same trade `.th` makes one line up,
* for the same reason: the first column is the row's identity, and it is what you read the table
* by. fs-select.js makes the same judgement in the other direction (MAX_ID_LINES: past five
* lines the identity has stopped being readable and the table cards instead).
* `break-word` puts the floor back at the longest WORD, the same trade `.th` makes above: the
* first column is the row's identity, and it is what you read the table by.
*
* A CARDED row is exempt and must be: `.table.fs-stacked .td` (theme/30-tables.css) restates
* `anywhere` from a later layer, so once every cell is on its own line there is no neighbouring
* column left to starve it and a long value may break wherever it likes. */
* A carded row is exempt and must be: `.table.fs-stacked .td` (theme/30-tables.css) restates
* `anywhere` from a later layer, and once every cell is on its own line there is no
* neighbouring column left to starve. */
.table .tr > .td:first-child { overflow-wrap: break-word; }
.table .tr:first-child .th {
@@ -118,13 +83,12 @@
vertical-align: top;
}
/* The empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps
* the table's own height when there is nothing to show.
/* The empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps the
* table's own height when there is nothing to show.
*
* IT IS OUT OF FLOW ON PURPOSE and stays that way: a single-cell row in a `display: table` spans
* only the FIRST column, so in flow the plate is a narrow box under column one — and, worse, its
* sentence then becomes that column's max-content and WIDENS it (measured: the Container column
* of an empty section went 275px -> 502px when the cell was put back in flow). Absolute takes it
* Out of flow on purpose: a single-cell row in a `display: table` spans only the FIRST column,
* so in flow the plate is a narrow box under column one and its sentence becomes that column's
* max-content, widening it (275px -> 502px on an empty section). Absolute takes it
* out of the column sizing entirely and `inset-inline: 0` spans it across the table.
*
* WHAT IT MUST NOT DO IS PIN ITSELF TO THE BOTTOM. It used to say `bottom: 0`, which inside the
@@ -147,11 +111,10 @@
line-height: 3em;
/* …and the plate must be exactly as tall as the row reserved for it, or it hangs out of the
* table at whichever end it is anchored to. `--fs-space-4` is two `--fs-space-2` at every
* density, so this and the row height above are the same number written twice — which is what
* lets the cell keep its own size while the four table flavours go on padding their ordinary
* cells differently (`--fs-space-2` here, `--fs-space-2-5` on a framed data table, and so on).
* Measured on Wireless, whose associated-stations table pads 10px a side: the plate came out
* 67px inside a 63px row and printed 4px BELOW the frame's bottom border. */
* density, so this and the row height above are the same number twice — which is what lets
* the cell keep its own size while the table flavours pad their ordinary cells
* differently. Measured on Wireless, whose table pads 10px a side: the plate came out 67px
* inside a 63px row and printed 4px below the frame's bottom border. */
padding-block: var(--fs-space-2);
}
+16 -18
View File
@@ -1,7 +1,6 @@
@layer base {
/* Page chrome: the sticky <header> band, dropdown anchoring, the tab strip,
* breadcrumbs, footer. styles/theme/ repaints these; what stays here is the
* geometry a view can rely on whatever the skin. */
/* Page chrome: the sticky <header> band, dropdown anchoring, the tab strip, breadcrumbs and the
* footer. styles/theme/ repaints these; what stays here is the geometry a view can rely on. */
header {
position: sticky;
top: 0;
@@ -35,10 +34,10 @@
outline: none;
}
/* The tab focus ring lives in theme/40-tabs.css now, and it had to move: an accent outline is
* invisible on the ACTIVE pill, whose fill is that same accent. The rule that stood here was
* repainted by the theme in full (same selectors, `outline: none` + a box-shadow ring), so
* leaving it would only be a dead declaration audit.py --strict reports. */
/* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full
* (same selectors), so a copy left behind would be a dead declaration tools/audit.py --strict
* reports. */
.cbi-tab-disabled[data-errors]::after {
content: attr(data-errors);
@@ -46,7 +45,7 @@
color: var(--fs-on-danger);
height: var(--fs-space-4);
min-width: var(--fs-space-4);
/* a pill, so it says so — `8px` was half the height above, restated by hand */
/* a pill, so it says so, rather than half the height above restated by hand */
border-radius: var(--fs-radius-pill);
text-align: center;
margin-inline-end: var(--fs-space-1);
@@ -62,17 +61,17 @@
margin: calc(var(--fs-space-2) * -1) 0 var(--fs-space-4) 0;
}
/* hangs directly under the tab strip. `top: 35px` was that strip's height written out again —
* a hidden pair with whatever sizes the tab. 100% IS the strip's height, by definition. */
/* hangs directly under the tab strip: 100% IS the strip's height by definition, where a literal
* would be a hidden pair with whatever sizes the tab */
.tabs .menu-dropdown, .tabs .dropdown-menu {
top: 100%;
border-width: 1px;
/* square top-LEFT only: the panel hangs off the tab above it and the two share that
* corner. Was a bare `0 6px 6px 6px`. */
/* square top-left only: the panel hangs off the tab above it and the two share that
* corner */
border-radius: 0 var(--fs-radius) var(--fs-radius) var(--fs-radius);
}
/* the caret beside a tab's label: centred on the text box rather than nudged 15px down it. */
/* the caret beside a tab's label, centred on the text box rather than nudged down it */
.tabs a.menu:after, .tabs .dropdown-toggle:after {
border-top-color: var(--fs-dim);
align-self: center;
@@ -110,11 +109,10 @@
color: var(--fs-dim);
}
/* Footer paint: theme/20-shell.css — absorbed. Left: the flex skeleton the layout
* needs — margin-top:auto pins the footer to the bottom of the flex-column body.
* Do not re-add `justify-content: space-between` / `footer span { margin-bottom }`:
* they came from a two-column footer this theme does not have and only pushed the
* single credit <span> off centre; theme centres it, so they are dead too. */
/* Footer paint: theme/20-shell.css — absorbed. Left: the flex skeleton, where margin-top:auto
* pins the footer to the bottom of the flex-column body. Do not re-add
* `justify-content: space-between` or a span margin — they came from a two-column footer this
* theme does not have. */
footer {
margin-top: auto;
display: flex;
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more