💋 Sync 2026-08-21 20:37:56

This commit is contained in:
github-actions[bot]
2026-08-21 20:37:56 +08:00
parent 2ebc248acf
commit d76cc8e3cd
19 changed files with 826 additions and 34 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease-common-bin PKG_NAME:=linkease-common-bin
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5 PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=11 PKG_RELEASE:=12
ARCH_HEXCODE:= ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c ARCH_HEXCODE=1b0c
endif endif
PKG_SOURCE_VERSION:=b5611e40da63fec2b6a0bb93b272fe0fdc704370 PKG_SOURCE_VERSION:=731629bd526b5215e0a0ad39eb740d45bb458ec5
PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz 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_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) 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 PKG_NAME:=linkease
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5 PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=14 PKG_RELEASE:=15
ARCH_HEXCODE:= ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c ARCH_HEXCODE=1b0c
endif endif
PKG_SOURCE_VERSION:=b5611e40da63fec2b6a0bb93b272fe0fdc704370 PKG_SOURCE_VERSION:=731629bd526b5215e0a0ad39eb740d45bb458ec5
PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz 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_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) 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 PKG_NAME:=linkeasefull
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=3.0.11 PKG_SOURCE_DATE:=3.0.11
PKG_RELEASE:=13 PKG_RELEASE:=14
ARCH_HEXCODE:= ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
@@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64
PKG_HASH:=skip PKG_HASH:=skip
endif endif
PKG_SOURCE_VERSION:=b5611e40da63fec2b6a0bb93b272fe0fdc704370 PKG_SOURCE_VERSION:=731629bd526b5215e0a0ad39eb740d45bb458ec5
PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz 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_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) PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH)
@@ -405,6 +405,16 @@ function refreshWirelessState() {
return _wirelessInit; return _wirelessInit;
} }
function waitForWirelessState() {
const wifiDevices = uci.sections('wireless', 'wifi-device');
const hasQcaWifi = wifiDevices.some(function(device) {
return (device.type == 'qcawifi' || device.type == 'qcawificfg80211');
});
const refresh = refreshWirelessState();
return hasQcaWifi ? Promise.resolve() : refresh;
}
function initNetworkState(refresh) { function initNetworkState(refresh) {
if (_state == null || refresh) { if (_state == null || refresh) {
const hasWifi = L.hasSystemFeature('wifi'); const hasWifi = L.hasSystemFeature('wifi');
@@ -1401,9 +1411,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* be found. * be found.
*/ */
getWifiDevice(devname) { getWifiDevice(devname) {
return initNetworkState().then(L.bind(function() { return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
refreshWirelessState();
const existingDevice = uci.get('wireless', devname); const existingDevice = uci.get('wireless', devname);
if (existingDevice == null || existingDevice['.type'] != 'wifi-device') if (existingDevice == null || existingDevice['.type'] != 'wifi-device')
@@ -1423,9 +1431,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* the configuration. * the configuration.
*/ */
getWifiDevices() { getWifiDevices() {
return initNetworkState().then(L.bind(function() { return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
refreshWirelessState();
const uciWifiDevices = uci.sections('wireless', 'wifi-device'); const uciWifiDevices = uci.sections('wireless', 'wifi-device');
const rv = []; const rv = [];
@@ -1469,9 +1475,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* be found. * be found.
*/ */
getWifiNetwork(netname) { getWifiNetwork(netname) {
return initNetworkState().then(L.bind(function() { return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
refreshWirelessState();
return this.lookupWifiNetwork(netname); return this.lookupWifiNetwork(netname);
}, this)); }, this));
}, },
@@ -1486,9 +1490,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* are found. * are found.
*/ */
getWifiNetworks() { getWifiNetworks() {
return initNetworkState().then(L.bind(function() { return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
refreshWirelessState();
const wifiIfaces = uci.sections('wireless', 'wifi-iface'); const wifiIfaces = uci.sections('wireless', 'wifi-iface');
const rv = []; const rv = [];
@@ -2556,12 +2556,33 @@ return view.extend({
s.addremove = false; s.addremove = false;
s.load = function() { s.load = function() {
this.radios = network.getWifiDevicesFromConfig().sort(function(a, b) { const configuredRadios = network.getWifiDevicesFromConfig().sort(function(a, b) {
return a.getName() > b.getName(); return a.getName() > b.getName();
}); });
this.wifis = network.getWifiNetworksFromConfig(); const hasQcaWifi = configuredRadios.some(function(radio) {
return isQcaWifiHwtype(uci.get('wireless', radio.getName(), 'type'));
});
return Promise.resolve(); if (hasQcaWifi) {
this.radios = configuredRadios;
this.wifis = network.getWifiNetworksFromConfig();
return Promise.resolve();
}
return network.getWifiDevices().then(L.bind(function(radios) {
this.radios = radios.sort(function(a, b) {
return a.getName() > b.getName();
});
return Promise.all(radios.map(function(radio) {
return radio.getWifiNetworks();
}));
}, this)).then(L.bind(function(networks) {
this.wifis = [];
for (const radioNetworks of networks)
this.wifis.push.apply(this.wifis, radioNetworks);
}, this));
}; };
s.cfgsections = function() { s.cfgsections = function() {
@@ -1,3 +1,5 @@
<h4 align="right"><strong>English</strong> | <a href="DEVELOPMENT_zh.md">简体中文</a></h4>
# Development Guide # Development Guide
This guide covers the complete development workflow for the Aurora theme, from environment setup to building production packages. This guide covers the complete development workflow for the Aurora theme, from environment setup to building production packages.
@@ -0,0 +1,395 @@
<h4 align="right"><a href="DEVELOPMENT.md">English</a> | <strong>简体中文</strong></h4>
# 开发指南
本指南覆盖 Aurora 主题从环境搭建到构建生产包的完整开发流程。
## 前置条件
- **[Node.js 20.19+ / 22.12+](https://nodejs.org/en/download)** —— JavaScript 运行时(与 Vite 7 的支持范围一致 —— 21.x 这类奇数版本不符合要求;在 `pnpm install` 时通过 `engines` + `engine-strict` 强制执行,因此不受支持的 Node 会立刻失败并给出清晰提示)
- **pnpm** —— 包管理器(通过 [Corepack](https://github.com/nodejs/corepack) 管理版本)
- **Tailwind CSS 知识** —— 写样式必备。参见 [Tailwind CSS 文档](https://tailwindcss.com/docs)
- **网络连通** —— 开发机必须与你的 OpenWrt 路由器处在同一网络
## 环境搭建
### 1. 克隆与安装
```bash
# 克隆仓库
git clone git@github.com:eamonxg/luci-theme-aurora.git
cd luci-theme-aurora/.dev/
# 启用 Corepack 来管理 pnpm 版本
corepack enable && corepack prepare
# 安装依赖
pnpm install
```
### 2. 配置环境
```bash
# 一站式向导:询问每一个 .env 值(路由器 IP、开发服务器 host/port
# 以当前 .env 条目为默认值),生成/安装 SSH 密钥(只提示一次路由器密码),
# 端到端验证模板同步,然后写入 .env
pnpm setup:router
# 非交互式:直接传入 IP(不再提问;开发服务器相关的值保持
# 已保存的 .env 条目或默认值)
pnpm setup:router 192.168.2.1
```
> 脚本名叫 `setup:router` 而不是 `setup`,因为 `pnpm setup` 会解析到 pnpm 自带的内置 setup 命令,永远不会执行包脚本。
**它做了什么**`scripts/setup.js`),按顺序:
1. **收集 `.env` 的值。** 不带参数时会逐个变量提问,并把当前 `.env` 条目作为默认值展示(回车留空即保持原值)。带 IP 参数时则非交互地把它当作 `VITE_OPENWRT_HOST`,开发服务器相关的值原样不动。此时还什么都没写 —— 只有下面每一步都成功后,`.env` 才会被更新。
2. **预检连接。**`<host>:22` 做一次超时 2 秒的原始 TCP 探测,这样不可达的设备、或关闭了 SSH 的设备会立刻带着清晰提示失败,而不是卡在 ssh 里面。
3. **找到或生成 SSH 密钥。** 它按顺序查找 `~/.ssh/id_ed25519.pub``id_rsa.pub``id_ecdsa.pub`,复用第一个存在的 —— 已有密钥绝不会被覆盖。**如果你完全没有密钥,它会生成一个**(`ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519`)。空口令是刻意的:`.ut` 同步必须在整个 `pnpm dev` 会话期间无人值守地运行。
4. **把公钥装到路由器上。** 它先测试免密 SSH 是否已经可用(`ssh -o BatchMode=yes … echo ok`),可用就跳过安装 —— 而在一次抹掉密钥的完整刷机之后,该测试会失败,安装便会重新执行。否则它会在一次交互式 SSH 会话中把密钥追加到 `/etc/dropbear/authorized_keys`;追加由 `grep -qxF` 守卫,所以重复运行不会产生重复条目。**这是唯一一个会询问路由器 root 密码的步骤,而且只问一次。** 之后它会重新测试免密认证,并对任何失败进行归类(密码错误 / 主机不可达 / 其他)。如果设备跑的是 openssh 而不是 dropbear,密钥应该放在 `/root/.ssh/authorized_keys` —— 手动装到那里,本步骤会检测到并跳过。
5. **端到端验证同步。** 它不满足于 `echo ok`,而是用 `ut-sync` 插件所使用的完全相同的 `tar -cf - | ssh … tar -xf -` 管道把 `ucode/template/themes/aurora/` 推上去(见 [模板(`.ut`)实时同步](#模板ut实时同步))。这一步通过了,`pnpm dev` 的模板同步也就通过了。
6. **写入 `.env`。** 受管理的键在原位重写,周围的注释因此保持意义;缺失的键追加到末尾;其他任何行原样透传。
> 第 4 步的密码提示来自**本地**的 ssh 客户端,它直接从 `/dev/tty` 读取而不是 stdin —— 所以即便脚本为了归类错误而管道化了 ssh 的 stderr,提示仍会正常显示。但这也意味着该步骤需要**开发机上**有一个控制终端(路由器一侧不受影响):请在普通 shell 里运行,而不要在 CI、`nohup` 或编辑器任务面板里运行,那些环境下 ssh 无从提问,运行会在认证处失败。不确定就用 `tty` 检查;在这类环境下,请改为手动安装密钥。
如果路由器就在默认的 `192.168.1.1` 上、且免密 SSH 已经可用,那么完全不需要 `.env` —— 下面每一个值都有可用的默认值。
**环境变量**(全部可选):
- `VITE_OPENWRT_HOST` —— 路由器裸地址,例如 `192.168.1.1`(默认值;也接受 `host:port` 和完整 URL 形式)。Web 代理目标和 `.ut` 同步的 SSH 目标(`root@<hostname>`)都由它推导。更花哨的需求(专用密钥、跳板机、非标准 ssh 端口)应写进 `~/.ssh/config``Host` 块,ssh 会自动读取。
- `VITE_DEV_HOST` —— 开发服务器 host(代码默认值 `127.0.0.1``.env.example` 里设为 `0.0.0.0` 以便局域网访问)
- `VITE_DEV_PORT` —— 开发服务器端口(默认 `5173`
## 开发流程
### 启动开发服务器
```bash
cd luci-theme-aurora/.dev/
pnpm dev
```
开发服务器会在 `http://127.0.0.1:5173` 启动,并把请求代理到你的 OpenWrt 设备。
**Vite 代理的工作方式:**
Vite 开发服务器用中间件重写本地请求,使 CSS/JS 资源由你的开发环境提供,而不是由路由器提供。这样就能在不部署到路由器的前提下实时编辑。具体实现见 `vite.config.ts`
**代理的关键行为:**
1. 把 `/cgi-bin``/luci-static` 请求代理到 OpenWrt 设备
2. 用中间件(`createLocalServePlugin`)重写 CSS 和 JS 文件的请求路径
3. 对 `/luci-static/aurora/main.css``/luci-static/aurora/login.css` 的 CSS 请求分别被重写为由 `.dev/src/media/main.css``.dev/src/media/login.css` 提供
4. JS 文件请求直接由 `.dev/src/resource/` 提供,中间件读取文件内容并返回
5. 向被代理的 HTML 响应注入 Vite HMR 客户端以支持实时重载
6. 把 `/` 重定向到 `/cgi-bin/luci` 以正确路由
### 代码风格与格式化
本项目使用 **Prettier** 进行代码格式化,并开启保存时自动格式化。
**Prettier 配置:**
- 位于 `.prettierrc`
- `.vscode/settings.json` 中的 VS Code 设置为 CSS 和 JS 文件启用保存时格式化
- 使用 `prettier-plugin-tailwindcss` 排序 Tailwind CSS 类名
### CSS 嵌套支持
得益于 **lightningcss**,你可以在样式表中自由使用 [CSS 嵌套语法](https://drafts.csswg.org/css-nesting/)。构建过程会自动把嵌套 CSS 编译成扁平的、浏览器兼容的形式。
它会被编译成在所有浏览器中都能工作的标准 CSS。
### CSS 架构
主题有两个彼此独立的 Tailwind CSS v4 入口,均来自 `.dev/src/media/`
- **`main.css`** —— LuCI 管理界面。它是一份 import 清单,禁用了 Tailwind 的自动源码扫描(`source(none)`),并按顺序引入 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`OKLCH 主题 token,经 `@theme inline` 映射)、共享的 `_icons.css``_base.css``_elements.css``_layout.css``components/` 下的每一个文件(每个 UI 组件一个片段 —— 按钮、卡片、弹窗、表格等等),以及 `_utilities.css`
- **`login.css`** —— 独立的登录页(`sysauth.ut`)。自包含:用 `source(none)` 引入 Tailwind 的 theme/utilities,不用完整 Preflight 而改用一份极小的本地 reset,并直接引入 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`。构建时 `login-css-prune` 插件(`vite.config.ts`)会剥掉该页 var() 链条永远够不到的每一个自定义属性,于是管理端尺寸的 token 表以登录页的尺寸发布。
第三方兼容补丁**不会**被打进 `main.css` —— 它们被拆成 `media/patches/` 下的按页文件并按需加载(见下文 [按需第三方补丁](#按需第三方补丁))。
**新增样式:**
- 新的 UI 组件 → 创建 `components/_<name>.css` 并在 `main.css` 中加一行 `@import`。每个文件都是自己的组织单元 —— 不要加 `@layer` 包裹:主题片段保持不分层,因此无论特异性如何都压过 Tailwind 分层的 base/utilities。
- 针对第三方 LuCI 应用/页面的兼容修复 → 在 `media/patches/` 下新建一个文件(见下文)。
所有规则都用 `@apply` 配合 Tailwind 工具类和 CSS 嵌套 —— 不写裸 CSS 属性。唯一刻意的例外是 `media/patches/*.css`,它按设计就是原生 CSS(见下文)。
### 按需第三方补丁
> 页面级 JS 补丁必须暴露 `window.aurora.patches[stem] = { mount, unmount }`(并在求值时自行挂载一次),这样客户端路由才能在同文档导航之间驱动它们 —— 见 `router_zh.md`
有些第三方 LuCI 应用发布的标记结构无法适配主题,需要一处狭窄的兼容覆盖。与其把每一个这样的补丁都打进 `main.css`(那会把它们发到**每一个**页面),不如让每个补丁成为一个独立 CSS 文件,**只在它所针对的页面上加载**。
**工作方式:**
1. **一页一文件,以 `data-page` 命名。** 每个补丁位于 `media/patches/<page>.css`,其中 `<page>` 是目标页面 `<body data-page="…">` 的值 —— 也就是请求路径各段用 `-` 连接的结果(例如 `admin-services-openclash-config`)。`header.ut` 在渲染时从 `ctx.request_path` 计算出同样的字符串,`request_path` 为空时回退到 `ctx.path``join('-', length(ctx.request_path) ? ctx.request_path : ctx.path)`),这样不带显式路径抵达的默认落地页也能解析出自己的补丁。
2. **构建是拆分,不是打包。** `vite.config.ts` 把每个 `media/patches/*.css` 都加为独立的 Rollup 入口,因此各自编译成 `htdocs/luci-static/aurora/patches/<page>.css`。它们不再是 `main.css` 的一部分。
3. **原生 CSS,不用 `@apply`。** 补丁是唯一写原生声明而非 Tailwind 工具类的地方。每个补丁都是独立构建入口,而旧的 `@reference "../main.css";` + `@apply` 方案会让每个文件各自携带一份 `@property`/辅助样板。把*同样的规则*改写成原生 —— 基于 v1.1.7 → v1.1.8 的转换实测,构建产物,选择器和取值完全相同:
| 补丁 | `@reference` + `@apply` | 原生 CSS | 节省 |
| --- | ---: | ---: | ---: |
| admin-dashboard | 6,940 B | 2,639 B | 62% |
| admin-modem-modemdata-modempreview | 162 B | 96 B | 41% |
| admin-modem-qmodem | 9,483 B | 4,497 B | 53% |
| admin-network-network | 160 B | 86 B | 46% |
| admin-services-openclash-config | 187 B | 87 B | 53% |
| admin-services-openclash-settings | 638 B | 509 B | 20% |
| admin-statistics-graphs | 1,609 B | 110 B | 93% |
| admin-system-filemanager | 576 B | 184 B | 68% |
| **合计** | **19,755 B** | **8,208 B** | **58%** |
这份开销是按入口计的、且大体固定(`@property` 注册、`--tw-*` 辅助链),所以补丁越小、比例越难看 —— 只有两条规则的 statistics 补丁付出了自身重量 15 倍的代价。uhttpd 按原样字节提供服务(不做 gzip),所以原始体积就是传输体积。注意运行时的 `:root` 暴露的是原始颜色 token 加上 `--radius-base``--app-shadow-*` —— `--radius-3xl` 这类名字只存在于 Tailwind 构建内部,所以补丁要写 `calc(var(--radius-base) * 3)` 来取圆角,绝不要写 `var(--radius-3xl)`。对主题仓库自己的补丁而言,旧的 Tailwind 模式仍然*受支持* —— 以 `@reference "../main.css";` 开头并使用 `@apply` 的文件照样能编译 —— 而且它有几个值得与上表权衡的实在好处:**构建期校验**(写错的工具类或颜色名会让构建失败,而写错的 `var()` 只会在运行时静默失效 —— `--radius-3xl` 那次回归正是这样溜进原生改写版的)、与组件片段**同一套词汇**以及 `dark:`/`md:`/`hover:` 变体简写,还有主题重新映射某个工具类时的**自动跟随**(圆角链或阴影 token 的改动会免费重新编译进 Tailwind 补丁;原生补丁则需要手动改)。原生是为了上面那组体积数字而定的默认约定,不是硬性门槛。由应用自带的补丁(直接装进设备的 `patches/` 目录)从来就没有选择余地:它们完全绕过构建,`@apply` 到浏览器就是一段无效文本 —— 一如既往,只能写原生 CSS。这也意味着第三方补丁作者不需要懂 Tailwind。请通过 `:root` 自定义属性取用主题值(`var(--surface-sunken)``var(--hairline)``var(--radius-3xl)``var(--shadow-lg)` 等),不要硬编码颜色或圆角;深色变体是一个普通选择器(`[data-darkmode="true"] & { … }`),断点是普通媒体查询(`@media (width < 48rem)`),CSS 嵌套依旧可用 —— 构建(lightningcss)会为受支持的浏览器压缩并降级它。
4. **`header.ut` 在渲染时发现补丁。** 每次(非登录页)渲染时,`header.ut` 用 ucode 的 `fs.lsdir()` 列出 `/www/luci-static/aurora/patches/`(读取十来个条目的 readdir —— 微秒级,被模板已有的 `ubus` 调用完全盖过),并把已安装的 `*.css` 文件名与请求的**累积路径段前缀**匹配:一个补丁匹配它的精确页面和任意子页面,但只在真正的段边界上 —— `admin-services-wol.css` 覆盖 `admin/services/wol/plus`,却绝不会覆盖某个自身段只是恰好以同样字符开头的兄弟应用(`admin/services/wol-plus`)。每个匹配到的补丁都会紧跟在 `main.css` 之后链接,按字典序排列 —— 于是通用补丁先加载、更具体的后加载,具体的那个在层叠中压在上面。没有匹配的页面什么都不会拿到 —— 没有额外请求,也没有 404。如果目录缺失或不可读,列表为空,页面就以未打补丁的状态渲染。
5. **补丁目录是一个即插即用的扩展点。** 由于发现发生在渲染时,补丁不必随主题发布:**任何包都可以把一个 `<page-prefix>.css` 装进 `/www/luci-static/aurora/patches/`**,主题就会在匹配的页面上加载它。安装/卸载的生命周期是自动的 —— 文件随包出现和消失,无需注册,也无需重建白名单。(以这种方式发布的补丁按原样作为纯 CSS 提供;主题自己的补丁也是同样写法,只是额外过一遍构建做压缩。)
6. **动态生成的页面由它们的固定前缀覆盖。** 有些应用为每个实体铸造一个页面 —— 例如 QModem 的短信会话渲染为 `admin-modem-qmodem-sms-conversation-<contact>`。把补丁按固定前缀命名(`admin-modem-qmodem-sms-conversation.css`),前缀匹配就会为每个会话页面加载它,与联系人名字无关。不需要通配符语法(文件名里的 `*` 也不受支持)。
**添加一个补丁:**
1. 在浏览器里打开目标页面并读取 `document.body.dataset.page` —— 那个精确字符串就是你的文件名(对于一族动态的按实体页面,改用它们的固定前缀 —— 见上文第 5 点)。
2. 创建 `media/patches/<那个字符串>.css`
```css
/* PATCH: <page> (luci-app-foo) */
[data-page="<page>"] {
/* 狭窄的、限定在选择器内的覆盖 —— 原生 CSS + CSS 嵌套,
主题值通过 var(--surface)、var(--hairline)、… 取用 */
}
```
3. 运行 `pnpm build`。没有白名单需要重新生成 —— 加载器会在渲染时发现 `patches/` 下安装了哪些 `.css` 文件。
4. 确认 `htdocs/luci-static/aurora/patches/<page>.css` 很小(只有你写的规则)。
> 移除补丁是对称的:删掉文件并重新构建 —— 加载器不再链接它,因为它不存在了。
**随第三方应用一起发布补丁**(无需主题发版):构建或手写一个以你页面 `data-page` 前缀命名的纯 CSS 文件,并在你的包 Makefile 中安装它:
```makefile
define Package/luci-app-foo/install
...
$(INSTALL_DIR) $(1)/www/luci-static/aurora/patches
$(INSTALL_DATA) ./htdocs/aurora-patch.css \
$(1)/www/luci-static/aurora/patches/admin-services-foo.css
endef
```
只要两个包都安装了,主题就会在 `admin-services-foo` 及其所有子页面上自动加载它。注意应用自带的补丁绕过了主题的 Tailwind 构建 —— 请写纯 CSS(你仍然可以引用主题的 CSS 自定义属性,例如 `var(--surface)`),并把每条规则都限定在你自己的 `[data-page^="…"]` 选择器之下。
**命名。** 文件名就是页面的 `data-page` 字符串;因为按前缀匹配,更宽的目标也自然生效:
| 你想打补丁的对象… | 要创建的文件 | 它会在哪些页面加载 |
| --- | --- | --- |
| 某个具体页面,`admin/services/foo/general` | `admin-services-foo-general.css` | 该页面(及其下任意子页面) |
| 整个应用,`admin/services/foo/…` 下所有页面 | `admin-services-foo.css` | `foo``foo/general``foo/rules`、… |
| 动态的按实体页面,例如 QModem 短信 `…/sms/conversation/<contact>` | `admin-modem-qmodem-sms-conversation.css`(固定前缀 —— 无需通配符) | 每一个会话页面,不论联系人是谁 |
由前缀匹配自然引出两条经验法则:
- **补丁默认作用于它的页面和所有子页面。** `admin-services-foo.css` 会在 `admin/services/foo/…` 下的每个页面加载。需要更精确的目标时,有两种收窄方式:在文件内部限定单条规则的作用域(`[data-page="admin-services-foo-general"] { … }` 只影响那一个页面),或者为页面专属规则再发布一个名字更长的文件(`admin-services-foo-rules.css`)—— 在两者都匹配的页面上,**两个都会加载**,名字短的在前,因此更具体的那个赢得层叠。
- **匹配遵守路径段边界**,所以前缀绝不会泄漏到长得像的兄弟上:`admin-services-wol.css` 覆盖 `admin/services/wol/plus`,但不覆盖位于 `admin/services/wol-plus` 的另一个应用。唯一无法避免的冲突是两条路径拼接出同一个 `data-page` 字符串(`wol/plus` vs `wol-plus`)—— 这样的补丁会在两个页面上都加载。如果这有影响,就把规则挂到你自己应用的类名/id 上,这样意外加载也匹配不到任何东西。
> 与 `_` 前缀的片段(那些是只供 `@import` 的碎片)不同,补丁文件名没有 `_` 前缀 —— 每一个都是会发布到 `htdocs/` 的真实构建入口。这一点在 `media/patches/` 内部同样成立:那里以 `_` 开头的文件是供其他补丁 `@import` 的共享片段,会被入口扫描跳过,永不发布。
**JS 负载。** 这套机制并不限于 CSS:同一次 `lsdir()` 扫描也会收集 `patches/<page>.js` 文件,作为 `<script defer src>` 输出在补丁样式表链接之后 —— 同样的按页前缀匹配,同样对第三方包即插即用的生命周期(发布一个纯脚本;它不能假设解析时就能通过 `L.require` 加载 LuCI 模块 —— 请在 DOM ready 之后运行,或轮询你的目标元素)。主题自有的 JS 补丁放在 `src/resource/patches/<page>.js`,和其他资源 JS 一样经过 Terser 处理,但落到 `aurora/patches/` 下,这样一次目录列举就能同时服务两种负载类型。第一个使用者:`admin-status-logs` 上的日志查看器增强(把只读的 `#syslog` textarea 解析成带颜色、按列对齐的视图,并带一道解析成功闸门 —— 遇到未知日志格式时回退到原生 textarea;这一个前缀覆盖了系统日志标签页、内核日志标签页以及每个受支持版本上的裸 `/logs` alias)。它的 CSS 刻意**不是**补丁:日志页面是原生 LuCI,所以它们的样式放在 `main.css` 内的 `components/_syslog.css` 里,那里完整的 Tailwind token 流水线(响应式圆角、阴影、配置应用的覆盖)都适用 —— patches/ 保持只服务于第三方兼容。
**别名 —— 一份负载,多个页面。** 当两个不相关的页面名需要同一份负载时,按它们的共同前缀命名会过度匹配(一个 `admin-status` 补丁会加载到*每一个* status 子页面上,包括最繁忙的概览页)。为此,`vite.config.ts` 里的 `PATCH_ALIASES` 会把构建产物(CSS 和 JS)复制成每个别名的名字,开发服务器则把别名请求解析回共享的源文件。用重复的 CSS *入口* 作为替代方案行不通:Rollup 会把内容相同的资源去重成一个文件,于是两个名字里的一个会悄无声息地永不发布。(这张映射表目前是空的 —— 日志查看器最后只需要 `admin-status-logs`,因为每个受支持版本都把两个日志页挂在 `admin/status/logs/*` 之下。)
### Mock 页面
给第三方应用的页面做样式 —— 编写或调整它的 `patches/*.css`,或拿它来检验 `main.css`/组件的改动 —— **无需安装那个应用(甚至无需设备)**。把页面渲染后的 HTML 保存一次,之后就能在主题实时热重载的情况下对着它开发。
- **快照放在哪:** `.dev/mocks/*.html`(被 git 忽略 —— 快照体积大、与设备/分支强相关、还会过期,所以只留在本地)。全新克隆里这个目录不必存在;首次捕获时会自动创建。
- **Mock 工具条:** 这个开发服务器交出的每一个 HTML 页面 —— 无论是代理的设备页面还是提供的快照 —— 都会带上 `scripts/mock-bar.client.js`(在 `/mocks/__bar.js` 提供),它是左下角的一个浮动工具条。它活在 Shadow DOM 里,因此主题和补丁 CSS 既不能重新给它上样式、也不会被它污染,而主题自己的浮动工具栏继续占据右下角。在设备页面上它会列出 `.dev/mocks/` 里有什么,这样不用手敲 `/mocks/` URL 也能进入该工作流:当本页 `data-page` 匹配到某个快照时会出现 `◆`,一键打开它;`⊕` 捕获当前打开的页面;`.dev/mocks/` 为空时工具条会缩成一个孤零零的 `⊕`。在快照内部,它会显示当前打开的是哪一个、可以逐个切换其余快照,而 `↩` 回到设备上的同一页面。`✕` 把它收起为一个点,状态记在 `localStorage['aurora.mockbar.collapsed']`。快照列表被内联注入在脚本旁边;两个标签都带 `data-aurora-mock`,捕获时正是靠它把这些标签剥掉 —— 快照绝不能把一份列表烤进去,因为每次提供服务时都会重新注入一份最新的。
- **捕获一个:** 让开发服务器代理一台装有该页面的设备,通过代理打开页面并点 mock 工具条上的 `⊕` —— 或者按 <kbd>Alt/Option+Shift+S</kbd>,或者在控制台里调用 `__auroraMockCapture()`。它会把实时 DOM POST 到 `/mocks/__save`,后者写出 `.dev/mocks/<data-page>.html` —— 以页面的 `data-page` 命名,包含 doctype,仅开发用的 script 标签被剥除。(该端点只接受带有辅助脚本自定义头的请求,跨源页面在没有本服务器绝不批准的 CORS 预检的情况下发不出这个头。)手动捕获同样可行:在 DevTools 控制台运行 `copy(document.documentElement.outerHTML)`,把粘贴内容存成 `.dev/mocks/<name>.html` —— 文件名随意;页面真正的身份是它 `<body>` 里已有的 `data-page` 属性,补丁选择器匹配的正是它。
- **必须从运行着*本*主题的设备上捕获 —— 快照在主题之间不可移植。** 快照是渲染后页面的逐字拷贝,所以它在三个地方硬编码了渲染它的那个主题:样式表链接(`/luci-static/aurora/main.css` 加上该页的补丁)、内联 `<style>` 里设备存储的 UCI token 覆盖,以及主题自己的头部/导航标记。把一个在 `luci-theme-shadcn` 下捕获的快照丢进来(或者反过来),页面会**完全没有样式**,因为开发服务器只提供它自己的 `/luci-static/<theme>/` 前缀。破绽是终端里一行点名了另一个主题样式表的输出:
```
[Mocks] miss /luci-static/shadcn/main.css → 404 (mirror it at .dev/mocks/static/… to serve it)
```
那条通用提示建议的「镜像」在这里是错误的修法 —— 请从运行着本主题的设备上重新捕获该页面。如果你无论如何都要复用一个外来快照,那么只有该应用自己的内容区还有意义:把它的样式表链接指向本主题,并删掉那个内联 `<style>` 块,否则被捕获设备的颜色会覆盖当前检出的 token。
- **查看:** `pnpm dev`,然后打开 <http://localhost:5173/mocks/> —— 一个自动生成的索引会列出每个快照及其 `data-page` 和存在时长。`mock-pages-plugin`(在 `vite.config.ts` 中)在提供每个页面时注入 Vite HMR 客户端,因此编辑任何主题源码(`main.css`、某个组件、某个 `patches/*.css`、或被提供的 JS)都会触发常规的整页重载(见 [实时重载行为](#实时重载行为))。快照保留它绝对的 `/luci-static/…` 链接;主题的 CSS/JS、字体和图片都在本地解析并即时编译。提供服务时会补上 `outerHTML` 捕获会丢掉的 `<!doctype html>`,所以 mock 会像真实页面一样以标准模式渲染。
- **在快照之间原地导航:** 在 mock 内部,工具条还会接管快照自身 LuCI 链接(`/cgi-bin/luci/…`)的点击,按 `data-page`(精确匹配)把它们解析到已有快照上并直接跳转 —— 应用的标签栏或侧边栏用起来就跟在设备上一样。未捕获的目标会被拦下,并给出点名缺失快照的提示,而不是穿透到代理。工具条列出每一个快照,用 <kbd>[</kbd>/<kbd>]</kbd>(或它的 ‹/› 按钮)循环切换,并链接回索引。`↩` 离开去往真实页面:它的目标来自 LuCI 自己内联引导代码里的 `requestpath`,但仅在该值与快照的 `data-page` 一致时才用(手工拼装的 mock 可能带着它所依据页面的路径段);否则回退到拆分 `data-page`,而只要某个路径段自身含有连字符(`admin-status-disks-info`),这种拆分就是有损的;再不行就回退到本标签页最后访问过的设备页面。
- **第三方资源:** 快照引用的应用自有 css/js(例如 `qmodem-next.css`,或某个只存在于设备上的自定义 logo)不在本仓库里。要提供它们,就把它的 URL 镜像到 `.dev/mocks/static/` 之下(例如 `.dev/mocks/static/luci-static/resources/qmodem/qmodem-next.css`);那里的文件按原样提供(无 HMR)。mock 页面请求的未命中会立即 404 —— 绝不代理到路由器,因此 mock 完全可以离线使用 —— 而每次未命中都会打印一次性的终端提示,给出准确的镜像路径。(由 CSS 发起的请求,例如导航图标,携带的 referer 是样式表的 URL,无法归因到 mock 页面;因此任何穿透到代理的 `/luci-static` 请求都被限制在 5 秒内,路由器不可达时回答 504。)没有这些资源,主题依然生效。
- **无认证,无运行时:** 快照是静态 DOM,所以 `mock-pages-plugin` 会剥掉 LuCI 的运行时脚本(`luci.js`/`cbi.js`/`xhr.js` 以及 `/cgi-bin/` 端点),并在提供服务前注入一个空操作的 `L`/`LuCI`/`XHR` 桩。没有这一步,LuCI 会启动、轮询后端、拿到 403(没有会话)并弹出「会话已过期」弹窗。代价是:依赖框架的主题 JS(例如 `menu-aurora`)在 mock 里变成空操作 —— 但捕获的 DOM 本来就已经渲染好了,所以看上去仍然是对的。主题自己的内联脚本(深色模式、工具栏状态)以及任何 `src/media/` 下的 JS 仍然会运行。
### 设计 Token
这里没有本地的 `_tokens.css`,也没有生成步骤:`main.css`/`login.css` 直接 `@import "@eamonxg/luci-theme-tokens/dist/aurora/tokens.css"`,在构建时直接从 `node_modules` 解析。真正的源头在独立的 [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) npm 包里,本仓库以 devDependency 方式消费它:
- **`aurora/defaults.js`** —— 浅色和深色模式下 10 个可编辑的输入色(`bg``surface``text``brand``on_brand``link``info``warning``success``danger`),以 OKLCH 字符串表示。
- **`aurora/spec.js`** —— `DERIVATIONS`(其余每一个 token —— `text_muted``surface_sunken``hairline``brand_hover``brand_subtle``focus_ring``progress_start`/`progress_end``*_surface``scrim``mega_menu_bg` 等等 —— 如何通过 `mix`/`shade`/`set`/`alpha`/`const` 运算符从输入色计算出来),以及 `FIXED`(绕过推导的、按模式区分的字面量,例如阴影)。
- **`engine.js`** —— 这些运算符背后的 OKLCH/OKLAB 色彩数学,基于 [colorjs.io](https://colorjs.io/)。
- **`resolve.js`** —— `createResolver` 遍历一份 `DERIVATIONS` 规格并返回一张扁平的 `{token: oklchString}` 映射,其中不留任何 `color-mix()`/`var()``aurora/index.js` 把它预绑定到 Aurora 自己的规格上,导出为 `resolveMode(mode)`,从包的 `/aurora` 入口暴露。
- **`dist/aurora/tokens.css`** —— 由该包自己的 `build.mjs`(其 `prepublishOnly`)构建并随发布的 tarball 一起分发;本仓库不会重新生成它。
**修改一个颜色:**
1. 在 [`luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) 仓库里编辑 `aurora/spec.js`/`aurora/defaults.js`(推导规则、固定字面量、基础输入色),打一个 release tag 让 CI 跑测试、构建 `dist/` 并发布包,然后在这里 bump `@eamonxg/luci-theme-tokens` 这个 devDependency 的版本并运行 `pnpm install`。若要对着本地检出做未发版的迭代,改为在 `.dev` 下运行 `pnpm link ../../luci-theme-tokens`,而不必 bump/发布。
2. 运行 `pnpm build` —— Vite 直接从 `node_modules` 解析 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`,所以在本侧,一次颜色改动所需要的就只是一次版本 bump(或 `pnpm link`)。
3. 运行 `pnpm test` 检查色彩数学与派生 token 的不变量(`tests/resolve.test.js``tests/surfaces.test.js`,两者都从 `@eamonxg/luci-theme-tokens/aurora` 引入 `resolveMode`)—— 例如色相族、`bg`/`surface_sunken`/`surface` 之间的明度顺序,以及菜单背景的半透明性。
**来自 UCI 的运行时覆盖:** `header.ut` 在每次渲染时读取 `uci get_all aurora.theme`,并把存储的 token 以 CSS 自定义属性覆盖的形式,在 `main.css` 之后重新输出到一个内联 `<style>` 中。键按前缀分命名空间 —— `light_*``struct_*` 落到 `:root``dark_*` 落到 `[data-darkmode="true"]` —— 前缀被剥掉,`_` 映射为 `-`(例如 `light_surface_sunken``--surface-sunken`)。模板在一次遍历中把所有键扁平化成两个预先拼好的声明字符串(而不是逐键的模板循环),这把迭代工作量减半,并让输出的 `<style>` 保持紧凑。这正是 `luci-app-aurora-config` 写入的那个钩子。
### LuCI JavaScript API
LuCI 相关的 JavaScript 开发,请参考官方 API 文档:
- [LuCI JavaScript API Reference](http://openwrt.github.io/luci/jsapi/index.html)
### 实时重载行为
- **CSS 改动**:通过自定义 HMR 处理器触发整页重载
- **JS 改动**:通过自定义 HMR 处理器触发整页重载
- **模板改动**`.ut` 文件):经 SSH 自动同步到路由器并触发整页重载(需一次性运行 `pnpm setup:router`,见下)
### 模板(`.ut`)实时同步
`.ut` 模板文件是在 OpenWrt 设备上服务端渲染的,所以不像 CSS/JS 那样能本地提供 —— 开发服务器改为把它们推送到路由器。运行一次 `pnpm setup:router` 配置好免密 SSH;此后全自动:
- **启动时**,整个模板目录被推送一次(通过 ssh stdin 传一个 tarball —— Dropbear 没有给 scp 用的 SFTP 服务),这样开发服务器停机期间做的编辑绝不会让路由器上的文件陈旧。
- **保存时**,改动经防抖后再次推送整个目录,然后浏览器重载。
- **页面加载时**,对 `/cgi-bin` 的请求会等待任何进行中的推送,因此被代理的渲染绝不会用到陈旧模板。
**排障** —— 同步错误会连同修复方法一起打印:
- **主机密钥不匹配**(设备被刷过机):运行 `ssh-keygen -R <device-ip>`,然后重启开发服务器
- **认证失败**(公钥不在设备上,例如刷机之后):运行 `pnpm setup:router`
- **连接被拒绝/超时**:检查设备是否在线、SSH 是否启用
失败的同步会在下一次 `.ut` 改动时重试;没有 SSH 时 CSS/JS 的开发功能照常工作。
## 构建生产版本
### 构建命令
```bash
cd luci-theme-aurora/.dev/
pnpm build
```
它把所有资源编译到生产目录 `htdocs/luci-static/`OpenWrt 打包编译时 LuCI 会使用该目录。
**构建产物:**
```
htdocs/luci-static/
├── aurora/
│ ├── main.css # 压缩后的管理界面 CSS(经 lightningcss
│ ├── login.css # 压缩后的登录页 CSS(经 lightningcss
│ ├── fonts/ # Web 字体(Lato
│ └── images/ # Logo 资源 + PWA 图标
└── resources/
└── menu-aurora.js # 菜单配置(经 Terser 压缩)
```
**构建过程:**
1. Vite 构建两个 CSS 入口(`src/media/main.css``src/media/login.css`),直接从 `node_modules` 解析 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`(见[设计 Token](#设计-token)),并保留 Tailwind 原生的 `@layer` 结构
2. 自定义 Vite 插件(`luci-js-compress`)经 Terser 压缩 JS 文件
3. 从 `.dev/public/aurora/` 复制静态资源
## 打包编译
### 通过 GitHub Actions
**构建前端资源:**
1. 手动触发 `frontend-assets-build` 工作流
2. 它运行 `pnpm build`,若有变化则把产物自动提交到 `htdocs/`
**构建 `.ipk`/`.apk` 包:**
1. 推送版本 tag`v*`)、以带 `[build]` 的提交信息推到 `master`/`feat/**`,或手动触发工作流
2. `build-theme-package` 工作流会同时编译 `.ipk``.apk` 两种 OpenWrt 包
**PR 审查:**
触及 `.dev/``htdocs/``ucode/``root/` 的 Pull Request 会由 `claude-pr-review` 工作流自动审查 —— 它在源码 diff 上发布行内评论(生成的 `htdocs/` 产物被排除),外加一条总结评论。在 PR 评论中 `@claude` 可请求追加审查或提问。
**Issue 分诊:**
新 issue 由 `claude-issue-bot` 工作流处理 —— 它检查垃圾/重复内容、打标签,并发布一条深入的技术分析评论。在 issue 评论中 `@claude` 即可获得回复。
**工作流文件:** `.github/workflows/`
- `frontend-assets-build.yml` —— 构建资源并自动提交(手动触发)
- `build-theme-package.yml` —— 编译 `.ipk`/`.apk`
- `claude-pr-review.yml` —— PR 的 AI 代码审查(行内 + 总结评论)
- `claude-issue-bot.yml` —— AI issue 分诊与分析
## 目录结构
```
luci-theme-aurora/
├── .dev/ # 开发环境
│ ├── docs/ # 项目文档
│ │ └── DEVELOPMENT.md # 开发指南(本文件的英文版)
│ ├── mocks/ # /mocks/ 用的本地页面快照(被 git 忽略,见 Mock 页面)
│ ├── public/aurora/ # 公共静态资源
│ │ ├── fonts/ # Web 字体(Lato
│ │ └── images/ # 主题图片 + PWA 图标
│ ├── scripts/ # 构建脚本 + 开发服务器客户端辅助脚本
│ │ ├── clean.js # 构建清理工具
│ │ ├── mock-bar.client.js # 注入到设备页面和 /mocks/ —— 快照工具条、捕获、链接接管
│ │ └── setup.js # pnpm setup:router —— .env 向导 + 到路由器的免密 SSH
│ ├── src/ # 源码
│ │ ├── assets/icons/ # SVG 图标
│ │ ├── media/ # CSS 源码(Tailwind CSS v4
│ │ │ ├── main.css # 管理界面入口(import 清单;token 来自 @eamonxg/luci-theme-tokens
│ │ │ ├── login.css # 登录页入口
│ │ │ ├── _base.css # 文档基座(html/body 视口背景)
│ │ │ ├── _elements.css # 基础元素样式(标题、链接等)
│ │ │ ├── _layout.css # 页面布局/结构
│ │ │ ├── _utilities.css # 自定义工具类
│ │ │ ├── components/ # 每个 UI 组件一个片段
│ │ │ └── patches/ # 按页的第三方补丁(按需加载,一个 data-page 一个文件)
│ │ └── resource/ # JavaScript 资源
│ │ └── menu-aurora.js # 菜单逻辑
│ ├── tests/ # 全部测试套件(pnpm test
│ │ ├── resolve.test.js # 解析后 token 的不变量(对着 @eamonxg/luci-theme-tokens/aurora
│ │ ├── surfaces.test.js # 表面/色相分层的不变量
│ │ ├── overlay.test.js # 遮罩/布局 CSS 断言
│ │ └── navigation-*.test.js # 导航模型/渲染/样式
│ ├── .env.example # 环境变量模板
│ ├── .prettierrc # Prettier 配置
│ ├── package.json # Node.js 依赖
│ ├── pnpm-lock.yaml # pnpm 锁文件
│ └── vite.config.ts # 带自定义插件的 Vite 配置
├── .github/ # GitHub 配置
│ ├── ISSUE_TEMPLATE/ # Issue 模板
│ ├── workflows/ # GitHub Actions 工作流
│ └── renovate.json # Renovate 依赖更新配置
├── .vscode/ # VS Code 工作区设置
│ └── settings.json # 保存时自动格式化设置
├── htdocs/luci-static/ # 构建产物(由 Vite 生成)
│ ├── aurora/ # 主题 CSS 与资源
│ │ ├── fonts/ # 构建后的字体文件
│ │ ├── images/ # 构建后的图片 + PWA 图标
│ │ ├── main.css # 编译后的管理界面 CSS
│ │ ├── login.css # 编译后的登录页 CSS
│ │ └── patches/ # 编译后的按页补丁(由 header.ut 按需链接)
│ └── resources/ # 构建后的 JavaScript 模块
│ └── menu-aurora.js # 压缩后的菜单逻辑
├── root/etc/uci-defaults/ # OpenWrt 系统集成
│ └── 30_luci-theme-aurora # 主题自动配置脚本
├── ucode/template/themes/aurora/ # LuCI ucode 模板
│ ├── header.ut # 头部模板
│ ├── footer.ut # 底部模板
│ └── sysauth.ut # 登录页模板
├── LICENSE # Apache License 2.0
├── Makefile # OpenWrt 包 Makefile
├── README.md # 英文文档
└── README_zh.md # 中文文档
```
## 工具与技术
- **[Tailwind CSS v4](https://tailwindcss.com/)** —— 工具类优先的 CSS 框架
- **[Vite](https://vitejs.dev/)** —— 构建工具与开发服务器
- **[pnpm](https://pnpm.io/)** —— 快速、节省磁盘空间的包管理器
- **[lightningcss](https://lightningcss.dev/)** —— CSS 压缩器
- **[colorjs.io](https://colorjs.io/)** —— 设计 token 生成所用的 OKLCH/OKLAB 色彩数学(由 [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) 使用)
- **[Terser](https://terser.org/)** —— JavaScript 压缩器
- **[Prettier](https://prettier.io/)** —— 代码格式化工具
- **[prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)** —— Tailwind 类名排序
- **[tw-animate-css](https://github.com/Wombosvideo/tw-animate-css)** —— Tailwind CSS 的动画工具类
- **[tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar)** —— 自定义滚动条样式插件
+2
View File
@@ -1,3 +1,5 @@
<h4 align="right"><strong>English</strong> | <a href="router_zh.md">简体中文</a></h4>
# The client-side router # The client-side router
How the theme turns a menu click into an in-document view swap instead of a How the theme turns a menu click into an in-document view swap instead of a
+221
View File
@@ -0,0 +1,221 @@
<h4 align="right"><a href="router.md">English</a> | <strong>简体中文</strong></h4>
# 客户端路由
本文讲清楚三件事:主题如何把一次菜单点击变成文档内的视图替换、而不是整页加载;哪些情况下它刻意不这么做;以及一个跑在 LuCI 里的路由必须守住哪些不变量。源码在 `.dev/src/resource/router-aurora.js`,由 `footer.ut``menu-aurora.js` 一同加载。**没有改动 luci-base,也没有改动任何 view** —— 路由是纯增量的主题 JS,加上三处很小的模板钩子(补丁清单、`<footer>` 边界,以及 header.ut 自己渲染的样式表上的 `data-aurora-*` 标记)。
![一次 LuCI 导航:设备做了什么、浏览器做了什么,以及同文档路由删掉了其中哪些步骤](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.zh-cn.svg)
## 前人的工作
[luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap) 解决的是同一个问题,读它的代码启发了这里的两处实现:隐藏标签页时暂停 `L.Poll`,以及沿 dispatch 路径折叠 view 的只读状态(两者都在下文)。其余部分是独立实现,且有一处选择是刻意分道扬镳的。footstrap 用 **History API**`pushState`/`popstate`)驱动导航,需要自己记账滚动位置,并用 `prototype.render` 守卫来修复过期渲染;本路由建立在 **Navigation API** 之上(见「内核」一节),把滚动、历史和取代关系都交还给浏览器,因而完全不需要那些东西 —— 代价是只能跑在较新的浏览器上,在其余浏览器里主题退回它本来就是的普通 MPA。在这个共同的基座之上,本路由还加了会话过期闸门、直接从服务端自己的外壳复制 `template` 页面(而不是手工移植),以及用视图过渡为替换做交叉淡入 —— 每一项在下文各有一节。
## 收益实测
**Cudy TR3000**mediatek/filogicARMv8),OpenWrt SNAPSHOT r0-20d94d5,部署本分支,纯 HTTP,热缓存,2026-08-18。两条路径在同一个循环里测量(`bench-fullload.mjs`,RUNS=10,对下面 8 个页面取中位数),因此它们看到的设备状态相同。整页加载的多次运行波动为 ±40 ms:结论在比例,不在具体数字。
### 一次整页加载的时间都花在哪
| 阶段 | ms | 具体是什么 |
|---|--:|---|
| dispatch #1 —— 页面 HTML | 0→123 | TTFB 118:菜单树、ACL 折叠、`view.ut``header.ut` |
| dispatch #2 —— `admin/translations/<lang>` | 124→209 | *第二个* CGI 进程,阻塞解析,不可缓存 |
| DOMContentLoaded | 215 | 外壳回来了,与刚刚被丢弃的那份逐字节相同 |
| view 模块 + ubus 数据 + 渲染 | 215→321 | 静态资源已经是缓存命中 |
**321 ms 里有 209 ms 花在任何页面相关的事情发生之前。** 两次 dispatch 都在重新推导一个浏览器屏幕上本来就有的外壳;view 自己的 ubus 调用要到 227 ms 才开始。同文档替换删掉了这两次 dispatch,只留下最后一行 —— 同样这 8 个页面在热缓存下的中位数是 **91 ms**,数据调用从 227 ms 提前到 2 ms 开始。
### 一次 dispatch 让设备付出多少
在设备本机测量,所以数字里不含网络(`bench-dispatch.sh`)。
| 请求 | ms | 字节 |
|---|--:|--:|
| 页面 HTML,一个 `view` 节点 | 75.4 | 18,583 |
| `admin/translations/en` | 62.7 | **13** |
| `admin/translations/zh-cn` | 60.3 | 229,503 |
| `admin/menu` —— 每 *会话* 一次,不是每次导航 | 68.2 | 45,022 |
| 静态 `main.css` | **0.8** | 191,899 |
| 一次 dispatch 内部,按进程计 | ms |
|---|--:|
| fork + ucode VM | 2.2 |
| `import luci.dispatcher`runtime、http、ubus、uci、core、authplugins | 37.2 |
| 菜单树:stat 8 个 `menu.d` 文件 + 解析 28,307 B 的索引缓存 | 13.8 |
| 经 ubus 的 `session.get` + `session.access` | 6.0 |
`en` 那一行是对照组:一个 **13 字节** 的响应仍然要花 62.7 ms,而一个 191,899 字节的静态文件 0.8 ms 就送出去了。成本在 dispatch,不在负载 —— 而一次整页加载要把这一整块付 **两遍**。(`zh-cn` 客户端还要在每次导航时重新传输 229,503 B:`http.uc` 里的 `write_headers()` 设置了 `Cache-Control: no-cache``Expires: 0`,且没有 `ETag``Last-Modified`,因此根本没有可供重新校验的依据。)
### 端到端,从点击到视图绘制
每页取 10 次中位数,整页加载 = 导航开始 → `#view` 的第一个非 spinner 子节点;路由 = 点击 → 该次导航的 `finished` promise。
| 页面 | 整页加载 | 路由(热) | 快了 |
|---|--:|--:|--:|
| status/routesj | 326 | 92 | 72 % |
| status/nftables | 316 | 90 | 72 % |
| status/logs | 281 | 100 | 64 % |
| status/processes | 457 | 228 | 50 % |
| status/channel_analysis | 401 | 54 | 87 % |
| status/realtime | 211 | 37 | 82 % |
| system/system | 496 | 132 | 73 % |
| system/admin | 231 | 40 | 83 % |
中位数 **快 73 %**,范围 50–87 %。另一套独立的测量工具 `bench-router.mjs timing` 在同一天跑了两次,结果分别是 72 % 和 74.5 % —— 这些差异都落在设备自身的波动范围内,所以请把范围、而不是某个数字,当作结论。
Speculation Rules 预取够不到这块收益:该 API 只在安全上下文可用,所以在 HTTP 上是失效的;而在 HTTPS 上,文档预取也只能藏起第一次 dispatch —— 语言包是子资源,要等文档到达之后才会去取。
## 为什么这件事做得成
对一个 `view` 节点,dispatcher 渲染的是 `view.ut`:先是主题 header,然后 `<div id="view">` 里带一段内联的 `L.require('ui').then(ui => ui.instantiateView('<path>'))`,最后是主题 footer。服务端决定 *哪一个* view;客户端负责渲染它。路由做的就是在不重新加载的前提下重复 `view.ut` 做的事:把路径拿到客户端已经持有的菜单树里解析(`ui.menu.load()``sessionStorage` 供给),替换内容区域,重新实例化 view,并把 URL 交给浏览器自己管。
## 内核:只用 Navigation API
`navigation.addEventListener('navigate', …)` + `event.intercept()`。一个事件就覆盖了链接点击、`location.assign`、回退/前进到同文档条目,以及我们自己的 `navigation.navigate()`;浏览器负责写 URL 和历史条目,通过 `event.signal` 暴露取代关系,并且(配合 `scroll: 'after-transition'`)在回退/前进时恢复滚动、在压栈时滚到顶部 —— 所以路由里没有任何 `pushState`/`popstate` 代码,没有滚动记账,也没有「是片段跳转还是导航」的启发式判断。
为什么用这个 API,而不是 footstrap 用的 History API
- **URL、历史和滚动都归浏览器管。** `pushState` 会把这三者、以及让它们与渲染结果保持一致的责任全塞给路由;而在这里,路由永远只负责渲染。
- **取代关系是内建的。** 更新的一次导航会中止更旧那次的 `event.signal`;下文的世代闸门只是一个检查,不是状态机,也不需要 `render` 守卫去修复过期的绘制。
- **各种导航形式都汇聚到同一个监听器** —— 链接点击、`location.assign`、回退/前进到同文档条目、我们自己的 `navigation.navigate()` —— 因此需要保证正确的只有一条路径。
- **回退方案是免费的。** 在没有该 API 的地方,主题就是它本来的 MPA;不需要 polyfill,也不需要按特性分叉。
**没有该 API 的浏览器保持 MPA。** `footer.ut` 只在 `window.navigation` 存在时才 require 该模块,而 `__init__` 会再次检查它真正用到的接口面:`navigation.addEventListener``NavigateEvent`,以及其原型上的 `intercept`。Chrome/Edge **105+**、Safari 26.2+、Firefox 147+ 会启用路由 —— 是 105 而不是首次发布 Navigation API 的 102,因为直到 Chrome 108 之前该方法都叫 `transitionWhile()``canIntercept` 也还叫 `canTransition`;正是「以 `intercept` 为闸门」把下限抬到了 105。主题声明的下限(Chrome 111 / Safari 16.4 / Firefox 128)照旧生效。这是一个刻意的取舍:一条构造上就正确的代码路径,胜过再加一条 History API 路径、把下文所有内容的面积翻倍。
## 兼容性
### 浏览器 —— 按平台特性分列
| 特性 | 用途 | 是否必需? | Chrome / Edge | Safari | Firefox | 缺失时 |
|---|---|---|---|---|---|---|
| Navigation API`navigation.addEventListener('navigate')``NavigateEvent.intercept()``event.destination/signal``navigation.navigate()/back()` | 整个路由 | **是 —— 闸门** | 105+2022 | 26.2+2026-01 | 147+2026-01 | `router-aurora.js` 根本不会被加载(`footer.ut` 检查 `window.navigation`);主题就是之前那个普通 MPA |
| `document.startViewTransition()`(同文档) | 替换时的交叉淡入 | 否 | 111+ | 18+ | 144+ | 无动画直接替换;在 `prefers-reduced-motion` 下同样关闭 |
| `fetch(url, { priority: 'low' })` | 悬停预热模块 | 否 | 101+ | 17.2+ | 132+ | 该选项被忽略,fetch 仍以默认优先级执行 |
| `MutationObserver``DOMParser``WeakSet``URL``Element.replaceWith``:scope``matchMedia`、可选链 / `??=` | 渲染完成检测、模板外壳、投毒闸门、暂存 | 是 | ≥ 85 | ≥ 14 | ≥ 79 | 全都在主题声明的下限之内(Chrome 111 / Safari 16.4 / Firefox 128 |
所以路由的实际下限是 Chrome/Edge 105、Safari 26.2、Firefox 147;更老的一切都保持主题现有的下限和行为。已在 Chrome 151 上实机验证(headless`bench-router.mjs`);Safari/Firefox 仅凭特性检测 —— 闸门检的是同一套 API 接口面,不是 UA 嗅探。
### OpenWrt / LuCI
主题本来就要求 OpenWrt 23.05+(ucode 模板)。除下面点名的两处与版本相关的项目外,路由只触碰在 `openwrt/luci``openwrt-23.05``openwrt-24.10``openwrt-25.12``master` 分支上完全一致的 luci-base 接口面(对照分支源码核查,2026-08):带实例缓存和 `prototype.constructor``L.require``L.view``L.dom.content``data-idref` 注册表、`L.env.{scriptname, base_url, resource_version, media, requestpath, dispatchpath, pathinfo, nodespec}``L.hasSystemFeature``L.Poll.{queue, start, stop, active, timer}`(以及 `start()``tick` 的重置,正是它让进入的 view 的首次轮询重新上膛)、`ui.menu.load()` 那棵会话缓存的树及其 `satisfied` / `firstchild_ineligible` / `wildcard` / `action.type``view``alias``firstchild``template`)、`setupDOM``poll-start` 处理器注册的 `poll-status` 指示器 id(拆除时按这个名字隐藏它)、`ui.instantiateView``ui.hideIndicator``ui.hideModal``uci.state.values` / `uci.unload()` / `uci.load()``network.js` 基于 uci 的状态、`Request.addInterceptor` / `rpc.addInterceptor` 以及 `setupDOM``-32002``session.access` 的探测、`dispatcher.uc``ctx_append` acl 折叠、`view.ut``#view` + 内联 `instantiateView` 外壳,还有 `dispatcher.uc``resolve_firstchild` / `node_weight` / alias 重新分发语义(逐行移植)。
**有两处接口面在这些分支之间并不一致**,解析器是按较新的那一版写的:
- **`node.css`** 只在 master 上进入了 `build_pagetree` 的 schema7c6d8ff2026-08)。23.05、24.10 和 25.12 的任何节点上都没有 `css`,所以 `nodeCss()` 返回 `null`,该特性在那里就是单纯的失效状态。
- **通配符下降。** `wildcardaction` 存在于 25.12 和 master,不存在于 23.05 或 24.10 —— 键不存在时会退回 `node.action`,那正是这些版本本来的行为,所以这部分是安全的。不安全的是围绕它的 *解析规则*25.12 和 master 会先下降到匹配的 `satisfied` 子节点,然后才把剩余段当作参数;而 23.05 和 24.10 一旦到达 `wildcard` 节点,就立刻把剩下的每一段都捕获为参数。路由移植的是 25.12/master 的规则。因此在 23.05 或 24.10 上,一棵同时有 `foo/*` 和真实 `foo/bar` 子节点的树,在路由里和在 dispatcher 里会解析出不同结果 —— 正是「点击打开一个页面、F5 打开另一个页面」这种故障,而这个解析器存在的意义就是避免它。该规则和 `wildcardaction` 是同一个提交引入的(df90c60a7,2026-01-17),其声明目的是让 `path/*` 能携带一个区别于裸路径的 action,所以在此之前这种结构根本没有定义好的行为,一棵为 23.05/24.10 编写的树不太可能用到它 —— 但这是一个论证,不是对每一份已安装 `menu.d` 的普查,而且路由从未在这两个版本上跑过。请把 23.05/24.10 当作「已审阅」,而非「已验证」。
目前的实机验证:OpenWrt SNAPSHOT r0-20d94d52026-08mediatek/filogic),以及 ipq60xx 上一个更早的 SNAPSHOT。23.05 / 24.10 / 25.12 仅凭分支源码,未上设备。
上面这份清单同时也是可执行的:`router-aurora.js` 里的 `contract()` 会在启动时逐一查找其中每一个接口面(`L.view``L.require``L.dom.content``L.env.{base_url,resource,media}``L.Request.addInterceptor``L.uci.{load,unload,state}``rpc.addInterceptor``poll.{queue,start,stop,active}``ui.menu.load``ui.hideModal``ui.hideIndicator``E`),只要有任何一项缺失,就记录是哪一项并且不激活 —— 在一个已经演进过的 luci-base 上,主题仍然是它原本的 MPA,而不是一个坏掉的路由。
## 什么会被拦截
只有当**全部**条件成立时,`navigate` 事件才会被拦截:
- `event.canIntercept`(同源、并非仅限跨文档),不是 `hashChange`,没有 `downloadRequest`,没有 `formData`,且 `navigationType !== 'reload'`
- 目标**不是文档自身的 URL**(片段除外)。同 URL 导航到达时的 `navigationType``'replace'` 而非 `'reload'`,但它其实就是换了个名字的重新加载:luci-base 的 `ui.changes.apply/revert``window.location = window.location.href.split('#')[0]` 结尾(过期弹窗的按钮也一样),正是为了让服务端重新渲染外壳 —— 在「系统 → 语言和界面」下换了主题、换了语言、换了主机名、菜单树变了 —— 而拦截它会导致替换后仍显示旧外壳,直到 F5。点击当前页面自己的链接,在这里和在 MPA 里一样,就是一次重新加载;
- 目标路径(去掉 `L.env.scriptname` 后)在菜单树中解析到一个**可服务节点**(见下);
- 文档没有被**投毒**(见下),且其会话没有被判定为**已过期**(见下);
- 路由在本文档中**已激活**:只有当它启动时所在的页面本身可服务,它才会激活。`call`/`cbi`/`function` 页面携带的脚本(遗留的 `XHR.poll`、内联定时器)只有文档死亡才能收回,因此从这类页面点出去的第一次点击总是整页加载。
其他一切原样放行:浏览器执行普通的完整导航,也就是主题以前的行为。带修饰键的点击和 `target=_blank` 根本不会到达这个事件。
### 可服务节点
用 dispatcher 自身规则的移植版来解析,而不是照着意思重写:
- `alias` → 从根开始跳到 `action.path` 并继续;
- `firstchild` → 跑 dispatcher 同款的 `resolve_firstchild()` / `node_weight()`:候选是带 `title``action` 为对象的 `satisfied` 子节点;权重为 `min(order ?? 9999, 9999)``auth.login` 再加 10000;一个 `firstchild` 候选只有在能继续解析下去时才算数;`firstchild_ineligible` 被排除;同分时保持键顺序。跳过 ACL 检查,因为 `/admin/menu` 已经按会话过滤过了;
- `wildcard` 节点先被下降进入 —— 匹配到 `satisfied` 子节点的段优先于参数捕获 —— 只有剩下的部分才成为请求参数;有参数时跑节点的 `wildcardaction`(即 `path/*` 条目自己的 action),裸路径时跑 `action`。这是 25.12/master 的规则;23.05 和 24.10 则是在第一个 `wildcard` 节点处就开始捕获 —— 见上文「OpenWrt / LuCI」;
- 一个跳数计数器(32)用来打断外来 `menu.d` 里的环;
- **任何一段匹配不到 `satisfied` 子节点,就终止本次尝试。** dispatcher 会退回到最深的已满足祖先并从那里重新解析;路由则返回 `null`,把这次导航交给服务端。这是刻意的:退回只花一次整页加载,而祖先猜错要付的是打开错误页面。
有两条轨道被同时维护,正如整页加载所做的那样:**请求到的** 段 → `L.env.requestpath``L.env.pathinfo``body[data-page]`**解析出的** 段 → `L.env.dispatchpath``L.env.nodespec`、菜单高亮、标题。一旦选中的子节点和 dispatcher 选的不一样,就会出现「点击打开一个页面、F5 打开另一个页面」—— 这正是解析器必须是移植而非重写的原因。
| 节点 | 是否服务 |
|---|---|
| `view` | 是 —— `view.<path>` |
| `alias``firstchild` | 是 —— 递归解析到叶子 |
| 页面本身是 view 外壳的 `template`(状态 → 概览) | 是 —— 外壳只取一次,见下 |
| Lua `template``call``function``cbi``rewrite` | 否 → 整页加载 |
`rewrite` 是刻意不解析的。节点和它的 action *确实* 在树里,但要跟进它就得重新实现 `dispatcher.uc``splice(request_path, 0, action.remove)` 并从结果重新分发;那里差一位就会打开错误的页面,而这比它退回的那次重新加载糟糕得多。
### template 节点:直接用服务端自己的外壳,绝不手工移植
`admin/status/overview` 是一个 `template`,其服务端定义了页面级全局(`progressbar``renderBox``renderBadge`),输出一个 `<h2>` 和一个 `div.includes`(服务端渲染的 Lua include),然后实例化 `view.status.index`。第一版曾在路由里重新实现这些辅助函数,结果在第一个真实页面上就跑偏了(网络徽章丢了标签:上游的 `renderBadge` 会接收移植版不知道的额外 `L.itemlist` 参数)。所以路由什么都不移植:当指向 template 节点的链接被悬停或聚焦时,它的页面会**每个文档只取一次**(在它解析完成前到达的所有意图事件共享同一个进行中的请求),用 `DOMParser` 解析,并把 `#tabmenu``<footer>` 之间的内容区保存为该页面的 *外壳* —— 克隆每一个节点,把 `#view` 替换成一个空 div,从内联的 `instantiateView('…')` 脚本里读出类名,其余的内联脚本(那些辅助函数)在暂存时重放进全局作用域。luci-base 自己的引导代码(`luci.js``L = new LuCI(env)`)也在这个区域里,会被过滤掉。如果当前文档 *就是* 那个 template(会话从「概览」开始),外壳直接取自实时区域,不发生任何 fetch。template 节点只有在它的外壳已知之后才会被拦截 —— 于是一个 Lua template 页面(没有 `instantiateView` 调用)在一次悬停取回后就被记为不可服务,永远不会进入路由的错误路径;而一个没有先悬停就被点击的 template 就是一次普通整页加载,并顺便为本文档后续的使用播下外壳。它的状态 include 模块是携带 `oneshot`/`hide` 状态的单例,整页加载会重置这些状态 —— 这一点是对着真实的整页加载验证的,不是靠推测。
## 导航流程
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`handler 按顺序:
1. **世代。** `const gen = ++this.gen`;此后每一次 DOM 写入都以它为闸门。
`event.signal` 能中止我们自己的 await,但它无法取消一个 LuCI XHR(`L.Request` 交回的是一个裸 promise`XMLHttpRequest` 只在 *已解析的* `Response` 上才浮现,那时中止已经太晚),也无法取消一条已经在跑的 `View.__init__` 链,所以世代才是正确性机制,signal 只是卫生措施。
2. **拆除离场文档的状态**,也就是文档死亡本会免费替你做掉的事:
- `Poll``queue.length = 0; stop(); start()` —— 三个步骤。清空队列丢掉旧 view 的轮询器;`stop()` 丢掉 tick;在空队列上 `start()` 会把 `tick = 0` 重新上膛,这样进入的 view 的 `poll.add()` 会自动启动并立刻触发,而不是等上最多 `interval` 秒去对齐一个存活下来的 tick。上游的 `initDOM()` 在第一个 view 之前对空队列做的也是同样的 `Poll.start()`
- `uci`:对出现在 `state.values` **或** `uci.loaded` 中的每个包执行 `unload()`(文档启动时缓存是空的;有四个已发布的应用把 `load()` 的返回值当作存在性检查,当缓存回答 `[]` 时会在页面上盖一个错误提示;而且 `uci.loaded` 会一直保留某个包的请求 promise —— 包括已经 reject 的那个 —— 直到 `unload()`,所以一个失败的加载留在那里会被交给之后的每一个 view)。然后,如果 `L.network` 已被加载,就重新发起 `load(['network','luci'])` —— 当 `L.hasSystemFeature('wifi')` 时再加上 `'wireless'` —— 并且 **await 它**;一旦 reject 就传播到硬加载回退路径,而不是把 `network.js` 留在空配置上:`network.js` 只填充一次它的 `_state`,此后一律从 uci 缓存作答(`getWifiDevices()` *就是* `uci.sections('wireless','wifi-device')`),所以只卸载不重填,会让本文档剩下的时间里每一个消费者都拿到空配置。未保存的本地编辑会随页面消亡,这和整页加载一样;已保存的更改在服务端,「未保存更改」指示器不受影响。
- 路由启动之后注册的裸 `setInterval` 会被清除(`setInterval`/`clearInterval``__init__` 里被挂钩,也就是 `L.require('router-aurora')` 实例化该类的时候;`poll.timer` 这个 `L.Poll` 自己持有的唯一 interval 会被跳过)。`setTimeout` 和 rAF **不** 动:核心把工具提示、通知超时和一个请求超时都放在 `setTimeout` 上,而且已发布的 view 里没有任何自我重排的 timeout。
- view 在**渲染期间**注册的 `window`/`document` 监听器会被移除。有好几个已发布的 view 每次渲染都加(statistics 图表:一个匿名 `resize`,之后会对着已分离的 DOM 抛错;nlbwmon`tooltip-open`/`touchstart`;核心自己的下拉组件:每个实例一个 `window` click/touchstart),于是它们会越积越多,并作用在早已消失的页面上。钩子只记录渲染窗口内的注册。一次**热**渲染不会执行任何模块,所以它的注册在构造上就是每次渲染一份,会在下次拆除时移除。一次**冷**渲染还会跑模块的顶层,那些注册必须存活下来(移除它们是单向的 —— 编辑器的模块求值期监听器再也回不来了),所以冷注册只被记在类的账上,等到该类之后的某次热渲染注册了相同的 target/type,证明它确实是每次渲染一份时,才会被释放。
- `ui.hideIndicator('poll-status')` —— luci-base 会留下一个 *正在刷新* / *已暂停* 的指示器,而文档死亡本会把它一并带走。
- `ui.hideModal()`,以及主题自己的各种界面(大菜单、移动端抽屉、命令面板)关闭。
- 页面级的补丁 CSS 被禁用,其 JS 补丁被卸载(见下)。
uci 的清空是这一整步里唯一被 await 而非发射即忘的部分,因此它是 `teardown()` 返回之后的一个独立步骤。
3. **环境。** `L.env.requestpath/dispatchpath/pathinfo/nodespec``body[data-page]``document.title`。alias 在服务端会被重新分发,所以 `requestpath``data-page` 承载的是 alias 的目标,而 `pathinfo` 保留请求时的 URL`firstchild` 则两者都保留请求路径。标题后缀(` - 主机名`)从初始文档里读出,因此它与模板输出的内容一致。`nodespec` 驱动 `L.hasViewPermission()`,进而决定「保存/应用」页脚的只读状态 —— 而它的 `readonly` 是**沿 dispatch 路径折叠**出来的,与 `dispatcher.uc` 的做法一致:`ctx_append` 收集每个节点的 `depends.acl`,对并集做一次 `check_acl_depends()`,只要 *任意* 一个组可写就算可写,所以只有当路径上每一个带 acl 的节点都只读时,页面才是只读的。树里的逐节点标志(`apply_tree_acls`)只覆盖该节点自身的 acl;直接把叶子节点原样递过去,会让一个只读用户在某个只读组下的每个页面上都拿到可用的「保存并应用」。树对象不会被修改(`nodespec` 是一份拷贝)。`data-page``ui.tabs` 会话状态和主题页面级 CSS 的键。
4. **外壳装饰。** `menu-aurora.js` 暴露了 `syncRoute()`:它依据 `L.env.dispatchpath` 在每一个导航界面上重新标注 `is-active-page`/`aria-current`,展开活动的侧边栏/移动端分组并收起其余的,重建头部面包屑,并为新的分区重新渲染 `#tabmenu`。菜单**不会**重建 —— 大菜单在构造时就完成了测量和绑定,命令面板的索引是同一模型的扁平数组 —— 只有它们的状态在变。
5. **暂存。** 一个全新的 `<div id="view" class="view-staging">` 被插到 `#tabmenu` 之后,也就是**树序上的第一个** —— `getElementById('view')` 返回第一个匹配项,所以 LuCI 的 view 链写入的一切都进了这个暂存元素,而离场的页面仍留在屏幕上(变暗,`.view-leaving`)。这个暂存区不可见但**参与布局**(`visibility:hidden; height:0; overflow:hidden`,绝不用 `display:none`):实时图表在 `render()` 内部按 `#view.offsetWidth` 给自己定尺寸,而 `display:none` 的暂存区会递给它们一个宽度为 0 的画布。此时还什么都没被移除。
6. **补丁。** `header.ut` 把已安装的按需补丁词干输出为 `body[data-patches]`;路由施加与模板在渲染时相同的段前缀规则:匹配的 `patches/<stem>.css` 链接被确保存在(`<link data-aurora-patch>`,对屏幕上的页面启用,对其余页面 `disabled` —— 而不是移除,所以回来时不花任何代价);匹配的 `patches/<stem>.js` 文件只加载一次,其 `window.aurora.patches[stem]``{ mount, unmount }` 对按访问驱动(要挂载的词干列表属于计算出它的那次导航,所以一次被取代的导航之后不会挂载任何东西);路由添加的 URL 带着与模板自身链接相同的 `?v=PKG_VERSION` luci.mk 戳记,从 `body[data-asset-version]` 读出,因此它们命中同一个缓存条目 —— 一个什么都不注册的 JS 补丁就只是被执行一次,和 MPA 一样。补丁脚本在求值时自行挂载;如果用户在它到达之前已经导航走了,它的 `load` 处理器会检查当前页面是否仍然需要那个词干,否则就卸载它(期间到达的同词干页面会让它保持挂载)。
menu.d 节点自身的 `css``header.ut` 为被分发的节点链接 `<resource>/<node.css>`,标记为 `data-aurora-node-css`)用同样的方式维护:每张样式表一个 `<link>`,对解析出的叶子声明了它的那个页面启用,对其余每个页面 `disabled`,永不移除。这两个属性都豁免于投毒闸门。
7. **视图。**
- **冷**(本文档中从未 require 过 `view.<path>`):`window.L.require(className)` —— 这个 require *就是* 渲染(LuCI 在首次 require 时实例化),而且它必须走 `window.L` 这个运行时实例,绝不能走模块工厂拿到的那个原型式 `L``ui``itemlist`/`showModal` 挂在 `window.L` 上;通过错误的 `L` require 进来的 view 会在三层模块之后死在 `L.itemlist is not a function`,而且由于 `require()` 按名字缓存,绑定会被 *第一个* 请求者固定下来);
- **热**`require()` 交回那个 `__init__` 已经跑过的缓存实例;LuCI 的类系统设置了 `prototype.constructor`,所以 `new instance.constructor()` 会跑一次全新的 `__init__``load()``render()``dom.content('#view')`,与整页加载的起点完全一致。两种情况下,require 到的值都会用 `instanceof L.view` 检查;不是的话就抛进硬加载路径,而不是把一个非 view 暂存起来。
- **完成是被观测的,不是被假定的**:暂存元素上的一个 `MutationObserver` 会在非 spinner 子节点落地时(或空渲染时 spinner 被移除时)解析。15 秒内没有完成算 **失败**,不算完成:提交 spinner 并释放序列化,会让仍在运行的链绘制到后来某次导航的 `#view` 里,所以超时会 reject,catch 路径硬加载目标地址。完成时 —— 并且仅当这次导航仍是最新的一次 —— 离场区域(`#tabmenu``<footer>` 之间除暂存元素外的一切)被移除,暂存的 view 在 `document.startViewTransition()` 可用且未开启减弱动效时于其内部显现;该次导航的 `finished` promise 在这次替换之后 resolve。每个离场元素在 `remove()` 之前都要走一遍 `L.dom.content(el, null)`:正是它清掉了元素的 `data-idref` 注册表条目,否则那些条目会把已分离的子树及其类实例一直吊着不放 —— 这正是下文浸泡测试所测量的东西。
- **渲染是被串行化的。** 进行中的 LuCI XHR 和正在跑的 `View.__init__` 链都无法取消(原因同上),而每条链都会绘制到绘制时刻*恰好排第一*的那个 `#view` 里。所以一次导航会先等待上一次导航的完成(受同一个超时约束),然后才拆除任何东西或暂存任何东西 —— 上一条链会完成到它自己的暂存元素里,随后被丢弃。因此快速的 A→B→C 绝不会交错:当 C 在 B 还没开跑时就到达,B 会被跳过(`event.signal` / 世代),而 C 会等待真正在飞的那次渲染。文档最初那次由 LuCI 渲染的 view 也按同样方式追踪,所以首次加载期间的一次点击不会被它覆盖绘制 —— 而一次永不完成的首次渲染会让那个等待 reject,于是第一次导航走硬加载回退,而不是在一条可能仍会绘制的链旁边做暂存。那次首渲染同样运行在一个渲染窗口内(该窗口在路由自己的监听器注册之后打开),所以它添加的监听器像冷渲染一样被记在其类的账上;而它在路由加载之前注册的那些则够不着。代价是慢加载期间的点击要等那次加载完成;替代方案 —— 逐类包裹 `prototype.render` 并通过重新导航来修复过期的冷渲染 —— 会留下一个真实的窗口,而且需要三套机制去做一套就够的事。
8. **焦点与播报。**`preventScroll` 聚焦 `#maincontent``tabindex=-1`);新的 `document.title` 被写入 `#aurora-nav-status``role=status``aria-live=polite`),因为同文档替换不会触发屏幕阅读器会播报的 load 事件。这个地标带着 `outline-none`iOS WebKitSafari 和 iOS 版 Chrome 都一样)会为编程式聚焦绘制焦点环,而这个环紧贴吸顶头部下方的上边缘被用户报告为「一个永远走不完的进度条」—— 它从来就不是进度条。
9. **进度。** 存活超过 150 ms 的导航会插入 `#aurora-nav-progress` —— 形态上就是 Turbo Drive 那根条:顶部一条发丝细线,`width` 以内联方式驱动,并以越来越小的步长**涓流**推进(每 300 ms `+ (100 - w) / 30`)直到提交,这样慢渲染看起来一直在动而不是卡住;提交时填到 100 %,淡出(`data-state="done"`)并从 DOM 中**移除**。更短的导航保持沉默,重叠的导航共用同一根条。浏览器自带的进度条在这里帮不上忙:它只在文档加载时显示,而同文档替换恰恰不是文档加载 —— 这也正是 GitHub、YouTube、Turbo/HEY 和每一个 nprogress 用户都要自己画一根的原因。减弱动效只去掉过渡,不去掉这根条。
10. 任何异常 → `console.error`(静默回退会让每一次路由回归都看起来像「页面就是有点慢」)→ `location.href = destination` —— 一次硬性的整页加载,绝不留下卡住的页面。会先设置一个 `bypass` 标志,这样那次写入产生的 `navigate` 事件会直接放行,而不是被拦回失败路径;随后 handler 会停在一个永不落定的 promise 上,这样就不会再有别的东西对着一个正在离开的文档运行。
## 过期闸门
luci-base 用 `notifySessionExpiry()` 应对失效会话:`Poll.stop()` 加上一个唯一按钮是硬重载的弹窗。同文档替换会径直 `hideModal()``Poll.start()` 穿过它继续浏览,然后每个页面依次报错(实测:`bench-router.mjs expiry` 对着上一版路由 —— `expiredFullLoad: false`)。所以路由会监听 luci-base 依据的同样两个信号 —— 任一 `L.Request` 上带 `X-LuCI-Login-Required: yes``403`,以及 luci-base 在 `-32002` 之后发起的 `session.access` 探测被拒绝或出错 —— 此后什么都不再拦截:下一次点击是整页加载,dispatcher 会把它变成登录页。其他对象上的一次拒绝属于 ACL 事务,会被忽略。什么都不重置:这个标志随文档一起消亡,就像会话那样。同一个标志也让下文的可见性闸门不会去重启一个被过期停掉的轮询。
## 隐藏的标签页
luci-base 在后台标签页里会继续轮询。路由在 `visibilitychange` → hidden 且轮询原本处于活动状态时停掉 `Poll`,并在回来时重新启动 —— 除非用户自己暂停过,或期间会话已失效。在一台性能孱弱的路由器上,那是没人在看的 RPC 开销。
## 投毒闸门
view 写进 `<head>``<style>`/`<link rel=stylesheet>` 会在整页加载时随文档消亡,却会在同文档替换中**存活**,然后污染其后的每一个页面(某个已发布的文件管理器用一条未分层的 `!important` 规则在每个配置页上隐藏了保存/重置)。移除它并不是一个选项:在模块求值时导入 CSS 的库永远不会再执行一次,所以删除是单向的(某个编辑器页面回来时变成了一个两百万像素高的黑矩形)。因此这里用的是闸门,不是清扫:在拦截之前,`#view` 之外任何一张不属于主题自己的样式表都会把文档标记为**已投毒**,这次导航就是整页加载 —— 新文档不带任何 view CSS,所以路由会立刻恢复工作。「自己的」意思是*被标记过的*:header.ut 会给它渲染的一切打戳(`main.css`、字体、自定义与 token `<style>` 上的 `data-aurora-shell`;补丁上的 `data-aurora-patch`menu.d 节点 css 上的 `data-aurora-node-css`)。闸门用来比对的启动快照按这些标记过滤,所以启动页自己的模块在路由加载之前插入的样式表仍然算作外来,而不会被「祖父条款」放行到文档余下的时间里。正确性优先于速度,绝不反过来。
一个基于「归属」的精细化方案(从调用栈上把插入模块的身份戳到每张样式表上,对依赖闭包中包含该模块的页面启用它,对其余页面 `disabled`)曾被实现、在设备上验证过,然后又被**移除**了:在这台设备上只有一个 view 页面插入自己的 CSS,省下的不过是离开它时的一次重载,而代价是三处 monkeypatch 加一段内联模板脚本,它的故障模式 —— 某个页面悄无声息地少了一个共享库的 CSS —— 比它避免掉的那次重载更糟。只有当出现一批真实的自带样式的 view 页面时才值得重新考虑。
## 悬停时的模块预热
进入(`pointerover`/`focusin`/`pointerdown`)一个指向可服务节点的链接时,会用 `priority: 'low'` `fetch()` 它的 view 模块 —— 不是 `require()`,因为那会把它渲染出来。URL 是逐字节按 `LuCI.require()` 的构造方式拼出来的(`<base_url>/<把 . 换成 / 的 name>.js?v=<resource_version>`),否则就会错过 HTTP 缓存。这个遍历是传递性的:取回内容的**前 4 KB** 会被扫描,找出开头连续的那串 `'require x'` 字符串字面量,用的正则**不是**行锚定的(已发布的文件被压缩到了一行上),带点的名字会以同样方式预热;不带点的名字要么是 luci-base 那些没有对应文件的内建(`view``baseclass``dom``poll``request``session`),要么是外壳早已加载的扁平库,因此直接拒绝。按类名去重;一旦有一次导航到该链接完成提交就停止。这只在冷导航上体现出来;热导航本来就是 0 字节的缓存命中。
## 刻意不做的事
- **不做 History API 路径。** 见「内核」。
- **路由激活期间不做文档预取。** 路由接管时,`speculationrules` 脚本会在启动时被移除 —— 对一个路由永远不会去加载的文档做悬停预取,纯属浪费路由器 CPU。没有 Navigation API 的浏览器保留这些规则和 MPA 路径。
- **永不使用 `unload`/`beforeunload`**bfcache)。
- **不取消进行中的 XHR** —— 根本没有可用来取消的句柄(见第 1 步);世代闸门让这件事成为浪费,而不是缺陷。这只能由上游解决。
- **不清扫 view 的全局监听器或 timeout** —— 那是对模块求值期注册的单向删除。如果哪天真出现了一个每次渲染都注册的冒犯者,答案是有针对性的拆除,而不是一个全局钩子。
- **`ui.changes.confirm/revert``awaitReconnect`** 保留它们硬性的 `window.location` 写入 —— 回滚/重启这样的边界*就应该*是一个全新的文档。
## 验证矩阵
- 单元测试(`.dev/tests/router.test.js`):解析器对着一棵固定装置树(alias 链、嵌套 firstchild、权重、ineligible、unsatisfied、通配符参数、环);URL → 段;补丁前缀匹配;对压缩过的 head 做 pragma 扫描;只读折叠;过期信号;同 URL 重载规则;解析出的叶子的 node css;契约检查。
- 设备测试(`.claude/skills/aurora-performance/scripts/bench-router.mjs`CDP):
1. 在每种导航模式下完整走一遍每个可点击节点,每一个都与同 URL 的真实整页加载对比 —— `data-page``dispatchpath`、URL、标题、标签数、footer 是否存在、控制台是否干净;
2. 点击 → 视图绘制,N 次中位数,路由 vs 整页加载,冷热皆测;
3. 浸泡测试:12 个页面上做 60 次导航,第一轮之后堆内存 / DOM 节点 / 监听器 / 轮询队列长度保持平稳;
4. 穿过 alias 和 firstchild URL 的前进/后退链 —— 不发生重载;
5. 投毒闸门:`<head>` 里一个外来 `<style>` 使下一次导航变成整页加载,再下一次又恢复为同文档替换;
5b. 样式表:同上,但作用在遍历时发现的、真正会插入自己样式表的每一个 view 页面上(而不是注入一个假的)—— 分别以同文档方式到达和直接落地(它的模块在路由启动之前就插入了),离开时无论哪种方式都是整页加载;
5c. 卫生检查:替换之后 DOM 里没有残留的进度条,live region 存在且携带标题,隐藏标签页停止轮询、可见时恢复;
6. nodecss:一个 menu.d 节点声明了 `css` 的页面 —— 到达时链接启用,离开后禁用,返回时重新启用且不产生重复(没有已安装节点声明 css 时跳过);
7. 过期(放在最后,会毁掉会话):从文档内部 fetch 登出,一个失败的 RPC → luci-base 的弹窗和 `Poll.stop()`;下一次导航是落在登录表单上的整页加载。
该遍历还会把 `nodespec.readonly``L.hasViewPermission()`、启用的 node-css 链接集合以及 live region 文本与整页加载对比,并报告哪些页面带有不属于主题的样式表。
- 设备测试(`bench-fullload.mjs`,CDP):一次整页加载的时间都花在哪 —— dispatch #1、阻塞解析的语言包、DOMContentLoaded、view 自己的 ubus 窗口 —— 以及同一页面走路由的情况,两者在同一个循环里,因此可以相减。
- 设备测试(`bench-dispatch.sh`,在路由器上运行):在任何页面相关工作之前,一次 CGI dispatch 的成本 —— 进程、模块图、菜单树、会话探测 —— 外加一次导航会拉取的每个响应的回环成本与大小。`en` 语言包那一行是把 dispatch 成本和负载成本区分开的对照组。
- 性能 skill`.claude/skills/aurora-performance/`)在 `references/measuring.md` 中记录了全部三套测量工具;本路由所消除的服务端成本,就是 `references/server.md` 里的 S1/S2 预算。
+1 -1
View File
@@ -9,7 +9,7 @@ LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CS
LUCI_DEPENDS:=+luci-base LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.2.4 PKG_VERSION:=1.2.4
PKG_RELEASE:=70 PKG_RELEASE:=71
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:= LUCI_MINIFY_CSS:=
+3 -3
View File
@@ -23,7 +23,7 @@
## 特性 ## 特性
- **现代化**:内容优先的现代化 UI 设计,布局整洁,动画优雅。 - **现代化**:内容优先的现代化 UI 设计,布局整洁,动画优雅。
- **快且丝滑的导航体验**:在支持的浏览器上,切换页面时只更新内容、不整页刷新,切换起来丝滑流畅,加载速度大幅提升——实测页面切换中位数快约 67%(详见[路由文档](.dev/docs/router.md#why-it-pays-measured))。这套无刷新切换的思路参考了 [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)。 - **快且丝滑的导航体验**:在支持的浏览器上,切换页面时只更新内容、不整页刷新,切换起来丝滑流畅,加载速度大幅提升——实测页面切换中位数快约 67%(详见[路由文档](.dev/docs/router_zh.md#收益实测))。这套无刷新切换的思路参考了 [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)。
- **移动端友好**:针对移动端的交互和显示进行了优化,适配手机和平板设备。 - **移动端友好**:针对移动端的交互和显示进行了优化,适配手机和平板设备。
- **主题切换**:内置主题切换器,支持在自动(跟随系统)、浅色和深色模式之间无缝切换。 - **主题切换**:内置主题切换器,支持在自动(跟随系统)、浅色和深色模式之间无缝切换。
- **命令面板(⌘K**:在顶栏一键搜索并跳转到任意页面。 - **命令面板(⌘K**:在顶栏一键搜索并跳转到任意页面。
@@ -131,7 +131,7 @@ make package/luci-theme-aurora/compile -j$(nproc) V=s
## 加入贡献 ## 加入贡献
Aurora 使用 **Vite** 与现代前端工具链构建,并尝试将 AI 融入开发全链路。详见[开发文档](.dev/docs/DEVELOPMENT.md)。欢迎提交建议或 PR。 Aurora 使用 **Vite** 与现代前端工具链构建,并尝试将 AI 融入开发全链路。详见[开发文档](.dev/docs/DEVELOPMENT_zh.md)。欢迎提交建议或 PR。
[discord.gg/EBncRrzfTw](https://discord.gg/EBncRrzfTw) [discord.gg/EBncRrzfTw](https://discord.gg/EBncRrzfTw)
@@ -154,7 +154,7 @@ Aurora 使用 **Vite** 与现代前端工具链构建,并尝试将 AI 融入
[Apache 2.0](LICENSE)。致谢: [Apache 2.0](LICENSE)。致谢:
- [luci-theme-bootstrap](https://github.com/openwrt/luci/tree/master/themes/luci-theme-bootstrap) - [luci-theme-bootstrap](https://github.com/openwrt/luci/tree/master/themes/luci-theme-bootstrap)
- [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap) — 一个自带客户端路由的 LuCI 主题。Aurora 的同文档导航借鉴了它的部分思路,并改用 Navigation API 实现——见[路由文档](.dev/docs/router.md) - [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap) — 一个自带客户端路由的 LuCI 主题。Aurora 的同文档导航借鉴了它的部分思路,并改用 Navigation API 实现——见[路由文档](.dev/docs/router_zh.md)
- [Vite](https://vitejs.dev/) - [Vite](https://vitejs.dev/)
- [Tailwind CSS](https://tailwindcss.com/) - [Tailwind CSS](https://tailwindcss.com/)
- [Tabler Icons](https://tabler.io/icons) — 界面图标集 - [Tabler Icons](https://tabler.io/icons) — 界面图标集
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?= FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),) ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION) PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=31 PKG_RELEASE:=32
endif endif
LUCI_TITLE:=Footstrap Theme LUCI_TITLE:=Footstrap Theme
+1 -1
View File
@@ -1,6 +1,6 @@
# luci-theme-footstrap (the package) # luci-theme-footstrap (the package)
A LuCI theme for OpenWrt **24.10 and newer** (ucode templates). Installing and using it: A LuCI theme for OpenWrt **23.05 and newer** (ucode templates). Installing and using it:
[the repository README](../README.md). Developer documentation: [`../docs/`](../docs/README.md). [the repository README](../README.md). Developer documentation: [`../docs/`](../docs/README.md).
Internal name: `footstrap`. Media path: `/luci-static/footstrap`. Internal name: `footstrap`. Media path: `/luci-static/footstrap`.
@@ -124,9 +124,51 @@ function build() {
node.addEventListener('widget-change', () => apply(w.getValue())); node.addEventListener('widget-change', () => apply(w.getValue()));
return node; 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 sliderCtl = (current, min, max, apply, label, opts) => {
const o = opts || {}; const o = opts || {};
const w = new ui.RangeSlider(String(current), { const w = new RangeSlider(String(current), {
min: min, max: max, step: o.step || 1, calcunits: o.unit || null min: min, max: max, step: o.step || 1, calcunits: o.unit || null
}); });
const node = w.render(); const node = w.render();
@@ -724,6 +724,22 @@
stroke: var(--fs-text) !important; stroke: var(--fs-text) !important;
} }
/* and the axis labels beside them, for the same reason and with a worse symptom. Every <text>
* in the .svg carries an inline fill of the file's own light grey plus a one-pixel black halo,
* drawn for the black background the file assumes rather than for the panel this theme paints
* behind it. On a light palette that grey lands on white at 1.16:1 the numbers are legible
* only as the shadow around them; --fs-text measures 17.9:1 there, and 14.7:1 on the dark
* panel, where it is the halo that has to go instead. Both come from an inline declaration, so
* both need the flag. Reported from the forum.
*
* The weight is not decoration: the SVG hardcodes a 9pt label, the smallest type this theme
* renders anywhere, and at the regular weight it is also the thinnest. */
#view div[style] > svg text[style] {
fill: var(--fs-text) !important;
text-shadow: none !important;
font-weight: var(--fs-weight-bold);
}
/* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label) + /* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label) +
* pages/20-overview.css (chevron variant) absorbed. */ * pages/20-overview.css (chevron variant) absorbed. */
} }
+39 -3
View File
@@ -28,18 +28,54 @@
* a heavy grey cloud under a white card in light mode. */ * a heavy grey cloud under a white card in light mode. */
padding: var(--fs-space-6) var(--fs-space-6) var(--fs-space-6); box-shadow: var(--fs-shadow-pop); padding: var(--fs-space-6) var(--fs-space-6) var(--fs-space-6); box-shadow: var(--fs-shadow-pop);
} }
/* The router's own name, first line of the card (sysauth.ut) the shape an appliance console
* uses for this (FortiGate prints the hostname on its login page for the same reason): a
* CENTRED identity block, set apart from the form, above a left-aligned heading and left-aligned
* fields. Centred and separated is what makes the mixed alignment read as deliberate rather than
* as a stray line: the 24px below is the card's own padding, so the block sits in a gutter of the
* same size as the one around it, and the eye takes it as a header rather than as the heading's
* first line.
*
* Still SMALLER than the heading it stands over (16px against the h2's 20px). It answers
* "which box is this", which matters only until you have read it; `Authorization Required`
* answers "what now", which is the page's actual subject. Set larger, the two swap roles and the
* heading reads as a subtitle.
*
* Not a heading element, and that is on purpose WCAG's outline is about the page's SUBJECT,
* and a second heading above the h2 would announce the login form as two sections. It is a <p>
* in the template.
*
* The eyebrow tokens are NOT reused, though this is an eyebrow's position: they carry
* `text-transform: uppercase`, and a hostname is a name `Rb5009-Usb` is not what the admin
* typed, and case is the only thing telling two similar names apart on a router farm.
*
* `overflow-wrap: anywhere` because the card is a DEFINITE 400px (above) and a hostname is one
* unbreakable token of up to 63 characters nothing in it is a break opportunity, so without
* this a long one runs out of the card instead of wrapping inside it. */
.fs-login-host {
margin: 0 0 var(--fs-space-6); text-align: center;
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-dim);
overflow-wrap: anywhere;
}
.fs-login .cbi-map, .fs-login .cbi-map,
.fs-login .cbi-section, .fs-login .cbi-section,
.fs-login .cbi-section-node { .fs-login .cbi-section-node {
background: none; border: 0; padding: 0; margin: 0; box-shadow: none; background: none; border: 0; padding: 0; margin: 0; box-shadow: none;
} }
/* the login card IS the panel the generic "heading gets its own card" rule would /* the login card IS the panel the generic "heading gets its own card" rule would
* draw a second frame inside it */ * draw a second frame inside it. `h1` because this page has no title bar to carry the
.fs-login .cbi-map > h2, * document's top-level heading (sysauth.ut says why); the card rule in theme/45-misc.css is
* written for `.cbi-map > h2` and so does not reach it, but the reset stays listed the
* heading's level is not what should decide whether a second frame appears inside this card. */
.fs-login .cbi-map > h1,
.fs-login .cbi-map-descr { .fs-login .cbi-map-descr {
background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0; background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0;
} }
.fs-login .cbi-map > h2 { /* the h2's size, on an h1: the ramp puts an h1 at 26px (theme/45-misc.css) and in a 400px card
* that wraps `Authorization Required` onto two lines. The heading was the right SIZE all along
* only its level was wrong. `margin`, because the ramp gives an h1 a bottom margin and this
* card sets its own rhythm. */
.fs-login .cbi-map > h1 {
font-size: var(--fs-type-xl); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; margin: 0 0 var(--fs-space-1-5); font-size: var(--fs-type-xl); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; margin: 0 0 var(--fs-space-1-5);
} }
.fs-login .cbi-map-descr { color: var(--fs-dim); font-size: var(--fs-type); margin: 0 0 var(--fs-space-5); } .fs-login .cbi-map-descr { color: var(--fs-dim); font-size: var(--fs-type); margin: 0 0 var(--fs-space-5); }
@@ -32,6 +32,16 @@
.cbi-section > h3:first-child, .cbi-section > h3:first-child,
.cbi-section > legend { margin-bottom: var(--fs-space-3); } .cbi-section > legend { margin-bottom: var(--fs-space-3); }
/* A SECOND heading inside the same card the one that follows a table rather than opening the
* section had no air at all: measured 0px above it on Status Overview, where the stock
* includes stack `table + h3 + table` inside one .cbi-section and the heading read as another
* row of the table it was captioning. The rule above cannot cover it: `:first-child` is what
* excludes the one that opens the card, and that one takes its gap from the wrapper instead.
* Reported from the forum. The two values are the ladder's own: a heading is separated from
* what came before by more than it is from what it introduces. */
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
/* ---- tables in cards ---- /* ---- tables in cards ----
* Discriminator: a key/value include (System, Memory) has NO id; a data table has one * Discriminator: a key/value include (System, Memory) has NO id; a data table has one
* (status_leases) or the JS tag .fs-dt. Key/value is not itself a card no frame, no clip; * (status_leases) or the JS tag .fs-dt. Key/value is not itself a card no frame, no clip;
@@ -24,12 +24,45 @@
catalogue for. catalogue for.
-#} -#}
{%
/* WHICH ROUTER THIS IS — requested as openwrt/luci#8961, by an admin coming from
luci-theme-material, which prints the hostname in the top bar and therefore also on its login
page. This one had no chrome at all (blank_page below), so the only place the name appeared
was the browser TAB.
A SECOND `ubus.call('system', 'board')`: header.ut makes its own inside its own scope and an
include cannot hand a local back, so the choice is one extra ubus round trip on the login
render or threading `boardinfo` out through a global. One call, on the one page that renders
once per session, is the cheaper of the two.
It discloses nothing new. The same `boardinfo.hostname` already reaches an unauthenticated
browser through <title> (partials/head.ut) — as it does in every stock theme — so this moves
a string that was always on the wire into the place a reader looks. */
const boardinfo = ubus.call('system', 'board') ?? {};
-%}
{% include('header', { blank_page: true }) %} {% include('header', { blank_page: true }) %}
{# The class is what 10-login.css hooks: the form used to be found by {# The class is what 10-login.css hooks: the form used to be found by
`form:has(> .cbi-map input[name="luci_username"])` — 49 characters written 17 times, ~0.8 KB — `form:has(> .cbi-map input[name="luci_username"])` — 49 characters written 17 times, ~0.8 KB —
because the markup was assumed to be stock LuCI's. It is OURS: name the form instead. #} because the markup was assumed to be stock LuCI's. It is OURS: name the form instead. #}
<form method="post" class="fs-login"> <form method="post" class="fs-login">
{#
The router's name, above everything else in the card — an admin with three of these open has no
other way to tell the tabs apart once the form is focused. `?? 'OpenWrt'` is the fallback
partials/head.ut and partials/brand.ut already use, so the line never renders empty; the
striptags/entityencode pair is theirs too, and load-bearing: the hostname is admin-controlled
text printed to an UNAUTHENTICATED page, and one containing `&` broke entity parsing here
before it broke anything else (see the <title> note in partials/head.ut).
A <p> and NOT a heading: the card's h2 is `Authorization Required` and stays the page's one
heading. An outline is about the page's SUBJECT, and a heading above that one would announce
the login form as two sections — a screen reader walking the headings would hear the router's
name as a section title with the form nested under it. It is printed above the h2 and centred
(10-login.css) because that is where a reader looks for "which box is this", not because it
outranks anything.
-#}
<p class="fs-login-host">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</p>
{# {#
TWO INDEPENDENT ALERTS, because neither variable on its own says which of the two dispatcher TWO INDEPENDENT ALERTS, because neither variable on its own says which of the two dispatcher
branches rendered this page — and each of the two spellings in the tree picks one and gets the branches rendered this page — and each of the two spellings in the tree picks one and gets the
@@ -67,7 +100,19 @@
{% endif %} {% endif %}
<div class="cbi-map"> <div class="cbi-map">
<h2 name="content">{{ _('Authorization Required') }}</h2> {#
An h1, where every other LuCI theme leaves this an h2. The login page has no chrome
(blank_page above), so header.ut's `.fs-title-main` h1 never renders here and the document
went out with its top-level heading MISSING — the one sentence saying where you are, and
the heading a screen reader's `1` shortcut jumps to. Stock's h2 is a copy of a page that
does have a title bar above it; this one does not, so the card's heading IS the page title.
`name="content"` is upstream's legacy anchor and travels with the element, not with its
level. The card's own type scale is in 10-login.css: an h1 is 26px in the ramp
(theme/45-misc.css) and this one is set back to the h2's 20px, because the SIZE was never
what was wrong — only the level.
-#}
<h1 name="content">{{ _('Authorization Required') }}</h1>
<div class="cbi-map-descr"> <div class="cbi-map-descr">
{{ _('Please enter your username and password.') }} {{ _('Please enter your username and password.') }}
</div> </div>
+2 -2
View File
@@ -24,11 +24,11 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand PKG_NAME:=wwand
PKG_RELEASE:=2 PKG_RELEASE:=3
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=61f4c2dc728d70e490c94b3e1d8cb2262c262ebb PKG_SOURCE_VERSION:=0c0e182952c1f2e11df1d4156dbef9e31ea41352
PKG_SOURCE_DATE:=2026-08-17 PKG_SOURCE_DATE:=2026-08-17
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip