update 2026-09-20 01:22:33

This commit is contained in:
action committed 2026-09-20 01:22:33 +08:00
1 parent 42e0cfbec4
commit deb27aee9d
864 files changed
+180566 -42

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# Optional — run `pnpm setup:router` once and it writes/updates every value below.
# OpenWrt router address — bare IP/hostname; the web proxy target and the
# .ut-sync ssh target both derive from it (ssh key selection etc. belongs
# in ~/.ssh/config)
# VITE_OPENWRT_HOST=192.168.1.1
# Dev server bind / port
VITE_DEV_PORT=5173
# 0.0.0.0 = LAN; 127.0.0.1 = local only
VITE_DEV_HOST=0.0.0.0
# Optional — local source checkouts the .claude agents read to verify claims
# against luci-base / uhttpd (see .claude/skills/luci-knowledge/SKILL.md).
# Unset = the agents shallow-clone into a temp dir on demand.
# LUCI_SRC=/path/to/openwrt-luci # git clone https://github.com/openwrt/luci
# UHTTPD_SRC=/path/to/uhttpd # git clone https://git.openwrt.org/project/uhttpd.git
+4
View File
@@ -0,0 +1,4 @@
# Fail `pnpm install` early with a clear message on unsupported Node versions
# (the range in package.json `engines` mirrors Vite 7's requirement) instead
# of letting the install die somewhere deep inside a dependency.
engine-strict=true
+4
View File
@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}
+91
View File
@@ -0,0 +1,91 @@
# DOM compat ledger
Every page-scoped rule in the theme exists because some page's DOM did not
sit right under Aurora. This directory records which page, which package,
where in the menu, which element, what went wrong, why the rule is written
the way it is, and how it was verified — so the next edit to `_table.css`
can see what it is about to undo.
"compat" means page DOM ↔ theme. Stock LuCI pages (Software, Mount Points,
Wireless) count as much as third-party apps.
```
pages/<data-page prefix>.md one file per page prefix; entries appended in time order
fixtures/<data-page>--<slug>.html trimmed DOM + checks, run by tests/compat.test.js
```
The page prefix follows the same rule as `media/patches/`: the file
`admin-system-diskman.md` also covers `admin-system-diskman-partition-…`.
Rules that are not tied to one page but were added for one (`.td.cbi-value-field`
for Dockerman) go under the page that motivated them, and the `dom:` line
names the selector so a grep for the selector finds the entry.
## Entry format
```
## <slug> — <one line: what the rule does>
- page: <data-page> · <Menu → Path as shown in the UI>
- package: <luci-app-x (author)> | stock <luci-mod-x>
- dom: <selector / element, plus the inline style or attribute the fix depends on>
- symptom: <what the user sees>
- cause: <why, one or two lines> | unknown (<what the record lacks>)
- fix: <file> `<selector>` <utilities> · <commit> · <version>
- verified: <device/fixture, method, numbers before → after> | not measured
- fixture: fixtures/<name>.html | none (<why>)
- issue: #n | — · mirror: shadcn <commit> | pending | n/a · status: active | removed <commit>
```
Facts only. A cause that cannot be recovered from history is written as
`unknown`, never guessed. `status: removed` entries stay: a removed rule is
the one most likely to be re-added blindly.
Before editing a component partial or a patch, grep here for the file and
every selector you touch:
```
grep -rn "_table.css" .dev/compat/pages/
grep -rln "status_leases" .dev/compat/pages/
```
## Fixtures
A fixture is a trimmed copy of the real page: `<body data-page="…">`,
`#maincontent > #view`, only the offending block (three table rows are
enough), any `<style>` the page injects itself, and the theme CSS by relative
path. Keep the viewport meta: without it a mobile viewport lays out at 980px
and the `max-md:` card rules never apply. When the wrap depends on the
content width, put the width measured on the device as `max-width` on the
block's own container (not on `.cbi-section`, whose padding eats into it).
The runner injects the theme font itself.
```html
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../../htdocs/luci-static/aurora/main.css">
<!-- add ../../../htdocs/luci-static/aurora/patches/<prefix>.css when the page has one -->
<script type="application/json" id="compat">
{ "ledger": "#65", "viewports": [1280, 390],
"checks": [ { "nowrap": "table[id^=partition-table] td" },
{ "fits": "table[id^=partition-table]" } ] }
</script>
```
Checks:
| check | passes when |
|---|---|
| `nowrap: <sel>` | every matched element's text sits on one line, and no text starts below an inline checkbox/indicator inside it |
| `fits: <sel>` | `scrollWidth ≤ clientWidth + 1` and the element's right edge is inside its parent's |
| `js: "<expr>"` | the expression is truthy in the page — escape hatch; the ledger entry says why |
A check may carry its own `"viewports": [...]` (a mobile card layout wraps on
purpose). Capture the DOM from a device running this theme (`.dev/mocks/`
snapshot, DevTools `copy(el.outerHTML)`, or the probe script in
`.claude/skills/luci-dom-compat/scripts/`), strip it down, and confirm the
check fails against the pre-fix CSS before committing it.
## Running
`cd .dev && pnpm build && pnpm test` — `tests/compat.test.js` opens each
fixture in headless Chrome over CDP (`CHROME_BIN`, then the macOS app path,
then `google-chrome`/`chromium` on PATH). Without Chrome the test skips with
a hint; with `CI` set it fails.
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>compat: Dockerman overview info table (#96)</title>
<link rel="stylesheet" href="../../../htdocs/luci-static/aurora/main.css">
<script type="application/json" id="compat">
{
"ledger": "#96",
"viewports": [1440, 1024, 390],
"checks": [
{ "nowrap": "td.cbi-value-field[data-name=entry]" },
{ "fits": "table.table" }
]
}
</script>
</head>
<body data-page="admin-services-dockerman-overview">
<div id="maincontent">
<div id="view">
<div class="cbi-map">
<div class="cbi-section cbi-tblsection">
<table class="table cbi-section-table"><thead class="thead cbi-section-thead"></thead><tbody class="tbody cbi-section-tbody"><tr id="cbi-json-ib12" class="tr cbi-section-table-row" data-sid="ib12" data-section-id="ib12"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib12-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib12.entry"><output for="cbid.json.ib12.entry">Plugins</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib12-value" data-index="1" data-depends="[]" data-field="cbid.json.ib12.value"><output for="cbid.json.ib12.value">{"Volume":["local"],"Network":["bridge","host","ipvlan","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","local","splunk","syslog"]}</output></div></td></tr><tr id="cbi-json-ib13" class="tr cbi-section-table-row" data-sid="ib13" data-section-id="ib13"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib13-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib13.entry"><output for="cbid.json.ib13.entry">MemoryLimit</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib13-value" data-index="1" data-depends="[]" data-field="cbid.json.ib13.value"><output for="cbid.json.ib13.value">true</output></div></td></tr><tr id="cbi-json-ib24" class="tr cbi-section-table-row" data-sid="ib24" data-section-id="ib24"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib24-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib24.entry"><output for="cbid.json.ib24.entry">CgroupDriver</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib24-value" data-index="1" data-depends="[]" data-field="cbid.json.ib24.value"><output for="cbid.json.ib24.value">cgroupfs</output></div></td></tr><tr id="cbi-json-ib39" class="tr cbi-section-table-row" data-sid="ib39" data-section-id="ib39"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib39-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib39.entry"><output for="cbid.json.ib39.entry">Runtimes</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib39-value" data-index="1" data-depends="[]" data-field="cbid.json.ib39.value"><output for="cbid.json.ib39.value">{"io.containerd.runc.v2":{"path":"runc","status":{"org.opencontainers.runtime-spec.features":"{\"ociVersionMin\":\"1.0.0\",\"ociVersionMax\":\"1.2.1\",\"hooks\":[\"prestart\",\"createRuntime\",\"createContainer\",\"startContainer\",\"poststart\",\"poststop\"],\"mountOptions\":[\"async\",\"atime\",\"bind\",\"defaults\",\"dev\",\"diratime\",\"dirsync\",\"exec\",\"iversion\",\"lazytime\",\"loud\",\"mand\",\"noatime\",\"nodev\",\"nodiratime\",\"noexec\",\"noiversion\",\"nolazytime\",\"nomand\",\"norelatime\",\"nostrictatime\",\"nosuid\",\"nosymfollow\",\"private\",\"ratime\",\"rbind\",\"rdev\",\"rdiratime\",\"relatime\",\"remount\",\"rexec\",\"rnoatime\",\"rnodev\",\"rnodiratime\",\"rnoexec\",\"rnorelatime\",\"rnostrictatime\",\"rnosuid\",\"rnosymfollow\",\"ro\",\"rprivate\",\"rrelatime\",\"rro\",\"rrw\",\"rshared\",\"rslave\",\"rstrictatime\",\"rsuid\",\"rsymfollow\",\"runbindable\",\"rw\",\"shared\",\"silent\",\"slave\",\"strictatime\",\"suid\",\"symfollow\",\"sync\",\"tmpcopyup\",\"unbindable\"],\"linux\":{\"namespaces\":[\"cgroup\",\"ipc\",\"mount\",\"network\",\"pid\",\"time\",\"user\",\"uts\"],\"capabilities\":[\"CAP_CHOWN\",\"CAP_DAC_OVERRIDE\",\"CAP_DAC_READ_SEARCH\",\"CAP_FOWNER\",\"CAP_FSETID\",\"CAP_KILL\",\"CAP_SETGID\",\"CAP_SETUID\",\"CAP_SETPCAP\",\"CAP_LINUX_IMMUTABLE\",\"CAP_NET_BIND_SERVICE\",\"CAP_NET_BROADCAST\",\"CAP_NET_ADMIN\",\"CAP_NET_RAW\",\"CAP_IPC_LOCK\",\"CAP_IPC_OWNER\",\"CAP_SYS_MODULE\",\"CAP_SYS_RAWIO\",\"CAP_SYS_CHROOT\",\"CAP_SYS_PTRACE\",\"CAP_SYS_PACCT\",\"CAP_SYS_ADMIN\",\"CAP_SYS_BOOT\",\"CAP_SYS_NICE\",\"CAP_SYS_RESOURCE\",\"CAP_SYS_TIME\",\"CAP_SYS_TTY_CONFIG\",\"CAP_MKNOD\",\"CAP_LEASE\",\"CAP_AUDIT_WRITE\",\"CAP_AUDIT_CONTROL\",\"CAP_SETFCAP\",\"CAP_MAC_OVERRIDE\",\"CAP_MAC_ADMIN\",\"CAP_SYSLOG\",\"CAP_WAKE_ALARM\",\"CAP_BLOCK_SUSPEND\",\"CAP_AUDIT_READ\",\"CAP_PERFMON\",\"CAP_BPF\",\"CAP_CHECKPOINT_RESTORE\"],\"cgroup\":{\"v1\":true,\"v2\":true,\"systemd\":true,\"systemdUser\":true,\"rdma\":true},\"seccomp\":{\"enabled\":true,\"actions\":[\"SCMP_ACT_ALLOW\",\"SCMP_ACT_ERRNO\",\"SCMP_ACT_KILL\",\"SCMP_ACT_KILL_PROCESS\",\"SLine truncated
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>compat: mini-diskmanager partition table (#65)</title>
<link rel="stylesheet" href="../../../htdocs/luci-static/aurora/main.css">
<!-- The rule the plugin injects into <head> for the colour swatch in front of
the filesystem name; the wrap in #65 is "swatch on one line, name below". -->
<style>
.partition-color-indicator {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid;
border-radius: 3px;
margin-right: 8px;
vertical-align: middle;
opacity: 0.85;
}
/* The 1153px below was measured without a scrollbar; the theme's stable
gutter would take 11px of it (see the ledger's scrollbar-gutter entry). */
html { scrollbar-gutter: auto; }
</style>
<script type="application/json" id="compat">
{
"ledger": "#65",
"viewports": [1280, 390],
"checks": [
{ "nowrap": "#partition-table-mmcblk0 tr.tr:not(.table-titles) td" },
{ "fits": "#partition-table-mmcblk0" },
{ "js": "getComputedStyle(document.querySelector('#partition-table-mmcblk0 td')).fontSize === '12px'" }
]
}
</script>
</head>
<body data-page="admin-services-mini-diskmanager-minidiskmanager">
<div id="maincontent">
<div id="view">
<div class="cbi-section">
<!-- 1153px is the table width measured on the device at a 1280px viewport;
max-width keeps the mobile viewport unconstrained. -->
<div class="partition-table-container" style="max-width: 1153px">
<table class="table" id="partition-table-mmcblk0" style="border:1px solid var(--border-color-medium)!important; table-layout:fixed; border-collapse:collapse; width:100%; font-size:12px;"><tr class="tr table-titles"><th class="th left" style="width: 180px; min-width: 180px; text-align: left; cursor: default;" data-sortable-row="true"><span style="display: inline-block;"><input type="checkbox" id="wipeall-checkbox" title="Select all partitions for wiping" style="margin-right: 8px; vertical-align: middle; cursor: pointer;" disabled=""></span><span style="pointer-events: none; user-select: none;">Partition</span></th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Type / Filesystem</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Mount Point</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Label</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Size</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Used</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Unused</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Flags</th></tr><tr class="tr cbi-rowstyle-2"><td class="td left" data-title="Partition"><label data-tooltip="Click to select" style="cursor: pointer; display: inline-flex; align-items: center; gap:6px;"><input type="checkbox" class="partition-select-checkbox" name="partition_select" value="mmcblk0p2" data-partition="mmcblk0p2" aria-label="/dev/mmcblk0p2"><span>/dev/mmcblk0p2</span></label></td><td class="td left" data-title="Type / Filesystem"><span class="partition-color-indicator 0bootconfig"></span> 0BOOTCONFIG</td><td class="td left" data-title="Mount Point">-</td><td class="td left" data-title="Label">-</td><td class="td left" data-title="Size">256.00 KiB</td><td class="td left" data-title="Used">-</td><td class="td left" data-title="Unused">-</td><td class="td left" data-title="Flags">-</td></tr><tr class="tr cbi-rowstyle-1"><td class="td left" data-title="Partition"><label data-tooltip="Click to select" style="cursor: pointer; display: inline-flex; align-items: center; gap:6px;"><input type="checkbox" class="partition-select-checkbox" name="partition_select" value="mmcblk0p18" data-partition="mmcblk0p18" aria-label="/dev/mmcblk0p18"><span>/dev/mmcblk0p18</span></label></td><td class="td left" data-title="Type / Filesystem"><span class="partition-color-indicator squashfs"></span> SQUASHFS</td><td class="td left" data-title="Mount Point">/rom</td><td class="td left" data-title="Label">-</td><td class="td left" data-title="Size">2.00 GiB</td><td class="td left" data-title="Used">110.0 MiB</td><td class="td left" data-title="Unused">0</td><td class="td left" data-title="Flags">-</td></tr><tr class="tr cbi-rowstyle-2"><td class="td left" data-title="Partition"><label data-tooltip="Click to select" style="cursor: pointer; display: inline-flex; align-items: center; gap:6px;"><input type="checkbox" class="partition-select-checkbox" name="partition_select" value="mmcblk0p6" data-partition="mmcblk0p6" aria-label="/dev/mmcblk0p6"><span>/dev/mmcblk0p6</span></label></td><td class="td left" data-title="Type / Filesystem"><span class="partition-color-indicator ext4"></span> Ext4</td><td class="td left" data-title="Mount Point">/mnt/mmcblk0p6</td><td class="td left" data-title="Label">-</td><td class="td left" data-title="Size">113.26 GiB</td><td class="td left" data-title="Used">2.0 MiB</td><td class="td left" data-title="Unused">105.2 GiB</td><td class="td left" data-title="Flags">storage</td></tr></table>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,25 @@
# admin-dashboard
Patch file: `.dev/src/media/patches/admin-dashboard.css` (scope `[data-page="admin-dashboard"] .Dashboard > .section-content`, line 8-10). Route: main.css `@layer luci-mod-dashboard` (7f25128) → `_patches.css` (aedf3dc, 7d9f6a1) → own on-demand file with `@reference`/`@apply` (1c924cf, PR #82) → native CSS (e14cfcd). Page package: stock luci-mod-dashboard (menu.d "admin/dashboard" "Dashboard"); it ships its own `view/dashboard/css/custom.css`.
## dashboard-cards-and-dark — card decoration on `.dashboard-bg`, dark-mode svg invert, title rule, wifi device cell
- page: admin-dashboard · Dashboard
- package: stock luci-mod-dashboard
- dom: `.dashboard-bg` (card), `.dashboard-bg.tr` (row variant, no radius), `img[src*=".svg"]`, `.title h3`, `.router-status-wifi .wifi-info .devices-info .tr .td:nth-child(3)`
- symptom: not recorded beyond the subjects ("dark mode display issue in luci-mod-dashboard"; 56db83c: "Adapt for missing data-page="admin-dashboard" on default login redirect to dashboard", "Remove border-radius on .dashboard-bg for mobile view")
- cause: unknown for the card/wifi/h3 rules (7f25128 has subject only). For the scope: 56db83c body says the `[data-page="admin-dashboard"]` attribute was missing when the login redirect landed on the dashboard, so the scope was dropped; 1c924cf restored it when the patch became on-demand (PR #82: "Backfilled missing data-page scoping in the original patches (… dashboard)"), and header.ut now computes the page name from `ctx.path` when `request_path` is empty (DEVELOPMENT.md §On-Demand Third-Party Patches, item 1)
- fix: patches/admin-dashboard.css — `.dashboard-bg { background: var(--surface-overlay) }` + `&.tr { border-radius: 0 }` + `&:not(.tr) { border: 1px solid var(--hairline); box-shadow: var(--app-shadow-lg); &:hover {…} }` (line 11-27); `img[src*=".svg"] { [data-darkmode="true"] & { filter: invert(100%) } }` (line 38-42); `.title h3 { color: var(--text); border: 0; padding-bottom: 1rem; @media (width < 48rem) { margin-inline: 0; padding-bottom: 0.5rem } }` (line 93-102); `.router-status-wifi .wifi-info .devices-info .tr .td { &:nth-child(3) { position: relative; top: 0; width: 100%; padding-inline: 0 } @media (width < 48rem) { flex: 1 1 50% } }` (line 104-117) · 7f25128 2025-09-28 · 0.5.1_beta (first tag v0.5.5_beta); 56db83c 2025-10-12 · 0.5.9_beta (first tag v0.6.0_beta) dropped the scope and added `.tr { rounded-none }`; the original `.router-status-lan .devices-info tr th { max-md:flex-[1_1_50%] }` from 7f25128 was removed by c3fae55 2025-09-29 "fix: mobile display issue in luci-mod-dashboard th" (subject only)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## dashboard-dark-text-and-inline-tables — force theme text colour in dark mode, compact assoclist cells, undecorate inline info tables
- page: admin-dashboard · Dashboard (issue #66 names the System, DHCP Devices and Wireless panels)
- package: stock luci-mod-dashboard
- dom: `.dashboard-bg span/td/th` (dark mode), `.dashboard-bg .td/.th` (assoclist cells carry `.td/.th` classes), `.dashboard-bg .table:not(.assoclist)` with its `tr` and `td` (the DNS-style inline info tables)
- symptom: issue #66: "Using luci-mod-dashboard with aurora in dark mode results in multiple UI readability and layout issues … Some elements are rendered with very low contrast (almost black on dark background), and spacing/padding in certain tables and panels (System, DHCP Devices, Wireless) appears inconsistent." (OpenWrt 25.12.4, two screenshots)
- cause: the patch's own comment: "Dark mode: override custom.css hardcoded light-mode colors on all text nodes" — luci-mod-dashboard's `custom.css` sets text colours the theme's dark palette does not reach; the spacing/inline-table rules have no recorded cause (944e7c5 has subject only)
- fix: patches/admin-dashboard.css — `& span, & td, & th { [data-darkmode="true"] & { color: var(--text) !important } }` (line 30-36); `& .td { padding-block: 0.5rem; @media (width < 48rem) { padding-block: 0.25rem } }` and `& .th { padding-block: 0.5rem }` (line 45-54); `& .table:not(.assoclist) { margin-bottom: 0; border-radius: 0; border: 0; background: transparent; box-shadow: none; & tr { &:hover { background: transparent } @media (width < 48rem) { display: table-row; border: 0; padding: 0 } } & td { border: 0; padding: 0.125rem 0; @media (width < 48rem) { display: table-cell } &:first-child { padding-right: 0.5rem; white-space: nowrap; opacity: 0.7 } } }` (line 57-91) · 944e7c5 2026-05-31 (in `_patches.css`) · 0.11.10 (first tag v0.12.0)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: #66 (still open) · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-modem-modemdata-modempreview
## modemdata-badge-width — let signal badges inside info cards size to content
- page: admin-modem-modemdata-modempreview · Modem → Modem Data → (preview) (menu path inferred from the page name; not in the local LuCI checkouts)
- package: luci-app-modemdata (third-party; named in the patch header and PR #80)
- dom: `.ifacebox-body .ifacebadge` on the "General Information" cards (PR #80 text)
- symptom: PR #80 body: "Aurora's default ifacebox styles spread modemdata rows apart, wrap signal badges vertically, and leave cards at uneven heights."
- cause: unknown beyond the PR wording — the merged diff (910c85d, 12 lines) contains only the badge width override; the row-spacing and card-height parts of the PR description have no rule in the commit
- fix: patches/admin-modem-modemdata-modempreview.css line 8-12 `[data-page="admin-modem-modemdata-modempreview"] { & .ifacebox-body .ifacebadge { width: auto !important; } }` · 910c85d 2026-06-28 (author CJay D Acopra, PR #80) · 1.0.2 (first tag v1.1.0) — added to `_patches.css` as `@apply w-auto!`, split into its own file by 1c924cf, native CSS by e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: PR #80 · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-modem-qmodem
## qmodem-theming — colours, borders, hover states, tab and table styling across the QModem pages
- page: admin-modem-qmodem (prefix; covers `-overview`, `-sms`, `-sms-conversation-<contact>`, `-sms_sim`, `-config_advanced`, `-settings`, `-network_config`) · Modem → QModem → … (menu paths inferred from the page names; the app is not in the local LuCI checkouts)
- package: luci-app-qmodem (third-party; named in the patch header and PR #92)
- dom: `[data-page^="admin-modem-qmodem"]`: `.copyright-section`, `fieldset.cbi-section > legend`, `fieldset.cbi-section`, `.cbi-section-table-titles.named::before` (line 9-42); `[data-page^="admin-modem-qmodem-sms-conversation"]`: `#sms-messages-area`, `.sms-message-bubble`, `.sms-message-content`, `.sms-message-meta` (line 44-64); `[data-page="admin-modem-qmodem-config_advanced"]`: `.cbi-tabmenu li` and `.cbi-tab` (line 66-96; the file's comment: "targets <li> directly (not li > a) because qmodem's tab markup has no <a> inside tabs"); `[data-page="admin-modem-qmodem-sms"]`: `.td > div`, `.td > span[style*="margin-left"]`, `.sms-conversations` (+ `> .cbi-section`, `fieldset.cbi-section`, `.table.cbi-section-table`), `.cbi-section-table-row.tr` (+ hover), `.sms-unread-conversation`, `.sms-unread-badge` (line 98-175); `[data-page="admin-modem-qmodem-sms_sim"]`: `.cbi-section-table-row.tr` (+ hover) (line 177-197); `[data-page="admin-modem-qmodem-settings"]`: `.cbi-section-table-descr.named::before` (line 201-206)
- symptom: not recorded per rule. PR #92 body: "Styles qmodem overview, SMS, conversation, advanced config, network config, and settings pages with consistent theming (borders, colors, hover states, table formatting, and tab styling)."
- cause: unknown (commit 7066351 has subject only; the PR body lists scope, not what each page did wrong)
- fix: patches/admin-modem-qmodem.css (whole file) · 7066351 2026-07-25 (author shiraneko, PR #92 by cjayacopra) · 1.1.2 (first tag v1.2.0) — written with `@reference`/`@apply`, rewritten natively by e14cfcd 2026-07-27 (1.1.8; radii became `calc(var(--radius-base) * n)`)
- verified: not measured (no record)
- fixture: none (backfill; DOM snapshots exist in `.dev/mocks/admin-modem-qmodem*.html` — overview, sms, sms_sim, config_advanced, settings, network_config)
- issue: PR #92 · mirror: pending · status: active
@@ -0,0 +1,47 @@
# admin-network-network
## bridge-vlan-descr-hidden — hide the table description row on mobile
- page: admin-network-network · Network → Interfaces → Devices → Bridge VLAN Filtering (efa07d8 body: "Network -> Interfaces -> Devices -> Bridge VLAN Filtering")
- package: stock luci-mod-network
- dom: `.table tr.cbi-section-table-descr` — global selector, added for this page
- symptom: not recorded beyond "to improve layout" (efa07d8 body)
- cause: unknown (efa07d8 body states the action, not what the descr row did wrong)
- fix: components/_table.css `&.table-titles, &.cbi-section-table-titles, &.cbi-section-table-descr { max-md:hidden }` (line 58-62) — `.cbi-section-table-descr` was added to the existing list · efa07d8 2025-09-18 · 0.3.7_alpha (first tag v0.4.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## ifacebox-cells-full-row — interface status/box cells stack full-width on mobile
- page: admin-network-network · Network → Interfaces (menu.d title "Interfaces")
- package: stock luci-mod-network
- dom: `td[data-name="_ifacebox"]`, `td[data-name="_ifacestat"]` — DummyValue cells of the `interface` GridSection (interfaces.js:296-297 query `[data-name="_ifacestat"] > div` and `[data-name="_ifacebox"] .ifacebox-body`)
- symptom: not recorded (bec6830 subject: "feat: enhance ifacebox display in td")
- cause: unknown (commit bec6830 has subject only)
- fix: components/_table.css `[data-page="admin-network-network"] & { &[data-name="_ifacebox"], &[data-name="_ifacestat"] { … } }` nested under `& td, & .td` (line 214-219) `max-md:basis-[100%]` · bec6830 2025-09-23 · 0.4.3_alpha (first tag v0.5.0_beta). The same commit added the `td &` variants of `.ifacebox` that 397e46b later moved under `#cbi-network-interface` (next entry)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## interface-cards-floor — equal-width interface cards, horizontal card layout on mobile
- page: admin-network-network · Network → Interfaces
- package: stock luci-mod-network
- dom: `#cbi-network-interface` (id of `form.Map('network')` + GridSection `'interface'`, interfaces.js:497-501) — five descendant rules on `.ifacebox`, `.ifacebox-head`, `.ifacebox-body`, `.ifacebox-body > div`, `.ifacebox-body small`
- symptom: 397e46b body: "The interfaces overview sizes every card by its content, so a bridge carrying seven device icons rendered far wider than a single-port one."
- cause: 397e46b body: "The shared `.ifacebox` min-width had been masking half of that until 1.2.7 lowered it to stop the port status grid overlapping. The floor now sits on the overview's own section instead — `#cbi-network-interface`, the scope luci-theme-bootstrap already uses for this page — at 152px, and the five `td &` rules that only ever applied there move under it too rather than reaching every `.ifacebox` inside any table cell."
- fix: components/_card.css `#cbi-network-interface &` at line 72-74 (`.ifacebox`: `max-md:flex-row md:min-w-38`), 83-85 (`.ifacebox-head`: `max-md:flex max-md:w-auto max-md:shrink-0 max-md:items-center max-md:justify-center max-md:rounded-l-3xl max-md:rounded-tr-none max-md:border-r max-md:border-b-0`), 107-109 (`.ifacebox-body`: `max-md:flex-row max-md:items-center max-md:rounded-r-3xl max-md:rounded-bl-none max-md:py-2 max-md:pr-2 max-md:pl-4`), 144-146 (`.ifacebox-body > div`: `max-md:w-auto max-md:flex-1 max-md:space-y-1`), 163-165 (`.ifacebox-body small`: `max-md:mt-0`) · 397e46b 2026-08-30 · 1.2.10 (first tag v1.3.0). The mobile rules were first written as `td &` in bec6830 (2025-09-23)
- verified: no before/after measurement of the card widths recorded; the body states the size changes it made: "icons 24px -> 20px, the gap between adjacent ones 8px -> 4px, card padding 16px -> 12px and the label line spacing 16px -> 4px", floor 152px, `.ifacebox-body > img` / badge icons "intrinsic 32px" → 20px, zone swatch 33px tall → vertical padding 2px
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## bridge-vlan-dropdown-clip — let the Bridge VLAN table's dropdowns escape their wrapper on mobile
- page: admin-network-network · Network → Interfaces → Devices → Configure → Bridge VLAN filtering (path from efa07d8's wording of the same table; c5a2dff itself only says "bridge-vlan table")
- package: stock luci-mod-network
- dom: `[data-name="bridge-vlan"] > div` — the SectionValue option `bridge-vlan` wrapping the Bridge VLAN TableSection (tools/network.js:1354); patch rule under `@media (width < 48rem)`
- symptom: dropdown clipped on mobile (c5a2dff subject: "fix(mobile): resolve clipped dropdown in bridge-vlan table")
- cause: unknown (commit c5a2dff has subject only; the diff shows only the override, not which rule clipped the dropdown)
- fix: patches/admin-network-network.css line 8-12 `@media (width < 48rem) { [data-name="bridge-vlan"] > div { overflow: visible !important; } }` · c5a2dff 2025-10-19 · 0.6.1_beta (first tag v0.7.0_beta) — written as `@layer patches > admin-network-network { … @apply max-md:!overflow-visible }` in main.css, moved to `_patches.css` by aedf3dc, split into its own on-demand file by 1c924cf (#82, `@reference` + `@apply`), rewritten as native CSS by e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
See also: e86b70f lists "Network => (Interfaces, Wireless, Firewall)" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`.
@@ -0,0 +1,14 @@
# admin-network-wireless
## assoclist-full-row-cells — Associated Stations cells take a full row on mobile
- page: admin-network-wireless · Network → Wireless, "Associated Stations" table (wireless.js:2667 creates `#wifi_assoclist_table`, h3 at :2680). The same id is also created by luci-mod-status 60_wifi.js:243 on Status → Overview; 1dbd95a does not say which page it was written against
- package: stock luci-mod-network (and luci-mod-status for the overview copy)
- dom: `#wifi_assoclist_table td`, `#wifi_assoclist_table .td` (class `table assoclist`); rule also moves the `data-title` ::before label (`before:max-md:mb-1.5`)
- symptom: not recorded (1dbd95a subject: "Ensure each table cell in Associated Stations takes up a full row on mobile")
- cause: unknown (commit 1dbd95a has subject only)
- fix: components/_table.css `#wifi_assoclist_table &` nested under `& td, & .td` (line 156-158) `max-md:flex-[0_0_100%] max-md:px-3 max-md:py-2 before:max-md:mb-1.5` · 1dbd95a 2025-10-17 · 0.5.14_beta (first tag v0.6.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
See also: 50c2b84 lists "Network => Wireless host: Fix overflow when td text is too long" as one motivation for `.td.cbi-value-field { break-all }` — that lineage is recorded in `admin-services-dockerman.md` (the rule's current form comes from a2d110a). e86b70f lists "Network => (Interfaces, Wireless, Firewall)" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`.
@@ -0,0 +1,13 @@
# admin-services-dockerman
## info-table-entry-column — value cells break only words that do not fit
- page: admin-services-dockerman-overview · Services → Dockerman JS → Overview
- package: luci-app-dockerman (openwrt/luci, the JS rewrite)
- dom: `.cbi-section.cbi-tblsection > table.table.cbi-section-table > tr.cbi-section-table-row > td.td.cbi-value-field[data-name=entry|value]` — `docker info` rendered as two-column TableSections; the value column holds multi-KB JSON with no spaces (Runtimes, Plugins)
- symptom: the entry column collapsed to ~42px and showed one character per line (`MemoryLimit` over 7–8 lines) beside the JSON values
- cause: `.td.cbi-value-field` carried `word-break: break-all` at every width (added in 50c2b84 as an overflow guard). `break-all` drops every cell's min-content to about one character; auto table layout then hands spare width out by max-content, so the JSON took nearly all of it. bootstrap sets no word-break on desktop cells — its labels stay whole but the JSON stretches the table to ~3.5k px and the page scrolls sideways.
- fix: `components/_table.css` `& td, & .td { &.cbi-value-field { wrap-anywhere md:max-w-prose } }` — `wrap-anywhere` keeps break-all's min-content (same overflow guard) but only breaks a word that does not fit; `md:max-w-prose` caps each cell's intrinsic contribution so one huge value cannot starve the other columns (the used width may still exceed it: 944px measured) · a2d110a · 1.3.2. Lineage: 50c2b84 2025-10-12 (0.5.8_beta) added `&.cbi-value-field { break-all }` for "System => (Software, Mount Points): Fix overflow when td text is too long" and "Network => Wireless host: Fix overflow when td text is too long", replacing the global `td max-w-94` it removed for passwall (see `admin-services-passwall.md`); 031283c 2026-07-09 added the mobile-card utilities on the same rule (`max-md:mb-1 max-md:basis-full max-md:self-start max-md:text-left` and the `[data-widget=…] max-md:basis-1/2` sub-rule)
- verified: device, Chromium, Environment table before → after: entry column 42 → 260px at 1440, 42 → 196 at 1024, 41 → 152 at 800; short words split across lines 41 → 0 / 0 / 2; mobile cards unchanged; section height 8301 → 3797px at 1440; firewall zone and rule GridSections kept identical column widths at 1440/1024/800/390; a file:// fixture gave the same result in Chromium, WebKit and Firefox. Side effect noted, not measured: on admin-system-mounts the GridSection value cells take the 65ch cap instead of the page's `max-w-104` (same specificity, later rule).
- fixture: fixtures/admin-services-dockerman-overview--info-table.html (entry column `nowrap` at 1440/1024/390 — at 800 two short words still split, so that width is not asserted)
- issue: #96 · mirror: shadcn f4d7219 · status: active
@@ -0,0 +1,25 @@
# admin-services-mini-diskmanager
## partition-table-font — the table's own font-size reaches its cells
- page: admin-services-mini-diskmanager-minidiskmanager · Services → Disk Manager
- package: luci-app-mini-diskmanager (4IceG/luci-app-mini-diskmanager)
- dom: `table#partition-table-<dev> > tr.tr > td.td` — the `<table>` carries inline `table-layout:fixed; width:100%; font-size:12px`; first `th` 180px, the other seven `width:12.5%; min-width:90px`; the type cell is `<span class="partition-color-indicator"> NAME`
- symptom: type/filesystem and mount-point cells wrap — swatch on one line, name below; long names break mid-word (`0BOOTCONFIG` / `1`, `/mnt/mmcblk0` / `p6`)
- cause: `td, .td` set `text-base` (16px) directly, so the table's inline 12px never reached the cells; fixed columns sized for 12px got 16px text and `wrap-break-word` split what did not fit. bootstrap puts its font size on `.table` and lets cells inherit.
- fix: `components/_table.css` `table.table, .table` `text-base max-md:text-sm` (moved off `td, .td`, which now inherit) · dd7c64b · 1.3.3
- verified: device 192.168.8.1, CDP before/after in one page load — cells 16 → 12px; wrapped cells 5 → 1 at 1280 (the remaining `0BOOTCONFIG1` wraps under bootstrap at that width too), 5 → 0 at 1920, 0 → 0 at 390; eleven stock pages (overview, routes, processes, interfaces, firewall zones/rules, DHCP, wireless, mounts, startup, SSH keys) showed zero font-size/line-height changes on any text-bearing table element at 1280 and 390
- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html
- issue: #65 · mirror: shadcn 4f1c7f4 · status: active
## partition-table-gutter — the stable scrollbar gutter leaves the type cell 0.9px short at 1280
- page: admin-services-mini-diskmanager-minidiskmanager · Services → Disk Manager
- package: luci-app-mini-diskmanager (4IceG/luci-app-mini-diskmanager)
- dom: `table#partition-table-<dev>` (inline `table-layout:fixed; width:100%`); type cell `<span class="partition-color-indicator"> 0BOOTCONFIG`
- symptom: with classic scrollbars at a 1280px viewport the swatch and `0BOOTCONFIG` split onto two lines; overlay scrollbars are unaffected
- cause: `_elements.css` `html` `[scrollbar-gutter:stable]` (added so short pages and open modals stop shifting the layout) reserves the 11px thin scrollbar on every page, which is what a scrolling page already paid; the #65 fit had 0.6px to spare
- fix: none on the page; the fixture pins `html { scrollbar-gutter: auto }` because its 1153px width was measured without a scrollbar, so it keeps guarding #65's inherited 12px
- verified: headless Chrome, fixture at 1280 — table 1152.7 → 1142.1px, cell content 114.9 → 113.4px against a 114.3px line (fits → wraps); `pnpm test` compat fails without the pin and passes with it
- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html
- issue: — · mirror: shadcn 50c53a9 · status: active
@@ -0,0 +1,12 @@
# admin-services-openclash-config
## openclash-subinfo-icons — fix the subscription-info icon size
- page: admin-services-openclash-config · Services → OpenClash → Config Manage (controller openclash.lua:84 entry title "Config Manage")
- package: luci-app-openclash (vernesong; the ImmortalWrt feed copy was checked)
- dom: `.sub_div img` — `.sub_div` comes from `luasrc/view/openclash/sub_info_show.htm`, the template used by config.lua:191
- symptom: not recorded (87522d8 subject: "feat: add openclash patch and remove btn spacing")
- cause: unknown (commit 87522d8 has subject only)
- fix: patches/admin-services-openclash-config.css line 8-13 `[data-page="admin-services-openclash-config"] { .sub_div img { height: 1.25rem; width: 1.25rem; } }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha) — written as `@layer patches > luci-app-openclash { [data-page=…] .sub_div img { @apply h-5 w-5 } }` in main.css; `_patches.css` via aedf3dc; own file via 1c924cf; native via e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-services-openclash-log
## openclash-log-dark-bg — dark page background on the Server Logs page
- page: admin-services-openclash-log · Services → OpenClash → Server Logs (controller openclash.lua:85 entry title "Server Logs")
- package: luci-app-openclash (vernesong)
- dom: `[data-page="admin-services-openclash-log"]` (the body element itself)
- symptom: not recorded (87522d8 subject only)
- cause: unknown (commit 87522d8 has subject only)
- fix: (historic, main.css `@layer patches > luci-app-openclash`) `[data-page="admin-services-openclash-log"] { @apply dark:bg-aurora-night-teal; }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 30f922d (2025-11-09 "chore: remove patch styles and adjust DOM indentation" — body: "Removed temporary patch styles … Test case: openclash → Server Logs (dark mode background)"; 0.7.9_beta, first tag v0.8.0_beta)
@@ -0,0 +1,23 @@
# admin-services-openclash-settings
## openclash-settings-select-full — selects fill the value field on mobile
- page: admin-services-openclash-settings · Services → OpenClash → Plugin Settings (controller openclash.lua:71 entry title "Plugin Settings")
- package: luci-app-openclash (vernesong)
- dom: `.cbi-value-field .cbi-input-select` under `@media (width < 48rem)`
- symptom: not recorded (a667f02 subject: "feat: add openclash cbi-input-select style patch")
- cause: unknown (commit a667f02 has subject only; its diff is a whole-file rewrite of main.css, the rule as first written was `[data-page="admin-services-openclash-settings"] .cbi-value-field .cbi-input-select { @apply max-md:!w-full }`)
- fix: patches/admin-services-openclash-settings.css line 9-13 `@media (width < 48rem) { .cbi-value-field .cbi-input-select { width: 100% !important; } }` · a667f02 2025-09-26 · 0.4.6_alpha (first tag v0.5.0_beta) — `_patches.css` via aedf3dc; own file via 1c924cf; native via e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## openclash-diag-grid — stack the Debug Logs diagnostic controls in one column on mobile
- page: admin-services-openclash-settings · Services → OpenClash → Plugin Settings → Debug Logs tab (settings.lua:81 tab "debug", :1308 DummyValue rendering `debug.htm`)
- package: luci-app-openclash (vernesong)
- dom: `.diag-style` and `.diag-style > div` — from `luasrc/view/openclash/debug.htm` (which itself styles `.diag-style` for `[data-darkmode="true"]` at its line 12-17)
- symptom: not recorded (87522d8 subject only)
- cause: unknown (commit 87522d8 has subject only)
- fix: patches/admin-services-openclash-settings.css line 15-33 `.diag-style { & > div { display: flex; flex-direction: column; align-items: center; gap: 0.5rem } @media (width < 48rem) { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 1rem; & > div { width: 100% !important; gap: 0.25rem } } }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha) — first written unscoped under `@layer plugins > luci-app-openclash` (applied on every page); 7d9f6a1 merged `_plugins.css` into `_patches.css`; 1c924cf scoped it to this page (PR #82: "openclash `.diag-style` → settings"); native via e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,14 @@
# admin-services-passwall
## td-global-max-width — a global `max-w-94` on every table cell
- page: admin-services-passwall-node_list · Services → PassWall → Node List (controller passwall.lua:39 entry title "Node List") — the page that got the rule removed; the page it was added for is not recorded
- package: luci-app-passwall (xiaorouji; the ImmortalWrt feed copy was checked)
- dom: `.table td, .table .td` (global) — on Node List the `td.cbi-section-actions` cell holds the row's action buttons
- symptom: on removal — 50c2b84 subject: "Services => passwall => Node List cbi-section-actions: Fix overflow beyond td max width" (the action buttons ran past the capped cell). On addition — not recorded
- cause: addition: unknown (efa07d8's body describes two other changes — "Removed conflicting styling that prevented setting `overflow-auto`, which was necessary to maintain the sticky positioning of `th` elements" and hiding `.cbi-section-table-descr` on the Bridge VLAN page — and says nothing about `max-w-94`, which the diff adds to td alongside `max-md:max-w-full` and col caps). Removal: the fixed cap could not hold the Node List action cell (50c2b84 subject); d276fd3 later restates the lesson: "the fix avoids applying a global maximum width to table data cells (`td`) to prevent regressions in CBI section actions (e.g., in Services -> passwall -> Node List)"
- fix: (historic, main.css `& td, & .td`) `max-w-94` · efa07d8 2025-09-18 · 0.3.7_alpha (first tag v0.4.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 50c2b84 (2025-10-12 "fix: table content overflow", 0.5.8_beta, first tag v0.6.0_beta). The same commit added `.td.cbi-value-field { break-all }` as the replacement overflow guard for "System => (Software, Mount Points)" and "Network => Wireless host" — see `admin-services-dockerman.md`; per-page caps came back scoped: `[data-page="admin-system-mounts"]` (d276fd3, `admin-system-mounts.md`) and `[id*="status_leases"]` (80be54c, `admin-status-overview.md`)
See also: bbb355b 2025-10-26 "fix: resolve tooltip flicker and add footer spacing" — body: "added top margin to the footer to avoid overlap with main content in plugins that lack proper view containers (e.g., PassWall)"; a global `footer` rule (currently _layout.css line 644), not traced further here.
@@ -0,0 +1,23 @@
# admin-services-samba4
## samba4-cell-padding — tighter th/td padding inside the Network Shares form
- page: admin-services-samba4 · Services → Network Shares (title from luci-app-samba4 menu.d; 1dbd95a writes it as "NAS => Network Shares => Shared Directories")
- package: stock luci-app-samba4
- dom: `#cbi-samba4` (id of `form.Map('samba4')`, samba4.js:23) containing the `sambashare` TableSection "Shared Directories" (samba4.js:93); rule hits every `th/.th` and `td/.td` under it
- symptom: not recorded (1dbd95a subject: "Reduce cell padding in NAS => Network Shares => Shared Directories")
- cause: unknown (commit 1dbd95a has subject only; no symptom or measurement)
- fix: components/_table.css `#cbi-samba4 &` (nested under `& th, & .th`, line 114-116, and under `& td, & .td`, line 148-150) `px-0.5 py-1.5` · 1dbd95a 2025-10-17 · 0.5.14_beta (first tag v0.6.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## samba4-section-overflow — horizontal scroll on the samba4 card
- page: admin-services-samba4 · Services → Network Shares (menu path inferred from the page name)
- package: stock luci-app-samba4
- dom: `#cbi-samba4 .cbi-section`
- symptom: not recorded (2e1d18a subject: "table overflow issue on samba4, package, and autoreboot pages")
- cause: unknown (commit 2e1d18a has subject only). The diff shows the section went from `overflow-x-auto` to `overflow-visible` globally and got `#cbi-samba4 & { @apply overflow-x-auto; }` back for this page only, alongside dropping `table-fixed` from `.table`
- fix: (historic, main.css `.cbi-section`) `#cbi-samba4 & { @apply overflow-x-auto; }` · 2e1d18a 2025-08-31 · 0.1.3_alpha (first tag v0.2.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 6b9b328 (2025-09-03 "feat: make dropdown fill based on content, simplify table" — `.cbi-section` went back to global `overflow-x-auto`, so the per-page override was deleted with it)
@@ -0,0 +1,12 @@
# admin-statistics-graphs
## rrd-graphs-dark — invert and hue-rotate the rendered graph images in dark mode
- page: admin-statistics-graphs · Statistics → Graphs (menu.d title "Graphs"; edf378d: "Affected pages: Statistics => Graphs")
- package: stock luci-app-statistics
- dom: `[data-plugin] img` — the per-plugin tab containers built in graphs.js:75 (`'data-plugin': plugin`) holding the rrdtool PNGs; dark scope is `[data-darkmode="true"]` on `<html>`
- symptom: not recorded beyond "Adapt img background color for dark mode" (edf378d body)
- cause: edf378d body: "Adapt img background color for dark mode" — the graphs are server-rendered images whose light background does not follow the theme; nothing further recorded
- fix: patches/admin-statistics-graphs.css line 8-10 `[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img { filter: hue-rotate(150deg) invert(100%); }` · edf378d 2025-10-17 · 0.5.16_beta (first tag v0.6.0_beta) — written as `@layer luci-app-statistics { [data-page=…] [data-plugin] img { @apply dark:hue-rotate-150 dark:invert } }` in main.css; `_plugins.css` after aedf3dc, merged into `_patches.css` by 7d9f6a1, own file via 1c924cf, native via e14cfcd (the e14cfcd body cites this file: "the two-rule statistics patch alone went 1,609 B → 110 B")
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-status-logs
## log-viewer — parse the read-only `#syslog` textarea into a column-aligned, colour-coded view
- page: admin-status-logs (prefix; covers `admin-status-logs-syslog`, `admin-status-logs-dmesg` and the bare `/logs` alias) · Status → System Log / Kernel Log (menu.d: "admin/status/logs" "System Log" alias → "…/syslog" "System Log", "…/dmesg" "Kernel Log")
- package: stock luci-mod-status
- dom: `textarea#syslog` inside `#content_syslog`; the patch inserts `div.syslog-view` (rows `.log-row.lv-*` with `.log-time/.log-sev/.log-tag/.log-msg`) after the textarea and adds `.log-enhanced` to the container. Styling lives in components/_syslog.css in main.css, not in a patch
- symptom: none — this is an enhancement, not a breakage fix (a5b44da: "The first payload reworks the System/Kernel Log pages")
- cause: a5b44da body: "the read-only #syslog textarea stays in place as LuCI's polled data source while a dirty-checked viewer parses each line (classic logread and the bracketed RPC format, whitelist-gated) into aligned time/level/source columns with per-severity colors, whole lines scrolling sideways, and a flash-free takeover via an insertion observer. Unknown formats trip a parse gate and the stock textarea simply shows again. One admin-status-logs name covers both tabs on every supported release — 23.05, 24.10 and master all mount the pages under admin/status/logs/*." Why the CSS is not a patch: "The viewer's CSS is core-page styling, not third-party compat, so it lives in components/_syslog.css inside main.css where the full utility pipeline applies"
- fix: src/resource/patches/admin-status-logs.js (whole file; router lifecycle `window.luciPatches["admin-status-logs"] = { mount, unmount }` at line 292-295, `mount()` at line 296) · a5b44da 2026-07-27 · 1.1.7 (first tag v1.2.0). Follow-ups: ee16953 2026-07-29 (1.1.9) "contain select-all, follow the tail, leave dmesg unsplit" — Ctrl+A grabbed the whole page, tail-follow when pinned to the bottom, dmesg rendered verbatim because "kernel lines have no uniform tag grammar"; e681ecb 2026-07-29 (1.1.10) "render every log token verbatim, copies byte-identical" — timestamps/colons were being rewritten, now "joining the rendered tokens with single spaces reproduces the source line byte for byte, and a test pins that round-trip"; 664c795 2026-08-16 (1.2.1) and e748f7c 2026-08-26 (1.2.6) wired the mount/unmount lifecycle for the same-document router (subjects only read)
- verified: a5b44da: "Node tests pin the parser against each format family and the payload's home" (`.dev/tests/log-patch.test.js`); e681ecb: round-trip test. No rendering measurement recorded
- fixture: none (backfill; DOM snapshot exists in `.dev/mocks/admin-status-logs-syslog.html`)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,34 @@
# admin-status-overview
## leases-td-max-width — cap DHCP lease cells so DUID/IAID columns cannot widen the table
- page: admin-status-overview · Status → Overview, "Active DHCP Leases" / "Active DHCPv6 Leases" tables (luci-mod-status 40_dhcp.js:110 `#status_leases`, :169 `#status_leases6`; `[id*=]` matches both). The commit subject says "DHCP > Leases"; the ids exist only in this status include
- package: stock luci-mod-status
- dom: `.table[id*="status_leases"] .td` (class `table leases` / `table leases6`, div-based `.td` cells)
- symptom: table overflow from the DUID/IAID columns (80be54c subject)
- cause: unknown (commit 80be54c has subject only; no DOM or widths recorded)
- fix: components/_table.css `&[id*="status_leases"] { & .td { … } }` (line 137-141) `max-w-62 max-md:max-w-full` · 80be54c 2025-12-12 · 0.9.1_beta (first tag v0.10.0)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## section-actions-mobile — action-button cell spans the mobile card, buttons share the row
- page: admin-status-overview · Status → Overview → Active DHCP Leases; also System → Mount Points and Network → Interfaces / Wireless / Firewall (all five listed in e86b70f)
- package: stock luci-mod-status / luci-mod-system / luci-mod-network (per the pages listed)
- dom: `.table td.cbi-section-actions` (`> div` wrapper, `> div > .cbi-button`) and `.table td > .cbi-button`
- symptom: not recorded (e86b70f subject: "cbi-button display on mobile for cell view")
- cause: unknown (commit e86b70f has subject only)
- fix: components/_table.css `&.cbi-section-actions` nested under `& td, & .td` (line 190-200) `max-md:flex-[0_0_100%]`, `& > div` `max-md:w-full`, `& > div > .cbi-button` `max-md:flex-1`; plus `& > .cbi-button` (line 202-204) `max-md:w-full` · e86b70f 2025-10-17 · 0.5.15_beta (first tag v0.6.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## section-actions-mobile-v0 — original scaffold rule for the actions cell on mobile
- page: unknown (076281f is the initial scaffold commit; no page recorded)
- package: unknown
- dom: `.table td.cbi-section-actions` (`& div`, `& .btn, & .cbi-button`) and a sibling `.td.cbi-section-actions { p-2 max-md:p-1 … }` block in the button layer
- symptom: not recorded
- cause: unknown (076281f "chore: setup luci-theme-aurora with Vite and TailwindCSS" has subject only)
- fix: (historic, main.css) `&.cbi-section-actions { @apply max-md:mt-3 max-md:border-t max-md:pt-3; & div { max-md:flex max-md:flex-wrap max-md:justify-start max-md:gap-2 } & .btn, & .cbi-button { max-md:min-w-0 max-md:flex-1 … } }` · 076281f 2025-08-25 · 0.1.0 (first tag v0.2.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 386dec5 (2025-08-30 "feat: optimize mobile display for table" rewrote the mobile table block and dropped it; the `.td.cbi-section-actions` button-layer block was dropped by 4c99ac8 2025-09-05 "chore: adjust button nesting style structure", which replaced it with the generic `.cbi-section-actions > div { flex flex-row … }`). e86b70f re-added a different rule seven weeks later — see the entry above
@@ -0,0 +1,12 @@
# admin-system-diskman
## diskman-partition-bar — keep the injected partition bar inside the card on small screens, clip segment labels
- page: admin-system-diskman (prefix; also `-partition-<dev>`, `-btrfs-<uuid>`) · System → Disk Man (controller diskman.lua:24 entry title "Disk Man", alias to `disks`; issue #58 calls it the storage area on the home page)
- package: luci-app-diskman (lisaac; the ImmortalWrt feed copy was checked)
- dom: `td[colspan]:has(> div[title])` and `td[colspan] > div[title]`, `.tr > div[style*="nowrap"] > div[title]` — disk_info.htm:34/88 emit `<tr width="100%" style="white-space:nowrap;"><td style="… white-space:nowrap;" colspan="15">` and :36 `<div class="tr cbi-section-table-row …"><div style="white-space:nowrap; position:absolute; width:100%">`, with one inline-block `div[title]` per partition sized in percent
- symptom: issue #58: "首页的存储区域会超出宽度,导致页面可以左右滑动" (the storage area exceeds the width; page scrolls sideways), Safari 26 / iOS 26, OpenWrt 24.10.5, theme 0.11; 49fff90 body: "drags the page ~300px wide"
- cause: 49fff90 body: "luci-app-diskman injects the partition bar as a full-span row whose cell carries an inline white-space:nowrap. Once .tr becomes a flex row on mobile, that cell's automatic minimum size is its min-content — the whole unwrapped bar — so it cannot shrink and drags the page ~300px wide (reported in #58 with the page DOM). … clip each segment so its label stops spilling onto the neighbouring one (visible on desktop too)". The file adds: "The segments are inline-blocks sized in percent: any whitespace between them adds a word space on top of the 100% they already sum to" (hence `font-size: 0` on the cell)
- fix: patches/admin-system-diskman.css line 9-35 `[data-page^="admin-system-diskman"] { td[colspan]:has(> div[title]) { flex: 0 0 100%; min-width: 0; overflow: hidden; border-radius: var(--radius-base); font-size: 0 } td[colspan] > div[title], .tr > div[style*="nowrap"] > div[title] { overflow: hidden; font-size: 0.75rem; line-height: 1.5rem; text-overflow: ellipsis } }` · 49fff90 2026-08-02 · 1.1.13 (first tag v1.2.0)
- verified: not measured (the body states the before width, "~300px wide", from the reporter's DOM; no after-number recorded)
- fixture: none (backfill; DOM snapshot exists in `.dev/mocks/admin-system-diskman.html`)
- issue: #58 · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-system-filemanager
## filemanager-container-scroll — scrollable file-manager container, flat status bar
- page: admin-system-filemanager · System → File Manager (menu.d title "File Manager")
- package: luci-app-filemanager (in openwrt/luci applications)
- dom: `#file-manager-container` (the app's own `<style>` block targets it, filemanager.js:269-272) and its `#status-bar`
- symptom: not recorded (347d645 subject: "feat: optimize mobile h3 display and add layer plugins")
- cause: unknown (commit 347d645 has subject only)
- fix: patches/admin-system-filemanager.css line 8-17 `[data-page="admin-system-filemanager"] { #file-manager-container { overflow: auto; #status-bar { background: var(--surface); border: 0; } } }` · 347d645 2025-09-12 · 0.2.1_alpha (first tag v0.3.0-alpha) — first written unscoped as `@layer plugins > luci-app-filemanager { #file-manager-container { @apply overflow-auto; #status-bar { @apply border-0 dark:bg-slate-900 } } }` (applied on every page); `_plugins.css` after aedf3dc, merged into `_patches.css` by 7d9f6a1; 1c924cf scoped it to the page (PR #82: "Backfilled missing data-page scoping … filemanager"); native via e14cfcd (the dark-only `bg-slate-900` became an always-on `var(--surface)`)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,14 @@
# admin-system-mounts
## mounts-td-max-width — cap cells on Mount Points so a long mount path cannot widen the table
- page: admin-system-mounts · System → Mount Points → "Mounted file systems" (menu.d title "Mount Points"; d276fd3: "Tests: System -> Mount Points -> Mounted file systems")
- package: stock luci-mod-system (page depends on block-mount)
- dom: every `td/.td` under `.table` on the page. The "Mounted file systems" table is a plain `E('table', { class: 'table' })` (mounts.js:168) with no id, so the page scope is the only handle; the rule also reaches the `mount`/`swap` GridSections
- symptom: a long docker directory path breaks the Mount Points layout (issue #24: "docker目录比较长,mounts下显示问题", Chrome 142, OpenWrt 24.10, screenshot only)
- cause: d276fd3 body: "the fix avoids applying a global maximum width to table data cells (`td`) to prevent regressions in CBI section actions (e.g., in Services -> passwall -> Node List)" — i.e. the cap is page-scoped because the earlier global `max-w-94` on td was removed in 50c2b84 for passwall (see `admin-services-passwall.md`). Why the path overflowed rather than wrapped is not recorded
- fix: components/_table.css `[data-page="admin-system-mounts"] &` nested under `& td, & .td` (line 152-154) `max-w-104 max-md:max-w-full` · d276fd3 2025-11-28 · 0.8.6_beta (first tag v0.9.0_beta)
- verified: d276fd3 says "Tests: System -> Mount Points -> Mounted file systems"; no numbers. a2d110a (2026-09-13) notes: "On admin/system/mounts the GridSection value cells now take the 65ch cap instead of the page's 26rem one (same specificity, later rule). That only shifts width distribution and was not measured: the test device has no block-mount, so the page is absent."
- fixture: none (backfill)
- issue: #24 · mirror: pending · status: active
See also: 50c2b84 lists "System => (Software, Mount Points): Fix overflow when td text is too long" for `.td.cbi-value-field { break-all }` — recorded in `admin-services-dockerman.md`. e86b70f lists "System => Mount Points" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`.
@@ -0,0 +1,17 @@
# admin-system-package-manager
## progress-title-10px — shrink the progress bar's title text below the sm breakpoint
- page: admin-system-package-manager · System → Software (menu.d title "Software")
- package: stock luci-app-package-manager
- dom: `.cbi-progressbar[title]` (package-manager.js:1253 `E('div', { class: 'cbi-progressbar', title })`); the theme prints the title through `before:content-[attr(title)]` with `before:whitespace-nowrap` (_progress.css line 2)
- symptom: progress text clipping on the package page (cdcc680 subject)
- cause: unknown (commit cdcc680 has subject only). What the diff shows: the title is a nowrap ::before centred in the bar; the fix only lowers its font size under the sm breakpoint. 386dec5 (2025-08-30) had earlier given every progressbar `max-md:before:text-[10px]`; when that left is not traced here
- fix: components/_progress.css `[data-page="admin-system-package-manager"] &` nested under `.cbi-progressbar` (line 3-5) `max-sm:before:text-[10px]` · cdcc680 2025-12-05 · 0.8.10_beta (first tag v0.9.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
See also (global rules motivated by this page, not page-scoped):
- 2e1d18a 2025-08-31 "fix: table overflow issue on samba4, package, and autoreboot pages" — dropped `table-fixed` from `.table` globally (see `admin-services-samba4.md`).
- 50c2b84 2025-10-12 "System => (Software, Mount Points): Fix overflow when td text is too long" — `.td.cbi-value-field { break-all }`, now `wrap-anywhere md:max-w-prose` (see `admin-services-dockerman.md`).
- edf378d 2025-10-17 "Adjust .controls button layout — Affected pages: Statistics => Graphs, System => Software (filter form and paginator)" — `.controls { m-2 flex flex-wrap items-center gap-3 … }` in main.css at the time; current location not traced here.
@@ -0,0 +1,64 @@
# <Title: one line, the change, not the complaint>
**Status:** draft · **Decision:** pending | submitted <PR link> | interim in Aurora | dropped <why> · **Target:** openwrt/luci `<branch>` | uhttpd | rpcd | eamonxg feed · **Date:** <YYYY-MM-DD>
## Problem
Two or three sentences: what happens today, on which request, with the
measured cost (from `dispatch-cost.md` or a named bench run). One sentence on
who pays (every navigation / every session / every install).
## Evidence
| # | fact | where |
|---|---|---|
| 1 | … | `openwrt/luci@<branch> modules/luci-base/ucode/http.uc:499` |
| 2 | … | `uhttpd file.c:344` |
Unknowns (not yet verified) listed here, each with the command that settles it.
## Today
```mermaid
sequenceDiagram
participant B as Browser
participant U as uhttpd
participant D as CGI dispatcher (ucode)
B->>U: GET …
U->>D: fork + exec (2 ms) + import (37 ms)
D-->>B: 200, Cache-Control: no-cache
```
Label edges with cost (ms, bytes, RTT). One diagram per mechanism.
## Proposal
```mermaid
flowchart LR
A[before] -->|change| B[after]
```
Change sketch: exact file and function, approximate size in lines, the new
headers/config/API. Behaviour on 23.05 / 24.10 / 25.12 / master. What stays
byte-identical for users who do not opt in.
## Alternatives considered
Each in one line with why it loses (measured or structural).
## Measurement plan
Before/after commands (`bench-dispatch.sh`, `bench.mjs`, `bench-fullload.mjs`),
the numbers expected to move, the device class. "not measured yet" is a
valid entry; an invented number is not.
## Interim in Aurora
What this theme (or the eamonxg feed) can do without upstream, and what it
cannot; link the `/luci-change` plan if one exists.
## Submission
Repository and branch, commit granularity (one logical change), cover-letter
contents: problem, measurement before/after with device, compatibility
statement, test performed.
+395
View File
@@ -0,0 +1,395 @@
<h4 align="right"><strong>English</strong> | <a href="DEVELOPMENT_zh.md">简体中文</a></h4>
# Development Guide
This guide covers the complete development workflow for the Aurora theme, from environment setup to building production packages.
## Prerequisites
- **[Node.js 20.19+ / 22.12+](https://nodejs.org/en/download)** - JavaScript runtime (mirrors Vite 7's support range — odd releases like 21.x don't qualify; enforced at `pnpm install` time via `engines` + `engine-strict`, so an unsupported Node fails fast with a clear message)
- **pnpm** - Package manager (managed via [Corepack](https://github.com/nodejs/corepack))
- **Tailwind CSS knowledge** - Required for styling. See [Tailwind CSS Documentation](https://tailwindcss.com/docs)
- **Network access** - Development machine must be on the same network as your OpenWrt router
## Environment Setup
### 1. Clone and Install
```bash
# Clone the repository
git clone git@github.com:eamonxg/luci-theme-aurora.git
cd luci-theme-aurora/.dev/
# Enable Corepack to manage pnpm version
corepack enable && corepack prepare
# Install dependencies
pnpm install
```
### 2. Configure Environment
```bash
# One-shot wizard: asks for every .env value (router IP, dev-server host/port,
# current .env entries as defaults), generates/installs an SSH key (one
# router-password prompt), verifies template sync end-to-end, and writes .env
pnpm setup:router
# Non-interactive: pass the IP directly (no prompts; dev-server values keep
# their saved .env entries or defaults)
pnpm setup:router 192.168.2.1
```
> The script is named `setup:router` (not `setup`) because `pnpm setup` resolves to pnpm's own built-in setup command and would never run a package script.
**What it does** (`scripts/setup.js`), in order:
1. **Collects the `.env` values.** With no argument it prompts for each variable in turn, showing the current `.env` entry as the default (an empty answer keeps it). With an IP argument it takes that as `VITE_OPENWRT_HOST` non-interactively and leaves the dev-server values alone. Nothing is written yet — `.env` is only updated once every step below has succeeded.
2. **Pre-flights the connection.** A raw TCP probe of `<host>:22` with a 2s timeout, so an unreachable device or one with SSH disabled fails immediately with a clear message instead of hanging inside ssh.
3. **Finds or generates an SSH key.** It looks for `~/.ssh/id_ed25519.pub`, `id_rsa.pub`, `id_ecdsa.pub` in that order and reuses the first one present — an existing key is never overwritten. **If you have no key at all, it generates one** (`ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519`). The empty passphrase is deliberate: `.ut` sync has to run unattended for the whole `pnpm dev` session.
4. **Installs the public key on the router.** It first tests whether passwordless SSH already works (`ssh -o BatchMode=yes … echo ok`) and skips the install if so — after a full reflash that wipes the key, the test fails and the install simply runs again. Otherwise it appends the key to `/etc/dropbear/authorized_keys` over one interactive SSH session; the append is guarded by `grep -qxF`, so re-running never duplicates the entry. **This is the only step that asks for the router's root password, and it asks once.** Afterwards it re-tests passwordless auth and classifies any failure (wrong password / host unreachable / other). On a device running openssh rather than dropbear the key belongs in `/root/.ssh/authorized_keys` instead — install it manually there and this step detects it and skips.
5. **Verifies the sync end-to-end.** Rather than trusting `echo ok`, it pushes `ucode/template/themes/aurora/` through the exact `tar -cf - | ssh … tar -xf -` pipeline the `ut-sync` plugin uses (see [Template (`.ut`) Live Sync](#template-ut-live-sync)). If this passes, `pnpm dev`'s template sync will too.
6. **Writes `.env`.** Managed keys are rewritten in place so surrounding comments keep their meaning, missing ones are appended, and any other line passes through untouched.
> The password prompt in step 4 comes from the **local** ssh client, which reads it straight from `/dev/tty` rather than stdin — so it shows up normally even though the script pipes ssh's stderr in order to classify errors. It does mean the step needs a controlling terminal **on the development machine** (the router side is unaffected): run it from a normal shell, not from CI, `nohup`, or an editor task pane, where ssh has no way to ask and the run fails at authentication. Check with `tty` if unsure; in such an environment, install the key by hand instead.
If the router is at the default `192.168.1.1` and passwordless SSH already works, no `.env` is needed at all — every value below has a working default.
**Environment Variables** (all optional):
- `VITE_OPENWRT_HOST` - bare router address, e.g. `192.168.1.1` (default; `host:port` and full-URL forms also accepted). The web proxy target and the `.ut`-sync SSH target (`root@<hostname>`) both derive from it; anything fancier (a dedicated key, a jump host, a non-standard ssh port) belongs in a `Host` block in `~/.ssh/config`, which ssh picks up automatically.
- `VITE_DEV_HOST` - Development server host (code default: `127.0.0.1`, `.env.example` sets `0.0.0.0` for LAN access)
- `VITE_DEV_PORT` - Development server port (default: `5173`)
## Development Workflow
### Start Development Server
```bash
cd luci-theme-aurora/.dev/
pnpm dev
```
The development server will start at `http://127.0.0.1:5173` and proxy requests to your OpenWrt device.
**How Vite Proxy Works:**
The Vite development server uses middleware to rewrite local requests to serve CSS/JS resources from your development environment instead of the router. This enables live editing without deploying to the router. For detailed implementation, see `vite.config.ts`.
**Key proxy behaviors:**
1. Proxies `/cgi-bin` and `/luci-static` requests to OpenWrt device
2. Uses middleware (`createLocalServePlugin`) to rewrite request paths for CSS and JS files
3. CSS requests to `/luci-static/aurora/main.css` and `/luci-static/aurora/login.css` are rewritten to serve from `.dev/src/media/main.css` and `.dev/src/media/login.css` respectively
4. JS file requests are served directly from `.dev/src/resource/` with middleware reading and returning file content
5. Injects Vite HMR client into proxied HTML responses for live reload support
6. Redirects `/` to `/cgi-bin/luci` for proper routing
### Code Style and Formatting
This project uses **Prettier** for code formatting with automatic formatting on save.
**Prettier Configuration:**
- Located in `.prettierrc`
- VS Code settings in `.vscode/settings.json` enable format-on-save for CSS and JS files
- Uses `prettier-plugin-tailwindcss` to sort Tailwind CSS classes
### CSS Nesting Support
Thanks to **lightningcss**, you can freely use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) in your stylesheets. The build process automatically compiles nested CSS into flat, browser-compatible format.
This will be compiled to standard CSS that works in all browsers.
### CSS Architecture
The theme has two independent Tailwind CSS v4 entry points, both sourced from `.dev/src/media/`:
- **`main.css`** — the LuCI admin UI. It is an import manifest that disables Tailwind's automatic source scan (`source(none)`) and pulls in (in order) `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` (OKLCH theme tokens, mapped via `@theme inline`), shared `_icons.css`, `_base.css`, `_elements.css`, `_layout.css`, every file in `components/` (one partial per UI component — buttons, cards, modals, tables, etc.), and `_utilities.css`.
- **`login.css`** — the standalone login page (`sysauth.ut`). Self-contained: imports Tailwind theme/utilities with `source(none)`, omits full Preflight in favor of a tiny local reset, and imports `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` directly. At build time the `login-css-prune` plugin (`vite.config.ts`) strips every custom property the page's var() chains never reach, so the admin-sized token sheet ships login-sized.
Third-party compatibility patches are **not** bundled into `main.css` — they are split into per-page files under `media/patches/` and loaded on demand (see [On-Demand Third-Party Patches](#on-demand-third-party-patches) below).
**Adding new styles:**
- New UI component → create `components/_<name>.css` and add an `@import` line to `main.css`. Each file is its own organizational unit — don't add `@layer` wrappers: theme partials stay unlayered, so they outrank Tailwind's layered base/utilities regardless of specificity.
- Compatibility fix for a third-party LuCI app/page → add a new file under `media/patches/` (see below).
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties. The one deliberate exception is `media/patches/*.css`, which is plain native CSS by design (see below).
### On-Demand Third-Party Patches
> Page-scoped JS patches must expose `window.luciPatches[stem] = { mount, unmount }` (and mount themselves once at eval) so the client-side router can drive them across same-document navigations — see `router.md`.
Some third-party LuCI apps ship markup that doesn't adapt to the theme and needs a narrow compatibility override. Instead of bundling every such patch into `main.css` (which would ship them to **every** page), each patch is a standalone CSS file loaded **only on the page it targets**.
**How it works:**
1. **One file per page, named by `data-page`.** Each patch lives at `media/patches/<page>.css`, where `<page>` is the value of `<body data-page="…">` for the target page — i.e. the request path segments joined by `-` (e.g. `admin-services-openclash-config`). `header.ut` computes the same string at render time from `ctx.request_path`, falling back to `ctx.path` when `request_path` is empty (`join('-', length(ctx.request_path) ? ctx.request_path : ctx.path)`) so default landings reached without an explicit path still resolve their patch.
2. **Build splits, not bundles.** `vite.config.ts` adds every `media/patches/*.css` as its own Rollup entry, so each compiles to `htdocs/luci-static/aurora/patches/<page>.css`. They are no longer part of `main.css`.
3. **Plain CSS, not `@apply`.** Patches are the one place that writes native declarations instead of Tailwind utilities. Each patch is its own build entry, and the old `@reference "../main.css";` + `@apply` setup made every file carry its own `@property`/helper boilerplate. Rewriting the *same rules* natively — measured on the v1.1.7 → v1.1.8 conversion, built output, identical selectors and values:
| patch | `@reference` + `@apply` | native CSS | saved |
| --- | ---: | ---: | ---: |
| 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% |
| **total** | **19,755 B** | **8,208 B** | **−58%** |
The overhead is per-entry and fixed-ish (`@property` registrations, `--tw-*` helper chains), so the smaller the patch, the worse the ratio — the two-rule statistics patch paid 15× its own weight. uhttpd serves identity bytes (no gzip), so raw size is wire size. Note the runtime `:root` exposes the raw color tokens plus `--radius-base` and `--app-shadow-*` — the `--radius-3xl`-style names only exist inside the Tailwind build, so a patch writes `calc(var(--radius-base) * 3)` for radii, never `var(--radius-3xl)`. The old Tailwind mode is still *supported* for theme-repo patches — a file starting with `@reference "../main.css";` and using `@apply` continues to compile — and it has real upsides worth weighing against the table above: **build-time validation** (a typo'd utility or color name fails the build, while a typo'd `var()` fails silently at runtime — exactly how the `--radius-3xl` regression slipped into the native rewrite), the **same vocabulary** as the component partials with the `dark:`/`md:`/`hover:` variant shorthands, and **automatic tracking** when the theme remaps a utility (a radius-chain or shadow-token change recompiles into Tailwind patches for free; native patches need a hand edit). Native is the default convention for the size numbers, not a hard gate. App-shipped patches (installed straight into the device's `patches/` directory) have never had a choice: they bypass the build entirely, so `@apply` would reach the browser as dead text — plain CSS only, as always. It also means third-party patch authors need no Tailwind knowledge. Reach theme values through the `:root` custom properties (`var(--surface-sunken)`, `var(--hairline)`, `var(--radius-3xl)`, `var(--shadow-lg)`, …) rather than hardcoding colors or radii; the dark variant is a plain selector (`[data-darkmode="true"] & { … }`), breakpoints are plain media queries (`@media (width < 48rem)`), and CSS Nesting still works — the build (lightningcss) minifies and lowers it for the supported browsers.
4. **`header.ut` discovers patches at render time.** On each (non-login) page render, `header.ut` lists `/www/luci-static/aurora/patches/` with ucode's `fs.lsdir()` (a readdir of a dozen entries — microseconds, dwarfed by the template's existing `ubus` call) and matches the installed `*.css` filenames against the **cumulative path-segment prefixes** of the request: a patch matches its exact page and any subpage, but only on real segment boundaries — `admin-services-wol.css` covers `admin/services/wol/plus`, yet never a sibling app whose own segment merely starts the same way (`admin/services/wol-plus`). Every matching patch is linked right after `main.css`, in lexical order — so a general patch loads before a more specific one and the specific one cascades on top. Pages with no match get nothing — no extra request, no 404. If the directory is missing or unreadable, the list is empty and the page renders unpatched.
5. **The patches directory is a drop-in extension point.** Because discovery is at render time, patches don't have to ship with the theme: **any package may install a `<page-prefix>.css` into `/www/luci-static/aurora/patches/`** and the theme will load it on matching pages. Install/uninstall lifecycle is automatic — the file appears and disappears with the package, no registration or allow-list rebuild. (Patches shipped this way are plain CSS served as-is; the theme's own patches are written the same way and only pass through the build for minification.)
6. **Dynamically generated pages are covered by their fixed prefix.** Some apps mint a page per entity — e.g. QModem's SMS conversations render as `admin-modem-qmodem-sms-conversation-<contact>`. Name the patch after the fixed prefix (`admin-modem-qmodem-sms-conversation.css`) and the prefix match loads it for every conversation page, regardless of the contact name. No wildcard syntax is needed (and `*` in a filename is not supported).
**Adding a patch:**
1. Open the target page in the browser and read `document.body.dataset.page` — that exact string is your filename (for a family of dynamic per-entity pages, use their fixed prefix instead — see point 5 above).
2. Create `media/patches/<that-string>.css`:
```css
/* PATCH: <page> (luci-app-foo) */
[data-page="<page>"] {
/* narrow, selector-scoped overrides — native CSS + CSS Nesting,
theme values via var(--surface), var(--hairline), … */
}
```
3. Run `pnpm build`. There is no allow-list to regenerate — the loader discovers whatever `.css` files are installed under `patches/` at render time.
4. Verify `htdocs/luci-static/aurora/patches/<page>.css` is small (just your rules).
> Removing a patch is symmetric: delete the file and rebuild — the loader stops linking it because it no longer exists.
**Shipping a patch with a third-party app** (no theme release needed): build or hand-write a plain CSS file named after your page's `data-page` prefix and install it from your package's 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
```
The theme loads it automatically on `admin-services-foo` and all its subpages whenever both packages are installed. Note app-shipped patches bypass the theme's Tailwind build — write plain CSS (you can still target the theme's CSS custom properties, e.g. `var(--surface)`), and scope every rule under your own `[data-page^="…"]` selector.
**Naming.** The filename is the page's `data-page` string; matching by prefix means broader targets also just work:
| You want to patch… | File to create | Then it loads on |
| --- | --- | --- |
| One specific page, `admin/services/foo/general` | `admin-services-foo-general.css` | that page (and any subpage under it) |
| A whole app, all pages under `admin/services/foo/…` | `admin-services-foo.css` | `foo`, `foo/general`, `foo/rules`, … |
| Dynamic per-entity pages, e.g. QModem SMS `…/sms/conversation/<contact>` | `admin-modem-qmodem-sms-conversation.css` (the fixed prefix — no wildcard needed) | every conversation page, whatever the contact |
Two rules of thumb that follow from prefix matching:
- **A patch applies to its page and all subpages by default.** `admin-services-foo.css` loads on every page under `admin/services/foo/…`. When you need finer targeting, narrow it in either of two ways: scope individual rules inside the file (`[data-page="admin-services-foo-general"] { … }` only affects that one page), or ship an additional, longer-named file (`admin-services-foo-rules.css`) for page-specific rules — on a page matching both, **both load**, shorter name first, so the more specific file wins the cascade.
- **Matching respects path-segment boundaries**, so a prefix never leaks onto a lookalike sibling: `admin-services-wol.css` covers `admin/services/wol/plus` but not a different app at `admin/services/wol-plus`. The one unavoidable collision is two paths joining to the same `data-page` string (`wol/plus` vs `wol-plus`) — such a patch loads on both pages. If that matters, key rules to your app's own class names/ids so an accidental load matches nothing.
> Unlike the `_`-prefixed partials (which are `@import`-only fragments), patch filenames have no `_` prefix — each is a real build entry that ships to `htdocs/`. This also holds inside `media/patches/` itself: a `_`-prefixed file there is a shared fragment for other patches to `@import`, skipped by the entry scan and never shipped.
**JS payloads.** The mechanism is not CSS-specific: the same `lsdir()` sweep also collects `patches/<page>.js` files, emitted as `<script defer src>` right after the patch stylesheet links — same per-page prefix matching, same drop-in lifecycle for third-party packages (ship a plain script; it must not assume LuCI modules are loadable via `L.require` at parse time — run after DOM ready or poll for your target element). Theme-owned JS patches live at `src/resource/patches/<page>.js` and build through the same Terser pass as other resource JS, but land under `aurora/patches/` so one directory listing serves both payload types. First user: the log-viewer enhancement on `admin-status-logs` (parses the read-only `#syslog` textarea into a colored, column-aligned view, with a parse-success gate that falls back to the stock textarea on unknown log formats; the one prefix covers the System Log tab, the Kernel Log tab and the bare `/logs` alias on every supported release). Its CSS is deliberately **not** a patch: the log pages are stock LuCI, so their styling lives in `components/_syslog.css` inside `main.css`, where the full Tailwind token pipeline (reactive radii, shadows, config-app overrides) applies — patches/ stays reserved for third-party compatibility.
**Aliases — one payload, several pages.** When two unrelated page names need the same payload, naming the file after their shared prefix would over-match (an `admin-status` patch would load on *every* status subpage, including the busiest overview page). For that case `PATCH_ALIASES` in `vite.config.ts` duplicates the built output (CSS and JS) under each alias name, and the dev server resolves alias requests to the shared source. Duplicate CSS *entries* would not work as an alternative: Rollup deduplicates identical-content assets into a single file, so one of the two names would silently never ship. (The map is currently empty — the log viewer turned out to need only `admin-status-logs`, since every supported release mounts both log pages under `admin/status/logs/*`.)
### Mock Pages
Style a third-party app's page — write or adjust its `patches/*.css`, or check a `main.css`/component change against it — **without having the app (or a device) installed**. Save the page's rendered HTML once, then develop against it with the theme live and hot-reloading.
- **Where snapshots live:** `.dev/mocks/*.html` (git-ignored — snapshots are large, device/fork-specific and go stale, so they stay local). The directory need not exist in a fresh clone; it's created on first capture.
- **The mock bar:** every HTML page this dev server hands out — proxied device pages and served snapshots alike — gets `scripts/mock-bar.client.js` (served at `/mocks/__bar.js`), a floating bar in the bottom-left corner. It lives in a Shadow DOM, so theme and patch CSS can neither restyle it nor be polluted by it, and the theme's own floating toolbar keeps the bottom-right corner. On a device page it lists what `.dev/mocks/` holds, so the workflow is reachable without typing the `/mocks/` URL: `◆` appears when this page's `data-page` matches a snapshot and opens it in one click, `⊕` captures the open page, and an empty `.dev/mocks/` shrinks the bar to a lone `⊕`. Inside a snapshot it names the open one, steps through the rest, and `↩` goes back to the same page on the device. `✕` collapses it to a dot, remembered in `localStorage['aurora.mockbar.collapsed']`. The snapshot list is injected inline next to the script; both tags carry `data-aurora-mock`, which is how a capture strips them back out — a snapshot must never bake in a list that is re-injected, current, on every serve.
- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and hit `⊕` on the mock bar — or press <kbd>Alt/Option+Shift+S</kbd>, or call `__auroraMockCapture()` in the console. It POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — named after the page's `data-page`, doctype included, dev-only script tags stripped. (The endpoint only accepts requests carrying the helper's custom header, which cross-origin pages can't send without a CORS preflight this server never approves.) Manual capture still works: run `copy(document.documentElement.outerHTML)` in the DevTools console and save the paste as `.dev/mocks/<name>.html` — the filename is free; the page's real identity is the `data-page` attribute already in its `<body>`, which patch selectors match.
- **Capture it from a device running _this_ theme — snapshots are not portable between themes.** A snapshot is a verbatim copy of a rendered page, so it hard-codes the theme that rendered it in three places: the stylesheet links (`/luci-static/aurora/main.css` plus that page's patch), the device's stored UCI token overrides in an inline `<style>`, and the theme's own header/nav markup. Drop a snapshot captured under `luci-theme-shadcn` in here (or vice versa) and the page renders **completely unstyled**, because a dev server only serves its own `/luci-static/<theme>/` prefix. The giveaway is a terminal line naming the other theme's stylesheet:
```
[Mocks] miss /luci-static/shadcn/main.css → 404 (mirror it at .dev/mocks/static/… to serve it)
```
Mirroring, which that generic hint suggests, is the wrong fix here — re-capture the page from a device running this theme. If you must reuse a foreign snapshot anyway, only the app's own content region means anything: point its stylesheet links at this theme, and delete the inline `<style>` block, or the captured device's colors override this checkout's tokens.
- **View:** `pnpm dev`, then open <http://localhost:5173/mocks/> — an auto-generated index lists every snapshot with its `data-page` and age. The `mock-pages-plugin` (in `vite.config.ts`) serves each page with the Vite HMR client injected, so editing any theme source (`main.css`, a component, a `patches/*.css`, or served JS) triggers the usual full reload (see [Live Reload Behavior](#live-reload-behavior)). The snapshot keeps its absolute `/luci-static/…` links; theme CSS/JS, fonts and images resolve locally and compile on the fly. Serving prepends the `<!doctype html>` that `outerHTML` captures drop, so mocks render in standards mode exactly like the real page.
- **Navigate between snapshots in place:** inside a mock the bar also takes over clicks on the snapshot's own LuCI links (`/cgi-bin/luci/…`), resolving them against the captured snapshots by `data-page` (exact match) and jumping straight to the matching mock — an app's tab bar or the sidebar works just like on the device. Uncaptured targets are blocked with a hint naming the missing snapshot instead of falling through to the proxy. The bar lists every snapshot, cycles with <kbd>[</kbd>/<kbd>]</kbd> (or its ‹/› buttons) and links back to the index. `↩` leaves for the real page: its target is the `requestpath` from LuCI's own inline bootstrap, but only when that agrees with the snapshot's `data-page` (a hand-assembled mock can carry the segments of the page it was built from); otherwise it falls back to splitting `data-page`, which is lossy whenever a path segment contains a dash of its own (`admin-status-disks-info`), then to the last device page visited in this tab.
- **Third-party assets:** an app's own css/js the snapshot references (e.g. `qmodem-next.css`, or a device-only custom logo) isn't in this repo. To serve it, mirror its URL under `.dev/mocks/static/` (e.g. `.dev/mocks/static/luci-static/resources/qmodem/qmodem-next.css`); files there are served as-is (no HMR). Misses requested by a mock page 404 immediately — never proxied to the router, so mocks stay fully offline-capable — and each miss prints a one-time terminal hint with the exact mirror path. (CSS-initiated requests, e.g. nav icons, carry the stylesheet's URL as referer and can't be attributed to the mock page; any `/luci-static` request that falls through to the proxy is therefore bounded to 5s and answers 504 when the router is unreachable.) The theme still applies without them.
- **No auth, no runtime:** a snapshot is static DOM, so `mock-pages-plugin` strips LuCI's runtime scripts (`luci.js`/`cbi.js`/`xhr.js` and `/cgi-bin/` endpoints) and injects a no-op `L`/`LuCI`/`XHR` stub before serving. Without this, LuCI boots, polls the backend, gets 403 (no session) and pops the "Session expired" modal. The trade-off: framework-dependent theme JS (e.g. `menu-aurora`) no-ops in mocks — the captured DOM is already rendered, so it still looks right. The theme's own inline scripts (dark mode, toolbar state) and any `src/media/` JS still run.
### Design Tokens
There is no local `_tokens.css` and no generation step: `main.css`/`login.css` `@import "@eamonxg/luci-theme-tokens/dist/aurora/tokens.css"` directly, resolved straight out of `node_modules` at build time. The source of truth lives in the standalone [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) npm package, consumed here as a devDependency:
- **`aurora/defaults.js`** — the 10 editable input colors (`bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger`) for light and dark mode, as OKLCH strings.
- **`aurora/spec.js`** — `DERIVATIONS` (how every other token — `text_muted`, `surface_sunken`, `hairline`, `brand_hover`, `brand_subtle`, `focus_ring`, `progress_start`/`progress_end`, `*_surface`, `scrim`, `mega_menu_bg`, …) is computed from the inputs via `mix`/`shade`/`set`/`alpha`/`const` operators, and `FIXED` (mode-specific literals such as shadows that bypass derivation).
- **`engine.js`** — the OKLCH/OKLAB color math behind those operators, via [colorjs.io](https://colorjs.io/).
- **`resolve.js`** — `createResolver` walks a `DERIVATIONS` spec and returns a flat `{token: oklchString}` map with no `color-mix()`/`var()` left in it; `aurora/index.js` pre-binds this to Aurora's own spec as `resolveMode(mode)`, exported from the package's `/aurora` entry point.
- **`dist/aurora/tokens.css`** — built by the package's own `build.mjs` (its `prepublishOnly`) and shipped in the published tarball; nothing in this repo regenerates it.
**Changing a color:**
1. Edit `aurora/spec.js`/`aurora/defaults.js` in the [`luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) repo (derivation rules, fixed literals, base input colors), tag a release so CI tests, builds `dist/`, and publishes the package, then bump the `@eamonxg/luci-theme-tokens` devDependency version here and run `pnpm install`. For unreleased iteration against a local checkout, run `pnpm link ../../luci-theme-tokens` from `.dev` instead of bumping/publishing.
2. Run `pnpm build` — Vite resolves `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` straight from `node_modules`, so a version bump (or `pnpm link`) is all a color change needs on this side.
3. Run `pnpm test` to check the color-math and derived-token invariants (`tests/resolve.test.js`, `tests/surfaces.test.js`, both importing `resolveMode` from `@eamonxg/luci-theme-tokens/aurora`) — e.g. hue families, lightness ordering between `bg`/`surface_sunken`/`surface`, and translucency of menu backgrounds.
**Runtime overrides from UCI:** `header.ut` reads `uci get_all aurora.theme` on each render and re-emits stored tokens as CSS custom-property overrides in an inline `<style>` after `main.css`. Keys are namespaced by prefix — `light_*` and `struct_*` land in `:root`, `dark_*` in `[data-darkmode="true"]` — with the prefix stripped and `_` mapped to `-` (e.g. `light_surface_sunken` → `--surface-sunken`). The template flattens all keys in a single pass into two pre-joined declaration strings (rather than per-key template loops), which halves the iteration work and keeps the emitted `<style>` compact. This is the hook `luci-app-aurora-config` writes through.
### LuCI JavaScript API
For LuCI-specific JavaScript development, refer to the official API documentation:
- [LuCI JavaScript API Reference](http://openwrt.github.io/luci/jsapi/index.html)
### Live Reload Behavior
- **CSS changes**: Trigger full page reload via custom HMR handler
- **JS changes**: Trigger full page reload via custom HMR handler
- **Template changes** (`.ut` files): Auto-synced to router over SSH and trigger full page reload (one-time `pnpm setup:router` required, see below)
### Template (`.ut`) Live Sync
The `.ut` template files are rendered server-side on the OpenWrt device, so unlike CSS/JS they can't be served locally — the dev server pushes them to the router instead. Run `pnpm setup:router` once to configure passwordless SSH; after that it's fully automatic:
- **On startup**, the whole template directory is pushed (as one tarball over ssh stdin — Dropbear has no SFTP server for scp), so edits made while the dev server was down never leave the router stale.
- **On save**, changes are debounced and the directory is pushed again, then the browser reloads.
- **On page load**, requests to `/cgi-bin` wait for any in-flight push, so a proxied render never uses a stale template.
**Troubleshooting** — sync errors are printed with the fix:
- **Host key mismatch** (device was reflashed): Run `ssh-keygen -R <device-ip>`, then restart the dev server
- **Authentication failed** (public key not on device, e.g. after a reflash): Run `pnpm setup:router`
- **Connection refused/timed out**: Check that the device is online and SSH is enabled
A failed sync is retried on the next `.ut` change; CSS/JS dev features work normally without SSH.
## Building for Production
### Build Command
```bash
cd luci-theme-aurora/.dev/
pnpm build
```
This compiles all assets to the production directory `htdocs/luci-static/`, which is used by LuCI during OpenWrt package compilation.
**Build Output:**
```
htdocs/luci-static/
├── aurora/
│ ├── main.css # Minified admin UI CSS (via lightningcss)
│ ├── login.css # Minified login page CSS (via lightningcss)
│ ├── fonts/ # Web fonts (Lato)
│ └── images/ # Logo assets + PWA icons
└── resources/
└── menu-aurora.js # Menu configuration (minified via Terser)
```
**Build Process:**
1. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`), resolving `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` straight from `node_modules` (see [Design Tokens](#design-tokens)) and keeping Tailwind's native `@layer` structure
2. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser
3. Static assets copied from `.dev/public/aurora/`
## Package Compilation
### Via GitHub Actions
**Build frontend assets:**
1. Manually trigger the `frontend-assets-build` workflow
2. It runs `pnpm build`, then auto-commits the output to `htdocs/` if anything changed
**Build `.ipk`/`.apk` packages:**
1. Push a version tag (`v*`), push to `master`/`feat/**` with `[build]` in the commit message, or manually trigger the workflow
2. The `build-theme-package` workflow compiles both `.ipk` and `.apk` OpenWrt packages
**PR review:**
Pull requests that touch `.dev/`, `htdocs/`, `ucode/`, or `root/` are automatically reviewed by the `claude-pr-review` workflow — it posts inline comments on the source diff (generated `htdocs/` output is excluded) plus a summary comment. Mention `@claude` in a PR comment to request a follow-up review or ask a question.
**Issue triage:**
New issues are handled by the `claude-issue-bot` workflow — it checks for spam/duplicates, applies labels, and posts a deep technical analysis comment. Mention `@claude` in an issue comment to get a response.
**Workflow Files:** `.github/workflows/`
- `frontend-assets-build.yml` — Build assets and auto-commit (manual trigger)
- `build-theme-package.yml` — Compile `.ipk`/`.apk` packages
- `claude-pr-review.yml` — AI code review for PRs (inline + summary comments)
- `claude-issue-bot.yml` — AI issue triage and analysis
## Directory Structure
```
luci-theme-aurora/
├── .dev/ # Development environment
│ ├── docs/ # Project documentation
│ │ └── DEVELOPMENT.md # Development guide (this file)
│ ├── mocks/ # Local page snapshots for /mocks/ (git-ignored, see Mock Pages)
│ ├── public/aurora/ # Public static assets
│ │ ├── fonts/ # Web fonts (Lato)
│ │ └── images/ # Theme images + PWA icons
│ ├── scripts/ # Build scripts + dev-server client helpers
│ │ ├── clean.js # Build cleanup utility
│ │ ├── mock-bar.client.js # Injected into device pages and /mocks/ — snapshot bar, capture, link takeover
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
│ │ ├── media/ # CSS source (Tailwind CSS v4)
│ │ │ ├── main.css # Admin UI entry point (import manifest; tokens via @eamonxg/luci-theme-tokens)
│ │ │ ├── login.css # Login page entry point
│ │ │ ├── _base.css # Document foundation (html/body viewport bg)
│ │ │ ├── _elements.css # Base element styles (headings, links, …)
│ │ │ ├── _layout.css # Page layout/structure
│ │ │ ├── _utilities.css # Custom utility classes
│ │ │ ├── components/ # One partial per UI component
│ │ │ └── patches/ # Per-page third-party patches (on-demand, one file per data-page)
│ │ └── resource/ # JavaScript resources
│ │ └── menu-aurora.js # Menu logic
│ ├── tests/ # All test suites (pnpm test)
│ │ ├── resolve.test.js # Resolved token invariants (against @eamonxg/luci-theme-tokens/aurora)
│ │ ├── surfaces.test.js # Surface/hue layering invariants
│ │ ├── overlay.test.js # Overlay/layout CSS assertions
│ │ └── navigation-*.test.js # Navigation model/rendering/styles
│ ├── .env.example # Environment variables template
│ ├── .prettierrc # Prettier configuration
│ ├── package.json # Node.js dependencies
│ ├── pnpm-lock.yaml # pnpm lock file
│ └── vite.config.ts # Vite configuration with custom plugins
├── .github/ # GitHub configuration
│ ├── ISSUE_TEMPLATE/ # Issue templates
│ ├── workflows/ # GitHub Actions workflows
│ └── renovate.json # Renovate dependency update config
├── .vscode/ # VS Code workspace settings
│ └── settings.json # Auto-format on save settings
├── htdocs/luci-static/ # Build output (generated by Vite)
│ ├── aurora/ # Theme CSS and assets
│ │ ├── fonts/ # Built font files
│ │ ├── images/ # Built images + PWA icons
│ │ ├── main.css # Compiled admin UI CSS
│ │ ├── login.css # Compiled login page CSS
│ │ └── patches/ # Compiled per-page patches (linked on demand by header.ut)
│ └── resources/ # Built JavaScript modules
│ └── menu-aurora.js # Minified menu logic
├── root/etc/uci-defaults/ # OpenWrt system integration
│ └── 30_luci-theme-aurora # Theme auto-setup script
├── ucode/template/themes/aurora/ # LuCI ucode templates
│ ├── header.ut # Header template
│ ├── footer.ut # Footer template
│ └── sysauth.ut # Login page template
├── LICENSE # Apache License 2.0
├── Makefile # OpenWrt package Makefile
├── README.md # English documentation
└── README_zh.md # Chinese documentation
```
## Tools and Technologies
- **[Tailwind CSS v4](https://tailwindcss.com/)** - Utility-first CSS framework
- **[Vite](https://vitejs.dev/)** - Build tool and development server
- **[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager
- **[lightningcss](https://lightningcss.dev/)** - CSS minifier
- **[colorjs.io](https://colorjs.io/)** - OKLCH/OKLAB color math for design token generation (used by [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens))
- **[Terser](https://terser.org/)** - JavaScript minifier
- **[Prettier](https://prettier.io/)** - Code formatter
- **[prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)** - Tailwind class sorting
- **[tw-animate-css](https://github.com/Wombosvideo/tw-animate-css)** - Animation utilities for Tailwind CSS
- **[tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar)** - Custom scrollbar styling plugin
@@ -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.luciPatches[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)** —— 自定义滚动条样式插件
+665
View File
@@ -0,0 +1,665 @@
<h4 align="right"><strong>English</strong> | <a href="router_zh.md">简体中文</a></h4>
# The client-side router
> The router now ships from `@eamonxg/luci-theme-devkit` (`runtime/router.js`,
> page-scoped patches in `runtime/patches.js`), built into
> `resources/router-aurora.js` by its Vite plugin; this theme keeps only the
> markers, the `luci-navigate` listener in `menu-aurora.js` and the CSS. The
> design below is unchanged; the devkit copy is the maintained one.
How the theme turns a menu click into an in-document view swap instead of a
full page load, where it deliberately does not, and the invariants a router
inside LuCI has to keep. Source: `.dev/src/resource/router-aurora.js`,
loaded from `footer.ut` next to `menu-aurora.js`. **No changes to luci-base
or to any view** — the router is additive theme JS plus three small template
hooks (a patch manifest, a `<footer>` boundary, and `data-aurora-*` markers
on the stylesheets header.ut itself renders).
![One LuCI navigation: what the device does, what the browser does, and which of those steps the same-document router deletes](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.svg)
## Prior art
[luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)
solves the same problem, and reading it informed two pieces here: pausing
`L.Poll` on a hidden tab, and folding a view's read-only state along its
dispatch path (both below). The rest is independent, and one choice diverges
deliberately. footstrap drives navigation through the **History API**
(`pushState`/`popstate`) with its own scroll bookkeeping and a
`prototype.render` guard to repair stale renders; this router is built on
the **Navigation API** instead (see "Kernel"), which hands scroll, history
and supersession to the browser and needs none of that — at the cost of
running only on newer browsers, where the theme falls back to the plain MPA
it already is. On top of that shared base this router also adds a
session-expiry gate, reproduces `template` pages from the server's own shell
rather than hand-porting them, and cross-fades the swap with a view
transition — each its own section below.
## Why it pays, measured
**Cudy TR3000** (mediatek/filogic, ARMv8), OpenWrt SNAPSHOT r0-20d94d5, this
branch deployed, plain HTTP, warm cache, 2026-08-18. Both paths are measured
in one loop (`bench-fullload.mjs`, RUNS=10, medians over the 8 pages below)
so they see the same device state. Run-to-run spread is ±40 ms on a full
load: the ratios are the claim, the digits are not.
### Where a full load's time goes
| stage | ms | what it is |
|---|--:|---|
| dispatch #1 — the page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut` → `header.ut` |
| dispatch #2 — `admin/translations/<lang>` | 124→209 | a *second* CGI process, parser-blocking, uncacheable |
| DOMContentLoaded | 215 | the shell is back, byte-identical to the one just discarded |
| view module + ubus data + render | 215→321 | static assets are already cache hits |
**209 of the 321 ms passes before anything page-specific has happened.** Both
dispatches re-derive a shell the browser already had on screen; the view's own
ubus calls do not start until 227 ms. A same-document swap deletes both
dispatches and keeps the last row — the same 8 pages land at a median of
**91 ms** warm, with the data calls starting at 2 ms instead of 227 ms.
### What one dispatch costs the device
Measured on the device itself, so no network is in the number
(`bench-dispatch.sh`).
| request | ms | bytes |
|---|--:|--:|
| page HTML, a `view` node | 75.4 | 18,583 |
| `admin/translations/en` | 62.7 | **13** |
| `admin/translations/zh-cn` | 60.3 | 229,503 |
| `admin/menu` — once per *session*, not per navigation | 68.2 | 45,022 |
| static `main.css` | **0.8** | 191,899 |
| inside one dispatch, per process | ms |
|---|--:|
| fork + ucode VM | 2.2 |
| `import luci.dispatcher` (runtime, http, ubus, uci, core, authplugins) | 37.2 |
| menu tree: stat 8 `menu.d` files + parse the 28,307 B index cache | 13.8 |
| `session.get` + `session.access` over ubus | 6.0 |
The `en` row is the control: a **13-byte** response still costs 62.7 ms, while
a 191,899-byte static file is served in 0.8 ms. The cost is the dispatch, not
the payload — and a full page load pays that block **twice**. (`zh-cn` clients
additionally re-transfer 229,503 B every navigation: `write_headers()` in
`http.uc` sets `Cache-Control: no-cache` and `Expires: 0` with no `ETag` or
`Last-Modified`, so there is nothing to revalidate against.)
### End to end, click → view painted
Median of 10 per page, full load = navigation start → first non-spinner child
of `#view`; router = click → the navigation's `finished` promise.
| page | full load | router (warm) | faster |
|---|--:|--:|--:|
| 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 % |
Median **73 % faster**, range 50–87 %. `bench-router.mjs timing`, an
independent harness, was run twice the same day and landed at 72 % and
74.5 % — all of that is inside the device's own spread, so treat the range,
not the digit, as the result.
Speculation-rules prefetch cannot reach it: the API is secure-context only,
so it is inert on HTTP, and on HTTPS a document prefetch hides only the
first dispatch — the catalog is a subresource, fetched after the document
arrives.
## Why it is possible
For a `view` node the dispatcher renders `view.ut`: the theme header, then
`<div id="view">` with an inline `L.require('ui').then(ui =>
ui.instantiateView('<path>'))`, then the theme footer. The server decides
*which* view; the client renders it. The router repeats what `view.ut` does
without the reload: resolve the path against the menu tree the client
already holds (`ui.menu.load()` serves it from `sessionStorage`), swap the
content region, re-instantiate the view, and let the browser own the URL.
## Kernel: the Navigation API, and only that
`navigation.addEventListener('navigate', …)` + `event.intercept()`. One
event covers link clicks, `location.assign`, back/forward traversals to
same-document entries, and our own `navigation.navigate()`; the browser
writes the URL and history entry, exposes `event.signal` for supersession,
and (with `scroll: 'after-transition'`) restores scroll on traversal /
scrolls to top on push, so the router carries no `pushState`/`popstate`
code, no scroll bookkeeping, and no fragment-vs-navigation heuristics.
Why this API rather than the History API footstrap uses:
- **The browser owns URL, history and scroll.** `pushState` puts the
router in charge of all three and of keeping them consistent with what
it rendered; here it only ever renders.
- **Supersession is built in.** A newer navigation aborts the older one's
`event.signal`; the generation gate below is a check, not a state
machine, and no `render` guard is needed to repair a stale paint.
- **Every navigation kind arrives at one listener** — link click,
`location.assign`, back/forward to a same-document entry, our own
`navigation.navigate()` — so there is exactly one path to keep correct.
- **The fallback is free.** Where the API is missing, the theme is the
MPA it already was; nothing has to be polyfilled or feature-forked.
**Browsers without the API stay MPA.** `footer.ut` only requires the module
when `window.navigation` exists, and `__init__` re-checks the surface it
actually uses: `navigation.addEventListener`, `NavigateEvent`, and
`intercept` on its prototype. Chrome/Edge **105+**, Safari 26.2+, Firefox
147+ get the router — 105, not the 102 that first shipped the Navigation
API, because the method was called `transitionWhile()` until Chrome 108 and
`canIntercept` was `canTransition`; gating on `intercept` is what makes the
floor 105. The theme's declared floor (Chrome 111 / Safari 16.4 /
Firefox 128) keeps working as it does today. This is a deliberate trade:
one code path, correct by construction, over a second history-API path that
would double the surface of everything below.
## Compatibility
### Browsers — per platform feature
| Feature | Used for | Required? | Chrome / Edge | Safari | Firefox | Without it |
|---|---|---|---|---|---|---|
| Navigation API (`navigation.addEventListener('navigate')`, `NavigateEvent.intercept()`, `event.destination/signal`, `navigation.navigate()/back()`) | the whole router | **yes — gate** | 105+ (2022) | 26.2+ (2026-01) | 147+ (2026-01) | `router-aurora.js` is not even loaded (`footer.ut` checks `window.navigation`); the theme is the plain MPA it was before |
| `document.startViewTransition()` (same-document) | crossfade at the swap | no | 111+ | 18+ | 144+ | swap without animation; also off under `prefers-reduced-motion` |
| `fetch(url, { priority: 'low' })` | hover module prewarm | no | 101+ | 17.2+ | 132+ | the option is ignored, the fetch still runs at default priority |
| `MutationObserver`, `DOMParser`, `WeakSet`, `URL`, `Element.replaceWith`, `:scope`, `matchMedia`, optional chaining / `??=` | render completion, template shells, poison gate, staging | yes | ≥ 85 | ≥ 14 | ≥ 79 | all inside the theme's declared floor (Chrome 111 / Safari 16.4 / Firefox 128) |
So the router's effective floor is Chrome/Edge 105, Safari 26.2, Firefox 147;
everything older keeps the theme's existing floor and behaviour. Verified
live in Chrome 151 (headless, `bench-router.mjs`); Safari/Firefox by feature
detection only — the gate is the same API surface, not a UA sniff.
### OpenWrt / LuCI
The theme already requires OpenWrt 23.05+ (ucode templates). Except for the
two version-scoped items called out below, the router touches only luci-base
surfaces that are identical in the `openwrt-23.05`, `openwrt-24.10`,
`openwrt-25.12` and `master` branches of `openwrt/luci` (checked against the
branch sources, 2026-08): `L.require` with instance caching and
`prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref`
registry, `L.env.{scriptname, base_url, resource_version, media,
requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`,
`L.Poll.{queue, start, stop, active, timer}` (and `start()`'s reset of
`tick`, which is what re-arms an incoming view's first poll),
`ui.menu.load()`'s session-cached tree with `satisfied` /
`firstchild_ineligible` / `wildcard` / `action.type` (`view`, `alias`,
`firstchild`, `template`), the `poll-status` indicator id `setupDOM`'s
`poll-start` handler registers (the teardown hides it by that name),
`ui.instantiateView`, `ui.hideIndicator`,
`ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`,
`network.js`'s uci-backed state, `Request.addInterceptor` /
`rpc.addInterceptor` and the `-32002` → `session.access` probe in
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding, `view.ut`'s `#view` +
inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
`node_weight` / alias re-dispatch semantics (ported line for line).
**Two surfaces are not the same across those branches**, and the resolver is
written against the newer one:
- **`node.css`** entered `build_pagetree`'s schema in master only
(7c6d8ff, 2026-08). 23.05, 24.10 and 25.12 carry no `css` on any node, so
`nodeCss()` returns `null` and the feature is simply inert there.
- **Wildcard descent.** `wildcardaction` exists in 25.12 and master, not in
23.05 or 24.10 — an absent key just falls back to `node.action`, which is
what those releases do anyway, so that part is safe. The *resolution rule*
around it is not: 25.12 and master descend into a matching `satisfied`
child before treating trailing segments as args, while 23.05 and 24.10
capture every remaining segment the moment a `wildcard` node is reached.
The router ports the 25.12/master rule. On 23.05 or 24.10 a tree that has
both `foo/*` and a real `foo/bar` child would therefore resolve differently
in the router than in the dispatcher — the exact "click opens one page, F5
opens another" failure this resolver exists to avoid. Both the rule and
`wildcardaction` came in as one commit (df90c60a7, 2026-01-17) whose stated
purpose is to let `path/*` carry an action distinct from the bare path, so
the shape had no defined behaviour before it and a tree written for
23.05/24.10 is unlikely to use it — but that is an argument, not a survey
of every installed `menu.d`, and the router has not been run on either
release. Treat 23.05/24.10 as inspected, not verified.
Live verification so far: OpenWrt SNAPSHOT r0-20d94d5 (2026-08,
mediatek/filogic) and an earlier SNAPSHOT on ipq60xx. 23.05 / 24.10 / 25.12
by branch source only, not on device.
That list is also executable: `contract()` in `router-aurora.js` looks every
one of those surfaces up at boot (`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`) and, if any is missing, logs which and does not
activate — the theme is the MPA it was, not a broken router, on a luci-base
that moved.
## What is intercepted
A `navigate` event is intercepted only when **all** hold:
- `event.canIntercept` (same-origin, not cross-document-only), not
`hashChange`, no `downloadRequest`, no `formData`, `navigationType !== 'reload'`;
- the destination is **not the document's own URL** (fragment aside). A
same-URL navigation arrives as `navigationType: 'replace'`, not
`'reload'`, yet it is a reload by another name: luci-base's
`ui.changes.apply/revert` end in `window.location =
window.location.href.split('#')[0]` (and the expiry modal's button in the
same) precisely so the server re-renders the shell — a theme switched
under System → Language and Style, a new language, a new hostname, a
changed menu tree — and intercepting it left the swap showing the old
shell until F5. A click on the current page's own link is the same reload
it is in the MPA;
- the destination path (minus `L.env.scriptname`) resolves in the menu tree
to a **serviceable node** (below);
- the document is not **poisoned** (below) and its session is not known to
be **expired** (below);
- the router **activated** in this document: it does so only when the page
it booted on is itself serviceable. A `call`/`cbi`/`function` page carries
scripts (legacy `XHR.poll`, inline timers) that only a document death
retires; the first click away from one is always a full load.
Anything else falls through untouched: the browser performs the ordinary
full navigation, i.e. exactly what the theme did before. Modifier-clicks and
`target=_blank` never reach the event.
### Serviceable nodes
Resolved with a port of the dispatcher's own rules, not a paraphrase:
- `alias` → jump to `action.path` from the root and continue;
- `firstchild` → the same `resolve_firstchild()` / `node_weight()` the
dispatcher runs: candidates are `satisfied` children with a `title` and an
object `action`; weight `min(order ?? 9999, 9999)` +10000 for
`auth.login`; a `firstchild` candidate counts only if it resolves further;
`firstchild_ineligible` excluded; ties keep key order. The ACL check is
skipped because `/admin/menu` is already filtered for the session;
- `wildcard` nodes are descended into first — a segment that matches a
`satisfied` child wins over arg capture — and only the remainder becomes
request args; with args present the node's `wildcardaction` (the `path/*`
entry's own action) runs, and `action` for the bare path. This is the
25.12/master rule; 23.05 and 24.10 capture at the first `wildcard` node
instead — see "OpenWrt / LuCI" above;
- a hop counter (32) breaks cycles in a foreign `menu.d`;
- **any segment that does not match a `satisfied` child ends the attempt.**
The dispatcher would fall back to the deepest satisfied ancestor and
re-resolve from there; the router returns `null` and hands the navigation
to the server. Deliberate: the fallback costs one full load, guessing the
ancestor wrong costs the wrong page.
Two tracks are kept, as a full load keeps them: **requested** segments →
`L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved**
segments → `L.env.dispatchpath`, `L.env.nodespec`, the menu highlight, the
title. Pick a different child than the dispatcher would and a click opens
one page while F5 opens another — that is why the resolver is a port.
| node | served |
|---|---|
| `view` | yes — `view.<path>` |
| `alias`, `firstchild` | yes — resolved to a leaf, recursively |
| `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below |
| Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load |
`rewrite` is deliberately not resolved. The node and its action *are* in the
tree, but following it means re-implementing `dispatcher.uc`'s
`splice(request_path, 0, action.remove)` and re-dispatching from the result;
an off-by-one there opens the wrong page, which is worse than the reload it
falls back to.
### Template nodes: the server's own shell, never a hand port
`admin/status/overview` is a `template` whose server side defines page
globals (`progressbar`, `renderBox`, `renderBadge`), emits an `<h2>` and a
`div.includes` (server-rendered Lua includes), and then instantiates
`view.status.index`. A first version re-implemented those helpers in the
router and drifted on the first real page (the network badges lost their
labels: upstream's `renderBadge` takes extra `L.itemlist` arguments the port
did not know about). So the router does not port anything: when a link to a
template node is hovered or focused, its page is **fetched once per
document** (the in-flight request is shared by every intent event that
arrives before it resolves), parsed with `DOMParser`, and the content region between `#tabmenu` and
`<footer>` is kept as the page's *shell* — every node cloned, `#view`
replaced by an empty div, the inline `instantiateView('…')` script read for
the class name, the remaining inline scripts (the helpers) replayed into
global scope on staging. luci-base's own bootstrap (`luci.js` and
`L = new LuCI(env)`) also lives in that region and is filtered out. If the
document *is* that template (the session started on Overview), the shell is
taken from the live region and no fetch happens. A template node is only
intercepted once its shell is known — so a Lua template page (no
`instantiateView` call) is remembered as unservable after one hover fetch
and never enters the router's error path, and a template clicked without a
prior hover is a plain full load that seeds the shell for the rest of the
document. Its status include modules are singletons carrying
`oneshot`/`hide` state that a full load would reset — verified against a
real full load, not against expectation.
## The navigation procedure
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,
handler in order:
1. **Generation.** `const gen = ++this.gen`; every later DOM write is gated
on it.
`event.signal` aborts our own awaits, but it cannot cancel a LuCI XHR
(`L.Request` hands back a bare promise; the `XMLHttpRequest` only surfaces
on the *resolved* `Response`, too late to abort) or a `View.__init__`
chain already running, so the generation is the correctness mechanism and
the signal is hygiene.
2. **Teardown of the departing document state**, i.e. what a document
death would have done for free:
- `Poll`: `queue.length = 0; stop(); start()` — three steps. The flush
drops the old view's pollers; `stop()` drops the tick; `start()` on an
empty queue re-arms `tick = 0` so the incoming view's `poll.add()`
auto-starts and fires immediately instead of waiting up to `interval`
seconds for the surviving tick to align. Upstream's `initDOM()` does
the same `Poll.start()` on an empty queue before the first view.
- `uci`: `unload()` every package present in `state.values` **or**
`uci.loaded` (documents start with an empty cache; four shipped apps
read `load()`'s return as an existence check and draw an error over
the page when the cache answers `[]`; and `uci.loaded` keeps a
package's request promise — a rejected one included — until
`unload()`, so a failed load left there would be handed to every later
view). Then, if `L.network` has been loaded,
`load(['network','luci'])` — plus `'wireless'` when
`L.hasSystemFeature('wifi')` — is re-issued and **awaited** —
and a rejection propagates to the hard-load fallback rather than
leaving `network.js` on an empty config: `network.js` fills its `_state` once and
from then on answers out of the uci cache (`getWifiDevices()` *is*
`uci.sections('wireless','wifi-device')`), so dropping those without
refilling hands every consumer an empty config for the rest of the
document. Unsaved local edits die with the page as they would on a
full load; saved changes live on the server and the Unsaved-changes
indicator is unaffected.
- bare `setInterval`s registered since the router booted are cleared
(`setInterval`/`clearInterval` are hooked in `__init__`, i.e. when
`L.require('router-aurora')` instantiates the class; `poll.timer`, the
one interval `L.Poll` owns, is skipped). `setTimeout` and rAF are
**not** touched: the
core keeps tooltips, notification timeouts and a request timeout on
`setTimeout`, and there is no self-rescheduling timeout in any
shipped view.
- `window`/`document` listeners a view registered **while it rendered**
are removed. Several shipped views add them per render (statistics
graphs: an anonymous `resize` that later throws against detached DOM;
nlbwmon: `tooltip-open`/`touchstart`; the core's own dropdown widgets:
one `window` click/touchstart per instance), so they accumulate and
act on pages that are gone. The hook records registrations inside the
render window only. A **warm** render evaluates no module, so its
registrations are per-render by construction and go on the next
teardown. A **cold** render also runs the module's top level, whose
registrations must survive (removing them is one-way — an editor's
module-eval listeners never come back), so cold registrations are only
credited to the class and released when a later warm render of the
same class registers the same target/type, which proves them
per-render.
- `ui.hideIndicator('poll-status')` — luci-base leaves a
*Refreshing* / *Paused* indicator behind that a document death would
have taken with it.
- `ui.hideModal()`, the theme's own surfaces (mega
menu, mobile drawer, palette) close.
- page-scoped patch CSS is disabled and its JS patch unmounted (below).
The uci flush is the one part of this that is awaited rather than
fire-and-forget, so it is a separate step after `teardown()` returns.
3. **Environment.** `L.env.requestpath/dispatchpath/pathinfo/nodespec`,
`body[data-page]`, `document.title`. An alias is re-dispatched
server-side, so `requestpath` and `data-page` carry the alias target while
`pathinfo` keeps the URL as requested; a `firstchild` keeps the requested
path in both. The title suffix (` - hostname`) is read off the initial
document, so it matches whatever the template emitted. `nodespec` drives
`L.hasViewPermission()` and therefore the Save/Apply footer's readonly
state — and its `readonly` is **folded down the dispatch path** the way
`dispatcher.uc` does it: `ctx_append` collects every node's
`depends.acl` and one `check_acl_depends()` over the union is writable as
soon as *any* group is writable, so a page is readonly only when every
acl-bearing node on its path is. The tree's per-node flag
(`apply_tree_acls`) covers that node's own acl alone; handing the leaf
node over as-is gave a read-only user a live Save & Apply on every page
under a read-only group. The tree object is not mutated (`nodespec` is a
copy). `data-page` keys `ui.tabs` session state and the theme's
page-scoped CSS.
4. **Chrome.** `menu-aurora.js` exposes `syncRoute()`: it re-marks
`is-active-page`/`aria-current` from `L.env.dispatchpath` across every
nav surface, expands the active sidebar/mobile group and collapses the
rest, rebuilds the header crumb and re-renders `#tabmenu` for the new
section. Menus are **not** rebuilt — the mega menu measures and binds on
construction and the palette index is a flat array of the same model —
only their state changes.
5. **Staging.** A fresh `<div id="view" class="view-staging">` is inserted
right after `#tabmenu`, i.e. **first in tree order** —
`getElementById('view')` returns the first match, so everything LuCI's
view chain writes goes into the staged element while the outgoing page
stays on screen (dimmed, `.view-leaving`). The stage is invisible but
**laid out** (`visibility:hidden; height:0; overflow:hidden`, never
`display:none`): the realtime graphs size themselves from
`#view.offsetWidth` inside `render()`, and a `display:none` stage handed
them a 0-wide canvas. Nothing is removed yet.
6. **Patches.** `header.ut` emits the installed on-demand patch stems as
`body[data-patches]`; the router applies the same segment-prefix rule the
template applies at render time: matching `patches/<stem>.css` links are
ensured (`<link data-luci-patch>`, enabled for the page on screen,
`disabled` — not removed — for the rest, so a return costs nothing);
matching `patches/<stem>.js` files are loaded once and their
`window.luciPatches[stem]` `{ mount, unmount }` pair is driven per
visit (the list of stems to mount belongs to the navigation that computed
it, so a superseded one mounts nothing later); URLs the router adds carry
the same `?v=PKG_VERSION` luci.mk stamps on the template's own links,
read from `body[data-asset-version]`, so they hit the same cache entry — a JS patch that registers nothing is simply executed once,
MPA-style. A patch script mounts itself when it evaluates; if the user
has navigated on before it arrives, its `load` handler checks whether
the current page still wants that stem and unmounts it otherwise (a
same-stem page reached meanwhile keeps it mounted).
A menu.d node's own `css` (`header.ut` links `<resource>/<node.css>` for
the dispatched node, marked `data-luci-node-css`) is kept the same
way: one `<link>` per stylesheet, enabled for the page whose resolved leaf
declares it, `disabled` for every other page, never removed. Both
attributes are exempt from the poison gate.
7. **View.**
- **cold** (`view.<path>` never required in this document):
`window.L.require(className)` — the require *is* the render (LuCI
instantiates on first require) and it must go through `window.L`, the
runtime instance, never the prototypal `L` a module factory receives
(`ui` hangs `itemlist`/`showModal` on `window.L`; a view required
through the wrong `L` dies three modules down on `L.itemlist is not a
function`, and because `require()` caches by name the binding is fixed
by the *first* requirer);
- **warm**: `require()` hands back the cached instance whose `__init__`
already ran; LuCI's class system sets `prototype.constructor`, so
`new instance.constructor()` runs a fresh `__init__` → `load()` →
`render()` → `dom.content('#view')`, exactly what a full load starts
from. Either way the required value is checked with
`instanceof L.view`; anything else throws into the hard-load path
rather than staging a non-view.
- **completion** is observed, not assumed: a `MutationObserver` on the
staged element resolves when a non-spinner child lands (or the spinner
is removed for an empty render). Not completing within 15 s is a
**failure**, not a completion: committing the spinner and releasing the
serialization would let the still-running chain paint into a later
navigation's `#view`, so the timeout rejects and the catch path
hard-loads the destination. On completion — and only if this navigation
is still the latest — the outgoing region
(everything between `#tabmenu` and `<footer>` except the staged
element) is removed and the staged view is unhidden inside
`document.startViewTransition()` when available and reduced motion is
off; the navigation's `finished` promise resolves after that swap.
Each departing element goes through `L.dom.content(el, null)` before
`remove()`: that is what drops its `data-idref` registry entries, which
would otherwise hold the detached subtree and its class instances
alive — which is what the soak test below measures.
- **Renders are serialized.** Neither an in-flight LuCI XHR nor a running
`View.__init__` chain can be cancelled (same reason), and every chain
paints into *whichever* `#view` is first at paint time. So a navigation
first awaits the previous one's completion
(bounded by the same timeout) before it tears anything down or stages
anything — the previous chain finishes into its own staged element,
which is then discarded. Rapid A→B→C therefore never interleaves:
B is skipped when C arrives before B ran (`event.signal` /
generation), and C waits for whichever render is actually in flight.
The document's initial LuCI-rendered view is tracked the same way, so a
click during the first load cannot be painted over by it — and a first
render that never completes rejects that wait, so the first navigation
takes the hard-load fallback instead of staging next to a chain that
may still paint. That first render also runs inside a render window
(opened after the router's own listeners are registered), so the
listeners it adds are credited to its class like a cold render's; the
ones it registered before the router loaded are out of reach. The cost
is that a click during a slow load waits for that load; the alternative —
wrapping `prototype.render` per class and repairing stale cold renders
by re-navigating — leaves a real window open and needs three mechanisms
where one suffices.
8. **Focus and announcement.** `#maincontent` (`tabindex=-1`) with
`preventScroll`; the new `document.title` is written into
`#luci-nav-status` (`role=status`, `aria-live=polite`), since a
same-document swap fires no load a screen reader would announce. The
landmark carries `outline-none`: iOS WebKit (Safari and Chrome for iOS
alike) paints its focus ring for programmatic focus, and the ring's top
edge just under the sticky header was reported as "a progress bar that
never goes away" — it was never the bar.
9. **Progress.** A navigation that outlives 150 ms gets `#luci-nav-progress`
inserted — Turbo Drive's bar, in shape: a hairline at the top whose
`width` is driven inline and **trickles** in ever-smaller steps
(`+ (100 - w) / 30` every 300 ms) until commit, so a slow render keeps
visibly moving instead of looking stuck; on commit it fills to 100 %,
fades (`data-state="done"`) and is **removed from the DOM**. Shorter
navigations stay silent, overlapping ones share the bar. The browser's
own progress bar is no help here: it only shows for document loads,
which is exactly what a same-document swap is not — hence GitHub,
YouTube, Turbo/HEY and every nprogress user draw their own. Reduced
motion drops the transitions, not the bar.
10. Any exception → `console.error` (a silent fallback makes every router
regression look like "the page is just slow") → `location.href =
destination` — a hard full load, never a stuck page. A `bypass` flag is
set first so the `navigate` event that write produces passes straight
through instead of being intercepted back into the failing path, and the
handler then parks on a never-settling promise so nothing else runs
against a document that is on its way out.
## The expiry gate
luci-base answers a dead session with `notifySessionExpiry()`: `Poll.stop()`
plus a modal whose only button is a hard reload. A same-document swap would
`hideModal()` and `Poll.start()` right through it and browse on, every page
erroring in turn (measured: `bench-router.mjs expiry` against the previous
router — `expiredFullLoad: false`). So the router listens for the same two
signals luci-base acts on — a `403` with `X-LuCI-Login-Required: yes` on any
`L.Request`, and the `session.access` probe luci-base fires after a
`-32002` coming back denied or errored — and from then on intercepts nothing:
the next click is a full load, which the dispatcher turns into the login
page. A denied call on any other object is an ACL matter and is ignored.
Nothing is reset: the flag dies with the document, as the session did. The
same flag keeps the visibility gate (below) from restarting a poll the
expiry stopped.
## Hidden tabs
luci-base keeps polling in a background tab. The router stops `Poll` on
`visibilitychange` → hidden when it was active and starts it again on
return — unless the user had paused it, or the session died meanwhile. On
a weak router that is RPC work nobody is looking at.
## The poison gate
A `<style>`/`<link rel=stylesheet>` a view writes into `<head>` dies with
the document on a full load and **survives** a same-document swap, painting
every page after it (a shipped file manager hides Save/Reset on every config
page with one unlayered `!important` rule). Removing it is not an option: a
library that imports CSS at module eval never runs again, so deletion is
one-way (an editor page came back as a black rectangle two million pixels
tall). Hence a gate, not a sweep: before intercepting, any sheet outside `#view`
that is not one of the theme's own marks the document **poisoned** and the
navigation is a full load — the fresh document carries no view CSS, so the
router resumes immediately. "Own" means *marked*: header.ut stamps everything
it renders (`data-luci-shell` on `main.css`, the font, custom and token
`<style>`s; `data-luci-patch` on patches; `data-luci-node-css` on the
menu.d node css). The boot snapshot the gate compares against is filtered by
those markers, so a sheet the boot page's own modules inserted before the
router loaded still counts as foreign instead of being grandfathered in for
the rest of the document. Correctness over speed, never the other way.
An owner-based refinement (stamp each sheet with the inserting module off
the call stack, enable it for pages whose dependency closure holds that
module, `disabled` for the rest) was built, verified on the device and
**removed again**: on this device one view page inserts its own CSS, the
saving is one reload when leaving it, and the price was three monkeypatches
plus an inline template script whose failure mode — a page silently missing
a shared library's CSS — is worse than the reload it avoids. Revisit only
with a real corpus of self-styling view pages.
## Module prewarm on hover
Entering (`pointerover`/`focusin`/`pointerdown`) a link to a serviceable
node `fetch()`es its view module with `priority: 'low'` — not `require()`,
which would render it. The URL is built byte-for-byte as `LuCI.require()`
builds it (`<base_url>/<name with . → />.js?v=<resource_version>`) or it
misses the HTTP cache. The walk is transitive: the fetched body's **first
4 KB** is scanned for the leading run of `'require x'` string literals with
a regex that is **not** line-anchored (shipped files are minified onto one
line), and dotted
names are warmed the same way; dotless names are either luci-base's file-less
built-ins (`view`, `baseclass`, `dom`, `poll`, `request`, `session`) or flat
libraries the chrome has already loaded, so they are declined outright.
Deduplicated per class name; stops once a navigation to that link has
committed. Cold navigations are the only place this shows; warm ones are
already 0-byte cache hits.
## What is deliberately not done
- **No history-API path.** See "Kernel".
- **No document prefetch while the router is active.** The
`speculationrules` script is removed at boot when the router takes over —
a hover prefetch of a document the router will never load is pure router
CPU. Browsers without the Navigation API keep the rules and the MPA path.
- **No `unload`/`beforeunload`**, ever (bfcache).
- **No cancellation of in-flight XHR** — there is no handle to cancel with
(see step 1); the generation gate makes it a waste, not a bug.
Upstream-only.
- **No sweeping of a view's global listeners or timeouts** — one-way
deletions of module-eval registrations. If a per-render offender ever
appears, the answer is a targeted teardown, not a global hook.
- **`ui.changes.confirm/revert` and `awaitReconnect`** keep their hard
`window.location` writes — a rollback/reboot boundary *should* be a fresh
document.
## Verification matrix
- Unit (`.dev/tests/router.test.js`): resolver against a fixture tree
(alias chain, nested firstchild, weights, ineligible, unsatisfied,
wildcard args, cycle); URL → segments; patch prefix matching; pragma scan
on a minified head; readonly folding; expiry signals; the same-URL reload
rule; node css of the resolved leaf; the contract check.
- Device (`.claude/skills/aurora-performance/scripts/bench-router.mjs`, CDP):
1. full walk of every clickable node in each nav mode, each compared
against a real full load of the same URL — `data-page`,
`dispatchpath`, URL, title, tab count, footer presence, console clean;
2. click → view painted, median of N, router vs full load, warm and cold;
3. soak: 60 navigations over 12 pages, heap / DOM nodes / listeners /
poll queue length flat after the first pass;
4. back/forward chain through alias and firstchild URLs — no reload;
5. poison gate: a foreign `<style>` in `<head>` makes the next
navigation a full load, the one after is a same-document swap again;
5b. sheets: the same, on every walked view page that really inserts its
own sheets (found on the walk) instead of an injected one — reached
same-document and landed on directly (its modules insert before the
router boots), leaving is a full load either way;
5c. hygiene: no progress bar left in the DOM after a swap, live region
present and carrying the title, a hidden tab stops polling and a
visible one resumes it;
6. nodecss: a page whose menu.d node declares `css` — link enabled on
arrival, disabled after leaving, re-enabled without a duplicate on
return (skipped when no installed node declares one);
7. expiry (last, destroys the session): logout fetched from inside the
document, one failing RPC → luci-base's modal and `Poll.stop()`; the
next navigation is a full load landing on the login form.
The walk also compares `nodespec.readonly`, `L.hasViewPermission()`, the
set of enabled node-css links and the live-region text against the full
load, and reports which pages carry sheets that are not the theme's.
- Device (`bench-fullload.mjs`, CDP): where one full load's time goes —
dispatch #1, the parser-blocking catalog, DOMContentLoaded, the view's own
ubus window — and the same page over the router, both in one loop so the
two are subtractable.
- Device (`bench-dispatch.sh`, run on the router): what one CGI dispatch costs
before any page-specific work — process, module graph, menu tree, session
probe — plus the loopback cost and size of each response a navigation pulls.
The `en`-catalog row is the control that separates dispatch cost from payload.
- The perf skill (`.claude/skills/aurora-performance/`) documents all three
harnesses in `references/measuring.md`; the server-side cost this router
removes is the S1/S2 budget in `references/server.md`.
+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/filogic,ARMv8),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` 的 schema(7c6d8ff,2026-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-20d94d5(2026-08,mediatek/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-luci-patch>`,对屏幕上的页面启用,对其余页面 `disabled` —— 而不是移除,所以回来时不花任何代价);匹配的 `patches/<stem>.js` 文件只加载一次,其 `window.luciPatches[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-luci-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` 被写入 `#luci-nav-status`(`role=status`、`aria-live=polite`),因为同文档替换不会触发屏幕阅读器会播报的 load 事件。这个地标带着 `outline-none`:iOS WebKit(Safari 和 iOS 版 Chrome 都一样)会为编程式聚焦绘制焦点环,而这个环紧贴吸顶头部下方的上边缘被用户报告为「一个永远走不完的进度条」—— 它从来就不是进度条。
9. **进度。** 存活超过 150 ms 的导航会插入 `#luci-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-luci-shell`;补丁上的 `data-luci-patch`;menu.d 节点 css 上的 `data-luci-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 预算。
+102
View File
@@ -0,0 +1,102 @@
# Agent workflows
Two commands, one knowledge skill, five vertical agents, one mechanical
guard. Everything an agent asserts about luci-base or uhttpd must come from
a source checkout (`$LUCI_SRC`, `$UHTTPD_SRC` in the git-ignored `.dev/.env`)
and be cited as `file:line`; the facts they start from live in
`.claude/skills/luci-knowledge/`.
| Command | Use when | Output |
|---|---|---|
| `/luci-change <request>` | anything that edits `.dev/src`, `ucode/`, `htdocs/`, `root/`, `Makefile`, `vite.config.ts`, CI | a reviewed change in the working tree; commit only on request |
| `/upstream-rfc "<topic>"` | the fix belongs in luci-base / uhttpd / rpcd / the feed | `.dev/decisions/<slug>.md` with diagrams; no code change here |
| `/luci-vertical-review [base]` | step 6 of `/luci-change`, or any branch | ranked, verified findings |
## Changing this repository
```mermaid
flowchart LR
R[request] --> S{fix lives here?}
S -- no --> RFC[/upstream-rfc/]
S -- yes --> P[research + plan<br/>files · budget impact · floor · verification]
P --> G1{{gate 1<br/>user approves plan}}
G1 -- no --> P
G1 -- yes --> A[arm guard<br/>.claude/.change-approved]
A --> I[implement<br/>guard allows only approved paths]
I --> V[pnpm test · pnpm build · bench if device]
V --> G2{{gate 2<br/>luci-vertical-review}}
G2 -- blocker/major --> I
G2 -- clean --> D[report · disarm guard<br/>commit only if asked]
```
Gate 1 is plan mode: `ExitPlanMode` needs the user's selection; nothing is
edited before it. Gate 2 is the `luci-vertical-review` workflow:
`aurora-static-reviewer` (templates, CSS, tokens, patches, packaging, CI)
and `aurora-runtime-reviewer` (menu/router JS, teardown, polling, a11y)
review in parallel; every finding then goes to `luci-claim-verifier`, which
tries to refute it against the sources before it can be reported. With a
scope file the reviewers also check that the diff stayed inside the approved
paths.
The guard is `.claude/hooks/guard-edits.sh` (PreToolUse on
`Edit|Write|MultiEdit`): edits to shipped/source paths are refused unless
`.claude/.change-approved` lists a matching prefix. Docs, tests and `.claude/`
are not guarded. Edits made through Bash (`sed -i`, heredocs) are not seen
by the hook — the review's scope check is the second net. To bypass
deliberately, create the marker yourself; to disable, remove the hook from
`.claude/settings.json`.
## Proposing an upstream change
```mermaid
flowchart LR
T[topic] --> I1[analyst: server side<br/>dispatcher · http.uc · uhttpd]
T --> I2[analyst: client side<br/>luci.js · ui.js · theme templates]
I1 --> X{fix location?}
I2 --> X
X -- both say theme --> STOP[abort: use /luci-change]
X -- upstream/both --> C[claims with file:line]
C --> V[one verifier per claim<br/>refute-first]
V --> K[confirmed / corrected]
K --> W[writer: RFC from TEMPLATE.md<br/>mermaid before/after · revises an existing file]
W --> Q[critic: every sentence<br/>treated as a claim]
Q -- gaps --> W2[one revision]
Q -- none --> O[.dev/decisions/slug.md]
W2 --> O
```
Refuted claims never reach the document; unknowns are listed, not filled.
The writer may write only under `.dev/decisions/`. Bounds: at most 12
claims are verified (the rest are logged as dropped), one revision pass.
## Decision records (`.dev/decisions/`, local)
Proposals from `/upstream-rfc` and the closing report of every
`/luci-change` land in `.dev/decisions/`. The directory is git-ignored
except `TEMPLATE.md`: these files are drafts of one maintainer's decision
process — device numbers, half-verified claims, revisions — not
documentation. The durable record lives where the decision is acted on:
the commit body for a change here, the pull request for an upstream one.
Each file keeps a `Decision:` line (pending / submitted / interim /
dropped) so the directory doubles as the ledger.
## Agents
| agent | role | tools |
|---|---|---|
| `luci-runtime-analyst` | traces a request through luci-base/uhttpd sources, returns claims with evidence and where the fix lives | read-only |
| `luci-claim-verifier` | refutes one claim or finding; verdict confirmed / corrected / refuted | read-only |
| `aurora-static-reviewer` | templates, CSS/tokens/budgets, patches, packaging, CI | read-only |
| `aurora-runtime-reviewer` | `menu-aurora.js`, `router-aurora.js`, page JS patches, a11y | read-only |
| `upstream-rfc-writer` | writes/revises one proposal from verified claims | writes `.dev/decisions/` only |
## Where the pattern comes from
| practice | source | here |
|---|---|---|
| orchestrator–workers, parallel reviewers, evaluator–optimizer loop | Anthropic, *Building effective agents* (2024) | workflow scripts; implement → review → fix, two rounds max |
| explore → plan → code; plan mode as the approval point; a second Claude reviews | Anthropic, *Claude Code best practices* | `/luci-change` steps 1–2, gate 2 |
| human-in-the-loop for high-risk actions, mechanical guardrails | OpenAI, *A practical guide to building agents* (2025) | gate 1 + `guard-edits.sh` |
| design doc before a non-trivial change; small, single-purpose commits; reviewers judge correctness, not taste | Google engineering practices | RFC template; one logical change; evidence-only findings |
| fixed proposal sections (motivation, reference, drawbacks, alternatives, unresolved questions) | Rust RFC / ADR | `TEMPLATE.md` |
| one logical patch with a cover letter carrying measurements | Linux kernel / OpenWrt submission norms | RFC "Submission"; bench before/after |
@@ -0,0 +1,10 @@
// Theme identity read by the Vite build (vite.config.ts) and the devkit dev
// layer / bins (@eamonxg/luci-theme-devkit). Everything theme-specific lives
// here; the machinery is generic.
export default {
name: "aurora", // media dir (/luci-static/aurora) + ucode theme dir
css: ["main", "login"], // src/media/<e>.css → /luci-static/aurora/<e>.css
resources: ["menu-aurora"], // src/resource/<m>.js served at /luci-static/resources/<m>.js in dev
assets: { dir: "public/aurora" }, // served under /luci-static/aurora/ in dev
patchAliases: {}, // built patch payloads duplicated under extra page names
};
+30
View File
@@ -0,0 +1,30 @@
{
"name": "luci-theme-aurora",
"description": "Modern OpenWrt LuCI theme built with Vite and TailwindCSS",
"type": "module",
"scripts": {
"dev": "vite",
"setup:router": "luci-theme-setup",
"build": "npm run clean && vite build",
"clean": "luci-theme-clean",
"test": "node --test tests/*.test.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"devDependencies": {
"@eamonxg/luci-theme-devkit": "^0.3.1",
"@eamonxg/luci-theme-tokens": "^2.0.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.0",
"browserslist": "^4.28.2",
"colorjs.io": "^0.6.1",
"lightningcss": "^1.32.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.0",
"tailwindcss": "^4.1.11",
"terser": "^5.43.1",
"vite": "^7.1.1"
},
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c"
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
@@ -0,0 +1,7 @@
@font-face {
font-display: swap;
font-family: "Lato";
font-style: normal;
font-weight: 400;
src: url("/luci-static/aurora/fonts/lato-v24-latin-regular.woff2") format("woff2");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,23 @@
{
"name": "LuCI Aurora",
"short_name": "Aurora",
"start_url": "/cgi-bin/luci",
"scope": "/",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#f8fafc",
"icons": [
{
"src": "/luci-static/aurora/images/app-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/luci-static/aurora/images/app-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><image width="512" height="512" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAAABS3GwHAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAwKADAAQAAAABAAAAwAAAAABNOznKAAArEUlEQVR4Ae19CbQkVZlmrLm8/dXWtYCN2t02lDinqUE9wgjap7VPd8857UwDooK04xQzIirabSEUZ9IWKAoX3IZpS0YBcY5DDTo4I9IOatEiskgDLVXVCCgURVELtbwlt9jufP+NjMzIfJmREblFZL57q/JlZMTd4r//fu/9rySJJCAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAw5BCQk9T/3Lk5bfe5kjK7bh2Tdjwmfe2xHRY6yJLUR9GX0YJAIgjgA7nt68sT7KPlMeXPzRmdmWMKyx41JWYzM33C/pEiKT9XXzj+i9v/67ajowV+8TZxQyB2AvjglZ++YGFD5rPF9ZmTjbTMJJnJ1CkGvo9fkm7iu+zYat5+Hp+9Wtm5WylJT47vL+/esSNXiBuAov3hhkCsBPDea6/9WHlN6vP5dWmFyZIDZWdpf9w7eCLLqsVkvQyCWLQKWsF6QSmzXUxWf5gplP/ptq1bDyCrUJeGGx8H3vulCDegLlz8mc/8Rf530jvzGzJpzu7DoG4FwyEimOQwRbNlSSnaklqwD6QWnINq2fl+dlH6vlE8+utvfe5z+QG9imhmiCEQGwFc8PltD+RfO3aWpQGZwyB/MyC7vYfwQHIkWSs5sr7olFXD2QvCeEI22b3ZOfvBb1639cVmxcU9AYFYCODC66//S3NN+vbFddo4IW7PhoEkAyUoTJqNT96RlLx5KAWbQTGke9Sy+eN/+8QTe8/fudPuWZuioqGGgBZL7xV2sjmhTgJRnZ6277MhLFVyrBmFSdPp1SVHeodWZu9Q8+r8d8c2Pfvus868X7LZruxL849/8wvXCenQ00EYrsp6x30jvPd7Prdt8/wpmb+3YPuiWP/7gBa4aMC3QqoStx0sS5+zjkFd2qMvWv9HVbR/SP+m9FvhWYowkCOQtf/I1wRIH/jUp//jK2+Y+Fp5Spdkp1MDoEnFYW+5b83lhWLBmDbgbl2w5qEy7ZMM50dakT1gWOn7d+Y+fixslSLfcEIgFhVIL5mO3FvlJxr0XZrjFoOjyMzISqyc1Schj14P2+H1yqL1cahML1zwxe3/opTsh5WS88M1c0efvOmmm4rRGhK5kw6BWCTA5k9c/f7DZ6z4RnFGl2ORAEGj4kkHaEs0OaGXmKQsmiZUp72p48Yebd68R19gP3/V49K+3K6cFVSVeJZ8CMQiAUrjqRmmKeDAMag/7cbEkw4wG2hmrpSFkTKWgpxgbyit1N+QWrTerRTsE7t/T97z7ndtv1efN34+aa945ObcZYvtqhbPkweBWAigMJvK26o8AOu3e4DTcgwQKlnrzFaZXJjVmTyrT0EivEUz2VuURc0pGMXnL7xp+y/VgvX/tKJz/63XXvNM9y2LGgYBgVgIQFbVIVUdsFDJNdrJhGHlFKTDSgKh/OrSGuk1esE+X1205//9f/vsUxAeD46dMP+Xeth4+tYv5k4MYjBFG9EhEIsN8Fc33fjB0kn6160sGGsCtaDoYHRLuFPSkgRBQeuWJHXRKmp5+2WtJD3KTPsBSZF/9j8/ceWTndYvyvUeArFIAEWS0ljcNkKo7w6M90awHZit494KLaPMaKeojvwaeJMuUMts8bybb3xUy5sPQp+6Z6pUemJHTqxo7T1ah68xFglw3he2fbj4qsxXrMxoSYCmYGeQCzCh8YwLCFg+ioZtPvKCKcHVujc1Zz6mGuzX8sTEHdn9+WM7tl8517QecbMvEIhFAjiyVBhFCdB0hEjSubLOpQJIB0MDBFaokikrp5pr06diWbck5c0thqS8AmP6DlZmv1hRmrxfeJaaQrSnN2MhABkeduABRp0LoFikUE+hGLUyBlpwYE/DlMb2BknGuiU5K2eNsdTv6ky5Ws6b0pFSad/5X/7sk6mS/RCznP99x1VX7QWgXFKK2p7I3xICsSDfBV/a/tf5DalbsBaIOhZLH1pCZLAP+LuDFTCl6NjeliDNYKriyIoqqZJmg1ZKRgkks0cp2/8I5rHXltn9J71wcJ+Yme5+sGKRAG63lzPe1w+cbIEEaNaNEv4qFrb8OAwTcRbDMiWJZdSMoihnKOPpM1RayJ0vzR06ae2+915/w91MtX+SKpq/uDWXK1FxkaJBIEYCiNbREc1NSF+zEugH3cEH7IGueOIKk+1IJCIgEKS0I02DTE6HL+103NxazipPv+ezN7wEnep2lHvoji1bnvbKiu9gCMRIADTK/mEO7uiIPnXFoG97jmwz8hnR/SoB1L07tEZ4zxx4kRTk4HkURX6dLCuvg8b0dst2Chdu3/YEU6RfSib7H/qxY0+J7aF1EKz74Q5A3a3+/xA2AGBM7lEe/wLXBnNkg3EyIORPFRgxpsD1stjHIKcWTBIY5F71E4uCmXZUDS3KsizY2U9DrPwK+R+GhPiJIpnzUJeeR/0iAQLxSQAa/NZ8bvQHp8J6CHO9nZyVlwYJAK/bLBR0dIUxFWiOSBkNFTjMtjlF0IS0rMkbYThvdFT2bsm2C7aTOvK+T1/3HSz1uxcepn3fuCH3m9EHdus3rAxD6wz9eEISYHFD+r/bFAfIJYN+NDMMdbrwB/eHBKhyfL2ItbKeURzwFvqiqSBOEuWolg3ITkShkGRQVU1yTJM2I83h9z3wSP/UcJx7dm7d+lJQ+VF8FosEIFkPVRaj3sC7RhHCQe9UIX9OBX5WACSFCCAZSXdbJk4mZYf7kltm8j1AfbRKQ7ItRN0jBUxTpnVJuVBdKF+oyuzERblrH1U0Za8lsycL0/Ld+Sm99KOL/3akw8vEQgC+MVnmlx7WczWmBotAtHezYX8CkYcrQWolQ19x4gLrNyRbVrKqPHmoMIG4Sn/C0tqfWAoz0gvqtavLxT0XX/N392hl4wfSyskD39iyZSF0A0OSURBAXAPlM4I9Mqh2hWN3MHLDV4TZM5QgOnDN6RBkU22hekGE4MCWKKzIKFMHFstywZDSRFiL5mq08TaoYn+MCbiPmcbC0cs2X3VLcRVCkM1IP7r1k7mD1UqG+EIQQGyD5zJvwloo5vjrx9+22o/ba8rm/vcXjv5GMLrtlCrb2KUHbxG2//DOwJLGmtUCBWeV1qXy1gZU/NXMnCWxE+zxzR/b+iBUsB/L89ZD69enjuT+y2kgm/N9Dt3o3YijhCCAOKBObZKnB0uAuBlE3hzi5l6EjBASgHeb8pMqxAmI3+n8D9Fgs3rovoTpNhkfJNm0cCn/K8xE/JGjOJcpzN5/8Ki5/5LcnoPZy6/efvj3Mkfv+ujw7IiLhQAw9FgNR7xrOSdAoJqwOo4WTVe8OVgrijktAo8/TzVz9ULFtHD7XNXsAReoxyOmgFy1R8wkYoV6RESzFnsdNoy/bDNE2Pizlc+Unrv0o1tvM7PazmFwsYb2INRevvsrJ6uq8GF3X9HQ18DdwKQDYZdA7WVCcQfQRpd2cLVB9IKQVzYndKUDtmRDcNCSJVMBQeol6w90S7ohc7z8jx++dMvl1UYSeuED++B6aGdVA4AOZm+D607sLREgIAGqCbvJwFv9d6qPahfIT4ZwzxLqslJqd/hAY6pgas60ypAoazG/fdOHLv3U9z78ydz6nvWzxxV198IddgbrulZBdLqmVod1jFQxUABx4ajv5GggACIEmt3qMlHzWIfUQS9aNAx6whPY1cpfsrnSdZs3b6ZNoolLsRAAGEUmcZCIq0PAOY6+OAfK6wKXCN6PVt/IBDUSzlSoT6QLdZFQmpvkTMMXEVWvEgbatu0yDJxLNGnFR3pVbS/rqQK9l5W2rSslkwokEkGAdsdRgveRf9MfQkcKxeWSRvV23QVyI64pbvFi3UMTNTikAdV6UddcNz9QJR12+MnLLvvUm7qppx9lYyEAO8uOYnFWqPUr/XjpRNVJE2I81XCY7jiggUAzifJwFQgZu0yoigeqz75StKEGdVlbs+JY9SLLa5jDPtTsaZz3YiEADDBHfg74ON8+EW0D4QjnfCMBA1KmuKREAoGJdKe2mQJr4A/RPE1HSHaml/pPfbsk32Qr3KK9+pL9/eUDe38b8teuFORZrFOPpW1/P5JxDcwjPPchMvR6rg+1M20VB3yEyvLi3pX7O8pfVMFbt7LQgSr1RSkfLi8kvixt2rx5C3azJSfFgoSItFmiQU4OGOLsCYEBH/9IEBJyrAzuF1wsEu0LCM4V/qk7Pxk+f6ScDPxfkl+tOSxRLlE/2CO9TzeZcSDFlEKT6zTwIgECHBRLXJChWITPdu4WlI4GTyj2V3ZbT/PynNBpP0IsONe8T/V8p1Went+H8Vbi8ZZp4Jd9cmGAlclcDydwKLABoCK2TeQFwuyrW0GXoCQ6QjxTDIu3IKlt89EyuBa9KllOOlrB/uaOhRodnRkVPbfLYesvcAZSe4WDyz7GiyVy3DfUTj5WLIVKts5BiQq4uaEXTAbi689rk5WBD9OwYTlBKR4CULGYSiQXAsBbQjlGs7CV5KFyOyOYL4WouFFBDNXyXj2RvlFaLfs6Ealw+MyKonbXz/BNhcoZCwGkT0irXBsgVB9HPhMhPCI2VN+TlBrEhWirBGEji7sphhZSdJuoeR7Et9aPbqschvKxEACGax5sz+f4GwZQ9auPFeSlkfChMVeCfL9btk7eLine truncated

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="#000000">
<path fill-rule="evenodd" d="M15.75 4.5a3 3 0 1 1 .825 2.066l-8.421 4.679a3.002 3.002 0 0 1 0 1.51l8.421 4.679a3 3 0 1 1-.729 1.31l-8.421-4.678a3 3 0 1 1 0-4.132l8.421-4.679a3 3 0 0 1-.096-.755Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="#000000">
<path d="M11.47 3.841a.75.75 0 0 1 1.06 0l8.69 8.69a.75.75 0 1 0 1.06-1.061l-8.689-8.69a2.25 2.25 0 0 0-3.182 0l-8.69 8.69a.75.75 0 1 0 1.061 1.06l8.69-8.689Z"/>
<path d="m12 5.432 8.159 8.159c.03.03.06.058.091.086v6.198c0 1.035-.84 1.875-1.875 1.875H15a.75.75 0 0 1-.75-.75v-4.5a.75.75 0 0 0-.75-.75h-3a.75.75 0 0 0-.75.75V21a.75.75 0 0 1-.75.75H5.625a1.875 1.875 0 0 1-1.875-1.875v-6.198a2.29 2.29 0 0 0 .091-.086L12 5.432Z"/>
</svg>

After

Width:  |  Height:  |  Size: 539 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="#000000">
<path d="M6 3a3 3 0 0 0-3 3v2.25a3 3 0 0 0 3 3h2.25a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H6ZM15.75 3a3 3 0 0 0-3 3v2.25a3 3 0 0 0 3 3H18a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3h-2.25ZM6 12.75a3 3 0 0 0-3 3V18a3 3 0 0 0 3 3h2.25a3 3 0 0 0 3-3v-2.25a3 3 0 0 0-3-3H6ZM17.625 13.5a.75.75 0 0 0-1.5 0v2.625H13.5a.75.75 0 0 0 0 1.5h2.625v2.625a.75.75 0 0 0 1.5 0v-2.625h2.625a.75.75 0 0 0 0-1.5h-2.625V13.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="#000000">
<path fill-rule="evenodd" d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 0 0-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 0 0-2.282.819l-.922 1.597a1.875 1.875 0 0 0 .432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 0 0 0 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 0 0-.432 2.385l.922 1.597a1.875 1.875 0 0 0 2.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 0 0 2.28-.819l.923-1.597a1.875 1.875 0 0 0-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 0 0 0-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 0 0-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 0 0-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 0 0-1.85-1.567h-1.843ZM12 15.75a3.75 3.75 0 1 0 0-7.5 3.75 3.75 0 0 0 0 7.5Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg>

After

Width:  |  Height:  |  Size: 224 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M6 4v4" /><path d="M6 12v8" /><path d="M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M12 4v10" /><path d="M12 18v2" /><path d="M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M18 4v1" /><path d="M18 9v11" /></svg>

After

Width:  |  Height:  |  Size: 452 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" /><path d="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" /><path d="M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" /><path d="M14 7l6 0" /><path d="M17 4l0 6" /></svg>

After

Width:  |  Height:  |  Size: 502 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="24" height="24" fill="#ffffff" style="opacity:1;"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="24" height="24" fill="#000000" style="opacity:1;"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="24" height="24" fill="#000000" style="opacity:1;"><path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10L8.22 6.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5" /><path d="M12 12l8 -4.5" /><path d="M12 12l0 9" /><path d="M12 12l-8 -4.5" /></svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6v6h-6l0 -6" /><path d="M14 4h6v6h-6l0 -6" /><path d="M4 14h6v6h-6l0 -6" /><path d="M14 17a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /></svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6" /><path d="M15 9a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -10" /><path d="M9 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -14" /><path d="M4 20h14" /></svg>

After

Width:  |  Height:  |  Size: 482 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M918.8 83.7c-43.5-30.3-104-19.5-134.6 24.1l-425.8 606-127-135.7c-36.4-38.9-98.1-41.1-137-4.8-39 36.2-41.1 97.7-4.7 136.6l206.5 220.7c4.7 5 9.8 9.4 15.1 13.1.7.5 1.4 1 2.2 1.5 43.5 30.3 104 19.5 134.6-24.1l494-703.2c30.7-43.5 20.1-103.9-23.3-134.2z"/></svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -12" /><path d="M9 9h6v6h-6l0 -6" /><path d="M3 10h2" /><path d="M3 14h2" /><path d="M10 3v2" /><path d="M14 3v2" /><path d="M21 10h-2" /><path d="M21 14h-2" /><path d="M14 21v-2" /><path d="M10 21v-2" /></svg>

After

Width:  |  Height:  |  Size: 478 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4" /><path d="M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0" /><path d="M12 16h.01" /></svg>

After

Width:  |  Height:  |  Size: 380 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M13.41 10.59l2.59 -2.59" /><path d="M7 12a5 5 0 0 1 5 -5" /></svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24" fill="#000000" style="opacity:1;"><path fill-rule="evenodd" d="M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0M9 5a1 1 0 1 1-2 0a1 1 0 0 1 2 0M6.75 8a.75.75 0 0 0 0 1.5h.75v1.75a.75.75 0 0 0 1.5 0v-2.5A.75.75 0 0 0 8.25 8z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5" /><path d="M12 12l8 -4.5" /><path d="M12 12l0 9" /><path d="M12 12l-8 -4.5" /><path d="M16 5.25l-8 4.5" /></svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2" /></svg>

After

Width:  |  Height:  |  Size: 301 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="6"/>
</svg>

After

Width:  |  Height:  |  Size: 122 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8.1 8.1 0 0 0 -11.271 -6.305m-2.41 1.624a8.083 8.083 0 0 0 -1.819 2.681m-.5 -4v4h4" /><path d="M4 13a8.1 8.1 0 0 0 13.671 4.691m2.329 -1.691v-1h-1" /><path d="M3 3l18 18" /></svg>

After

Width:  |  Height:  |  Size: 377 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" /><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" /></svg>

After

Width:  |  Height:  |  Size: 291 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 15a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -4" /><path d="M17 17l0 .01" /><path d="M13 17l0 .01" /><path d="M15 13l0 -2" /><path d="M11.75 8.75a4 4 0 0 1 6.5 0" /><path d="M8.5 6.5a8 8 0 0 1 13 0" /></svg>

After

Width:  |  Height:  |  Size: 430 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /><path d="M21 21l-6 -6" /></svg>

After

Width:  |  Height:  |  Size: 268 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2" /><path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2" /><path d="M7 8l0 .01" /><path d="M7 16l0 .01" /><path d="M11 8h6" /><path d="M11 16h6" /></svg>

After

Width:  |  Height:  |  Size: 457 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg>

After

Width:  |  Height:  |  Size: 770 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3" /></svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path opacity=".05" d="M512 85.333c235.648 0 426.667 191.019 426.667 426.667S747.648 938.667 512 938.667 85.333 747.648 85.333 512 276.352 85.333 512 85.333zm0 128a298.667 298.667 0 1 0 0 597.334 298.667 298.667 0 0 0 0-597.334z"/><path d="M813.696 813.696c166.613-166.613 166.613-436.779 0-603.392s-436.779-166.613-603.392 0a64 64 0 0 0 90.496 90.496 298.667 298.667 0 1 1 422.4 422.4 64 64 0 0 0 90.496 90.496z"/></svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3" /></svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /><path d="M3.6 9h16.8" /><path d="M3.6 15h16.8" /><path d="M11.5 3a17 17 0 0 0 0 18" /><path d="M12.5 3a17 17 0 0 1 0 18" /></svg>

After

Width:  |  Height:  |  Size: 360 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>

After

Width:  |  Height:  |  Size: 236 B

@@ -0,0 +1,16 @@
/**
* Document foundation — shared by main.css and login.css.
* Solid bg on html/body — one paint path with header (no body::before;
* fixed pseudo made the same var(--bg) look mismatched).
*/
html {
@apply bg-bg min-h-dvh font-sans scheme-light;
&[data-darkmode="true"] {
@apply scheme-dark;
}
body {
@apply bg-bg min-h-dvh;
}
}
@@ -0,0 +1,90 @@
*:not(html) {
@apply [scrollbar-color:var(--text-muted)_transparent] [scrollbar-width:thin];
}
html {
/* Stable gutter: short pages and open modals (body overflow hidden) would
otherwise drop the classic scrollbar and shift the whole layout. */
@apply [scrollbar-color:var(--text-muted)_var(--surface)] [scrollbar-gutter:stable] [scrollbar-width:thin];
body {
@apply text-text relative flex min-h-dvh flex-col text-sm leading-relaxed font-normal;
&.modal-overlay-active {
@apply h-screen overflow-hidden;
}
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-text font-sans leading-tight font-semibold tracking-tight;
}
h1 {
@apply mb-4 pl-6 text-3xl max-md:pl-4 max-md:text-2xl;
}
h2 {
@apply mb-4 pl-5 text-2xl max-md:mb-2 max-md:pl-4 max-md:text-xl;
}
h3 {
@apply mb-4 text-xl font-semibold tracking-tight max-md:mb-2 max-md:text-lg;
}
h4 {
@apply mb-2 text-lg max-md:text-base;
}
h5 {
@apply mb-2 text-base max-md:text-sm;
}
h6 {
@apply mb-2 text-sm max-md:text-xs;
}
hr {
@apply border-hairline my-6 border-t max-md:my-4;
}
strong {
@apply font-semibold tracking-tight;
}
abbr {
@apply cursor-help;
}
p {
@apply text-text text-sm leading-relaxed;
}
pre {
@apply text-text-muted border-hairline bg-surface-sunken block rounded-xl border px-3 py-2 font-mono text-sm leading-relaxed wrap-break-word whitespace-pre-wrap;
}
code {
@apply text-text-muted bg-surface-sunken rounded px-2 py-1 font-mono text-sm;
}
a {
@apply text-link no-underline hover:underline;
}
var {
@apply text-link font-mono;
}
small {
@apply text-text text-xs font-normal;
}
em {
@apply text-text-muted font-medium italic;
}
@@ -0,0 +1,32 @@
/* Shared encoded icon payloads. Referencing these variables from masks keeps
Vite from inlining the same SVG once per selector and vendor declaration. */
:root {
@apply [--icon-activity:url('@assets/icons/activity.svg')];
@apply [--icon-adjustments:url('@assets/icons/adjustments.svg')];
@apply [--icon-apps:url('@assets/icons/apps.svg')];
@apply [--icon-arrow-down:url('@assets/icons/arrow-down.svg')];
@apply [--icon-arrow-down-dark:url('@assets/icons/arrow-down-dark.svg')];
@apply [--icon-arrow-right:url('@assets/icons/arrow-right.svg')];
@apply [--icon-box:url('@assets/icons/box.svg')];
@apply [--icon-category:url('@assets/icons/category.svg')];
@apply [--icon-chart-bar:url('@assets/icons/chart-bar.svg')];
@apply [--icon-check:url('@assets/icons/check.svg')];
@apply [--icon-cpu:url('@assets/icons/cpu.svg')];
@apply [--icon-error:url('@assets/icons/error.svg')];
@apply [--icon-gauge:url('@assets/icons/gauge.svg')];
@apply [--icon-info:url('@assets/icons/info.svg')];
@apply [--icon-package:url('@assets/icons/package.svg')];
@apply [--icon-phone:url('@assets/icons/phone.svg')];
@apply [--icon-radio:url('@assets/icons/radio.svg')];
@apply [--icon-refresh:url('@assets/icons/refresh.svg')];
@apply [--icon-refresh-off:url('@assets/icons/refresh-off.svg')];
@apply [--icon-router:url('@assets/icons/router.svg')];
@apply [--icon-search:url('@assets/icons/search.svg')];
@apply [--icon-server:url('@assets/icons/server.svg')];
@apply [--icon-settings:url('@assets/icons/settings.svg')];
@apply [--icon-shield:url('@assets/icons/shield.svg')];
@apply [--icon-spinning:url('@assets/icons/spinning.svg')];
@apply [--icon-tag:url('@assets/icons/tag.svg')];
@apply [--icon-world:url('@assets/icons/world.svg')];
@apply [--icon-x:url('@assets/icons/x.svg')];
}
@@ -0,0 +1,658 @@
/* The bar carries no colour transition: one would ease its bg/text over
--mega-menu-duration on every data-darkmode flip, lagging the bar behind
the untransitioned page in all three nav modes. The mega-menu wipe colour
lives on .desktop-menu-sheet below, which owns its transition. */
header {
@apply bg-bg sticky top-0 z-40 mb-2;
& .header-content {
@apply relative z-10 flex h-14 items-center justify-between px-6 py-3 max-md:px-4 max-md:py-2;
}
[data-nav-type="mega-menu"] & {
/* Compositor-only curtain (supersedes the height transition, which paid
main-thread layout + repaint on every frame and janked low-end devices):
the container is a STATIC clip frame rooted at the bar, sized by
--mega-menu-height (the tallest panel, measured once in menu-aurora.js
and kept across open/close), and the reveal is a pair of opposite
translateY transitions — .desktop-menu-sheet (the opaque panel surface,
overflow-hidden) slides down from behind the bar while
.desktop-menu-canvas inside it counter-slides by the same distance, so
the content nets to a fixed position and the sheet's bottom edge wipes
it in, visually identical to the old drawer but animated entirely on
the compositor thread.
Apple's layer split still applies: the surface that animates must not
carry a backdrop-filter, or every frame re-rasterises the blur. The
sheet is fully opaque; the frost lives on the separate
.desktop-menu-overlay curtain.
Opacity is not animated on open — visibility gates the panel so the
descending sheet is opaque from the first visible frame. Closing is the
opposite: the whole menu surface fades out immediately while the sheet
retracts, so the frost, scrim and menu colour do not disappear in
separate stages. */
/* Apple-style clip frame: the opened surface starts at the top of the
header, not below it. The header content floats above this layer. The
header-height band is part of the moving sheet, so open and close share
the same page-top origin. */
& .desktop-menu-container {
@apply pointer-events-none invisible absolute inset-x-0 top-0 z-0 h-[calc(var(--mega-menu-height,0px)+3.5rem+3rem)] w-full overflow-clip max-md:hidden;
&.active,
&.closing {
@apply visible;
}
&.active {
@apply pointer-events-auto;
}
&.closing {
@apply opacity-0 transition-opacity duration-[220ms] ease-[cubic-bezier(0.4,0,0.6,1)] motion-reduce:transition-none;
}
/* The sheet is the whole opened surface, including the header-height
band. Its closed bottom edge is page top (not the header bottom), so
the close stops at the same origin that the reveal starts from. The
canvas counter-translates by the same travel distance so the menu
content remains visually fixed.
Duration is distance-adaptive via --mega-menu-duration, set on the
header by menu-aurora.js from apple.com's flyout pacing (2px/ms over
the visible travel, clamped to 240–480ms). */
& .desktop-menu-sheet {
@apply bg-mega-menu-bg absolute inset-x-0 top-0 h-[calc(var(--mega-menu-height,0px)+3.5rem)] -translate-y-full overflow-hidden transition-[translate] duration-(--mega-menu-duration,300ms) ease-[cubic-bezier(0.4,0,0.6,1)] motion-reduce:transition-none;
&::after {
@apply border-hairline pointer-events-none absolute inset-0 border-b shadow-xl transition-opacity duration-(--mega-menu-duration,300ms) content-[''] motion-reduce:transition-none;
}
}
/* The counter-transform must mirror the sheet's endpoints and timing
exactly or the content drifts during the wipe — keep both in
lockstep. */
& .desktop-menu-canvas {
@apply absolute inset-0 translate-y-full transition-[translate] duration-(--mega-menu-duration,300ms) ease-[cubic-bezier(0.4,0,0.6,1)] motion-reduce:transition-none;
}
&.active .desktop-menu-sheet,
&.active .desktop-menu-canvas {
@apply translate-y-0;
}
&.closing .desktop-menu-sheet {
&::after {
@apply opacity-0;
}
}
/* Category panels live inside .desktop-menu-canvas, not the nav items:
the transformed #topmenu would otherwise become the containing block
of a position:fixed panel and shrink the canvas to the menu's width.
Each panel is the same centered three-column grid (max-width fixed),
so switching categories is a pure cross-fade with no width shift.
The entering panel floats up 6px — translate + opacity only. */
/* The server-rendered board is only a template that menu-aurora.js
clones into each panel's right column — the original stays hidden. */
& > .desktop-menu-board {
@apply hidden;
}
& .desktop-nav {
/* Centered three-column canvas: anchor / links / device board.
Fixed max-width keeps every category the same width, so switching
is a pure cross-fade. Below lg the side tracks drop and only the
links column remains (tablet → just the submenu). The panel scrolls
internally when its content exceeds the viewport below the header. */
/* Tailwind v4 translate-* utilities emit the native `translate`
property, so that is what must be transitioned — transitioning
`transform` here left the 6px entrance float snapping instead of
animating (only the opacity ever faded). */
@apply pointer-events-none absolute inset-x-0 top-14 mx-auto grid max-h-[calc(100dvh-3.5rem)] max-w-[72rem] translate-y-1.5 grid-cols-[12rem_minmax(0,1fr)_13rem] gap-x-8 overflow-y-auto overscroll-contain px-10 pt-13 pb-8 opacity-0 transition-[opacity,translate] duration-[250ms] ease-out max-lg:max-w-[44rem] max-lg:grid-cols-1 max-lg:px-8;
&.active {
@apply pointer-events-auto translate-y-0 opacity-100;
}
& .desktop-nav-anchor {
@apply self-start max-lg:hidden;
& .desktop-nav-title {
@apply text-text flex items-center gap-2.5 text-2xl font-semibold tracking-tight;
/* First-level icon: mask + currentColor so it follows the theme.
--menu-icon comes from the shared name → icon map in
components/_nav.css; unmapped → the var() fallback. The default
must live in the fallback, not as a --menu-icon declaration
here: this nested chain compiles to (0,5,1) and would outrank
every (0,2,0) [data-section] mapping. */
&::before {
@apply size-6 shrink-0 bg-current content-[''] [mask:var(--menu-icon,var(--icon-category))_center/contain_no-repeat];
}
}
}
& .desktop-nav-list {
/* Vertical fill, content-width columns. Row count per submenu via
--menu-rows (menu-aurora.js); more items grow rows, not width,
so the list stays inside the middle track. */
@apply grid grid-flow-col grid-rows-[repeat(var(--menu-rows,6),auto)] content-start justify-start gap-x-8 gap-y-1;
& > li {
@apply m-0 list-none;
& > a {
@apply text-text hover:bg-hover-faint block w-fit rounded-xl px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors duration-150;
&.is-active-page {
@apply bg-brand-subtle text-brand font-medium;
}
}
}
}
/* Right column: device summary, cloned per panel. self-start so it
sits at the top regardless of how tall the links column grows. */
& .desktop-menu-board {
@apply border-hairline flex flex-col gap-2 self-start border-l pl-7 max-lg:hidden;
& .board-label {
@apply text-text-subtle mb-0.5 text-[11px] font-medium tracking-wide uppercase;
}
& .board-line {
/* items-start keeps the icon aligned to the FIRST text line when a
long value wraps. Icon height (size-4) == text leading (leading-4)
so a single line still reads as vertically centered. */
@apply text-text-muted flex max-w-full items-start gap-2.5 text-xs;
&::before {
@apply size-4 shrink-0 bg-current content-[''];
}
& .board-text {
@apply min-w-0 leading-4 [overflow-wrap:anywhere];
}
}
& .board-line-host::before {
@apply [mask:var(--icon-router)_center/contain_no-repeat];
}
& .board-line-model::before {
@apply [mask:var(--icon-cpu)_center/contain_no-repeat];
}
& .board-line-firmware::before {
@apply [mask:var(--icon-package)_center/contain_no-repeat];
}
& .board-line-kernel::before {
@apply [mask:var(--icon-tag)_center/contain_no-repeat];
}
}
}
}
}
/* The bar must sit above the z-60 curtain for the whole open AND the
whole retract, or the scrim would dim the closing panel. */
[data-nav-type="mega-menu"]
&:has(.desktop-menu-container:is(.active, .closing)) {
@apply z-70;
}
.brand {
@apply hover:text-brand text-text inline-block shrink-0 text-xl font-semibold tracking-tight no-underline transition-[color,transform] duration-150 hover:-translate-y-0.5 max-md:flex-1 max-md:text-lg;
[data-nav-type="sidebar"] & {
@apply md:hidden;
}
[data-nav-type="sidebar"].sidebar-collapsed & {
@apply md:inline-block md:transition-opacity md:duration-150 md:starting:opacity-0;
}
}
.nav {
@apply absolute left-1/2 z-60 m-0 flex -translate-x-1/2 transform list-none items-center gap-1 p-0 max-md:hidden;
& > li {
@apply relative;
.menu {
@apply text-text hover:bg-hover-faint block rounded-xl px-3.5 py-1.5 font-medium no-underline;
/* Unified pill for both dropdown modes — the open/active category is a
filled brand pill, matching the submenu links and the sidebar. */
&.menu-active {
@apply bg-brand-subtle text-brand;
}
}
/* Dropdown panels only — mega-menu panels are reparented into
.desktop-menu-container (see above). */
& .desktop-nav {
@apply pointer-events-none left-0 z-40 px-0 pt-0 opacity-0 transition-opacity duration-150;
[data-nav-type="dropdown"] & {
@apply absolute top-full mt-2 min-w-48;
}
&.active {
@apply pointer-events-auto opacity-100;
}
& .desktop-nav-list {
[data-nav-type="dropdown"] & {
@apply border-hairline bg-surface-overlay flex flex-col gap-y-1 rounded-3xl border py-2 shadow-lg backdrop-blur-md backdrop-saturate-150;
}
& > li {
@apply m-0 list-none;
& > a {
@apply text-text block whitespace-nowrap no-underline;
[data-nav-type="dropdown"] & {
@apply hover:bg-hover-faint mx-2 rounded-xl px-4 py-2 transition-colors duration-150;
}
/* Same active pill as the mega submenu and the top-level
trigger — one unified vocabulary across both dropdown modes. */
&.is-active-page {
@apply bg-brand-subtle text-brand font-medium;
}
}
}
}
}
}
}
.navigation-controls {
@apply mr-3 flex shrink-0 items-center md:hidden;
[data-nav-type="sidebar"] & {
@apply md:flex;
}
/* D1 底线呼吸(2026-07 反转):展开后底线伸满 —— 三线齐整 = 内容
全部亮出;折叠态回落到短底线 = 还有内容收着,缺口招手可展开。
与耦合滑动同向:侧栏进场线补齐,退场线让位。dasharray 只改可见
长度,线帽形状不受影响。 */
[data-nav-type="sidebar"]:not(.sidebar-collapsed)
&
.navigation-toggle-line-bottom {
@apply md:[stroke-dasharray:15_15];
}
& .navigation-toggle {
@apply cursor-pointer rounded-none border-0 bg-transparent p-0 shadow-none transition-transform duration-150 hover:scale-105 active:scale-95;
& svg {
@apply text-text size-5;
}
& .navigation-toggle-line {
transform-box: fill-box;
@apply origin-center transition-[transform,opacity,stroke-dasharray] duration-150 ease-in-out;
/* Desktop sidebar: the dasharray breath runs in step with the
250ms coupled slide (the mobile X morph keeps its snappy 150). */
[data-nav-type="sidebar"] & {
@apply md:duration-[250ms];
}
}
/* Tabler menu-2 geometry: all three lines are full-length equals.
The signature short bottom line is a stroke-dasharray crop, not a
shorter segment or a scaleX — scaling distorts the round caps and
makes the "equal" collapsed state read as uneven lengths. */
& .navigation-toggle-line-bottom {
@apply [stroke-dasharray:7.5_15];
}
&.is-expanded {
& .navigation-toggle-line-top {
transform: translateY(5px) rotate(45deg);
}
& .navigation-toggle-line-middle {
transform: scaleX(0.35);
@apply opacity-0;
}
& .navigation-toggle-line-bottom {
transform: translateY(-5px) rotate(-45deg);
@apply [stroke-dasharray:15_15];
}
}
}
}
#indicators {
@apply mr-4 ml-auto flex shrink-0 flex-row-reverse gap-5 md:gap-8;
/* Same hover language as .navigation-toggle and .cmdk-trigger: a scale
nudge, never a background plate. */
& span[data-indicator] {
@apply size-5 cursor-pointer text-[0px] transition-transform duration-150 hover:scale-105 active:scale-95;
&::before {
@apply text-text absolute size-5 bg-current content-[''];
}
&[data-indicator="media_error"] {
&::before {
@apply [mask:var(--icon-error)_center/cover_no-repeat];
}
}
&[data-indicator="poll-status"] {
&[data-style="active"] {
&::before {
@apply [mask:var(--icon-refresh)_center/cover_no-repeat];
}
}
&[data-style="inactive"] {
&::before {
@apply [mask:var(--icon-refresh-off)_center/cover_no-repeat];
}
}
}
&[data-indicator="uci-changes"] {
@apply relative;
&::before {
@apply [mask:var(--icon-settings)_center/cover_no-repeat];
}
&[data-count]:not([data-count="0"]) {
&::after {
@apply bg-danger text-on-brand absolute -top-0.5 -right-0.5 z-10 flex min-h-3 min-w-3 items-center justify-center rounded-full px-0.5 text-[8px] leading-none font-bold shadow-sm content-[attr(data-count)];
}
}
}
}
}
}
body[data-nav-type="sidebar"] {
/* T4 shell: the sidebar is a full-height first column (brand → menu →
logout in one run), and the header spans only the content column — so
the sidebar's right border runs viewport top to bottom and the header
reads as the content area's toolbar, not a page-wide bar.
A CSS grid (not flex-wrap) keeps the header row at its natural height —
so collapsing the sidebar never stretches it — lets the sidebar column
collapse to zero, and lets #maincontent reclaim the freed space.
overflow-x-clip keeps the sliding sidebar out of horizontal overflow. */
/* Single source of truth for the sidebar column, so collapsing only has to
zero this one value instead of restating the grid template.
--sidebar-w-full stays constant: the inner panel width reads it, and
must not see the zeroed collapsed value. */
--sidebar-w-full: 17rem;
--sidebar-w: var(--sidebar-w-full);
/* The column eases with the panel's slide below (same curve and length),
so the content edge follows the sidebar edge frame by frame. */
@apply md:grid md:grid-cols-[var(--sidebar-w)_minmax(0,1fr)] md:grid-rows-[auto_minmax(0,1fr)] md:overflow-x-clip md:[transition:grid-template-columns_250ms_var(--ease-in-out)];
&.sidebar-collapsed {
@apply md:[--sidebar-w:0rem];
}
& > header {
@apply md:border-hairline md:col-start-2 md:row-start-1 md:mb-0 md:border-b;
}
& > .sidebar-panel {
@apply md:col-start-1 md:row-span-2 md:row-start-1;
}
& > #maincontent {
@apply md:col-start-2 md:row-start-2 md:min-w-0 md:pt-2;
/* This shell runs full-width and reads no width cap. Honouring
--content-width-centered here would shrink content twice — viewport
minus the sidebar, then clamped to 80rem and re-centred in what's left,
which puts the page visibly right-of-centre on a wide display. The
sidebar column is the margin; content fills the rest. Desktop only —
below md the sidebar is a drawer and the base rule's
max-md:w-full/px-3 still applies. */
@apply md:w-full md:max-w-none md:px-8;
}
& .header-crumb {
/* Expanded: the crumb is the header's leftmost element (brand lives in
the sidebar head), so it sits on the header's own padding. Collapsed:
burger + brand reappear before it and it takes back its old offset. */
@apply text-text-muted my-0 mr-auto ml-0 hidden min-w-0 list-none items-center gap-2 overflow-hidden p-0 text-sm md:flex;
& li {
@apply m-0 list-none whitespace-nowrap;
}
& .crumb-sep {
@apply opacity-50;
}
& .current {
@apply text-text min-w-0 truncate font-medium;
}
}
&.sidebar-collapsed .header-crumb {
@apply md:ml-6;
}
& .sidebar-panel {
@apply sticky top-0 hidden h-dvh w-full overflow-hidden rounded-none md:block;
}
/* Hidden (out of the tab order) only once the slide-out has run. The
hairline lives on the inner panel so it tracks the sliding edge. */
&.sidebar-collapsed .sidebar-panel {
@apply invisible [transition:visibility_0s_250ms];
}
& .sidebar-panel-inner {
@apply bg-bg border-hairline flex h-full w-(--sidebar-w-full) flex-col overflow-hidden border-r [transition:translate_250ms_var(--ease-in-out)];
}
&.sidebar-collapsed .sidebar-panel-inner {
@apply -translate-x-full;
}
/* Sidebar head: logo + hostname. h-14 matches the header row so its
bottom hairline continues the header's border in one unbroken line
across the viewport. box-content: the header is auto-height (56px
content + border OUTSIDE), so this border must sit outside its 56px
too — border-box would paint it one pixel higher than the header's. */
& .sidebar-head {
@apply border-hairline box-content flex h-14 shrink-0 items-center gap-2.5 border-b px-4;
& .sidebar-logo {
@apply size-5 shrink-0 rounded-md;
}
& .sidebar-brand {
@apply text-text hover:text-brand min-w-0 flex-1 truncate text-lg font-semibold tracking-tight no-underline transition-colors duration-150;
}
}
& .sidebar-list {
@apply m-0 flex-1 list-none space-y-0.5 overflow-y-auto p-3;
& li {
@apply m-0 list-none;
}
}
& .sidebar-list .nav-row {
@apply gap-2 rounded-xl px-3 py-2 text-lg font-semibold tracking-wide;
}
& .sidebar-list .nav-icon {
@apply size-4.5;
}
& .sidebar-submenu {
/* pl: the parent row's px-3 (0.75) + icon (1.125) + gap (0.5) put the
label at 2.375rem; sublinks bring their own px-3, so the list
supplies the remaining 1.625rem and sublink text hangs off the
parent label, not the row edge. */
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-6.5;
}
& .sidebar-submenu .navigation-sublink {
@apply px-3 py-1.5 text-sm;
}
& .sidebar-footer {
@apply border-hairline shrink-0 border-t p-3;
/* Logout: muted at rest, standard row treatment on hover — same
vocabulary as every other sidebar row (the v1 danger-red hover read
as unrelated to the rest of the menu). */
& .nav-link {
@apply text-text-muted hover:text-text;
}
}
}
/* Router progress (router.md) — Turbo Drive's bar: inserted by the router
once a navigation outlives its silent 150 ms, width driven inline and
trickling until commit (so a slow render keeps visibly moving), then
filled, faded and removed from the DOM. Only width and opacity move.
Reduced motion drops the transitions, not the bar. */
#luci-nav-progress {
@apply from-progress-start to-progress-end pointer-events-none fixed top-0 left-0 z-80 h-0.5 w-0 bg-linear-to-r [transition:width_.3s_var(--ease-out)];
&[data-state="done"] {
@apply opacity-0 [transition:width_.3s_ease-out,opacity_.15s_ease-in_.15s];
}
}
/* Router announcement (router.md): the new page's title for screen
readers after a same-document swap, which fires no load event. */
#luci-nav-status {
@apply sr-only;
}
#maincontent {
/* Centred shells (top-bar navigation) clamp here and gutter the remainder.
The sidebar shell overrides this — it caps the shell instead, see
body[data-nav-type="sidebar"] above. */
@apply mx-auto min-h-[calc(100vh-4rem)] w-23/24 max-w-(--content-width-centered) px-4 outline-none max-md:w-full max-md:px-3;
/* outline-none: the router focuses this landmark (tabindex=-1) after a
same-document swap for assistive tech. iOS WebKit paints its focus ring
for programmatic focus, and the ring's top edge just under the sticky
header read as a stuck progress bar; a landmark needs no visible ring. */
/* Router staging (router.md): the incoming region keeps its full
width for layout-measuring renders but takes no space and no paint. */
.view-staging {
@apply invisible h-0 overflow-hidden;
}
#view {
@apply mx-0 w-full bg-transparent p-0 shadow-none empty:hidden md:p-0;
/* Outgoing page while the router renders the next one (router.md). */
&.view-leaving {
@apply cursor-progress opacity-60 [transition:opacity_150ms_ease-out];
}
.cbi-title-section {
@apply mb-6 leading-relaxed max-md:mx-2 max-md:mb-3;
}
.controls {
@apply m-2 flex flex-wrap items-center gap-3 max-md:m-1 max-md:gap-2;
& label {
& input[type="radio"],
& input[type="checkbox"] {
@apply mr-1 align-middle;
}
}
}
& div[style] {
& > svg {
@apply dark:!bg-surface;
& line[style] {
@apply dark:!stroke-text;
}
}
}
}
.cbi-map-descr,
.cbi-section-descr {
@apply text-text-muted mb-6 text-sm leading-relaxed max-md:mx-2 max-md:mb-3 max-md:text-sm;
}
.cbi-page-actions {
@apply border-hairline mt-6 flex flex-wrap items-center justify-end gap-3 border-t pt-4 max-md:mx-2 max-md:mt-4 max-md:gap-2 max-md:pt-3;
}
.zone-forwards {
@apply flex flex-nowrap items-stretch gap-2 leading-relaxed;
& > span {
@apply text-text-muted flex shrink-0 basis-8 items-center justify-center self-center;
}
.zone-src,
.zone-dest {
@apply flex flex-1 flex-col gap-1.5;
}
}
/* Hidden while the view is leaving or loading so its jump to the new page
height is never seen; animate-none restarts its fade-in once it returns. */
&:has(> #view.view-leaving, > #view > .spinning) > footer {
@apply invisible animate-none;
}
}
/* Only the theme toggle captures the page: a router swap would crossfade
two page layouts on top of the content's own fade-in. */
:root:not(:active-view-transition-type(theme)) {
@apply [view-transition-name:none];
}
footer {
@apply text-text-muted mt-2 flex min-h-16 flex-wrap items-center justify-between text-xs;
& a {
@apply text-brand;
}
& span {
@apply inline-block text-center;
}
& .breadcrumb {
@apply bg-surface-overlay hover:bg-surface flex items-center gap-1 rounded-full px-3 py-1.5 text-xs shadow-sm backdrop-blur-md backdrop-saturate-150 transition-colors duration-150;
& li {
@apply list-none;
&.active a {
@apply text-text;
}
}
}
}
.control-group {
@apply mt-1;
}
.control-group,
.cbi-page-actions > div,
.cbi-section-actions > div {
@apply flex flex-row flex-nowrap items-center gap-2 max-md:flex-wrap max-md:gap-1;
}
@@ -0,0 +1,47 @@
/**
* Admin custom-background mode (scheme D) — active only when header.ut saw
* struct_main_bg and stamped <body data-bg>. Without the attribute none of
* these selectors match and the page paints exactly as it does today.
*
* Frost only the two small fixed chrome surfaces (the budget Apple's own
* iOS chrome runs on); cards stay blur-free alpha blends. Every color
* derives from --bg/--surface via color-mix, so custom palettes and dark
* mode follow automatically. The three tunables default in var() fallbacks,
* never on body — the config app's UCI overrides land on :root and a
* body-level declaration would mask them.
*/
body[data-bg] {
& .page-bg {
--page-bg: var(--main-bg);
--page-bg-lqip: var(--main-bg-lqip);
}
/* Two jobs on one element. relative: the fixed .page-bg paints above
non-positioned flow content (positioned elements come after in-flow
blocks in the painting order) — the login page has always dodged this
via .login-screen's relative; a negative z-index on the layer is NOT an
option, body's own opaque background would cover the image. canvas: a
--bg-derived translucent wash under ALL content is what makes ARBITRARY
plugin DOM readable — bare headings, descriptions and hand-rolled
widgets sit on this floor instead of the raw photo, with zero
per-plugin knowledge. 55% is a deliberate internal constant, not a
fourth tunable. */
& #maincontent {
@apply relative [background:color-mix(in_srgb,var(--bg)_55%,transparent)];
}
& header,
& .sidebar-panel-inner {
@apply [background:color-mix(in_srgb,var(--bg)_var(--main-bg-alpha,67%),transparent)] [backdrop-filter:blur(var(--main-bg-blur,20px))_saturate(150%)];
}
/* Scrim washes toward the theme bg to keep contrast; z-1 lifts it above
the ::after full-image layer (siblings otherwise paint after ::before). */
& .page-bg::before {
@apply pointer-events-none absolute inset-0 z-1 content-[''] [background:color-mix(in_srgb,var(--bg)_var(--main-bg-scrim,20%),transparent)];
}
& #maincontent .cbi-section {
@apply [background:color-mix(in_srgb,var(--surface)_var(--main-bg-alpha,67%),transparent)];
}
}
@@ -0,0 +1,21 @@
/**
* Generic full-page background layer — one pipeline for the login page and
* the admin UI. Pure mechanism: it consumes only the --page-bg pair; each
* entry maps its own tokens onto it (login.css → --login-bg pair,
* _page-bg-mode.css → --main-bg pair), so neither sheet drags the other's
* tokens along. Fixed sibling layer on its own GPU surface (iOS Safari has
* no background-attachment:fixed) so content paints never re-sample the
* image. LQIP paints first; the full image fades in on ::after once loaded
* (compositor-only opacity change).
*/
.page-bg {
@apply pointer-events-none fixed inset-0 transform-[translateZ(0)] bg-cover bg-center bg-no-repeat [background-image:var(--page-bg-lqip,var(--page-bg))];
&::after {
@apply pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 content-[''] [transition:opacity_0.5s_ease] [background-image:var(--page-bg)];
}
&.full-loaded::after {
@apply opacity-100;
}
}
@@ -0,0 +1,13 @@
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
@apply transition-none! animate-none!;
}
/* Loading indicators communicate in-progress state, not decoration —
keep them spinning so the page doesn't read as frozen. */
.spinning::before {
@apply animate-spin!;
}
}
@@ -0,0 +1,33 @@
/**
* Reduced-transparency fallback.
*
* When the user asks for less transparency, drop the most expensive layer —
* backdrop-filter blur — everywhere, and turn the one surface that relies on
* "translucent + blur" to stay readable (the mega-menu backdrop) opaque, so
* removing the blur doesn't let the page content bleed through it.
*
* surface-overlay / surface are already opaque, so dropping their blur is
* harmless; scrim keeps its translucency on purpose (it's a dimming layer).
* --mega-menu-bg points at surface-overlay via var() so it tracks light/dark
* automatically (resolved at the point of use, not here).
*/
@media (prefers-reduced-transparency: reduce) {
*,
*::before,
*::after {
/* standard property only — lightningcss adds the -webkit- prefix per browserslist */
@apply [backdrop-filter:none]!;
}
:root {
--mega-menu-bg: var(--surface-overlay);
}
/* With a custom admin background the translucent surfaces minus their
blur would leave text floating on the image — restore full opacity
instead. Deliberately declared on body: unlike the theme's own
defaults (var() fallbacks), this MUST outrank the :root UCI override. */
body[data-bg] {
--main-bg-alpha: 100%;
}
}
@@ -0,0 +1,89 @@
.hidden {
@apply hidden!;
}
.left {
@apply md:text-left!;
&:not(td) {
@apply text-left!;
}
}
.right {
@apply md:text-right!;
&:not(td) {
@apply text-right!;
}
}
.top {
@apply md:align-top!;
&:not(td) {
@apply align-top!;
}
}
.bottom {
@apply md:align-bottom!;
&:not(td) {
@apply align-bottom!;
}
}
.center {
@apply md:text-center!;
&:not(td) {
@apply text-center!;
}
}
.middle {
@apply md:align-middle!;
&:not(td) {
@apply align-middle!;
}
}
.nowrap:not(span) {
@apply md:whitespace-nowrap;
&:not(td) {
@apply whitespace-nowrap;
}
}
@keyframes aurora-fade-in {
from {
opacity: 0;
}
}
.fade-in {
@apply animate-[aurora-fade-in_250ms_ease_both];
}
/* Content that lands after first paint fades in once; none of it is
re-inserted by poll (the dashboard's polled tabs sit under .Dashboard).
A nav header.ut replayed before first paint (data-restored) never fades.
backwards: a filling animation stays a stacking context over dropdowns. */
#view > :not(.spinning),
#view > .cbi-map > .cbi-map-tabbed > [data-tab-active="true"],
.cbi-section-node-tabbed > [data-tab-active="true"],
#topmenu:not([data-restored]),
#sidebar-list:not([data-restored]) > *,
footer {
@apply animate-[aurora-fade-in_250ms_ease_backwards];
}
.fade-out {
@apply pointer-events-none opacity-0 transition-opacity duration-[250ms];
}
.spinning {
@apply relative inline-block pl-8!;
&::before {
@apply absolute top-1/2 left-[6px] size-5 -translate-y-1/2 animate-spin bg-current content-[''] [mask:var(--icon-spinning)_center/cover_no-repeat];
}
#view > & {
@apply first:mx-auto first:grid first:min-h-48 first:w-fit first:place-items-center first:max-md:min-h-[calc(100dvh-4rem)];
}
}
@@ -0,0 +1,84 @@
.label {
@apply bg-surface-sunken text-text rounded-full px-3 py-1 text-xs font-bold uppercase;
&.important {
@apply bg-info-surface text-info;
}
&.warning {
@apply bg-danger-surface text-danger;
}
&.success {
@apply bg-success-surface text-success;
}
&.notice {
@apply bg-warning-surface text-warning;
}
}
.zonebadge {
@apply border-hairline bg-surface-sunken text-text inline-flex items-center gap-1.5 overflow-visible rounded-lg border px-2 py-1 align-middle text-sm leading-normal font-semibold max-md:gap-1 max-md:px-1.5 max-md:text-xs;
&[style*="--zone-color-rgb"] {
@apply border-transparent bg-[rgb(var(--zone-color-rgb),.7)]!;
& em {
@apply text-text;
}
}
&.zonebadge-empty {
@apply text-text-muted border-dashed bg-transparent font-normal italic;
}
/* Bare zone-colour swatch: the interface overview / tooltip renders a
label-less `.zonebadge` as a colour chip prefixing an `.ifacebadge`. The
tag redesign's horizontal padding is meant for text labels, so keep the
label-less swatch a compact pill instead of a wide block. */
.ifacebadge > & {
@apply rounded-full px-1 py-0.5;
}
.cbi-dropdown & {
@apply px-1.5 py-0.5;
}
}
.ifacebadge {
@apply border-hairline bg-surface-sunken text-text hover:border-hairline hover:bg-surface-sunken inline-flex cursor-default flex-nowrap items-center gap-2 rounded-2xl border px-3 py-2 text-sm font-normal shadow-sm transition-[box-shadow,border-color,background-color] duration-150 hover:shadow-md max-md:gap-1.5 max-md:px-2.5 max-md:py-1.5 max-md:text-xs;
.zonebadge > & {
@apply rounded-2xl px-1.5 py-0;
& img {
@apply mr-1 w-4;
}
}
.cbi-dropdown & {
@apply gap-1 rounded-lg px-1.5 py-0.5 shadow-none hover:shadow-none;
& img {
@apply mr-1 w-4;
}
}
& > img,
& > .cbi-tooltip-container > img {
@apply w-5 shrink-0 self-center max-md:w-4;
}
& > span {
& > .nowrap {
@apply mb-1.5 inline-block text-sm max-md:text-xs;
}
}
&.ifacebadge-active {
@apply border-brand font-semibold;
}
&.large {
@apply min-w-[200px] flex-1 px-4 py-3 max-md:min-w-[180px] max-md:px-3 max-md:py-2.5;
}
}
@@ -0,0 +1,53 @@
button,
input[type="button"],
input[type="submit"],
input[type="reset"],
.btn,
.cbi-button {
/* transform stays in this list for the .cbi-button active:scale-95
variant in _form.css, which relies on this base transition. */
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-xl border px-3 py-1.5 text-sm font-medium antialiased transition-[background-color,border-color,transform] duration-150 max-md:text-base max-md:font-medium;
&[disabled] {
@apply cursor-not-allowed opacity-(--app-opacity-disabled);
}
.td & {
@apply inline-flex w-auto flex-none whitespace-nowrap max-md:min-h-9 max-md:text-sm;
}
}
.btn,
.cbi-button,
.cbi-button-default,
.cbi-button-download,
.cbi-button-find,
.cbi-button-link,
.cbi-button-up,
.cbi-button-down {
@apply border-hairline bg-surface-sunken text-text hover:bg-hover-faint;
}
.drag-handle {
@apply border-hairline bg-surface-sunken text-text-muted hover:bg-hover-faint;
}
.btn.primary,
.cbi-button-action,
.cbi-button-apply,
.cbi-button-reload,
.cbi-button-add,
.cbi-button-edit {
@apply border-brand bg-brand hover:bg-brand-hover text-on-brand;
}
.cbi-button-positive,
.cbi-button-fieldadd,
.cbi-button-save {
@apply border-transparent bg-brand-subtle text-brand hover:bg-brand-subtle-hover;
}
.cbi-button-negative,
.cbi-section-remove .cbi-button,
.cbi-button-reset,
.cbi-button-remove {
@apply border-transparent bg-danger-surface text-danger hover:bg-danger-surface-hover;
}
@@ -0,0 +1,207 @@
.cbi-section {
@apply mb-3 overflow-visible rounded-2xl p-5 max-md:p-3;
&[data-tab-title] {
@apply m-0 p-0;
}
&[data-tab-active="true"] {
@apply mb-3 p-5 max-md:p-3;
}
#maincontent & {
@apply bg-surface border-hairline border shadow-sm;
}
& > .cbi-title {
@apply flex items-start justify-between;
& > h3 {
@apply flex-1;
& .label[data-clickable] {
@apply text-text bg-surface-sunken;
@apply inline-flex size-7 cursor-pointer items-center justify-center p-0 hover:shadow-md active:scale-95 max-md:size-6;
&::before {
@apply size-4 bg-current transition-transform duration-150 content-[''];
}
}
& span[data-indicator="poll-status"] {
@apply relative size-5 cursor-pointer text-[0px];
&::before {
@apply absolute size-5 bg-current transition-transform duration-150 ease-out content-[''];
}
@variant hover {
&::before {
@apply scale-110;
}
}
&:active::before {
@apply scale-95;
}
&[data-style="active"] {
&::before {
@apply rotate-0 [mask:var(--icon-arrow-right)_center/cover_no-repeat];
}
}
&[data-style="inactive"] {
&::before {
@apply rotate-90 [mask:var(--icon-arrow-right)_center/cover_no-repeat];
}
}
}
}
}
legend {
@apply text-lg font-semibold;
}
& h3 {
@apply text-text border-hairline border-b pb-4 max-md:mx-0 max-md:pb-2;
}
& div[style*="display:grid"] {
@apply max-md:grid-cols-2! max-md:gap-3;
&:has(> .ifacebox) {
@apply place-items-center;
}
}
& .network-status-table {
@apply mb-3 flex flex-nowrap justify-around gap-4 max-md:flex-col;
}
& .cbi-section-create {
@apply mt-6 flex flex-wrap items-center gap-3 max-md:mx-0 max-md:mt-5 max-md:flex-col max-md:items-stretch max-md:gap-3;
& > div {
@apply flex-none max-md:w-full max-md:flex-1;
}
}
}
.ifacebox {
@apply border-hairline bg-surface-overlay hover:border-hairline max-md:border-hairline relative inline-flex min-w-28 flex-col items-stretch overflow-visible rounded-2xl border text-center text-base leading-5 shadow-md hover:shadow-xl max-md:min-w-30 max-md:flex-1 max-md:rounded-3xl max-md:shadow-sm;
#cbi-network-interface & {
@apply max-md:flex-row md:min-w-38;
}
& .ifacebox-head {
@apply border-hairline bg-surface-sunken text-text w-full rounded-t-2xl border-b px-4 py-3 text-center font-semibold tracking-tight transition-[background-color,border-color,color] duration-150 max-md:rounded-t-3xl max-md:px-3 max-md:py-2.5 max-md:text-sm max-md:leading-5 max-md:font-medium;
&[style*="--zone-color-rgb"] {
@apply bg-[rgb(var(--zone-color-rgb),.75)]!;
}
#cbi-network-interface & {
@apply max-md:flex max-md:w-auto max-md:shrink-0 max-md:items-center max-md:justify-center max-md:rounded-l-3xl max-md:rounded-tr-none max-md:border-r max-md:border-b-0;
}
&.cbi-tooltip-container {
@apply static bg-transparent;
& .cbi-tooltip {
@apply bottom-0 left-0;
}
}
&.active {
@apply border-brand bg-brand text-on-brand;
}
}
& .ifacebox-body {
/* Flex column base: `items-stretch` lets label/value rows span full width
(e.g. luci-app-modemdata) while `justify-center` keeps rows tight together
and balanced instead of spread apart. Icon-only cards (switch ports, the
interfaces list) centre horizontally via `text-center`/`mx-auto`. The
interfaces overview layers its richer layout under `.network-status-table`. */
@apply text-text flex w-full flex-1 flex-col items-stretch justify-center gap-1 rounded-b-2xl p-3 text-center max-md:rounded-b-3xl;
#cbi-network-interface & {
@apply max-md:flex-row max-md:items-center max-md:rounded-r-3xl max-md:rounded-bl-none max-md:py-2 max-md:pr-2 max-md:pl-4;
}
& > img {
@apply mx-auto w-5;
}
& > span {
@apply text-text space-y-1.5 max-md:space-y-1 max-md:text-sm max-md:leading-5;
.network-status-table & {
@apply w-full;
}
.cbi-tooltip-container + .cbi-tooltip-container {
@apply ml-1;
}
.cbi-tooltip {
@apply delay-200;
}
& .nowrap {
@apply inline-block;
}
& > .nowrap {
@apply not-last:mb-1;
}
& img {
@apply w-5 shrink-0;
}
}
& > div {
@apply block w-full space-y-0;
#cbi-network-interface & {
@apply max-md:w-auto max-md:flex-1 max-md:space-y-1;
}
}
& .cbi-tooltip-container {
@apply text-text static inline-flex cursor-help items-center justify-center;
& .cbi-tooltip {
@apply bottom-0 md:left-0;
& .nowrap {
@apply whitespace-nowrap;
}
}
}
& small {
@apply block text-xs leading-4;
#cbi-network-interface & {
@apply max-md:mt-0;
}
}
}
}
/*
Network interfaces overview (admin/network) — the page `.ifacebox` was designed
for. The rich equal-height card layout is scoped here (not on the base) so
third-party pages keep the plain, tolerant defaults above. Mirrors the
base-vs-`.network-status-table` split in luci-theme-bootstrap.
*/
.network-status-table .ifacebox-body {
@apply flex h-full flex-1 flex-col items-center justify-around gap-2;
& > div {
@apply flex flex-wrap gap-2 max-md:gap-1.5;
}
/* `flex-1` alone lets the badges shrink to min-content, so six SSIDs stay
crammed on one line instead of wrapping. The floor mirrors
luci-theme-bootstrap's `min-width: 220px` on the same badges. */
& .ifacebadge {
@apply min-w-55 flex-1;
}
}
@@ -0,0 +1,140 @@
.cbi-dropdown {
@apply inline-flex h-fit max-w-full min-w-fit shrink-0 items-center p-0 whitespace-nowrap shadow-sm;
&[disabled] {
@apply pointer-events-none opacity-(--app-opacity-disabled);
}
&:not(.btn):not(.cbi-button) {
@apply border-hairline bg-surface min-w-52.5 max-w-full rounded-2xl border md:max-w-100;
& > ul > li {
&[placeholder] {
@apply hidden;
}
}
}
& > ul {
@apply m-0 flex flex-1 list-none flex-nowrap items-center gap-0.5 overflow-hidden max-md:flex-wrap;
& > li {
@apply m-0 hidden list-none items-center gap-1 overflow-hidden px-2 py-1.5;
&[display] {
@apply !flex;
}
& .hide-open {
@apply block;
}
& .hide-close {
@apply hidden;
}
& > form {
@apply pointer-events-none hidden;
}
& > label {
@apply flex items-center gap-2;
}
& img {
@apply h-5 w-5 flex-none align-middle;
}
& span {
@apply truncate;
}
}
&.dropdown {
@apply border-hairline bg-surface-overlay absolute left-0 z-50 w-fit min-w-full overflow-y-auto rounded-lg border shadow-xl backdrop-blur-md backdrop-saturate-150;
& > li {
@apply text-text-muted hover:bg-brand-subtle min-h-9 w-full cursor-pointer px-3 py-1.5 font-medium;
}
}
&.preview {
@apply hidden;
}
}
&[empty] {
& > ul {
@apply max-h-px max-w-px;
& > li {
@apply block;
}
}
}
&[optional][open] > ul.dropdown > li[placeholder] {
@apply block;
}
&[open] {
@apply border-brand ring-focus-ring relative ring-2;
& > ul.dropdown {
@apply block w-auto max-w-none;
& > li {
@apply flex;
&[selected] {
@apply bg-brand-subtle text-brand;
}
&[unselectable] {
@apply opacity-(--app-opacity-disabled);
}
& .hide-open {
@apply hidden;
}
& .hide-close {
@apply block;
}
}
}
& > ul.preview {
@apply flex;
}
}
&[multiple] {
&[more],
&[empty] {
& > .more {
@apply flex flex-none shrink-0 px-2.5 py-1.5;
}
}
&[empty] > .more {
@apply flex-1;
}
&[open] {
& > ul.dropdown > li > form {
@apply flex items-center;
}
}
& > ul > li > label {
@apply flex;
}
}
& > .open {
@apply border-hairline flex flex-none shrink-0 cursor-pointer items-center justify-center border-l px-2.5 py-0.5 text-sm text-current;
}
& > .more {
@apply hidden;
}
}
@@ -0,0 +1,83 @@
.floating-toolbar {
/* Detached-bubble speed dial: every shortcut is its own floating circle
(elevation only, no border), collapsing drops the bubbles into the
toggle one after another and the toggle's X rotates into a +.
All motion is translate/scale/opacity/rotate — compositor-only (R1).
The stagger math runs on custom properties set by header.ut:
--fab-n (on .toolbar-list) item count
--fab-i (on each .toolbar-btn) index, 0 = top bubble
--fab-icon (on each .toolbar-btn) configured icon URL, drawn via
mask + bg-current so it follows the theme text color in both
modes (replaces the old <img> + dark:invert).
Travel per step = bubble 44px + 10px gap = 3.375rem (40px + 8px =
3rem below md). Expanding pops bottom-first ("jump up"), collapsing
drops top-first; --fab-d only ever delays the motion properties, so
hover color feedback stays instant. */
/* The stack keeps its layout height when collapsed (the bubbles must
travel a real distance into the toggle), so the container itself must
not catch clicks — only the buttons do. */
@apply pointer-events-none fixed right-4 bottom-4 z-40 flex flex-col items-center max-md:right-3 max-md:bottom-3;
.toolbar-list {
@apply mb-2.5 flex flex-col items-center gap-2.5 max-md:mb-2 max-md:gap-2;
}
.toolbar-btn {
@apply text-text-muted bg-surface-overlay pointer-events-auto grid size-11 cursor-pointer place-items-center rounded-full shadow-md max-md:size-10;
/* `visibility` rides the same transition so collapsed bubbles leave the
tab order, but only after the drop finishes (it flips at the end of
its transition window, so the motion itself is never clipped). */
@apply [transition:translate_320ms_cubic-bezier(0.34,1.25,0.5,1),scale_320ms_cubic-bezier(0.34,1.25,0.5,1),opacity_220ms_ease,visibility_320ms_ease,background-color_150ms_ease,color_150ms_ease];
@apply [transition-delay:var(--fab-d,0s),var(--fab-d,0s),var(--fab-d,0s),var(--fab-d,0s),0s,0s];
&:hover {
@apply text-text bg-hover-faint;
}
&:active {
@apply scale-[0.92];
}
&:not(.toggle) {
@apply [--fab-d:calc((var(--fab-n,1)-1-var(--fab-i,0))*60ms)] no-underline;
/* The stagger delay must not defer press feedback: `scale` shares the
delayed transition channel, so without this a press on the top
bubble would acknowledge (n-1)*60ms late. Nested (not merged into
the base `:active`) so it outranks the delay set just above. */
&:active {
@apply [--fab-d:0s];
}
.icon {
@apply size-5 shrink-0 bg-current [mask:var(--fab-icon)_center/contain_no-repeat] max-md:size-4.5;
}
}
&.toggle {
@apply text-text-subtle hover:text-text border-0;
.icon {
@apply grid size-5 place-items-center transition-[rotate] duration-[250ms] ease-out max-md:size-4.5;
svg {
@apply size-full;
}
}
}
}
&.collapsed {
.toolbar-btn {
&:not(.toggle) {
@apply pointer-events-none invisible scale-[0.35] opacity-0;
@apply [--fab-d:calc(var(--fab-i,0)*60ms)] translate-y-[calc((var(--fab-n,1)-var(--fab-i,0))*3.375rem)] max-md:translate-y-[calc((var(--fab-n,1)-var(--fab-i,0))*3rem)];
}
&.toggle .icon {
@apply rotate-45;
}
}
}
}
@@ -0,0 +1,82 @@
.cbi-input-invalid,
.cbi-value-error {
@apply !border-danger focus:!ring-danger;
}
.cbi-value {
@apply flex flex-row flex-wrap gap-6 py-1.5 max-md:flex-col max-md:gap-1.5 max-md:py-1;
&.hidden {
@apply hidden;
}
& + & {
@apply mt-3 max-md:mt-2;
}
& > .cbi-value-title {
@apply pt-1 text-right font-medium max-md:w-auto max-md:text-left md:flex-[0_0_12rem];
}
& > .cbi-value-field {
@apply max-w-full flex-1;
&:has(> .cbi-dropdown[disabled]) {
@apply cursor-not-allowed;
}
& .cbi-value-description {
@apply text-text-muted relative mt-1 pl-4 leading-none wrap-break-word;
&:not(:empty) {
&::before {
@apply bg-info absolute -left-0.5 inline-block size-4 content-[''] [mask:var(--icon-info)_center/cover_no-repeat];
}
}
}
}
& > .cbi-section,
& > .cbi-tblsection {
@apply w-full;
}
}
.cbi-dynlist {
@apply inline-flex w-max max-w-120 flex-col items-start gap-3 max-md:max-w-full!;
& .item {
@apply border-hairline bg-surface pointer-events-auto relative inline-flex cursor-move flex-col items-start gap-2 self-stretch overflow-hidden rounded-2xl border py-3 pr-10 pl-4 break-all shadow-sm transition-[transform,box-shadow,opacity,border-color,background-color] duration-150 select-text max-md:w-full max-md:py-2.5 max-md:pr-8 max-md:pl-3;
&::after {
@apply bg-surface-sunken text-text-muted absolute top-2.5 right-2 inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-xl text-sm font-medium content-['\00D7'];
}
&.dragging {
@apply scale-95 cursor-grabbing opacity-50 shadow-2xl;
}
&.drag-over {
@apply border-brand bg-brand-subtle ring-brand-subtle scale-105 shadow-lg ring-2;
}
& > span,
& > code {
@apply pointer-events-none w-full rounded-lg font-mono leading-relaxed;
}
}
& .placeholder {
@apply break-all;
}
& .add-item {
@apply border-hairline hover:border-hairline hover:bg-surface-sunken inline-flex items-center gap-1.5 self-stretch rounded-2xl border border-dashed bg-transparent px-1 py-1 transition-[border-color,background-color] duration-150;
& > input {
@apply min-w-61 flex-1;
}
& > .cbi-button {
@apply inline-flex min-h-6 min-w-6 shrink-0 cursor-pointer items-center justify-center rounded-xl px-2 py-0 text-sm font-medium shadow-none active:scale-95;
}
}
}
@@ -0,0 +1,69 @@
input {
@apply disabled:cursor-not-allowed disabled:opacity-(--app-opacity-disabled);
}
input[type="text"],
input[type="password"],
.cbi-input-text,
.cbi-input {
@apply text-text border-hairline bg-control-bg placeholder-text-muted focus:border-brand focus:ring-focus-ring relative w-52.5 max-w-full rounded-2xl border px-3 py-1.5 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-150 focus:ring-2 focus:outline-none;
.table.cbi-section-table & {
@apply w-full;
}
}
.cbi-input-text {
@apply min-w-20 max-md:min-w-14;
}
#localtime {
@apply min-w-70;
}
input[type="radio"],
input[type="checkbox"] {
@apply relative mr-3 inline-block h-4 w-4 cursor-pointer appearance-none align-middle disabled:cursor-not-allowed;
&::before {
@apply border-hairline bg-control-bg absolute top-0 left-0 h-4 w-4 border transition-[background-color,border-color,box-shadow] duration-150 content-[''];
}
&::after {
@apply bg-on-brand absolute top-0.5 left-0.5 h-3 w-3 opacity-0 transition-opacity duration-150 content-[''];
}
&:checked::before {
@apply border-brand bg-brand;
}
&:checked::after {
@apply opacity-100;
}
@variant hover {
&::before {
@apply border-hairline;
}
}
&:focus::before {
@apply border-brand ring-focus-ring ring-2 outline-none;
}
}
input[type="radio"] {
&::before {
@apply rounded-full;
}
&::after {
@apply [mask:var(--icon-radio)_center/cover_no-repeat];
}
}
input[type="checkbox"] {
&::before {
@apply rounded;
}
&::after {
@apply [mask:var(--icon-check)_center/cover_no-repeat];
}
}
.cbi-checkbox {
@apply relative inline-flex items-center align-middle;
}
@@ -0,0 +1,53 @@
.alert-message {
/* Radius matches `.cbi-section` (16px): the alert sits in the content layer,
same width and edge alignment as the sections it stacks above. The larger
radii in this theme belong to overlays (24px) and pills (32px). */
@apply bg-surface-sunken text-text border-hairline mb-4 rounded-2xl border p-6 max-md:mx-0 max-md:mb-3 max-md:p-4;
.modal & {
@apply mb-0;
}
.login-shell & {
@apply mb-0 px-2 py-2.5 text-center text-base max-md:px-0.5;
}
&.success {
@apply border-hairline bg-success-surface text-success;
}
&.info {
@apply border-hairline bg-info-surface text-info;
}
&.warning,
&.notice {
@apply border-hairline bg-warning-surface text-warning;
}
&.error,
&.danger {
@apply border-hairline bg-danger-surface text-danger;
}
h4,
h5,
pre,
ul,
li,
p {
@apply border-0 bg-transparent text-inherit;
}
& h4 {
@apply mb-2 text-lg font-semibold max-md:mb-1.5 max-md:text-base;
}
& p {
@apply text-sm leading-relaxed max-md:text-sm;
}
& .right {
@apply mt-4 max-md:mt-3;
}
}
@@ -0,0 +1,128 @@
#modal_overlay {
@apply pointer-events-none fixed top-0 bottom-0 hidden overflow-auto bg-transparent;
.modal-overlay-active & {
@apply bg-scrim pointer-events-auto inset-0 right-0 left-0 z-100 grid grid-cols-1 place-items-center backdrop-blur-md backdrop-saturate-150;
}
& > .modal {
@apply border-hairline bg-surface-overlay relative flex w-5xl flex-col gap-4 rounded-3xl border p-6 wrap-break-word whitespace-normal shadow-2xl max-md:w-full max-md:gap-3 max-md:p-4;
& h4 {
@apply mb-0 text-center;
& em {
@apply text-brand font-semibold;
}
}
& h5 {
@apply text-text my-3;
}
& p {
@apply text-text text-sm leading-relaxed;
}
& > ul {
@apply border-hairline overflow-auto rounded-2xl border p-3;
}
& label.btn {
@apply border-hairline bg-surface-sunken text-text hover:border-hairline border;
}
& pre {
@apply overflow-auto;
&.errors {
@apply border-hairline bg-danger-surface text-danger;
}
}
pre + pre {
@apply mt-3;
}
& .button-row,
& div.left,
& div.right {
@apply border-hairline flex shrink-0 gap-3 border-t p-4 max-md:gap-1.5 max-md:p-2.5;
}
& div.left {
@apply justify-start;
}
& .button-row,
& div.right {
@apply justify-end;
}
&.uci-dialog {
& ins,
& del,
& var {
@apply font-mono not-italic no-underline shadow-sm;
}
& ins {
@apply border-hairline bg-success-surface text-success border;
}
& del {
@apply border-hairline bg-danger-surface text-danger border;
}
& var {
@apply border-hairline bg-info-surface text-info border;
}
& .uci-change-legend {
@apply border-hairline bg-surface-sunken mt-4 grid grid-cols-2 gap-3 rounded-2xl border p-4 max-md:grid-cols-1 max-md:gap-2 max-md:p-3;
& .uci-change-legend-label {
@apply text-text flex items-center gap-2 text-sm font-medium;
& ins,
& del,
& var {
@apply flex h-6 min-w-6 items-center justify-center rounded-lg px-2 text-xs font-semibold;
}
& var {
& ins,
& del {
@apply ml-1 h-4 min-w-4 px-1;
}
}
}
}
& .uci-change-list {
@apply mt-4 space-y-3;
& h5 {
@apply bg-surface-sunken text-text mt-6 mb-2 rounded-xl px-3 py-2 text-sm font-semibold first:mt-0;
}
& ins,
& del {
@apply block rounded-xl px-4 py-3 text-sm;
& strong {
@apply text-sm;
}
}
& var {
@apply inline-block rounded-lg px-2 py-1 text-sm;
& ins,
& del {
@apply ml-1 inline-block px-1.5 py-0.5 text-xs;
}
}
}
}
}
}
@@ -0,0 +1,169 @@
/**
* Shared navigation vocabulary.
*
* One visual definition consumed by the desktop sidebar, the mobile
* drawer and (incrementally) the other navigation modes. Mode files
* (_layout.css, _overlay.css) keep positioning, sizing overrides and
* show/hide state only — never duplicate these recipes there.
*/
.nav-row {
@apply focus-visible:ring-focus-ring flex w-full cursor-pointer appearance-none items-center border-0 bg-transparent text-left no-underline shadow-none transition-colors duration-150 select-none hover:no-underline focus-visible:ring-2 focus-visible:outline-none;
}
.nav-link {
/* hover:no-underline: the global `a:hover { underline }` in _elements.css
outranks a bare class (a:hover is 0,1,1), so nav rows must opt out at
the same specificity tier. */
@apply text-text hover:bg-hover-faint block rounded-xl px-3 py-1.5 no-underline transition-colors duration-150 hover:no-underline;
}
.navigation-direct {
@apply text-text hover:text-text;
}
/* Direct destinations use the same selected state in both navigation modes:
a filled brand pill, matching the active sublink. */
.navigation-direct.is-active-page {
@apply text-brand hover:text-brand bg-brand-subtle;
}
:where(.sidebar-list) {
& .nav-row {
@apply text-text-muted hover:text-text;
}
& .navigation-direct {
@apply hover:bg-hover-faint;
}
}
.navigation-group-toggle {
/* size-4.5 ≈ the row's text height — 3.5 read as undersized next to the
text-lg category labels, on desktop and in the mobile drawer alike. */
&::after {
@apply size-4.5 shrink-0 bg-current opacity-55 transition-[transform,opacity] duration-[250ms] content-[''] [mask:var(--icon-arrow-right)_center/cover_no-repeat];
}
@variant hover {
&::after {
@apply opacity-100;
}
}
}
/* An expanded group's label turns brand and its arrow rotates open. */
.navigation-group.is-expanded > .navigation-group-toggle {
@apply text-brand;
&::after {
@apply rotate-90;
}
}
/* The active group keeps its brand label even when the user manually
collapses it — otherwise the active-page pill is hidden inside the inert
region and no "you are here" marker survives. No arrow rotation here: a
collapsed group's arrow must still point right. */
.navigation-group.is-active-group > .navigation-group-toggle {
@apply text-brand;
}
/* ── Row icons ──
A leading glyph inline with the label, on sidebar and mobile-drawer
rows alike (the mega-menu title draws its own ::before). bg-current +
mask lets the glyph track the row's text colour through hover, expanded
and active states; the size is set per surface — sidebar rows in
_layout.css, drawer rows in _overlay.css. */
.nav-icon {
@apply shrink-0 bg-current opacity-55 transition-opacity duration-150 [mask:var(--menu-icon,var(--icon-category))_center/contain_no-repeat];
}
.nav-label,
.nav-category-label {
@apply min-w-0 flex-1 truncate;
}
/* Full-strength glyph wherever the row itself is highlighted. */
.navigation-direct:hover .nav-icon,
.navigation-group-toggle:hover .nav-icon,
.navigation-direct.is-active-page .nav-icon,
.navigation-group.is-expanded > .navigation-group-toggle .nav-icon,
.navigation-group.is-active-group > .navigation-group-toggle .nav-icon {
@apply opacity-100;
}
.navigation-group-region {
@apply grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-[250ms] ease-out;
}
.navigation-group.is-expanded > .navigation-group-region {
@apply grid-rows-[1fr] opacity-100;
}
.navigation-submenu-list {
@apply min-h-0 list-none overflow-hidden;
}
.navigation-sublink {
@apply text-text-muted hover:bg-hover-faint hover:text-text relative flex rounded-xl font-medium no-underline transition-colors duration-150;
}
/* Active page: a filled brand pill, shared by the sidebar and the drawer. */
.navigation-sublink.is-active-page {
@apply text-brand hover:text-brand bg-brand-subtle font-semibold;
}
.nav-category-preview {
@apply text-text-subtle truncate pb-1;
& .nav-preview-current {
@apply text-brand;
}
}
/* ── First-level section icons ──
The name → icon map, shared by every navigation surface: the mega-menu
title (::before consumer in _layout.css) and the sidebar/mobile-drawer
row glyphs (.nav-icon below). Keyed on the LuCI node name (data-section,
set by menu-aurora.js) — stable and language-independent. Scoped to the
three row elements that carry the attribute so the selector can't
accidentally match unrelated data-section usage elsewhere; :is() keeps
every key at (0,2,0) — the mega-menu consumer's fallback chain would
outrank anything higher (see _layout.css). The only maintenance point:
add new first-level sections here; anything unmapped falls back to the
default icon at the consumer. */
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="status"] {
@apply [--menu-icon:var(--icon-activity)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="statistics"] {
@apply [--menu-icon:var(--icon-chart-bar)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="nlbw"] {
@apply [--menu-icon:var(--icon-gauge)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="system"] {
@apply [--menu-icon:var(--icon-settings)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="services"] {
@apply [--menu-icon:var(--icon-apps)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="nas"] {
@apply [--menu-icon:var(--icon-server)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="control"] {
@apply [--menu-icon:var(--icon-adjustments)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="network"] {
@apply [--menu-icon:var(--icon-world)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="vpn"] {
@apply [--menu-icon:var(--icon-shield)];
}
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="docker"] {
@apply [--menu-icon:var(--icon-box)];
}
/* "asterisk" covers VoIP/PBX apps (e.g. luci-app-asterisk forks) — no
official OpenWrt package registers a verified menu.d node under this
name, so this is a best-effort key. */
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="asterisk"] {
@apply [--menu-icon:var(--icon-phone)];
}
@@ -0,0 +1,96 @@
.mobile-menu-overlay {
/* The mobile surface is opaque, so filtering pixels behind it has no visual
effect and only adds a full-screen paint/compositing cost. */
@apply max-md:bg-mega-menu-bg max-md:invisible max-md:fixed max-md:inset-0 max-md:z-60 max-md:opacity-0 md:hidden;
/* Transitions live on the OPEN state only: opening fades in, closing cuts
instantly. Link taps close into a full-page navigation, and any close
fade racing the document swap freezes mid-frame as a ghost. */
&.mobile-menu-open {
@apply max-md:visible max-md:opacity-100 max-md:transition-[visibility,opacity] max-md:duration-[250ms];
}
.mobile-nav {
@apply max-md:flex max-md:h-full max-md:w-full max-md:flex-col max-md:pt-14;
& .mobile-nav-body {
@apply max-md:flex max-md:min-h-0 max-md:flex-1 max-md:flex-col max-md:px-5 max-md:pt-6;
& .mobile-nav-label {
@apply max-md:text-text-muted max-md:mb-3 max-md:text-sm max-md:font-medium;
}
}
& .mobile-nav-list {
@apply max-md:m-0 max-md:flex max-md:min-h-0 max-md:flex-1 max-md:-translate-y-2 max-md:list-none max-md:flex-col max-md:gap-y-4 max-md:overflow-y-auto max-md:overscroll-contain max-md:p-0 max-md:pb-5 max-md:opacity-0;
& .mobile-nav-item {
@apply max-md:shrink-0;
& .nav-row {
/* gap-3 (not 4): tight enough that the leading .nav-icon reads
attached to its label; justify-between still pins the group
arrow to the right edge. */
@apply max-md:min-h-13 max-md:justify-between max-md:gap-3 max-md:rounded-none max-md:bg-transparent max-md:px-0 max-md:py-2.5 max-md:text-2xl max-md:leading-tight max-md:font-medium max-md:tracking-tight;
& .nav-icon {
/* Row icons at drawer scale, against the text-2xl labels. */
@apply max-md:size-6;
}
}
& .mobile-nav-submenu {
& .mobile-nav-submenu-list {
/* pl: rows are px-0, so icon (1.5) + gap (0.75) put the label
at 2.25rem; sublinks bring px-3, the list supplies the rest. */
@apply max-md:m-0 max-md:p-0 max-md:pl-6;
}
& .mobile-nav-subitem {
& .mobile-nav-sublink {
@apply max-md:min-h-10 max-md:w-full max-md:items-center max-md:px-3 max-md:py-2 max-md:text-base;
}
}
}
}
}
& .mobile-nav-footer {
@apply max-md:border-hairline max-md:flex max-md:shrink-0 max-md:items-center max-md:justify-between max-md:border-t max-md:px-5 max-md:pt-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))];
& .mobile-nav-footer-action {
@apply max-md:flex max-md:items-center;
}
& .mobile-nav-logout {
@apply max-md:text-text max-md:hover:text-brand max-md:text-sm max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-150;
}
& .theme-switcher {
& .theme-option {
@apply max-md:px-2 max-md:py-1;
}
}
}
}
&.mobile-menu-open .mobile-nav-list {
@apply max-md:translate-y-0 max-md:opacity-100 max-md:transition-[translate,opacity] max-md:duration-[250ms] max-md:ease-out;
}
}
body.mobile-navigation-open > header {
@apply max-md:z-70 max-md:bg-transparent;
}
[data-nav-type="mega-menu"] {
/* Dim only: a blurred full-screen fade drops frames on weak GPUs. Visibility
flips on a delay instead of tweening, so the opacity fade stays composited. */
& .desktop-menu-overlay {
@apply bg-mega-menu-scrim pointer-events-none invisible fixed inset-0 z-60 opacity-0 [transition:opacity_220ms_cubic-bezier(0.4,0,0.6,1),visibility_0s_220ms] max-md:hidden;
&.active {
@apply pointer-events-auto visible opacity-100 [transition-delay:0s];
}
}
}
@@ -0,0 +1,164 @@
/**
* Command palette (⌘K) — Spotlight-style, available in all three nav types
* (sidebar / mega-menu / dropdown). The trigger sits at the header's right
* edge next to #indicators; ⌘K / Ctrl+K toggles the panel globally. Class
* names mirror luci-theme-shadcn's palette so the two themes stay legible
* side by side.
*
* The panel is built lazily by menu-aurora.js on first open; its index is
* the navigation model the menus already render from (no extra requests)
* plus three theme-mode command rows. An empty query browses the whole
* index; typed queries rank via a fuzzy subsequence scorer; a leading ">"
* (or full-width ">") scopes the list to the command rows.
*/
.cmdk-trigger {
/* Hover is a scale nudge with no plate behind it — the header's own
language, set by .navigation-toggle and shared with #indicators. The
size-8 box stays as the touch target; it just never paints.
order-last pins it to the bar's right edge: #indicators sits before it
and grows leftward, so a late indicator never shifts the trigger. */
@apply text-text relative order-last grid size-8 shrink-0 cursor-pointer place-items-center rounded-lg border-0 bg-transparent p-0 shadow-none transition-transform duration-150;
/* size-5 (not the button's own size): the same box #indicators gives its
glyphs, so the shared 24×24 Tabler canvas scales identically and the
search icon carries the same stroke weight as the poll/changes ones
sitting next to it. */
&::before {
@apply size-5 bg-current content-[''] [mask:var(--icon-search)_center/contain_no-repeat];
}
&:hover {
@apply scale-105;
}
&:active {
@apply scale-95;
}
&:focus-visible {
@apply ring-focus-ring ring-2 outline-none;
}
}
.cmdk-panel {
@apply border-hairline bg-surface/85 fixed top-24 left-1/2 z-120 w-[min(36rem,calc(100vw-2.5rem))] -translate-x-1/2 overflow-hidden rounded-2xl border shadow-xl backdrop-blur-xl backdrop-saturate-150 transition-[opacity,translate] duration-150 ease-out starting:-translate-y-2 starting:opacity-0;
/* <md: the same panel becomes a full-screen takeover (Algolia DocSearch
manner) — opaque page background (no full-screen blur cost on phones),
input bar pinned top with a Cancel exit, results fill the rest. */
@apply max-md:top-0 max-md:right-0 max-md:bottom-0 max-md:left-0 max-md:flex max-md:w-auto max-md:translate-none max-md:flex-col max-md:rounded-none max-md:border-0 max-md:bg-bg max-md:shadow-none max-md:backdrop-blur-none max-md:backdrop-saturate-100;
& .cmdk-inputrow {
@apply flex items-center gap-3 px-5 py-3.5;
@apply max-md:border-hairline max-md:bg-surface max-md:border-b max-md:px-4 max-md:py-2.5;
&::before {
@apply text-text-subtle size-5 shrink-0 bg-current content-[''] [mask:var(--icon-search)_center/contain_no-repeat];
}
& .cmdk-input {
@apply text-text placeholder-text-subtle w-full min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-base shadow-none focus:ring-0 focus:outline-none;
}
/* Shown only while the field holds a query: :placeholder-shown is true
exactly when the input is empty, so the state needs no JS mirror. */
& .cmdk-clear {
@apply text-text-subtle grid size-5 shrink-0 cursor-pointer place-items-center rounded-md border-0 bg-transparent p-0 shadow-none transition-colors duration-150;
&::before {
@apply size-4 bg-current content-[''] [mask:var(--icon-x)_center/contain_no-repeat];
}
&:hover {
@apply text-text;
}
.cmdk-input:placeholder-shown ~ & {
@apply hidden;
}
}
& .cmdk-cancel {
@apply text-brand hidden shrink-0 cursor-pointer border-0 bg-transparent p-1 text-sm shadow-none max-md:block;
}
}
& .cmdk-list {
@apply border-hairline max-h-80 overflow-y-auto border-t p-2 empty:hidden;
@apply max-md:max-h-none max-md:flex-1 max-md:overscroll-contain;
& .cmdk-row {
@apply text-text flex items-center gap-3 rounded-xl px-3 py-2 text-sm no-underline;
& .cmdk-title {
@apply min-w-0 truncate;
}
& .cmdk-group-name {
@apply text-text-subtle ml-auto shrink-0 text-xs;
}
& mark {
@apply text-brand bg-transparent font-semibold;
}
&.is-selected {
@apply bg-brand-subtle;
& .cmdk-title {
@apply text-brand font-medium;
}
}
}
& .cmdk-empty {
@apply text-text-subtle px-3 py-6 text-center text-sm;
}
}
& .cmdk-footer {
@apply border-hairline text-text-subtle flex items-center gap-4 border-t px-4 py-2 text-xs max-md:hidden;
& .cmdk-hint-close {
@apply ml-auto;
}
& kbd {
@apply border-hairline bg-surface-sunken text-text-muted rounded-md border px-1.5 py-px font-mono text-[11px];
}
}
}
/* A tab row reads "Parent › Title": the parent gives way first, and keeps
at least an ellipsis so the "›" never leads the row. Like the section
label, it stays subtle on the selected row. Unnested: the classes exist
only inside palette rows, and the short selectors keep main.css lean. */
.cmdk-title:has(> .cmdk-parent) {
@apply flex items-baseline;
}
.cmdk-parent {
@apply text-text-subtle min-w-0 truncate font-normal;
}
.cmdk-label {
@apply max-w-[calc(100%-1.5rem)] shrink-0 truncate;
&::before {
@apply text-text-subtle px-1.5 font-normal content-['›'];
}
}
/* The frost is decorative — both degradation paths go opaque. */
@supports not (backdrop-filter: blur(1px)) {
.cmdk-panel {
@apply bg-surface;
}
}
@media (prefers-reduced-transparency: reduce) {
.cmdk-panel {
@apply bg-surface;
}
}
@@ -0,0 +1,15 @@
.cbi-progressbar {
@apply bg-surface-sunken relative h-3.5 w-full cursor-help overflow-hidden rounded-full max-md:h-4;
&::before {
@apply text-text absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-2xl text-xs whitespace-nowrap content-[attr(title)] max-md:text-xs max-md:leading-normal;
}
[data-page="admin-system-package-manager"] & {
&::before {
@apply max-sm:text-[10px];
}
}
& > div {
@apply from-progress-start to-progress-end h-full bg-linear-to-r transition-[width] duration-[250ms];
}
}
@@ -0,0 +1,30 @@
.cbi-tabmenu {
@apply border-hairline bg-surface-sunken mb-1 flex items-center gap-1 overflow-x-auto rounded-4xl border p-1;
& li {
@apply relative m-0 min-w-fit shrink-0 list-none;
&[data-errors] {
&::after {
@apply bg-danger text-on-brand absolute -top-0.5 -right-1.5 z-20 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold content-[attr(data-errors)];
}
& > a {
@apply border-hairline border;
}
}
& > a {
@apply text-text-muted hover:text-text block rounded-4xl px-4 py-2 text-center text-sm font-medium whitespace-nowrap no-underline;
}
&.cbi-tab a {
@apply bg-text text-surface font-semibold shadow-sm;
}
}
}
[data-tab-title] {
@apply invisible h-0 overflow-hidden;
}
[data-tab-active="true"] {
@apply visible h-auto overflow-visible;
}
@@ -0,0 +1,7 @@
select {
@apply text-text border-hairline bg-control-bg focus:border-brand focus:ring-focus-ring min-w-52.5 max-w-full appearance-none md:max-w-100 rounded-2xl border px-3 py-1.5 pr-10 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-150 focus:ring-2 focus:outline-none;
@apply bg-size-[16px] bg-position-[right_0.75rem_center] bg-no-repeat [background-image:var(--icon-arrow-down)] dark:[background-image:var(--icon-arrow-down-dark)];
&[disabled] {
@apply cursor-not-allowed opacity-(--app-opacity-disabled);
}
}
@@ -0,0 +1,73 @@
.theme-switcher {
@apply bg-surface-sunken relative inline-flex items-center gap-0 rounded-full shadow-sm transition-transform duration-150 max-md:py-0.5;
@apply border-hairline border;
&::before {
@apply bg-surface-overlay absolute top-1 left-1 z-0 h-[calc(100%-0.5rem)] w-[calc(33.333%-0.5rem)] rounded-full shadow-md transition-transform duration-[250ms] content-[''];
}
footer & {
@apply max-md:hidden;
}
& .theme-option {
@apply relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-full px-3 py-1.5 transition-transform duration-150 hover:scale-105 active:scale-95;
& input[type="radio"] {
@apply sr-only;
}
& .theme-icon {
@apply text-text-muted flex items-center justify-center transition-colors duration-150;
& svg {
@apply size-4 transition-transform duration-150;
}
}
& .theme-label {
@apply text-text-muted hidden text-xs font-medium transition-colors duration-150;
}
&.active {
& .theme-icon {
@apply text-text;
& svg {
@apply scale-110;
}
}
& .theme-label {
@apply text-text;
}
}
&:hover:not(.active) {
& .theme-icon {
@apply text-text;
}
& .theme-label {
@apply text-text;
}
}
}
&:has(.theme-option[data-theme="device"].active) {
&::before {
@apply translate-x-0;
}
}
&:has(.theme-option[data-theme="light"].active) {
&::before {
@apply translate-x-[calc(100%+0.5rem)];
}
}
&:has(.theme-option[data-theme="dark"].active) {
&::before {
@apply translate-x-[calc(200%+1rem)];
}
}
}
Loaded 100 of 864 files, more files were not shown because too many files have changed in this diff. Show more