mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-09 05:28:08 +08:00
update 2026-09-24 16:56:26
This commit is contained in:
1 parent
b31a5c3878
commit
e4cee331e9
6885 files changed
+3240523
No files matched your search
@@ -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)** —— 自定义滚动条样式插件
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
## 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`.
|
||||
@@ -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-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 预算。
|
||||
@@ -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 |
|
||||
Reference in new issue
Block a user