update 2026-09-20 01:22:33

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

No files matched your search

@@ -0,0 +1,52 @@
---
name: aurora-runtime-reviewer
description: Reviews a diff in this repository for the client runtime of the Aurora theme — menu-aurora.js, router-aurora.js, page JS patches, header inline scripts — against L.require semantics, router invariants, SPA teardown, polling hygiene and keyboard/ARIA reachability. Read-only.
tools: Read, Grep, Glob, Bash
effort: high
skills: luci-knowledge
color: purple
---
You review changes to `.dev/src/resource/**`, inline `<script>` in
`ucode/template/themes/aurora/*.ut`, and `.dev/tests/**` for the Aurora
theme. Read-only: never edit, never commit.
Start with `git diff <base>` for those paths. Read
`references/luci-runtime.md` §1, §3–4, `references/spa-pitfalls.md` and the
relevant section of `.dev/docs/router.md` before judging. For every finding
give `file`, `line`, a one-sentence `claim`, a concrete `failure_scenario`
and `evidence` (`file:line` in luci-base or this repo).
Checklist (only what the diff touches):
- **Loader**: `L.require` returns an instance; re-render is
`new instance.constructor()`; the six in-memory names have no file; the
pragma head must survive Terser (`'require x'` string directives at the
top, nothing before them).
- **Boot order**: nothing in the theme `<head>` references `L`; a wrapper
around `L.require` installed from the footer is after `view.ut`'s first
call; `menu-aurora` must still expose `syncRoute()` / `closeSurfaces()`.
- **Router invariants**: `contract()` additions for any new luci-base
surface; generation gate before every paint; superseded renders never
resolve; same-URL / hash / form / download / expired / poisoned ⇒ full
load; teardown order from `aurora-contract.md`; departed regions through
`L.dom.content(n, null)`; patch `mount`/`unmount` symmetry.
- **Polling & timers**: three-step poll reset; `poll-status` hidden; no
bare `setInterval` left behind; `visibilitychange` gate intact.
- **Storage**: `localStorage`/`sessionStorage` reads wrapped in try/catch
and validated (the existing `readPaletteRecents()` pattern); keys
prefixed `aurora.`; anything cached from the server carries what
invalidates it (session id, `pkgs_update_time`) in the key.
- **Accessibility**: every pointer-only affordance has a keyboard path
(Enter/Space on triggers, focusable targets not `visibility:hidden`);
`aria-expanded`/`aria-controls`/`aria-current` kept in sync;
`prefers-reduced-motion` respected; IME (`isComposing`, keyCode 229) on
key handlers, as the palette already does.
- **Tests**: new behaviour is covered in `.dev/tests/` and `pnpm test`
passes.
- **Spec axis** (when an approved scope file is given): behaviour matches
the approved plan; no extra features.
Severity: `blocker` (breaks navigation, login, or polling), `major`
(leak, double render, a11y regression, contract break), `minor`. Evidence
or nothing; an empty list is a valid result.
@@ -0,0 +1,47 @@
---
name: aurora-static-reviewer
description: Reviews a diff in this repository for everything the Aurora theme ships as templates, CSS, static assets and package metadata — per-request ubus/uci/fs cost, escaping, @apply rules, budgets, patch naming, luci.mk rewrites, CI triggers. Read-only.
tools: Read, Grep, Glob, Bash
effort: high
skills: luci-knowledge
color: green
---
You review changes to `ucode/`, `.dev/src/media/`, `.dev/public/`, `Makefile`,
`root/`, `.dev/vite.config.ts`, `.dev/package.json` and `.github/` for the
Aurora theme. Read-only: never edit, never commit.
Start with `git diff <base> --stat` then the full diff of those paths. Read
`references/aurora-contract.md` and `references/luci-runtime.md` §1–2, §5
before judging. For every finding give `file`, `line`, a one-sentence
`claim`, a concrete `failure_scenario` (input/state → wrong output) and the
`evidence` (quoted source, `file:line`). Skip style/formatting.
Checklist (only what the diff touches):
- **Templates**: count added `ubus.call`/`uci`/`fs` calls per request and
demand a stated reason; nothing in `<head>` may use `L`; `{{ }}` is raw —
attributes need `entityencode(v, true)`, text `striptags()`; the
`blank_page` (login) path still renders; the `data-*` hooks and ids in
`aurora-contract.md` are kept or updated in all three layers; patch
discovery (`lsdir`, prefix on segment boundaries) unchanged; quoted
`{{ media }}…css"` / `{{ resource }}…js"` links are versioned by `luci.mk`,
`{{ resource }}/{{ dispatched.css }}` is not.
- **CSS**: `@apply` + nesting only (plain CSS only in `media/patches/`); no
`@layer` wrapper in partials; `main.css` import order is cascade order;
colours come from the tokens package, not literals; budgets in
`tests/build-performance.test.js` still hold — if `htdocs/` was rebuilt,
state the byte delta and whether `aurora-budgets.md` carries the same
number.
- **Patches**: file name is the `data-page` prefix; plain CSS; only `:root`
vars (`--radius-base` maths, `--app-shadow-*`); a JS patch exposes
`window.luciPatches[stem] = {mount, unmount}` and mounts once.
- **Packaging/CI**: `PKG_VERSION`/`PKG_RELEASE` bumped together; nothing
in `htdocs/` hand-edited; a workflow's `on:` events include every event
its job `if:` tests for.
- **Spec axis** (when an approved scope file is given): every changed path
is inside the listed prefixes; nothing unrelated was changed.
Severity: `blocker` (ships a broken page or breaks the router/login),
`major` (budget, per-request cost, contract break), `minor`. Report only
what you can evidence; an empty list is a valid result.
@@ -0,0 +1,36 @@
---
name: luci-claim-verifier
description: Adversarial verifier for one claim or one review finding about luci-base, uhttpd or this theme. Tries to refute it from the local sources first; returns confirmed / corrected / refuted with evidence. Read-only.
tools: Read, Grep, Glob, Bash
effort: high
skills: luci-knowledge
color: red
---
You are given exactly one claim (or one review finding) about luci-base,
uhttpd or the Aurora theme. Your job is to **refute** it. Only when you
fail to refute it may you confirm it.
Procedure:
1. Read `references/traps.md`. If the claim matches a known false belief,
refute it with the cited source.
2. Open the source the claim depends on (`$LUCI_SRC`, `$UHTTPD_SRC` — resolve
both from `.dev/.env`, shallow-clone per the `luci-knowledge` skill if
absent — or this repo). Do not trust the claim's own quote — re-read the
lines and 20 lines around them. Cite as `openwrt/luci@<branch>
<path>:<line>` / `uhttpd <file>:<line>`, never a machine path.
3. Check the branch: a fact true on master may be false on 23.05/24.10/25.12
(`references/luci-runtime.md` §6). A claim that silently assumes master
is `corrected`, not `confirmed`.
4. For a review finding, also check whether the failure scenario can
actually happen in this theme's code paths (grep the callers) — a true
statement about an unreachable path is `refuted` as a finding.
5. If a measurement is quoted, check it exists in
`references/dispatch-cost.md` or in a baseline; otherwise mark the number
`unsupported` (the claim may still stand without it).
Verdicts: `confirmed` (source agrees, scenario reachable), `corrected`
(true with a stated correction — give the corrected claim), `refuted`
(source disagrees or unreachable). Always cite `file:line` for the decisive
evidence. Default to `refuted` when you cannot find evidence either way.
Never edit files.
@@ -0,0 +1,37 @@
---
name: luci-runtime-analyst
description: Establishes how luci-base and uhttpd actually behave for a stated problem by reading the local sources ($LUCI_SRC, $UHTTPD_SRC) and returns claims with file:line evidence. Use for dispatcher, template scope, luci.js loader, caching headers, CGI vs mod-ucode, packaging questions.
tools: Read, Grep, Glob, Bash
effort: high
skills: luci-knowledge
color: blue
---
You are the luci-base / uhttpd source analyst for the Aurora theme.
Ground truth, in this order: `$LUCI_SRC` (openwrt/luci; check the branch
with `git -C "$LUCI_SRC" branch --show-current`, use `git show
<branch>:<path>` for 23.05/24.10/25.12), `$UHTTPD_SRC`, then
this repository (`ucode/`, `.dev/src/`, `.dev/docs/router.md`). Resolve
both variables from `.dev/.env` first (`grep -E '^(LUCI|UHTTPD)_SRC='
.dev/.env`); if absent, shallow-clone as described in the `luci-knowledge`
skill and say so. Cite evidence as `openwrt/luci@<branch> <path>:<line>` or
`uhttpd <file>:<line>` — never a machine path. The `luci-knowledge`
references are your map, not your evidence — re-open the file for every
claim you make.
Method:
1. Restate the problem as the concrete request path (which template, which
dispatcher action, which uhttpd handler, which client call).
2. Trace it through the source. For each fact record `file`, `lines`, a
verbatim `quote` (≤ 3 lines) and whether it is `verified` (read) or
`inferred` (reasoned).
3. Attach measured numbers only from
`.claude/skills/luci-knowledge/references/dispatch-cost.md` or from a
bench you ran; otherwise leave `measurement` empty.
4. Check `references/traps.md` before repeating anything that sounds familiar.
5. List what you could not determine as `unknowns` with the command that
would settle each.
Never propose a fix in this role; never edit files. Return only the
structured output you were asked for.
@@ -0,0 +1,40 @@
---
name: upstream-rfc-writer
description: Writes or revises one upstream proposal (luci-base / uhttpd / rpcd / feed) as .dev/decisions/<slug>.md from verified claims — problem, evidence table, before/after mermaid diagrams, change sketch, measurement plan, interim path for this theme. Writes only under .dev/decisions/.
tools: Read, Write, Edit, Grep, Glob, Bash
effort: high
skills: luci-knowledge
color: orange
---
You write upstream proposals for problems that cannot be fixed inside the
Aurora theme. You may create or edit files **only** under
`.dev/decisions/`; never touch code, templates, CSS or CI.
Inputs: the topic, the verified claims (each with `file:line` evidence and
a verdict), and `.dev/decisions/TEMPLATE.md`. Follow the template's
sections in order; delete a section only if it is truly empty.
Rules:
- Every statement of fact carries its `file:line` written as
`openwrt/luci@<branch> <path>:<line>` or `uhttpd <file>:<line>` — never a
machine path, never a home directory. Cite the skill's reference files by
section (`references/dispatch-cost.md §"Inside one dispatch"`), never by
line: those files are edited often. Claims marked `refuted` are not
mentioned; `corrected` ones use the corrected text. Numbers only from `references/dispatch-cost.md` or the supplied
claims; otherwise write "not measured yet" in the measurement plan.
- Diagrams are mermaid, and each one must show the **mechanism**, not a
box list: a `sequenceDiagram` for a request/response flow (browser →
uhttpd → CGI/ucode → response headers), a `flowchart LR` for
before/after architecture. One diagram per idea; label edges with the
cost (ms, bytes, RTT) where known.
- The change sketch names the exact function/file to modify upstream and
its size (lines), plus compatibility across 23.05/24.10/25.12/master.
- "Interim in Aurora" says what this theme (or the eamonxg feed) can do
without upstream, and what it cannot.
- "Submission" names the target repository, the branch, and the one-commit
cover-letter contents (measurement before/after, device).
- Plain language, short paragraphs, tables over prose. No hedging words
where the evidence is cited.
Return the written path and a list of sections you could not fill.
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# PreToolUse guard for Edit/Write/MultiEdit (see .dev/docs/workflows.md).
# Shipped or source paths may only be edited inside an approved scope:
# .claude/.change-approved lists the approved path prefixes, one per line
# (written by /luci-change after the user approves the plan, deleted when
# the change is reported). Anything else is blocked with exit 2.
# Known gap: edits made through Bash (sed -i, heredocs) are not seen here;
# the review workflow's scope check is the second net.
input=$(cat)
if command -v node >/dev/null 2>&1; then
path=$(printf '%s' "$input" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).tool_input?.file_path??""))}catch{}})')
else
path=$(printf '%s' "$input" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
fi
[ -z "$path" ] && exit 0
root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
case "$path" in
"$root"/*) rel="${path#"$root"/}" ;;
/*) exit 0 ;; # outside this project
*) rel="$path" ;;
esac
case "$rel" in
.dev/src/*|.dev/public/*|.dev/vite.config.ts|.dev/package.json|ucode/*|htdocs/*|root/*|Makefile|.github/*) ;;
*) exit 0 ;; # docs, tests, .claude, scratch: not guarded
esac
marker="$root/.claude/.change-approved"
if [ -f "$marker" ]; then
while IFS= read -r prefix || [ -n "$prefix" ]; do
[ -z "$prefix" ] && continue
case "$rel" in "$prefix"*) exit 0 ;; esac
done < "$marker"
echo "guard-edits: '$rel' is outside the approved scope in .claude/.change-approved — extend the plan (and the file) first." >&2
exit 2
fi
echo "guard-edits: '$rel' is a shipped/source path. Run /luci-change so the plan is approved first (it writes .claude/.change-approved); to bypass deliberately, create that file with the allowed path prefixes." >&2
exit 2
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/guard-edits.sh"
}
]
}
]
}
}
@@ -0,0 +1,116 @@
---
name: aurora-motion
description: Use when a change adds, removes or adjusts motion in the Aurora theme (transition, animation, View Transition, hover/press/focus feedback, menu or drawer timing, 动效/动画/过渡/丝滑) or when a request asks "what should this state look like when it changes". Maps each LuCI UI state to one effect (CSS animates, JS only decides when and how far), keeps main.css and the theme JS from growing, and rejects motion that janks a phone or replays on poll.
---
# Aurora motion: which state gets which effect
## First principles
1. **The device is small.** The router has little RAM and flash, and it serves every byte uncompressed. Motion must not grow the theme. Decorative motion must be net ≤ 0 bytes across main.css and the theme JS. Only a functional fix (§Traps) may add bytes, and each must stay under ~100 B.
2. **CSS does the moving; JS decides, in moderation.** Transitions and keyframes animate. JS may decide *when* and *how far* (see §JS). No animation library: `motion/mini` alone is 9,751 B raw (measured 2026-09-16). At HEAD, main.css had 1,270 B left under its 193 KB budget and menu-aurora.js had 306 B left under 22 KB.
3. **Motion explains a state change, nothing else.** If a state is not in the table below, the answer is "no motion". Plain and quiet beats impressive.
4. **Never cause jank.** The client is usually a phone on the router's WiFi. See §Jank.
## State → effect
These timings are the only ones allowed: 150 ms for controls, 250 ms for surfaces, 300–320 ms only for large panels. Curves: `ease-out` for entering elements, `ease-in-out` for symmetric changes. `cubic-bezier(0.34,1.25,0.5,1)` is the float-button's overshoot and nothing else. "Today" describes HEAD.
| State | Hook | Effect | Today |
|---|---|---|---|
| Hover 悬停 (desktop only) | `:hover` | Colour change, instant. Icon-size controls may use `scale-105`. | top nav pills instant; some links still `transition-colors` |
| Focus 键盘聚焦 | `:focus-visible` | A ring appears. No movement. | `ring-2 ring-focus-ring` |
| Press 按下 | `:active` | `scale-95`, only on icon-size controls. | header toggles, switcher, card icons |
| Toggle 开关 | class flip | The thumb slides or the icon morphs. | `.theme-switcher`, hamburger → X |
| Expand / collapse 展开收起 | theme classes | Fade. Grid rows only where they already animate. | mobile submenu |
| Menu / drawer 菜单抽屉 | theme classes | Slide (translate) plus fade. A page scrim only dims; it is never blurred. The desktop sidebar pushes: its grid column eases with the panel's slide, same curve and length. | mega-menu (unblurred curtain), mobile nav, sidebar (push) |
| Tooltip 提示 | hover / focus | Fade plus `scale-95` → 100, after the intent delay. | `_tooltip.css`, `delay-300` |
| Dropdown open 下拉打开 | `.cbi-dropdown[open]` | Opacity .99 → 1 over 120 ms. This is functional: it fires luci-base's focus hand-off. | **missing** (Trap 2) |
| Modal open 弹窗打开 | `body.modal-overlay-active` | Fade in, reusing `aurora-fade-in`. The overlay goes from `display:none` to shown, so the keyframe restarts by itself. | none |
| Modal close 弹窗关闭 | class removed | None. luci-base provides no exit hook. | — |
| Notification in 通知出现 | `.alert-message.fade-in` | Fade in. | `_utilities.css` |
| Notification out 通知消失 | `.fade-out` (dismiss or timer) | Fade out. It must be a *running* transition. | **broken** (Trap 1) |
| View loading 页面加载中 | `#view > .spinning` | Spinner only. It keeps spinning under reduced motion. | `.spinning` |
| Page ready 内容就绪 | direct children of `#view` | At most one fade in, reusing `aurora-fade-in`. No stagger, no rise, and nothing deeper than the direct children. | none |
| Route leave 离开页面 | `#view.view-leaving` | Dim to 60 %. | `_layout.css` |
| Route enter 进入页面 | router swap | Same as "Page ready". Use one mechanism only: the View Transition or the fade, never both stacked. | router root crossfade |
| Tab switch 切换标签 | `[data-tab-active="true"]` | Fade in the incoming pane. Its height snaps. | none |
| Busy action 操作进行中 | `.spinning` on a button | Spinner. | `.spinning` |
| Progress 进度 | `.cbi-progressbar > div` | Width. This is an existing exception; don't copy it. | `_progress.css` |
| Drag 拖拽 | `.dragging` | A static state style, no motion. | `_form.css` |
| Invalid / success / error 校验与结果 | `.cbi-input-invalid`, `.alert-message.*` | Colour plus text. No shake, no bounce. | — |
| Poll refresh 数据刷新 | `LuCI.poll`, every 5 s | **None, ever.** | — |
| Theme switch 主题切换 | header.ut | View Transition crossfade. | header.ut |
| Disabled 禁用 | `:disabled` | None. | — |
The source lines for every hook are in references/luci-hooks.md.
### Never
Blur or mask reveal, parallax, scroll-linked or scroll-triggered motion, stagger on rows, cards or sections, text reveal, tilt, magnetic or cursor-follow effects, hover lift or shadow on `.cbi-section` or `#view`, skeleton screens, layout (FLIP) animation, crossfading tab panes, shake, loops other than `.spinning`, autoplay, and any motion on polled content.
## Bytes
Measured on main.css (raw, Tailwind 4.3.2 plus lightningcss, 2026-09-16). What you write matters more than what the effect is:
| Written as | Cost |
|---|---|
| Add a selector to an existing motion rule's selector list | **+17 B** |
| One shorthand on a new selector: `@apply [transition:opacity_150ms_ease-out]` | +50 B |
| Reuse the keyframe: `@apply animate-[aurora-fade-in_250ms_ease_backwards]` | +59 B |
| New keyframe plus its use (e.g. opacity + scale pop-in) | +141 B |
| Press as shorthand: `[transition:scale_150ms] active:[scale:.95]` | +99 B |
| Split utilities: `transition-opacity duration-150 ease-out` | **+316 B** |
| Split utilities for press: `transition-transform duration-150 active:scale-95` | +415 B |
| `starting:` entry with split utilities (modal) | +508 B |
Tailwind v4's split utilities expand into `--tw-*` fallback chains. So:
- Write motion as **one shorthand declaration**, or reuse `aurora-fade-in`.
- Prefer adding a selector to an existing rule over writing a new one.
- Don't add `@starting-style`: an element that comes back from `display:none` restarts its keyframe anyway.
Check every change. Run `pnpm build`, then compare `wc -c < htdocs/luci-static/aurora/main.css` with `git show HEAD:htdocs/luci-static/aurora/main.css | wc -c`. Put the delta in the commit body. If decorative motion comes out positive, remove bytes elsewhere or drop it.
## Jank
- Animate only `opacity`, `translate`, `scale` and `transform`. Never width, height, margins, `box-shadow`, `filter`, `backdrop-filter` or background. The only existing exceptions are the progress-bar width, the mobile-submenu grid rows, and the desktop sidebar column (user's call, 2026-09-16: a content column that snaps and then slides reads as broken; at 4× CPU the 250 ms push cost ~3 ms layout + style per frame on the overview page).
- Never animate an element that carries `backdrop-blur`, because the blur is re-rasterised every frame. Animate an unblurred child instead. Under software compositing, the blurred mega-menu curtain dropped 3–5 frames per open; without the blur it dropped none (2026-09-16).
- Keep `visibility` out of the tween. Write `[transition:opacity_220ms_…,visibility_0s_220ms]` and put `[transition-delay:0s]` on the shown state. A tweened `visibility` runs on the main thread, and on show it also keeps the paired `opacity` fade off the compositor.
- A `color` or `background-color` transition repaints on the main thread every frame. Swap state colours instantly. Dropping the transition on the nav pills cut repaints by 61 %.
- Always name the transitioned properties. Never use `transition-all`, and never use `will-change`.
- Allow one motion per state change per region, lasting no more than 320 ms. Click, save and error feedback get no delay.
- Under reduced motion, show the end state at once, but keep durations at 1 ms rather than `none` (Trap 1).
- Verify on the Layout track at 4× CPU throttling: aurora-performance runtime.md R1–R3.
## JS
JS is worth its bytes where CSS cannot decide. The mega-menu in `menu-aurora.js` is the reference:
- **Hover intent and aim.** Delay the first open. Hold the open category while the pointer heads into its panel. Every timer is cleared on leave.
- **Measure once, then cache.** Measure after `document.fonts.ready`, in `requestIdleCallback`. Write the result to a custom property (`--mega-menu-height`) and let CSS use it. Invalidate on resize only.
- **Distance-adaptive duration.** Write the computed duration to a custom property as well (`--mega-menu-duration`).
- **Sequencing and cleanup.** Toggle classes (`.active` → `.closing` → removed). A `transitionend` wait always has a fallback timer that reads the real duration back.
- **Reduced motion.** Check `prefers-reduced-motion` before any JS-timed motion, and jump straight to the end state.
Not allowed:
- a `requestAnimationFrame` loop that writes styles every frame;
- scroll or pointer listeners whose only job is an effect;
- layout reads (`offsetHeight`, `getBoundingClientRect`) on every event of a hot handler;
- `el.animate()`, except for a one-off sequence CSS cannot express, cancelled on `luci-navigate`.
## Traps (reproduced on a device, 2026-09-16)
luci-base acts on `transitionend`, which only fires if a transition actually runs.
1. **A dismissed notification never leaves the DOM.** The fill of `.fade-in` (`both`) holds `opacity`, so the `.fade-out` transition never starts. Every Dismiss and every expired notification leaves an invisible 84 px gap. Fix: `both` → `backwards` (+5 B). In `_reduced-motion.css`, replace `transition-none!` with `delay-0! duration-1!` (+57 B).
2. **Arrow keys cannot enter a dropdown.** There is no transition on `.cbi-dropdown > ul`, so focus never moves into the list. Fix: `opacity-99 [transition:opacity_120ms_ease-out]`, with `&.dropdown { opacity-100 }` (+98 B).
Any change touching `.fade-in`/`.fade-out`, dropdowns or `_reduced-motion.css` must pass this, run after `pnpm build`:
```
MAIN_CSS=htdocs/luci-static/aurora/main.css node .claude/skills/aurora-motion/scripts/transitionend-probe.mjs
```
The edit itself goes through /luci-change.
@@ -0,0 +1,56 @@
# Evidence for the state table
luci-base line numbers are from openwrt/luci master (d19bf1358f, 2026-09-14),
through `$LUCI_SRC` (see luci-knowledge). Router line numbers are from
`@eamonxg/luci-theme-devkit` 0.3.1, `runtime/router.js`. Re-check them after
a version bump.
## Hooks luci-base emits
| State | Source | What happens |
|---|---|---|
| Modal open / close | `ui.js:4224`, `ui.js:4242` | `showModal` adds `body.modal-overlay-active`. `hideModal` only removes that class, so there is no exit hook. `#modal_overlay` is `hidden` until the class is present (`_modal.css`). |
| Notification | `ui.js:4359–4390`, `ui.js:4440` | `addNotification` creates `.alert-message.fade-in`. Dismiss adds `.fade-out`, and the node is removed on its `transitionend` (`ui.js:4364`). The time-limited variant adds `.fade-out` on a timer. |
| Dropdown | `ui.js:1300–1306`, `1390–1395`, `1416–1423` | Opening sets `[open]`, and the focus layer gets `.cbi-dropdown-open`. Focus moves into the list only on `transitionend` of the `ul`. |
| Dropdown keys | `ui.js:1881–1930` | ArrowUp/ArrowDown act only when a `li` or the `ul` already has focus. |
| Tabs | `ui.js:4728`, `4882–4887` | Panes get `data-tab-active="true"/"false"`, and the pane fires the `cbi-tab-active` event. In this theme, inactive panes are `invisible h-0` (`_segmented.css`), not `display:none`. |
| Busy button | `ui.js:5683` | Adds `.spinning` while a handler's promise is pending. |
| Drag | `ui.js:2442–2449` | Adds `.dragging` on `dragstart` and removes it on `dragend` (`.cbi-dynlist`). |
| Invalid input | `ui.js:2011` and the validators | `.cbi-input-invalid`. |
| Header indicator | `ui.js:4492` | `#indicators span[data-indicator][data-style=active\|inactive]`. |
| First render | `luci.js:2771` | `luci-loaded` fires after `initDOM()` and `Poll.start()`. |
| Poll | `luci.js:1093`, `luci.js:1165` | Ticks once a second and runs each callback every `pollinterval` seconds: 5 by default (`/etc/config/luci`; luci-base `header.ut:20`). Clicking the `poll-status` indicator pauses it (`luci.js:2742–2750`). |
For reference, bootstrap animates `max-height` over 125 ms on the dropdown
`ul` (`themes/luci-theme-bootstrap/.../cascade.css:1738`). That transition
is what makes the focus hand-off work there.
## Hooks the router emits
| Hook | Source | What happens |
|---|---|---|
| `.view-staging`, `#view.view-leaving` | `router.js:832–848` (`stage()`) | The incoming view renders off-screen, and the outgoing one is marked. |
| `document.startViewTransition(swap)` | `router.js:872` (`commit()`) | Skipped when the API is missing or reduced motion is set. |
| `luci-navigate` / `luci-navigated` | `router.js:733` / `router.js:756` | Document events; `detail` is the route. Neither fires on MPA browsers, so any motion must also work after a full page load. |
| Departed view | `router.js:852–861` | The old view is cleared through `dom.content()` and removed synchronously, so it has no exit animation. |
## Traps: how they were reproduced
`scripts/transitionend-probe.mjs` logs in, renders a stock `ui.Dropdown` and
calls `ui.addNotification` on a real page. It runs with and without
`prefers-reduced-motion` and can serve any build as main.css (`MAIN_CSS`).
| main.css | Dropdown after ArrowDown ×2 | Notification after Dismiss |
|---|---|---|
| HEAD (2026-09-16) | focus stays on the dropdown | still in the DOM, 84 px tall |
| HEAD + reduced motion | same | same |
| + `.fade-in` fill `backwards` + dropdown `ul` opacity transition | focus moves `li a` → `li b` | removed |
| + the above + reduced motion at `duration 1ms` instead of `transition: none` | `li a` → `li b` | removed |
The cause of Trap 1: Chrome does not start a transition on a property that
a filling CSS animation still holds (`.fade-in` uses `… both`). Removing
`.fade-in` before dismissing also made the removal work.
Upstream direction (decision doc only, through /upstream-rfc): luci-base
should not depend on `transitionend` alone, and should fall back when the
computed transition duration is `0s`.
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* transitionend-probe.mjs — luci-base removes a dismissed notification and
* moves dropdown focus into the list only on `transitionend`, so both break
* silently when the theme has no running transition there. Checks both on a
* real device, with and without prefers-reduced-motion. Writes nothing on
* the device. Raw CDP, no npm deps, node >= 22. Exit 1 if a contract fails.
*
* env:
* HOST http://192.168.1.1 (default: VITE_OPENWRT_HOST from .dev/.env)
* PAGE path after /cgi-bin/luci/ (default admin/system/system)
* MAIN_CSS file served as the theme's main.css (default: the device's own),
* e.g. htdocs/luci-static/aurora/main.css after `pnpm build`
* USER_NAME/PASS login (default root / empty)
* CHROME_BIN
*/
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
const root = resolve(import.meta.dirname, "../../../..");
const envFile = join(root, ".dev/.env");
const dotenv = existsSync(envFile)
? Object.fromEntries(readFileSync(envFile, "utf8").split("\n").filter((l) => /^[A-Z_]+=/.test(l)).map((l) => l.split("=", 2)))
: {};
const HOST = (process.env.HOST ?? (dotenv.VITE_OPENWRT_HOST ? `http://${dotenv.VITE_OPENWRT_HOST}` : "http://192.168.1.1")).replace(/\/+$/, "");
const PAGE = process.env.PAGE ?? "admin/system/system";
const MAIN_CSS = process.env.MAIN_CSS ? readFileSync(resolve(process.env.MAIN_CSS)) : null;
const CHROME = process.env.CHROME_BIN ?? (process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "google-chrome");
const login = await fetch(`${HOST}/cgi-bin/luci/`, {
method: "POST",
body: new URLSearchParams({ luci_username: process.env.USER_NAME ?? "root", luci_password: process.env.PASS ?? "" }),
redirect: "manual",
});
const cookie = (login.headers.get("set-cookie") ?? "").match(/(sysauth[^=]*)=([^;]+)/);
if (!cookie) throw new Error(`login failed (${login.status})`);
const profile = mkdtempSync(join(tmpdir(), "cdp-transitionend-"));
const chrome = spawn(CHROME, ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "about:blank"], { stdio: "ignore" });
let port = null;
for (let i = 0; i < 100 && !port; i++) {
await sleep(100);
const f = join(profile, "DevToolsActivePort");
if (existsSync(f)) port = +readFileSync(f, "utf8").split("\n")[0];
}
if (!port) throw new Error("chrome: no DevToolsActivePort");
const ws = new WebSocket((await (await fetch(`http://127.0.0.1:${port}/json/version`)).json()).webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.addEventListener("open", res, { once: true }); ws.addEventListener("error", () => rej(new Error("CDP connect failed")), { once: true }); });
let mid = 0; const pending = new Map(); const handlers = new Set();
ws.addEventListener("message", (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); }
else for (const h of handlers) h(m);
});
const send = (method, params = {}, sessionId) => new Promise((res, rej) => { const id = ++mid; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params, sessionId })); });
async function probe(reduce) {
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId: s } = await send("Target.attachToTarget", { targetId, flatten: true });
const ev = async (expression) => (await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, s)).result.value;
await send("Page.enable", {}, s);
await send("Network.setCookie", { name: cookie[1], value: cookie[2], domain: new URL(HOST).hostname, path: "/" }, s);
await send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }, s);
await send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: reduce ? "reduce" : "no-preference" }] }, s);
if (MAIN_CSS) {
await send("Fetch.enable", { patterns: [{ urlPattern: "*/luci-static/aurora/main.css*", requestStage: "Request" }] }, s);
handlers.add((m) => {
if (m.sessionId !== s || m.method !== "Fetch.requestPaused") return;
send("Fetch.fulfillRequest", { requestId: m.params.requestId, responseCode: 200, responseHeaders: [{ name: "Content-Type", value: "text/css" }, { name: "Cache-Control", value: "no-store" }], body: MAIN_CSS.toString("base64") }, s);
});
}
await send("Page.navigate", { url: `${HOST}/cgi-bin/luci/${PAGE}` }, s);
for (let i = 0; i < 80; i++) {
await sleep(250);
if (await ev(`!!(window.L?.loaded && document.querySelector('#view') && !document.querySelector('#view > .spinning'))`)) break;
}
// A stock single-select ui.Dropdown: open with ArrowDown, then ArrowDown again.
await ev(`L.require('ui').then(ui => {
const el = new ui.Dropdown('a', { a: 'A', b: 'B', c: 'C' }, {}).render();
el.id = 'probe-dd';
document.querySelector('#view').prepend(el);
el.focus();
})`);
const key = async () => {
for (const type of ["rawKeyDown", "keyUp"]) await send("Input.dispatchKeyEvent", { type, key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40, nativeVirtualKeyCode: 40 }, s);
await sleep(500);
};
const focused = `(document.activeElement.closest('#probe-dd li')?.dataset.value ?? 'none')`;
await key(); const first = await ev(focused);
await key(); const second = await ev(focused);
await ev(`document.querySelector('#probe-dd').remove()`);
// ui.addNotification, dismissed after its entrance has finished.
const note = await ev(`L.require('ui').then(ui => new Promise(res => {
const n = ui.addNotification(null, E('p', 'probe'), 'info');
setTimeout(() => {
n.querySelector('button.btn').click();
setTimeout(() => res({ removed: !n.isConnected, gapPx: n.isConnected ? n.offsetHeight : 0 }), 800);
}, 400);
}))`);
await send("Target.closeTarget", { targetId });
const dropdownOk = first === "a" && second === "b";
console.log(JSON.stringify({ reducedMotion: reduce, dropdownFocus: `${first} -> ${second}`, dropdownOk, notification: note, notificationOk: note.removed }));
return dropdownOk && note.removed;
}
let ok = true;
try {
for (const reduce of [false, true]) ok = (await probe(reduce)) && ok;
} finally {
chrome.kill();
ws.close();
await sleep(500);
rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
}
process.exit(ok ? 0 : 1);
@@ -0,0 +1,61 @@
---
name: aurora-performance
description: Use when developing an OpenWrt LuCI theme — editing ucode (.ut) templates, adding or loading CSS/JS/font/image assets, writing CSS animations or menu/drawer interactions, reviewing a theme PR for performance, or measuring TTFB, transfer size, memory, or dropped frames on router-served pages.
---
# Aurora Theme Performance
## Overview
Two machines are involved, and they have almost nothing in common. The
**server** is the router: 1–2 slow cores, 64–512 MB RAM, squashfs flash, a
single-process `uhttpd` with no dynamic compression — every cycle you spend
there repeats on every page view. The **client** is the browser, usually a
phone on the router's own WiFi, doing the actual painting and animating. Both
planes have budgets, and "it feels fine on my laptop" proves nothing on
either of them. Every optimization claim needs a measurement, not a
rationalization — "consistent with the existing pattern" is not a cost
justification.
## When to use
- Editing a `.ut` template, or anything that adds a `ubus`/`uci`/`fs` call
- Adding or changing what loads in `<head>` — CSS, JS, fonts, images
- Writing a CSS animation, transition, or JS-driven interaction
- Reviewing a theme PR for performance impact
- Measuring TTFB, transfer size, memory, or dropped frames
Not for: pure content/copy changes with no runtime surface (rewording a
label, fixing a typo, adjusting a translation string).
## The three planes
| Touching | Read |
|---|---|
| `.ut` templates, ubus/uci/fs calls, shipped file sizes | references/server.md |
| `<head>` links/scripts, assets, caching, compression | references/loading.md |
| CSS animations, JS interactions, transitions | references/runtime.md |
| Verifying/quantifying any of the above | references/measuring.md |
## Non-negotiables (quick reference)
- **S1** — Compute at build time, never at request time. → references/server.md
- **S2** — Per-request ubus/uci/fs calls are a budget. → references/server.md
- **S3** — Every byte sent is router CPU. → references/server.md
- **S4** — Memory has a ceiling. → references/server.md
- **L1** — Kill render-blocking requests. → references/loading.md
- **L2** — Download once, cache forever. → references/loading.md
- **L3** — Make identity-transfer assets compact. → references/loading.md
- **R1** — Animate compositor properties only. → references/runtime.md
- **R2** — JS must not force synchronous layout. → references/runtime.md
- **R3** — Accessibility is functionality. → references/runtime.md
## Budgets & ledger
The reference files above are generic to any LuCI theme. The concrete numbers
for THIS theme — budget table, optimization ledger, accepted exceptions — live
in `references/aurora-budgets.md`. Consult it before declaring a budget met or
re-proposing a previously rejected optimization. The measured baselines backing
those numbers sit in `baselines/` (git-ignored: they record device model and
LAN address, so they stay local). Porting this skill to another theme means
swapping `references/aurora-budgets.md` and `baselines/`.
@@ -0,0 +1,129 @@
# Performance — aurora budgets & ledger
Methodology lives in this skill's reference files (`server.md`, `loading.md`,
`runtime.md`, `measuring.md`, alongside this file). This file holds what is
specific to THIS theme: budgets, the optimization ledger, and accepted
exceptions. The measured baselines backing the numbers live in the skill's
`baselines/` directory (git-ignored — they record device model and LAN
address, so they stay local).
## Budgets
| Metric | Budget | Track | Source |
|---|---|---|---|
| main.css (identity/raw) | ≤ 187 KB | size | production build, 2026-09-19 (185,262 B; −4,968 B pseudo-elements as nested rules, one `content` each; 2026-09-17: 190,230 B; +0.6 KB palette tab rows "Parent › Title", unnested short selectors; −0.77 KB sidebar push as two transitions instead of run keyframes; +42 B replayed nav skips the fade; +~2 KB custom-background mode, +~0.5 KB router progress bar / live region; the bar is now Turbo-shaped — width/opacity only, inserted per navigation; −1.24 KB unblurred mega-menu curtain, delayed visibility, instant nav-pill colours) |
| login.css (identity/raw) | ≤ 12 KB | size | production build, 2026-09-19 (11,972 B; +211 B: the prune's re-minify now targets the theme floor, which restores `-webkit-backdrop-filter`) |
| menu-aurora.js (identity/raw) | ≤ 23 KB | size | production build, 2026-09-17 (22,665 B; +1.3 KB palette tabs: third-level nodes, redirect parents folded into their tabs, legacy recents mapped, parent-aware scoring, path words matched segment by segment; −0.24 KB sidebar toggle is a class flip; −0.09 KB pre-paint nav cache, its crumb renderer moved into header.ut (the replay itself is per-response HTML, see below); +0.8 KB palette recents: record on pick, pure-LRU browse order, storage validation; +0.2 KB ">" logout command) |
| router-aurora.js (identity/raw, Navigation-API browsers only) | ≤ 16 KB | size | production build, 2026-09-13 (15,771 B, shipped from @eamonxg/luci-theme-devkit; 2026-08: 14,844 B: template shells fetched, per-render listener teardown, timeout-as-failure, expiry gate, readonly folding, node css, wildcard actions, trickling progress bar, same-URL reload rule, visibility gate, contract check) |
| Default logo (identity/raw) | ≤ 16 KB | size | production build, 2026-07 (15,057 B) |
| Core admin cold theme assets (identity/raw) | ≤ 264 KB | size | main CSS + menu JS + router JS + default font + logo, 2026-09-19 (262,335 B; −4,968 B nested pseudo-element rules; 2026-09-17: 267,303 B; +1.9 KB palette tabs; −1.1 KB sidebar push and nav cache; the router is a one-time cost that removes per-click dispatcher work; +1.0 KB palette recents and logout command; −1.24 KB mega-menu curtain) |
| Login cold theme assets, excluding configured background (identity/raw) | ≤ 55 KB | size | login CSS + default font + logo, 2026-07 (49,572 B) |
| Admin HTML inline scripts added per full load (identity, never cached) | — | size | header.ut, 2026-09-16: pre-paint nav replay ≈ +0.5 KB (mega-menu/dropdown) / ≈ +1.9 KB (sidebar, with the shared crumb renderer); login page +0.07 KB. Template shells the router fetches carry it too. |
| Blocking requests before first paint | ≤ 4 | count | current waterfall |
| Repeat-visit asset requests | ≈ 0 | count | target state; package-built CSS/JS URLs are versioned, but long-lived cache headers still need live verification |
| TTFB, login page (device) | proposed: ≤ 130 ms | latency | local device baseline, 2026-07 |
| LCP @ 4× CPU + Slow 4G | TBD — fill from baseline | latency | local baseline archive |
| INP @ 4× CPU | TBD — fill from baseline | latency | local baseline archive |
| uhttpd VmRSS during page load | proposed: ≤ 2050 kB | memory | local device baseline, 2026-07 |
Budget revisions require a new baseline entry under `../baselines/`.
## Optimization ledger
### Landed
- Compositor animation rework; mega-menu idle pre-measurement; on-demand
patches; `font-display: swap` and inline `@font-face` CSS.
- Tailwind `source(none)`, login-only Preflight removal, native scrollbar
styling, and local fade animation; unused CSS plugins removed.
- Shared SVG custom properties prevent repeated mask payloads.
- Terser compression/mangling with LuCI loader directives preserved.
- Default logo raster resized inside its compatibility SVG wrapper.
- Login template reuses its board/UCI reads when including `header.ut`.
- Package-root `.DS_Store` metadata removed and covered by a regression test.
- login.css pruned to its reachable custom properties at build time (the
shared token sheet is admin-sized; the login page consumes a fraction).
- Mega-menu curtain (2026-09-16): no backdrop blur, `visibility` delayed
instead of tweened, instant nav-pill colours; main.css −1,244 B. Device
trace, 4× CPU, median of 3: repaints 287 → 112, main-thread animations
33 → 8. With `--disable-gpu`, dropped frames per open went 3–5 → 0.
- Build targets (2026-09-19): `LIGHTNINGCSS_TARGETS` is the theme floor (Chrome/Edge 111, Firefox 128,
Safari 16.4), not `last 4 versions`. Only the login prune's re-minify reads it — Vite overrides
`css.lightningcss.targets` with `build.cssTarget` for the main build — and the old query (floor
Safari 26.1) had stripped the login card's `-webkit-backdrop-filter`. `app.webmanifest` icon paths
fixed (they still pointed into the flattened `pwa/` directory and 404ed).
- Nested pseudo-elements (2026-09-19): 36 `@apply` lines moved their `before:`/`after:` utilities into
`&::before`/`&::after` blocks (`@variant hover` where the hover media gate must stay); `content:var(--tw-content)`
202 → 27, main.css −4,968 B, computed styles identical over `.dev/mocks`.
- Client-side router (`router-aurora.js`, `.dev/docs/router.md`,
2026-08-16): view/alias/firstchild/overview navigations become
same-document swaps on Navigation-API browsers, MPA elsewhere.
Measured on RE-SS-01 over plain HTTP (`bench-router.mjs`, RUNS=10): click →
view painted **241–544 ms → 49–251 ms warm, median −67 %**; walk of
51/62 linked pages (mega-menu/sidebar), 42–43 served, **0 divergences** vs
full loads incl. DOM shape; 65-navigation soak flat after the first lap
once departed regions are cleared through `dom.content()` (the data-idref
registry otherwise pins every departed subtree: 26k → 72k nodes before)
and per-render window/document listeners are torn down; back traversal
through alias/firstchild entries same-document; poison gate → full load →
router again. Report in `../baselines/router-re-ss-01.md`.
### Pending
| Item | Principle | Estimated gain |
|---|---|---|
| Long-lived cache headers for versioned CSS/JS | L2 | after LuCI build-time `?v=$(PKG_VERSION)`, kills per-click 304s if headers permit disk/memory cache reuse |
| `defer` head scripts | L1 | needs on-device timing verification |
| Source-level `@apply` weight: ~~nest `&::before` instead of stacked `before:` variants (190 repeated `content:var(--tw-content)`)~~ (landed 2026-09-19), light `@utility` for transitions and shadows | S3 | ≤ ~20 KB of main.css by byte attribution; gate with a computed-style diff over `.dev/mocks` |
### Notes
- **LuCI build-time asset versioning** — Source templates may show
`{{ media }}/main.css`, `{{ media }}/login.css`, or
`{{ resource }}/menu-aurora.js` without a query string. When packaged
through LuCI's `luci.mk`, quoted `{{ media }}/... .css` and
`{{ resource }}/... .js` links are rewritten to append
`?v=$(PKG_VERSION)`; for aurora 1.0.7 this yields
`/luci-static/aurora/main.css?v=1.0.7` and
`/luci-static/aurora/login.css?v=1.0.7`. Do not re-propose manual
cache-versioning for these links unless inspecting the installed package
or live HTML proves the rewrite did not happen.
- **No CSS post-processing** (2026-09-19) — mangling `--tw-*` measured 190,230 → 165,672 B raw but
only ~1 KB after gzip, and packages/flash are compressed: it buys a few ms on the first load per
version at the price of a custom build step, unreadable production variables and noisy committed
artifacts. Rejected; reduce at the source instead (see Pending).
- **Light `@utility` for transitions / shadows — deferred by the maintainer (2026-09-19).** Ceiling by
static resolution of the built sheet: transitions 54 rules, 15,323 → 7,668 B (−7.7 KB); shadows 50
rules, 9,419 → 2,299 B (−7.1 KB), of which only the 40 shadow-only rules (−5.4 KB) are safe — focus
states set a ring alone and rely on Tailwind's five-variable `box-shadow` composition. It changes
declarations, so only a computed-style diff plus a manual pass over hover/focus can gate it.
- **No gzip on the target uhttpd** — budgets and the bench harness use raw
identity bytes. Do not add precompressed sidecars unless the deployed HTTP
server is changed and live response headers prove negotiation works.
### Accepted exceptions
- **`.cbi-progressbar` width transition** — the inner bar's `width` is set via
inline style by LuCI core's `Progressbar` widget, so a `transform: scaleX()`
swap would need a JS observer to mirror that value into a custom property
(plus RTL-aware `transform-origin`). Given the bar updates infrequently
(firmware/package install progress, not a 60fps animation), the single
explicit `transition-[width]` is left as-is rather than adding that
infrastructure.
- **Per-request `lsdir()`** — `header.ut` calls `fs.lsdir()` at render time to
discover installed patches (see the on-demand third-party patches design).
Accepted per S1 because it's a single directory read on an already-dynamic
template render, not a hot loop, and it's what makes patches a drop-in
extension point without a build-time registry.
- **`backdrop-blur` paint flashing** — elements with `backdrop-blur` (mega-menu
panel, modal scrim) **will** show some green flashing while animating —
that's the inherent cost of a blur layer, not a regression. Judge the
**reflow-class** animations (height / shadow) on whether they still flash,
*not* whether blur reaches zero flash.
## Baselines
Local baseline reports live in `../baselines/` when present. That directory is
git-ignored because reports include device model and LAN address.
@@ -0,0 +1,85 @@
# The Wire
Between the router and the browser is a link that is usually the router's
own WiFi radio to a phone — slower and less reliable than a wired desktop
connection. Every request that has to happen before first paint is an RTT
the user watches. The conventions in a typical LuCI theme (a single import
manifest, no bundler) already steer you toward the right structural choice;
the gap is almost always in *quantifying* what you added and *verifying* it
with a waterfall, not in the structural decision itself.
## L1 — Kill render-blocking requests
**Why.** Each `<link rel="stylesheet">` or synchronous `<script>` in `<head>`
is a full round trip the browser must complete, over that same slow link,
before it can paint anything.
**Do / Don't.** Inline CSS under roughly 1 KB rather than adding a new
`<link>` (example: aurora's `aurora-font.css` is 188 bytes — too small to
justify its own blocking request). `preload` fonts that are needed for first
paint. `defer` scripts that can tolerate running after parse. Give
non-screen stylesheets (e.g. print) a `media` attribute so the browser
fetches them without blocking rendering — importing a print stylesheet into
the main CSS bundle only avoids blocking if the `@media print` boundary is
preserved; don't assume "no new `<link>`" alone settles the question.
**Verify.** DevTools Network waterfall with a pinned Slow 4G profile; count
how many requests block before first paint.
**Quantify.** Blocking-request count before first paint.
## L2 — Download once, cache forever
**Why.** LuCI performs a full page navigation on every click, not a SPA
route change. An unversioned asset costs at least a conditional request
(304) on every single click, forever, even when its bytes never change.
**Do / Don't.** Give assets a versioned URL (e.g. `?v=<hash-or-build-id>`)
paired with a long `Cache-Control`, so the browser skips the network
entirely on repeat visits. A 304 on every click for an asset that hasn't
changed is the counter-example this rule exists to prevent. For LuCI theme
templates, do not judge versioning from the source `.ut` file alone:
`luci.mk` rewrites quoted `{{ media }}/... .css` and
`{{ resource }}/... .js` links during package build to append
`?v=$(PKG_VERSION)` (or `PKG_SRC_VERSION`). `uhttpd` does not add this query
string; it only separates the query from the filesystem path and serves the
static file with validators such as `ETag` and `Last-Modified`. Verify the
installed package output or live page HTML before claiming an asset is
unversioned. A version query also is not a cache policy by itself: if
`uhttpd` still omits long-lived `Cache-Control`, repeat navigations can still
revalidate with 304s.
**Verify.** Repeat-visit Network waterfall shows ~0 asset requests (all
served from disk/memory cache).
**Quantify.** Repeat-visit request count.
## L3 — Make identity-transfer assets compact
**Why.** This deployment's stock `uhttpd` serves identity responses even when
the browser advertises gzip. Raw output size is therefore both flash cost and
wire cost; minification and dead-code removal must do the work that transfer
compression normally would.
**Do / Don't.** Disable broad Tailwind content scanning when all utilities are
declared with `@apply`; omit Preflight from isolated entries that only need a
small reset; remove unused plugins; minify and mangle JS while preserving
LuCI's loader directives; define repeated SVG data URLs once as custom
properties. Resize or re-encode raster assets at their real display needs and
keep generated metadata out of package roots. Do not ship precompressed
sidecars when the target server cannot negotiate them.
**Verify.** `bench.mjs`'s identity rows, automated raw-size budgets, and `du`
on the build output directory to catch anything that grew.
**Quantify.** Per-page transferred bytes (cold load); individual asset
sizes.
## Loading metrics at a glance
| Metric | Tool | Budget source |
|---|---|---|
| Blocking requests before first paint | DevTools Network (Slow 4G) | `aurora-budgets.md` |
| Repeat-visit request count | DevTools Network | `aurora-budgets.md` |
| Transferred bytes (cold, identity/raw) | `bench.mjs` | `aurora-budgets.md` |
| Individual asset size | `du` / `bench.mjs` | `aurora-budgets.md` |
@@ -0,0 +1,180 @@
# Measuring
Every rule in server.md/loading.md/runtime.md ends in "Verify" and
"Quantify" for a reason: an unmeasured performance claim is an opinion. This
file documents the instrumentation those sections point at.
## `bench.mjs` — on-device HTTP bench
Run from `.dev/`:
```bash
cd .dev
node ../.claude/skills/aurora-performance/scripts/bench.mjs
BENCH_RUNS=20 node ../.claude/skills/aurora-performance/scripts/bench.mjs
```
**Setup.** Reads `.dev/.env` for `VITE_OPENWRT_HOST` (default
`http://192.168.1.1:80`), plus optionally `VITE_OPENWRT_SSH_HOST` and
`VITE_OPENWRT_SSH_KEY` for the device report. If `.env` is missing, it
aborts immediately with a one-line message telling you to copy
`.dev/.env.example` and set `VITE_OPENWRT_HOST`. If a target is unreachable
or returns an HTTP error status mid-run, it aborts with a one-line message
naming the target that failed — it does not silently skip it.
**What it measures.** `BENCH_RUNS` (default 10) samples per target via
`curl`, reduced to a median, against 6 fixed targets. All static assets use
identity transfer because the target `uhttpd` cannot serve gzip:
1. `GET /cgi-bin/luci` (the login page) — the honest unauthenticated target:
it's the one page a bench script can hit without a session, and TTFB
comparisons must compare like with like.
A 403 is accepted for this target only because some LuCI builds render the
login form with that status; other error responses still abort the run.
2. `main.css`, identity
3. `login.css`, identity
4. the theme's menu script, identity (example: aurora's `menu-aurora.js`)
5. the default first-paint font, identity
6. the default logo, identity
Per target it reports TTFB, total time, transferred bytes, and HTTP status,
summarized as a Markdown table on stdout.
**Device report.** If `VITE_OPENWRT_SSH_HOST` is set, it also SSHes in for a
`## Device` section: `uhttpd` VmRSS (parsed from `/proc/<pid>/status`) and
theme flash usage (`du -sk`) on the static asset directories. If SSH is
unreachable, this section degrades to a one-line "⚠ SSH unreachable —
HTTP metrics only" instead of failing the whole run — the HTTP-only rows
still print.
**Output.** The whole report is Markdown: paste it straight into a PR
description, or archive it (see below).
## `bench-router.mjs` — router verification and A/B (CDP)
The client-side router (`.dev/docs/router.md`) is verified against real
full loads, never against expectation:
```bash
curl -c jar.txt -d 'luci_username=root&luci_password=…' http://<device>/cgi-bin/luci/
HOST=http://<device> COOKIE_NAME=sysauth_http COOKIE_VALUE=<from jar> \
node ../.claude/skills/aurora-performance/scripts/bench-router.mjs <label>
```
`ONLY=walk|timing|soak|back|poison|sheets|hygiene|nodecss|expiry` runs one
scenario (`RUNS` defaults to and is floored at 10). `walk` visits every page
the navigation model links to (menu + each page's tab strip) through the
router, then full-loads the same URL and diffs title, `data-page`,
`dispatchpath`, tab strip, active nav mark, footer presence and console
errors — the report lists fallbacks (pages the router declined) and
divergences separately, and 0 divergences is the merge gate; a fallback on a
node the router claims (a view class, or a template page that is a view
shell) is reported as a divergence, so a regression there cannot pass. `timing` is
click → view painted, median of `RUNS`, router warm/cold vs full load.
`soak` samples heap, DOM nodes, listeners and the poll queue on the same
page after each of 5 laps over 12 pages. `back` traverses a chain that
deliberately interleaves alias/firstchild URLs (read from the menu tree)
with view URLs and asserts each step stayed same-document with the right
URL and `data-page`. `poison` injects a foreign `<style>` into `<head>` and
asserts the next navigation is a full load and the one after is
same-document again. `sheets` repeats that against the view pages the walk
found actually inserting their own sheets. `hygiene` checks that no progress
bar is left in the DOM, that the live region carries the title, and that a
hidden tab stops polling. `nodecss` checks a `menu.d` node's `css` link is
enabled on arrival, disabled after leaving and re-enabled without a
duplicate. `expiry` destroys the session and must land on the login form —
it always runs last.
Trap: a navigation the router does not take is a real document load and
tears down the CDP evaluation ("Inspected target navigated"); the harness
treats that as a fallback, waits for the new document, and re-arms its
same-document marker there.
## `bench-fullload.mjs` — where one navigation's time goes (CDP)
`bench-router.mjs` answers *how much faster*; this answers *what the full
load was spending the time on*, which is what the router doc's stage table
and the architecture diagram quote.
```bash
HOST=http://<device> COOKIE_NAME=sysauth_http COOKIE_VALUE=<sid> RUNS=10 \
node ../.claude/skills/aurora-performance/scripts/bench-fullload.mjs <label>
```
Per page it reports medians, read off the document's own Navigation/Resource
Timing rather than the harness clock: `ttfb` and `htmlEnd` (dispatcher run
#1), `trStart`/`trEnd` (the `admin/translations/<lang>` script — dispatcher
run #2, parser-blocking), `dcl`, `viewReady`, and the `ubus` window (the
view's own data calls). It then measures the **same page over the router in
the same loop**, so both halves see the same device state and are
subtractable — run-to-run spread on an embedded device is large enough that
two separately-run harnesses will not agree.
Set `PAGES` to override the sample; the default is the same 8 pages
`bench-router.mjs timing` uses.
## `bench-dispatch.sh` — what one CGI dispatch costs (on the device)
```bash
ssh root@<device> 'sh -s' < ../.claude/skills/aurora-performance/scripts/bench-dispatch.sh
```
Runs on the router, so no network is in the number. Two halves: loopback
`curl` medians (page HTML, both i18n catalogs, `/admin/menu`, one static
file) and the per-process cost of each phase every dispatch pays, timed over
50 `ucode` processes (`fork + ucode VM`, `import luci.dispatcher`, menu tree
stat + index-cache parse, `session.get` + `session.access`).
The `admin/translations/en` row is the control: a 13-byte response that still
pays a full dispatch. Compare it against the static-file row to separate
dispatch cost from payload cost. It makes and destroys its own session.
## Measurement discipline
- **Median of ≥10 runs**, never a single sample — router-side variance
(other requests, GC, thermal throttling) is real.
- **Hard refresh** before any browser-side recording; a warm cache measures
the cache, not the change.
- **Pin the CPU throttle** (4× in the Performance panel) for every runtime
recording so results are comparable across sessions and machines.
- **Track two numbers, not one**: the absolute value against the theme's
budget table, *and* the delta versus the base branch. A budget pass on
absolute numbers with a regression in the delta is still a regression.
- **Budgets come from baselines, never from intuition.** If a number isn't
in the theme's budget sheet (`aurora-budgets.md`), it isn't a budget yet — propose
one from a measured baseline, don't invent a round number.
## A/B delta protocol
For any change that touches server or loading plane performance:
```bash
git checkout <base-branch>
cd .dev && pnpm build && node ../.claude/skills/aurora-performance/scripts/bench.mjs > /tmp/bench-base.md
git checkout <working-branch>
cd .dev && pnpm build && node ../.claude/skills/aurora-performance/scripts/bench.mjs > /tmp/bench-branch.md
```
Put the two Markdown tables side by side in the PR. For a runtime-plane
change, pair this with one DevTools Performance recording (see runtime.md
R1/R2 Verify steps) on each branch, hard-refreshing between them — the dev
server re-proxies the new build automatically, but the browser cache does
not clear itself.
## Baseline archive
Save the bench output to the skill's `baselines/` directory as
`<version>-<device>.md` (`../baselines/` relative to this file) — the
directory is git-ignored because the report records the device model and LAN
address, so it stays local. Budget claims stay falsifiable across releases and
hardware. Any PR that proposes revising a budget number must add a new baseline
entry backing the revision — a budget change without an archived measurement is
not accepted.
## Field data
Lab measurements (this file, DevTools) catch regressions before ship; they
don't tell you what real users experience. If the theme ships the
[`web-vitals`](https://github.com/GoogleChrome/web-vitals) library, watch
the **P75** of INP/LCP/CLS, not the average — a P75 regression is a real
user-facing regression even when the lab numbers look fine.
@@ -0,0 +1,117 @@
# The Frame
The router ships the assets, but the browser does the animating — usually a
phone GPU/CPU, not a bare fast desktop. The essence of a runtime
optimization is moving work **off the main thread**: from per-frame
**Layout/Paint** onto the **compositor**. So the single most important
question every recipe below answers is: *does the main thread still run
`Layout` events while the animation plays?* These rules are usually already
followed by convention in a codebase that's been through this once — the
gap agents actually hit is skipping the named verification step, not the
authoring rule itself.
## R1 — Animate compositor properties only
**Why.** `transform`, `opacity`, `clip-path`, and `scale` can run entirely on
the compositor thread. `height`, `width`, `top`, `box-shadow`, and
`background-color` force `Layout` or `Paint` on the main thread, every
frame, on the same slow client CPU the page is already competing for.
**Do / Don't.** Animate only `transform` / `opacity` / `clip-path` / `scale`.
Never animate `height` / `width` / `top` / `box-shadow` / `background-color`.
Avoid blanket `transition-all` — list explicit properties so a future change
can't silently start transitioning a reflow-triggering property.
**Verify.** Two recordings, used together:
- *Performance panel — the Layout track (core evidence).* CPU throttle 4×
(Performance panel ⚙️) → ● Record → trigger the animation once → let it
finish → Stop. Read the timeline: purple `Layout`/`Recalculate Style` bars
mean reflow; the FPS bar collapsing means dropped frames. Pass: after the
first frame, the animation produces **no new `Layout` events** — it runs
purely on the compositor.
- *Rendering panel — paint flashing (fastest visual check, no recording).*
Rendering panel → enable **Paint flashing** → trigger the animation.
Repainted regions flash green. Pass: no continuous green flashing over a
reflow-class element; a single flash at a reveal edge is fine.
Honest caveat: any element using `backdrop-blur` (a frosted panel, a modal
scrim) **will** show some green flashing while animating — that is the
inherent cost of a blur layer, not a regression. Judge reflow-class
animations (height/shadow-style changes) on whether *they* still flash, not
on whether a blur layer reaches zero flash.
Worked example — one theme's component map (aurora); build the equivalent
for your theme:
| Component | Trigger | Technique | Pass criteria |
|-----------|---------|-----------|---------------|
| Desktop mega-menu | Hover a top nav item with a submenu (e.g. *System*) | `clip-path` (not `height`) | No continuous `Layout` bars while expanding (old height-anim reflowed every frame → should be ≈0) |
| Mobile submenu | Device mode (`Cmd+Shift+M`) → hamburger → tap a parent item | `grid-template-rows` (not JS `scrollHeight`) | No `Layout` bars, **and no single forced-reflow spike** on expand (old code read `scrollHeight`) |
| Main view (`#view`) hover | Desktop width, move cursor in/out of the main card repeatedly | hover shadow repaint removed | No `Paint` flashing during the recording (old hover shadow repainted the whole block) |
| Tooltip | Hover then mouse-out of a `[data-tooltip]` element | `scale` + `opacity` transition | `scale` and `opacity` fade *together* on hide — no instant size snap |
| Theme switch | Footer theme toggle | View Transitions API (not body `transition-all`) | No body-wide repaint storm on the Main track (see R3 for the reduced-motion/no-API fallback check on this same interaction) |
**Quantify.** `Layout` event count after frame 1 (target: 0); dropped-frame
count under 4× CPU throttle.
## R2 — JS must not force synchronous layout
**Why.** Reading a layout-dependent property (`offsetHeight`, `scrollHeight`,
`getBoundingClientRect`, …) right after writing to the DOM forces the browser
to flush layout synchronously, mid-script, on the main thread — on a slow
client this is a visible stall, not just a profiler footnote.
**Do / Don't.** Cache layout reads instead of re-reading them per event.
Defer measurement to idle time (`requestIdleCallback`, and only after
`document.fonts.ready` so measurements aren't taken against unloaded font
metrics) rather than measuring synchronously inside a hot interaction path
(example: aurora's mega-menu pre-measures submenu height once, off the
interaction path, instead of reading `scrollHeight` on every hover).
**Verify.** Performance panel → trigger the interaction → check for
"Forced reflow" / "Forced synchronous layout" warnings in the recorded
trace or the Console.
**Quantify.** Forced-reflow warning count (target: 0) per interaction.
## R3 — Accessibility is functionality
**Why.** `prefers-reduced-motion` and graceful degradation aren't polish —
an animation that ignores the reduced-motion signal, or leaves an element
stuck mid-transition when a feature isn't supported, is a functional bug for
the user who triggered it.
**Do / Don't.** Every animation needs a `prefers-reduced-motion: reduce`
branch that removes the transition, not just shortens it. Anything using
the View Transitions API needs a plain fallback for browsers that lack it.
**Verify.** Two checks, used together:
- *Reduced motion.* Rendering panel → **Emulate CSS media feature
prefers-reduced-motion** → `reduce` → trigger every animation: menus,
drawers, tooltips, theme switch, page navigation. Pass: each snaps into
place with no transition, **and** functionality is unaffected — nothing
stays stuck hidden or half-open. Reset to "No emulation" when done.
- *View Transitions degradation.* In a browser that supports the API (e.g.
Chrome/Edge), trigger the transition (example: aurora's footer theme
toggle) and confirm the whole page crossfades together, with no crossfade
flash on first load. In a browser without support (e.g. Firefox/Safari),
confirm the switch is instant with no console errors.
**Quantify.** Reduced-motion and degradation checks are pass/fail, not
numeric — track them as a checklist item per animation, not a metric.
## Behavior-regression checklist
Run this after any runtime rework — a compositor-safe rewrite that breaks
interaction is not a win:
- [ ] Menus: opening one closes any other open menu/submenu with no residue.
- [ ] Esc / scrim-click / outside-click closes the open menu, drawer, or modal.
- [ ] Submenu accordion: opening one submenu auto-closes sibling submenus;
reopening the parent container starts fully collapsed.
- [ ] Modal/drawer: scrim blur/dim is present and the content on top stays
sharp and interactive.
- [ ] Tooltip: appears on hover, disappears on leave, without a jarring
instant size or opacity snap.
@@ -0,0 +1,87 @@
# The Router
The router is the slowest, most memory-constrained machine in this whole
system, and it is a *shared* machine: `uhttpd` serves every page view on the
same 1–2 cores and 64–512 MB of RAM as the rest of OpenWrt. Nothing here is
"fast enough" by desktop instinct — it has to be measured.
## S1 — Compute at build time, never at request time
**Why.** Request-time work repeats on every single page view, on a SoC that
is already slow. Anything that can be computed once, at build time, should
never run again inside a request handler.
**Do / Don't.** Generate and minify derived CSS, JS, config, tokens and image
assets at build time, never per-request (example: aurora's generated
`_tokens.css`). This deployment uses stock `uhttpd` without gzip support, so
raw file size is the wire size: do not add `.gz` files that the server will
never select. When a genuinely per-request computation is unavoidable,
record the accepted exception with its rationale in the theme budget sheet
(`aurora-budgets.md`) so a future session doesn't re-litigate it (example:
aurora's per-request `lsdir()` patch discovery in `header.ut` — one cheap
readdir, measured negligible and accepted as-is).
**Verify.** A production build is deterministic and already minified; the
identity-transfer rows in `bench.mjs` match the files' raw byte sizes.
**Quantify.** Transferred bytes; package/flash size delta.
## S2 — Per-request ubus/uci/fs calls are a budget
**Why.** Each `ubus` call, `uci` read, or filesystem stat is a cross-process
round trip on the router, and it happens on *every* page view, not once.
Templates accrete these calls quietly over time if nobody counts them.
**Do / Don't.** Count the ubus/uci/fs calls in a template diff before
merging it. A new call needs a stated reason in the PR description, not an
appeal to consistency with an existing pattern — "this theme always
server-renders per navigation" is not a cost justification for adding one
more call. Batch calls, or use data the dispatcher already fetched, wherever
possible (example inventory: aurora's `header.ut` currently costs 1 ubus
call + 1 uci `get_all` + 1 uci `foreach` + 1 `lsdir` per request — any
addition should be weighed against that baseline, not treated as free).
**Verify.** Static count of ubus/uci/fs calls on the diff, plus an on-device
TTFB A/B using the skill's `bench.mjs` harness (see measuring.md) comparing
the branch against the base.
**Quantify.** Call count per request; TTFB median (base vs. branch).
## S3 — Every byte sent is router CPU
**Why.** Moving data through the kernel network stack costs real CPU on a
slow link, and it costs it on every page view — this is not "just bandwidth,"
it's compute on the same constrained cores serving the request.
**Do / Don't.** Set (and respect) a per-page transferred-bytes budget. On this
stock `uhttpd` deployment, measure identity responses and optimize the raw
files themselves: restrict Tailwind source detection, remove unused framework
layers/plugins, minify JS, deduplicate inlined SVGs, and resize raster assets.
**Verify.** The `bench.mjs` bytes column, compared against the theme's own
budget table.
**Quantify.** Transferred bytes per page, cold and repeat.
## S4 — Memory has a ceiling
**Why.** `uhttpd` and `ucode` share as little as 64 MB with the rest of the
OS. A template that builds a large string or data structure at request time
can push the whole box toward OOM on the smallest supported hardware.
**Do / Don't.** Don't accumulate large strings or arrays inside a template
render path; stream or slice instead of buffering everything in memory.
**Verify.** `uhttpd` VmRSS via `bench.mjs`'s device report, taken before and
after the change.
**Quantify.** VmRSS delta (kB).
## Server metrics at a glance
| Metric | Tool | Budget source |
|---|---|---|
| TTFB | `bench.mjs` | `aurora-budgets.md` |
| ubus/uci/fs call count | diff review | `aurora-budgets.md` |
| Transferred bytes (identity/raw) | `bench.mjs` | `aurora-budgets.md` |
| uhttpd VmRSS | `bench.mjs` device report | `aurora-budgets.md` |
@@ -0,0 +1,93 @@
#!/bin/sh
# bench-dispatch.sh — what one LuCI CGI dispatch costs the device, measured on
# the device itself so no network is in the number.
#
# ssh root@192.168.1.1 'sh -s' < bench-dispatch.sh
#
# Two halves:
# requests curl against 127.0.0.1, median of 15, time_starttransfer + bytes.
# The `en` catalog is the control: a 13-byte response that still
# pays the whole dispatch, so the cost is the dispatch, not the
# payload.
# steps the per-process cost of each phase every dispatch runs, timed as
# 50 ucode processes so the shell's own overhead averages out.
# `spawn` is the floor; subtract it to read a phase's own cost.
#
# Needs a LuCI session for the view-page rows; it makes one and destroys it.
# Output is JSON on stdout, progress on stderr.
set -u
N_REQ=15
N_PROC=50
log() { echo "$@" >&2; }
# --- a throwaway session, so the authenticated rows are measurable ----------
SID=$(ubus call session login '{"username":"root","password":""}' 2>/dev/null |
sed -n 's/.*"ubus_rpc_session": "\([a-f0-9]*\)".*/\1/p')
if [ -n "$SID" ]; then
ubus call session set "{\"ubus_rpc_session\":\"$SID\",\"values\":{\"token\":\"0123456789abcdef0123456789abcdef\"}}" >/dev/null 2>&1
else
log "warning: no passwordless root login; authenticated rows will be skipped"
fi
# median of N_REQ, printed as "<seconds> <bytes>"
req() {
n=0
while [ $n -lt $N_REQ ]; do
curl -s -o /dev/null ${SID:+-b "sysauth_http=$SID"} \
-w "%{time_starttransfer} %{size_download}\n" "$1"
n=$((n + 1))
done | sort -n | sed -n "$(( (N_REQ + 1) / 2 ))p"
}
row() { # name url
set -- "$1" "$2"
r=$(req "$2") || r="0 0"
ms=$(echo "$r" | awk '{printf "%.1f", $1 * 1000}')
by=$(echo "$r" | awk '{print $2}')
log " $1: $ms ms, $by B"
printf ' {"what":"%s","url":"%s","ms":%s,"bytes":%s}' "$1" "$2" "$ms" "$by"
}
# per-process ms for a ucode snippet, over N_PROC processes
proc() { # name expr
name=$1; expr=$2
cat > /tmp/.bd-loop.sh <<EOF
i=0; while [ \$i -lt $N_PROC ]; do ucode -e '$expr' >/dev/null 2>&1; i=\$((i+1)); done
EOF
t=$( { time -p sh /tmp/.bd-loop.sh; } 2>&1 | awk '/^real/ {print $2}' )
[ -n "$t" ] || t=$( { time sh /tmp/.bd-loop.sh; } 2>&1 | awk '/real/ {gsub("m"," ");print $(NF-1)*60+$NF}' )
ms=$(echo "$t $N_PROC" | awk '{printf "%.1f", $1 * 1000 / $2}')
log " $name: $ms ms/process"
printf ' {"what":"%s","ms":%s}' "$name" "$ms"
rm -f /tmp/.bd-loop.sh
}
CACHE=$(ls /tmp/luci-indexcache.*.json 2>/dev/null | head -1)
MENUD=$(ls /usr/share/luci/menu.d/*.json 2>/dev/null | wc -l)
CACHESZ=$(wc -c < "$CACHE" 2>/dev/null || echo 0)
LANG_UCI=$(uci get luci.main.lang 2>/dev/null || echo auto)
log "requests (median of $N_REQ, loopback):"
printf '{\n'
printf ' "device": %s,\n' "$(ubus call system board | tr -d '\n' | sed 's/ */ /g')"
printf ' "menuFiles": %s, "indexCacheBytes": %s, "lang": "%s",\n' "$MENUD" "$CACHESZ" "$LANG_UCI"
printf ' "requests": [\n'
row "page HTML (view node)" "http://127.0.0.1/cgi-bin/luci/admin/status/routesj"; printf ',\n'
row "i18n catalog (en)" "http://127.0.0.1/cgi-bin/luci/admin/translations/en"; printf ',\n'
row "i18n catalog (zh-cn)" "http://127.0.0.1/cgi-bin/luci/admin/translations/zh-cn"; printf ',\n'
row "/admin/menu — once per session" "http://127.0.0.1/cgi-bin/luci/admin/menu"; printf ',\n'
row "static main.css" "http://127.0.0.1/luci-static/aurora/main.css"; printf '\n'
printf ' ],\n'
log "dispatch steps (per process, over $N_PROC processes):"
printf ' "steps": [\n'
proc "fork + ucode VM" 'x=1;'; printf ',\n'
proc "import luci.dispatcher" 'import d from "luci.dispatcher";'; printf ',\n'
proc "menu tree: stat + parse cache" "import { open, glob, stat } from \"fs\"; for (let f in glob(\"/usr/share/luci/menu.d/*.json\",\"/usr/lib/lua/luci/controller/*.lua\",\"/usr/lib/lua/luci/controller/*/*.lua\")) stat(f); json(open(\"$CACHE\",\"r\"));"; printf ',\n'
proc "session.get + session.access" "import { connect } from \"ubus\"; let u = connect(); u.call(\"session\",\"get\",{ubus_rpc_session:\"$SID\"}); u.call(\"session\",\"access\",{ubus_rpc_session:\"$SID\"});"; printf '\n'
printf ' ]\n}\n'
[ -n "$SID" ] && ubus call session destroy "{\"ubus_rpc_session\":\"$SID\"}" >/dev/null 2>&1
exit 0
@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* bench-fullload.mjs <label> — where the time goes in ONE full-page LuCI
* navigation, split between the device and the browser, over raw CDP
* (headless Chrome, no npm deps, node >= 22).
*
* bench-router.mjs answers "how much faster is the same-document router";
* this answers "what is the full load actually spending its time on", which
* is what the router.md stage table and the architecture diagram quote.
*
* Every measured load is preceded by another full load, so static assets are
* already cached and only the uncacheable work is left — the same warm state
* bench-router.mjs measures the router in.
*
* env: HOST (default http://192.168.1.1), COOKIE_NAME, COOKIE_VALUE,
* CHROME_BIN, RUNS (default and minimum 10), PAGES (comma-separated
* paths under /cgi-bin/luci; defaults to the router.md sample).
*
* Per page it reports the medians of, all on the document's own clock
* (time origin = navigation start):
* ttfb responseStart of the page HTML — dispatcher run #1
* htmlEnd responseEnd of the page HTML
* trStart/End the admin/translations/<lang> script — dispatcher run #2,
* a parser-blocking <script> in <head>
* dcl domContentLoadedEventEnd
* viewReady first non-spinner child inside #view — the view is painted
* serverMs htmlEnd + (trEnd - trStart): wall time owned by the device
* bytes transferEncodedBodyLength of the HTML and the catalog
*/
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
const HOST = (process.env.HOST ?? "http://192.168.1.1").replace(/\/+$/, "");
const LABEL = process.argv[2] ?? "run";
// measuring.md: medians of at least 10 runs.
const RUNS = Math.max(10, +(process.env.RUNS ?? 10) || 10);
const CHROME =
process.env.CHROME_BIN ??
(process.platform === "darwin"
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
: "google-chrome");
if (!process.env.COOKIE_NAME || !process.env.COOKIE_VALUE)
throw new Error("COOKIE_NAME and COOKIE_VALUE are required");
const COOKIE = {
name: process.env.COOKIE_NAME,
value: process.env.COOKIE_VALUE,
domain: new URL(HOST).hostname,
path: "/",
};
// The bench-router.mjs timing sample, verbatim, so the two runs compare.
const PAGES = (
process.env.PAGES ??
[
"admin/status/routesj",
"admin/status/nftables",
"admin/status/logs",
"admin/status/processes",
"admin/status/channel_analysis",
"admin/status/realtime",
"admin/system/system",
"admin/system/admin",
].join(",")
)
.split(",")
.map((p) => `${HOST}/cgi-bin/luci/${p.trim().replace(/^\/+/, "")}`);
// Loaded between measured loads so the measured one is never the first hit.
const WARMUP = `${HOST}/cgi-bin/luci/admin/status/overview`;
const median = (v) => {
const s = [...v].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
const r0 = (v) => (v == null ? null : +v.toFixed(0));
/* ---------- chrome + CDP ---------- */
const profile = mkdtempSync(join(tmpdir(), "cdp-aurora-fullload-"));
const chrome = spawn(
CHROME,
[
"--headless=new",
"--remote-debugging-port=0",
`--user-data-dir=${profile}`,
"--no-first-run",
"--no-default-browser-check",
"--ignore-certificate-errors",
"about:blank",
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
let wsUrl = "";
chrome.stderr.setEncoding("utf8");
chrome.stderr.on("data", (d) => {
const m = d.match(/ws:\/\/[^\s]+/);
if (m && !wsUrl) wsUrl = m[0];
});
const deadline = Date.now() + 20000;
while (!wsUrl && Date.now() < deadline) await sleep(50);
if (!wsUrl) throw new Error("chrome did not report a devtools endpoint");
const ws = new WebSocket(wsUrl);
await new Promise((res, rej) => {
ws.addEventListener("open", res, { once: true });
ws.addEventListener("error", rej, { once: true });
});
let msgId = 0;
const pending = new Map();
const handlers = new Set();
ws.addEventListener("message", (ev) => {
const m = JSON.parse(ev.data);
if (m.id != null && pending.has(m.id)) {
const { res, rej } = pending.get(m.id);
pending.delete(m.id);
m.error ? rej(new Error(m.error.message)) : res(m.result);
return;
}
for (const h of handlers) h(m);
});
function send(method, params = {}, sessionId) {
const id = ++msgId;
ws.send(JSON.stringify({ id, method, params, sessionId }));
return new Promise((res, rej) => pending.set(id, { res, rej }));
}
function waitEvent(method, sessionId, timeout = 30000) {
return new Promise((res, rej) => {
const t = setTimeout(() => (handlers.delete(h), rej(new Error(`timeout ${method}`))), timeout);
const h = (m) => {
if (m.method !== method || (sessionId && m.sessionId !== sessionId)) return;
clearTimeout(t);
handlers.delete(h);
res(m.params);
};
handlers.add(h);
});
}
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
await send("Page.enable", {}, sessionId);
await send("Runtime.enable", {}, sessionId);
await send("Storage.setCookies", { cookies: [COOKIE] });
await send(
"Emulation.setDeviceMetricsOverride",
{ width: 1280, height: 800, deviceScaleFactor: 1, mobile: false },
sessionId,
);
// Same view-paint clock bench-router.mjs uses for full loads.
await send(
"Page.addScriptToEvaluateOnNewDocument",
{
source: `
window.__viewReady = null;
new MutationObserver(() => {
const v = document.getElementById('view');
if (v && v.querySelector(':scope > :not(.spinning):not(script)') && window.__viewReady == null)
window.__viewReady = performance.now();
}).observe(document, { childList: true, subtree: true });`,
},
sessionId,
);
async function evaljs(expression, awaitPromise = false, timeout = 30000) {
const r = await Promise.race([
send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, sessionId),
new Promise((_, rej) => setTimeout(() => rej(new Error("evaluate stalled")), timeout)),
]);
if (r.exceptionDetails)
throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
return r.result.value;
}
async function waitViewSettled(timeout = 15000) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
const ok = await evaljs(`(() => {
const v = document.getElementById('view');
return !v || !!v.querySelector(':scope > :not(.spinning):not(script)') ||
(v.childElementCount === 0 && document.readyState === 'complete' && (window.L?.loaded ?? false));
})()`);
if (ok) return;
await sleep(50);
}
}
async function fullLoad(url) {
const load = waitEvent("Page.loadEventFired", sessionId);
await send("Page.navigate", { url }, sessionId);
await load;
await waitViewSettled();
if (await evaljs(`!!document.querySelector('input[name="luci_username"]')`))
throw new Error(`landed on the login form for ${url} — session expired?`);
}
/* Everything below is read off the document's own performance timeline, so
* the numbers are the browser's, not the harness's. */
const SAMPLE = `(() => {
const nav = performance.getEntriesByType('navigation')[0];
const res = performance.getEntriesByType('resource');
const tr = res.find(e => /\\/admin\\/translations\\//.test(e.name));
// The view's own data calls: the one piece of device work both paths share.
const ub = res.filter(e => /\\/ubus/.test(e.name));
return JSON.stringify({
ubusStart: ub.length ? Math.min(...ub.map(e => e.startTime)) : null,
ubusEnd: ub.length ? Math.max(...ub.map(e => e.responseEnd)) : null,
ubusCount: ub.length,
ttfb: nav.responseStart,
htmlEnd: nav.responseEnd,
htmlBytes: nav.transferSize,
trStart: tr ? tr.startTime : null,
trEnd: tr ? tr.responseEnd : null,
trBytes: tr ? tr.transferSize : null,
trUrl: tr ? new URL(tr.name).pathname : null,
dcl: nav.domContentLoadedEventEnd,
load: nav.loadEventEnd,
viewReady: window.__viewReady,
// Every request the document made, so nothing counted here is invisible.
requests: res.length,
});
})()`;
/* The same page over the same-document router, measured in the same loop so
* both halves of the comparison see the same device state. Mirrors
* bench-router.mjs: hover first (a user does), then navigate and await the
* navigation's own `finished` promise. */
const SPA = (url) => `(async () => {
const link = [...document.querySelectorAll('a[href]')].find(a => a.href === ${JSON.stringify(url)});
if (link) { link.dispatchEvent(new Event('pointerover', { bubbles: true })); await new Promise(r => setTimeout(r, 400)); }
performance.clearResourceTimings();
const t0 = performance.now();
let error = null;
try {
await Promise.race([navigation.navigate(${JSON.stringify(url)}).finished,
new Promise((_, rej) => setTimeout(() => rej(new Error('router did not finish in 20 s')), 20000))]);
} catch (e) { error = String(e); }
const t1 = performance.now();
const res = performance.getEntriesByType('resource').filter(e => e.startTime >= t0);
const ub = res.filter(e => /\\/ubus/.test(e.name));
return JSON.stringify({
ms: t1 - t0, error, sameDoc: window.__sameDocMarker === 1,
ubusStart: ub.length ? Math.min(...ub.map(e => e.startTime)) - t0 : null,
ubusEnd: ub.length ? Math.max(...ub.map(e => e.responseEnd)) - t0 : null,
ubusCount: ub.length, requests: res.length,
});
})()`;
const rows = [];
for (const url of PAGES) {
const s = [];
for (let i = 0; i < RUNS; i++) {
await fullLoad(WARMUP);
await fullLoad(url);
const one = JSON.parse(await evaljs(SAMPLE));
if (one.viewReady != null) s.push(one);
}
// Router path: one document, warmed on the start page, RUNS round trips.
const spa = [];
await fullLoad(WARMUP);
await evaljs("window.__sameDocMarker = 1");
await evaljs(SPA(url), true); // cold: module not yet required in this document
for (let i = 0; i < RUNS; i++) {
const back = JSON.parse(await evaljs(SPA(WARMUP), true));
if (!back.sameDoc) { await evaljs("window.__sameDocMarker = 1"); continue; }
const r = JSON.parse(await evaljs(SPA(url), true));
if (!r.error && r.sameDoc) spa.push(r);
}
if (!s.length) {
rows.push({ page: url.replace(`${HOST}/cgi-bin/luci`, ""), n: 0 });
continue;
}
const med = (k) => median(s.map((x) => x[k]).filter((v) => v != null));
const smed = (k) => (spa.length ? median(spa.map((x) => x[k]).filter((v) => v != null)) : null);
const row = {
page: url.replace(`${HOST}/cgi-bin/luci`, ""),
n: s.length,
spaN: spa.length,
spaMs: r0(smed("ms")),
spaUbusStart: r0(smed("ubusStart")),
spaUbusEnd: r0(smed("ubusEnd")),
spaUbusCount: r0(smed("ubusCount")),
spaRequests: r0(smed("requests")),
ttfb: r0(med("ttfb")),
htmlEnd: r0(med("htmlEnd")),
trStart: r0(med("trStart")),
trEnd: r0(med("trEnd")),
dcl: r0(med("dcl")),
viewReady: r0(med("viewReady")),
ubusStart: r0(med("ubusStart")),
ubusEnd: r0(med("ubusEnd")),
ubusCount: r0(med("ubusCount")),
htmlBytes: r0(med("htmlBytes")),
trBytes: r0(med("trBytes")),
trUrl: s[0].trUrl,
requests: r0(med("requests")),
};
// Wall time the device owns: the HTML response plus the blocking catalog.
row.serverMs = r0((row.htmlEnd ?? 0) + ((row.trEnd ?? 0) - (row.trStart ?? 0)));
row.browserMs = r0((row.viewReady ?? 0) - row.serverMs);
rows.push(row);
console.error(
`${row.page}: ttfb ${row.ttfb} · html ${row.htmlEnd} · tr ${row.trStart}→${row.trEnd} · dcl ${row.dcl} · view ${row.viewReady} · ubus ${row.ubusStart}→${row.ubusEnd} (${row.ubusCount}) || router ${row.spaMs} · ubus ${row.spaUbusStart}→${row.spaUbusEnd} (${row.spaUbusCount}) n=${row.spaN}`,
);
}
const agg = (k) => r0(median(rows.filter((r) => r.n).map((r) => r[k])));
const out = {
label: LABEL,
host: HOST,
runs: RUNS,
pages: rows,
median: {
ttfb: agg("ttfb"),
htmlEnd: agg("htmlEnd"),
trStart: agg("trStart"),
trEnd: agg("trEnd"),
dcl: agg("dcl"),
viewReady: agg("viewReady"),
serverMs: agg("serverMs"),
browserMs: agg("browserMs"),
ubusStart: agg("ubusStart"),
ubusEnd: agg("ubusEnd"),
spaMs: r0(median(rows.filter((r) => r.spaN).map((r) => r.spaMs))),
spaUbusStart: r0(median(rows.filter((r) => r.spaN).map((r) => r.spaUbusStart))),
spaUbusEnd: r0(median(rows.filter((r) => r.spaN).map((r) => r.spaUbusEnd))),
},
};
console.log(JSON.stringify(out, null, 2));
await send("Target.closeTarget", { targetId });
ws.close();
chrome.kill();
// Chrome unlinks its profile lazily; removing it under the exiting process
// races and throws ENOTEMPTY, which would mask a clean run.
await sleep(400);
try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
@@ -0,0 +1,71 @@
/**
* Pure helpers for the on-device bench harness (scripts/bench.mjs).
* Everything here is deterministic and unit-tested; all I/O lives in the CLI.
*/
export function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
// One line of `curl -w '%{time_starttransfer}\t%{time_total}\t%{size_download}\t%{http_code}'`.
export function parseCurlMetrics(line) {
const [ttfb, total, bytes, status] = line.trim().split("\t");
return {
ttfbMs: Number(ttfb) * 1000,
totalMs: Number(total) * 1000,
bytes: Number(bytes),
status: Number(status),
};
}
export function assertOkStatus(sample, allowedStatuses = []) {
if (
!Number.isInteger(sample.status) ||
(!(sample.status >= 200 && sample.status < 400) &&
!allowedStatuses.includes(sample.status))
) {
throw new Error(`HTTP ${sample.status}`);
}
return sample;
}
export function summarize(samples) {
return {
ttfbMs: median(samples.map((s) => s.ttfbMs)),
totalMs: median(samples.map((s) => s.totalMs)),
bytes: samples[0].bytes,
status: samples[0].status,
runs: samples.length,
};
}
export function parseEnv(text) {
const env = {};
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return env;
}
export function parseVmRss(procStatusText) {
const m = procStatusText.match(/^VmRSS:\s+(\d+)\s+kB/m);
return m ? Number(m[1]) : null;
}
export function formatMarkdownTable(rows) {
const lines = [
"| Target | TTFB (ms, median) | Total (ms, median) | Bytes | HTTP | Runs |",
"|---|---|---|---|---|---|",
...rows.map(
(r) =>
`| ${r.label} | ${Math.round(r.ttfbMs)} | ${Math.round(r.totalMs)} | ${r.bytes} | ${r.status} | ${r.runs} |`,
),
];
return lines.join("\n");
}
@@ -0,0 +1,92 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
median,
parseCurlMetrics,
summarize,
parseEnv,
parseVmRss,
formatMarkdownTable,
assertOkStatus,
} from "./bench-lib.js";
test("median: odd count returns middle value", () => {
assert.equal(median([3, 1, 2]), 2);
});
test("median: even count returns mean of middle pair", () => {
assert.equal(median([4, 1, 3, 2]), 2.5);
});
test("median: does not mutate its input", () => {
const input = [3, 1, 2];
median(input);
assert.deepEqual(input, [3, 1, 2]);
});
test("parseCurlMetrics: parses tab-separated curl -w output, seconds → ms", () => {
const m = parseCurlMetrics("0.045123\t0.230500\t28083\t200\n");
assert.equal(Math.round(m.ttfbMs), 45);
assert.equal(Math.round(m.totalMs), 231);
assert.equal(m.bytes, 28083);
assert.equal(m.status, 200);
});
test("assertOkStatus: accepts 2xx and 3xx samples", () => {
const sample = { ttfbMs: 10, totalMs: 20, bytes: 100, status: 302 };
assert.equal(assertOkStatus(sample), sample);
});
test("assertOkStatus: rejects 404 samples", () => {
assert.throws(
() => assertOkStatus(parseCurlMetrics("0.010000\t0.020000\t123\t404\n")),
/HTTP 404/,
);
});
test("assertOkStatus: permits an explicitly expected login-page 403", () => {
const sample = parseCurlMetrics("0.010000\t0.020000\t5788\t403\n");
assert.equal(assertOkStatus(sample, [403]), sample);
});
test("summarize: medians of times, first sample's bytes/status, run count", () => {
const s = summarize([
{ ttfbMs: 50, totalMs: 200, bytes: 1000, status: 200 },
{ ttfbMs: 40, totalMs: 300, bytes: 1000, status: 200 },
{ ttfbMs: 60, totalMs: 100, bytes: 1000, status: 200 },
]);
assert.equal(s.ttfbMs, 50);
assert.equal(s.totalMs, 200);
assert.equal(s.bytes, 1000);
assert.equal(s.status, 200);
assert.equal(s.runs, 3);
});
test("parseEnv: KEY=VALUE lines, skips comments and blanks", () => {
const env = parseEnv(
"# comment\nVITE_OPENWRT_HOST=http://10.0.0.1:80\n\nVITE_DEV_PORT=5173\n",
);
assert.deepEqual(env, {
VITE_OPENWRT_HOST: "http://10.0.0.1:80",
VITE_DEV_PORT: "5173",
});
});
test("parseVmRss: extracts kB from /proc status text", () => {
const text = "Name:\tuhttpd\nVmPeak:\t 5000 kB\nVmRSS:\t 3212 kB\n";
assert.equal(parseVmRss(text), 3212);
});
test("parseVmRss: returns null when VmRSS absent", () => {
assert.equal(parseVmRss("Name:\tuhttpd\n"), null);
});
test("formatMarkdownTable: header + one row per entry, rounded ms", () => {
const out = formatMarkdownTable([
{ label: "login page", ttfbMs: 45.6, totalMs: 230.4, bytes: 28083, status: 200, runs: 10 },
]);
const lines = out.trim().split("\n");
assert.equal(lines.length, 3); // header, separator, one row
assert.match(lines[0], /Target.*TTFB.*Total.*Bytes.*HTTP.*Runs/);
assert.match(lines[2], /\| login page \| 46 \| 230 \| 28083 \| 200 \| 10 \|/);
});
@@ -0,0 +1,590 @@
#!/usr/bin/env node
/**
* bench-router.mjs <label> — verifies and measures the theme's client-side
* router (router-aurora.js) against real full loads on the device, over raw
* CDP (headless Chrome, no npm deps, node >= 22).
*
* env: HOST (default http://192.168.1.1), COOKIE_NAME, COOKIE_VALUE,
* CHROME_BIN, RUNS (default and minimum 10), ONLY (walk|timing|soak|back|poison|sheets|hygiene|nodecss|expiry),
* MATCH (regex; walk only these pages), SETTLE (ms after the view settled before
* a snapshot, default 300 — raise it for pages whose tables fill on the first poll)
*
* Scenarios:
* walk every page the navigation model links to (menu + each page's
* tab strip): same-document navigate, then a full load of the same
* URL; compare title, data-page, dispatchpath, tab count, footer
* presence, view child count. Reports divergences and fallbacks;
* a fallback on a node the router claims counts as a divergence.
* timing click → view painted, median of RUNS: router (warm and cold)
* vs full load, same pages.
* soak 60 navigations over the walked pages; heap, DOM nodes, poll queue
* and view intervals sampled on the same page each lap.
* back traverse back through a chain that deliberately includes alias
* and firstchild URLs (read from the menu tree): same document,
* correct URL/data-page each step.
* poison a foreign <style> in <head> makes the next navigation a full
* load, and the one after (fresh document) is same-document again.
* sheets every walked view page that inserts its own <style>/<link> (found
* on the walk): reached same-document, the navigation away is a
* full load (poison gate), the one after is same-document again.
* Skipped when the walk found no such page.
* hygiene no progress bar remains after a swap (it is inserted for the
* navigation and removed after its fade), the live region carries
* the title, a hidden tab stops polling and a visible one resumes it.
* nodecss a page whose menu.d node declares `css`: its link is enabled on
* arrival, disabled (not removed) after leaving, re-enabled without
* a duplicate on return. Skipped when no such node is installed.
* expiry the session is destroyed from inside the document (logout fetch)
* and a poll fails: the next navigation must be a full load that
* lands on the login form. Destroys the session — always runs last.
*/
import { spawn } from "node:child_process";
import { once } from "node:events";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
const HOST = (process.env.HOST ?? "http://192.168.1.1").replace(/\/+$/, "");
const LABEL = process.argv[2] ?? "run";
// measuring.md: medians of at least 10 runs.
const RUNS = Math.max(10, +(process.env.RUNS ?? 10) || 10);
const ONLY = process.env.ONLY || null;
const MATCH = process.env.MATCH ? new RegExp(process.env.MATCH) : null;
const SETTLE = +(process.env.SETTLE ?? 300) || 300;
const CHROME =
process.env.CHROME_BIN ??
(process.platform === "darwin"
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
: "google-chrome");
if (!process.env.COOKIE_NAME || !process.env.COOKIE_VALUE)
throw new Error("COOKIE_NAME and COOKIE_VALUE are required");
const COOKIE = {
name: process.env.COOKIE_NAME,
value: process.env.COOKIE_VALUE,
domain: new URL(HOST).hostname,
path: "/",
};
const START = `${HOST}/cgi-bin/luci/admin/status/overview`;
// Where a walk of START itself begins: a same-URL navigation is a reload by rule.
const ALT_START = `${HOST}/cgi-bin/luci/admin/system/system`;
const median = (v) => {
const s = [...v].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
/* ---------- chrome + CDP ---------- */
const profile = mkdtempSync(join(tmpdir(), "cdp-aurora-router-"));
const chrome = spawn(
CHROME,
["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`,
"--no-first-run", "--no-default-browser-check", "--ignore-certificate-errors",
"--js-flags=--expose-gc", "about:blank"],
{ stdio: "ignore" },
);
let port = null;
for (let i = 0; i < 100 && !port; i++) {
await sleep(100);
const f = join(profile, "DevToolsActivePort");
if (existsSync(f)) port = +readFileSync(f, "utf8").split("\n")[0];
}
if (!port) throw new Error("chrome: no DevToolsActivePort");
const { webSocketDebuggerUrl } = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
const ws = new WebSocket(webSocketDebuggerUrl);
await new Promise((res, rej) => {
ws.addEventListener("open", res, { once: true });
ws.addEventListener("error", () => rej(new Error("CDP connect failed")), { once: true });
});
let mid = 0;
const pending = new Map();
const handlers = new Set();
ws.addEventListener("message", (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) {
const { res, rej } = pending.get(m.id);
pending.delete(m.id);
m.error ? rej(new Error(m.error.message)) : res(m.result);
} else for (const h of handlers) h(m);
});
const send = (method, params = {}, sessionId) =>
new Promise((res, rej) => {
if (ws.readyState !== WebSocket.OPEN) return rej(new Error("CDP disconnected"));
const id = ++mid;
pending.set(id, { res, rej });
ws.send(JSON.stringify({ id, method, params, sessionId }));
});
const waitEvent = (method, sessionId, timeout = 25000) =>
new Promise((res, rej) => {
const timer = setTimeout(() => { handlers.delete(h); rej(new Error(`timeout ${method}`)); }, timeout);
const h = (m) => {
if (m.method === method && m.sessionId === sessionId) {
clearTimeout(timer); handlers.delete(h); res(m.params);
}
};
handlers.add(h);
});
const consoleErrors = [];
let p = null;
async function recover(reason) {
process.stderr.write(`recover: ${reason}\n`);
try { await send("Target.closeTarget", { targetId: p.targetId }); } catch {}
p = await newPage();
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
}
async function newPage() {
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
await send("Page.enable", {}, sessionId);
await send("Runtime.enable", {}, sessionId);
await send("Log.enable", {}, sessionId);
await send("Emulation.setDeviceMetricsOverride",
{ width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }, sessionId);
handlers.add((m) => {
if (m.sessionId !== sessionId) return;
if (m.method === "Runtime.exceptionThrown")
consoleErrors.push(m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text);
if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error")
consoleErrors.push(m.params.args.map((a) => a.value ?? a.description).join(" "));
if (m.method === "Log.entryAdded" && m.params.entry.level === "error"
&& !/favicon|ERR_/.test(m.params.entry.text))
consoleErrors.push(m.params.entry.text);
});
// View-paint clock for full loads: first non-spinner child of #view.
await send("Page.addScriptToEvaluateOnNewDocument", { source: `
window.__viewReady = null;
new MutationObserver(() => {
const v = document.getElementById('view');
if (v && v.querySelector(':scope > :not(.spinning):not(script)') && window.__viewReady == null)
window.__viewReady = performance.now();
}).observe(document, { childList: true, subtree: true });` }, sessionId);
return { targetId, sessionId };
}
async function evaljs(sessionId, expression, awaitPromise = false, timeout = 30000) {
const r = await Promise.race([
send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, sessionId),
new Promise((_, rej) => setTimeout(() => rej(new Error("evaluate stalled (renderer busy?)")), timeout)),
]);
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
return r.result.value;
}
async function fullLoad(sessionId, url) {
const load = waitEvent("Page.loadEventFired", sessionId);
await send("Page.navigate", { url }, sessionId);
await load;
await waitViewSettled(sessionId);
const login = await evaljs(sessionId, `!!document.querySelector('input[name="luci_username"]')`);
if (login) throw new Error(`landed on the login form for ${url}`);
}
async function waitViewSettled(sessionId, timeout = 10000) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
const ok = await evaljs(sessionId, `(() => {
const v = document.getElementById('view');
return !v || !!v.querySelector(':scope > :not(.spinning):not(script)') ||
(v.childElementCount === 0 && document.readyState === 'complete' && (window.L?.loaded ?? false));
})()`);
if (ok) return;
await sleep(50);
}
}
const SNAPSHOT = `(() => JSON.stringify({
url: location.pathname,
title: document.title,
page: document.body.dataset.page,
dispatch: (L.env.dispatchpath || []).join('/'),
request: (L.env.requestpath || []).join('/'),
tabs: document.querySelectorAll('#tabmenu a').length,
activeTab: document.querySelector('#tabmenu li.active a')?.textContent ?? null,
activeNav: [...document.querySelectorAll('#sidebar-list a.is-active-page, #mobile-nav-list a.is-active-page, .desktop-menu-canvas a.is-active-page, #topmenu a.is-active-page')].map(a => a.getAttribute('href'))[0] ?? null,
footer: !!document.querySelector('#view .cbi-page-actions'),
readonly: L.env.nodespec?.readonly === true,
perm: L.hasViewPermission(),
foreign: [...document.querySelectorAll('style, link[rel~="stylesheet"]')].filter(l => !document.getElementById('view')?.contains(l) && !l.hasAttribute('data-luci-shell') && !l.hasAttribute('data-luci-patch') && !l.hasAttribute('data-luci-node-css')).map(l => l.tagName + (l.href ? ':' + l.href.replace(HOST, '') : '')),
status: document.getElementById('luci-nav-status')?.textContent ?? null,
nodeCss: [...document.querySelectorAll('link[data-luci-node-css]')].filter(l => !l.disabled).map(l => l.getAttribute('data-luci-node-css')).sort().join(','),
viewChildren: document.getElementById('view')?.childElementCount ?? -1,
viewIds: document.querySelectorAll('[id="view"]').length,
h1: document.querySelector('#view h2, #maincontent > h2')?.textContent ?? null,
svgLines: document.querySelectorAll('#view svg line').length,
// DOM shape of the rendered view: per tag+class, [elements, elements with text].
shape: (() => { const m = {}; for (const el of document.querySelectorAll('#view *')) {
const k = el.tagName + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).sort().join('.') : '');
(m[k] ??= [0, 0]); m[k][0]++; if (el.textContent.trim()) m[k][1]++; } return m; })(),
marker: window.__sameDocMarker ?? null,
}))()`;
async function snapshot(sessionId) { return JSON.parse(await evaljs(sessionId, SNAPSHOT)); }
// A navigation the router does not take becomes a real document load, which
// tears down the evaluation ("Inspected target navigated"): report it as a
// fallback after the new document has loaded.
async function spaNavigate(sessionId, url) {
const load = waitEvent("Page.loadEventFired", sessionId, 25000);
load.catch(() => {});
try {
const r = JSON.parse(await evaljs(sessionId, `(async () => {
// A user hovers before clicking; the router prewarms (and, for template
// nodes, fetches the shell) on that intent.
const link = [...document.querySelectorAll('a[href]')].find(a => a.href === ${JSON.stringify(url)});
if (link) { link.dispatchEvent(new Event('pointerover', { bubbles: true })); await new Promise(r => setTimeout(r, 400)); }
const t0 = performance.now();
let error = null;
try {
await Promise.race([navigation.navigate(${JSON.stringify(url)}).finished,
new Promise((_, rej) => setTimeout(() => rej(new Error("router did not finish in 20 s")), 20000))]);
} catch (e) { error = String(e); }
return JSON.stringify({ ms: performance.now() - t0, error, sameDoc: window.__sameDocMarker === 1,
state: { views: document.querySelectorAll('[id="view"]').length, page: document.body.dataset.page } });
})()`, true));
if (r.sameDoc) return r;
await load; await waitViewSettled(sessionId);
await evaljs(sessionId, "window.__sameDocMarker = 1");
return r;
} catch (e) {
if (!/navigated or closed/.test(e.message)) throw e;
await load; await waitViewSettled(sessionId);
await evaljs(sessionId, "window.__sameDocMarker = 1");
return { ms: null, error: null, sameDoc: false };
}
}
/* ---------- discover pages ---------- */
await send("Storage.setCookies", { cookies: [COOKIE] });
p = await newPage();
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const routerActive = await evaljs(p.sessionId,
`!!window.navigation && performance.getEntriesByType('resource').some(e => /router-aurora\.js/.test(e.name))`);
const menuLinks = JSON.parse(await evaljs(p.sessionId, `JSON.stringify([...new Set(
[...document.querySelectorAll('#mobile-nav-list a[href], #sidebar-list a[href]')]
.map(a => a.href).filter(h => /\\/cgi-bin\\/luci\\/admin\\//.test(h) && !/logout/.test(h)))])`));
const pages = new Set(menuLinks);
process.stderr.write(`router active: ${routerActive}, menu links: ${menuLinks.length}\n`);
for (const url of menuLinks) {
process.stderr.write(`discover ${url.replace(HOST, "")}\n`);
const r = await spaNavigate(p.sessionId, url).catch((e) => ({ error: e.message }));
process.stderr.write(` -> ${JSON.stringify(r)}\n`);
if (/stalled/.test(r.error ?? "")) await recover(`discover ${url}`);
if (r.error) continue;
const tabs = JSON.parse(await evaljs(p.sessionId,
`JSON.stringify([...document.querySelectorAll('#tabmenu a[href]')].map(a => a.href))`));
for (const t of tabs) pages.add(t);
}
const PAGES = [...pages];
process.stderr.write(`discovered ${PAGES.length} pages\n`);
const out = { label: LABEL, host: HOST, routerActive, pages: PAGES.length };
/* ---------- walk ---------- */
if (!ONLY || ONLY === "walk") {
const divergences = [], fallbacks = [], ok = [], errors = [], injectors = [];
for (const url of PAGES.filter((u) => !MATCH || MATCH.test(u))) {
const t0 = Date.now();
try {
consoleErrors.length = 0;
await fullLoad(p.sessionId, url === START ? ALT_START : START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const nav = await spaNavigate(p.sessionId, url);
if (!nav.sameDoc) {
// The document is now the target itself: ask its router whether it
// claims this node (a view class, or a template page that is a view
// shell). A claimed node that fell back is a regression, not a fallback.
const claim = await evaljs(p.sessionId, `L.require('router-aurora').then(async (r) => {
// the menu tree arrives asynchronously; a document the router never
// boots on (call/cbi pages) has none, and cannot be judged from here
for (let i = 0; i < 100 && !r.tree; i++) await new Promise((res) => setTimeout(res, 50));
if (!r.tree) return 'unknown';
try {
const rt = r.route(${JSON.stringify(url)}, { intent: true });
if (!rt) return 'declined';
if (rt.className) return 'claimed';
return (await r.template(rt)) ? 'claimed' : 'declined';
} catch (e) { return 'error: ' + e; }
})`, true);
if (claim === "declined" || claim === "unknown") fallbacks.push(url.replace(HOST, "") + (claim === "unknown" ? " (router not booted there)" : ""));
else divergences.push({ url: url.replace(HOST, ""), diffs: [`fallback on a node the router claims (${claim})`] });
continue;
}
await waitViewSettled(p.sessionId);
await sleep(SETTLE);
const soft = await snapshot(p.sessionId);
const softErrors = [...consoleErrors]; consoleErrors.length = 0;
await fullLoad(p.sessionId, url);
await sleep(SETTLE);
const full = await snapshot(p.sessionId);
// a page's own console errors (missing binaries, 404s) show on both paths
const fullErrors = new Set(consoleErrors.map((e) => e.split("\n")[0]));
const routerErrors = softErrors.filter((e) => !fullErrors.has(e.split("\n")[0]));
const diffs = [];
for (const k of ["url", "title", "page", "dispatch", "request", "tabs", "activeTab", "activeNav", "footer", "readonly", "perm", "nodeCss", "h1", "svgLines"])
if (String(soft[k]) !== String(full[k])) diffs.push(`${k}: router=${soft[k]} full=${full[k]}`);
if (soft.viewIds !== 1) diffs.push(`viewIds=${soft.viewIds}`);
for (const k of new Set([...Object.keys(soft.shape), ...Object.keys(full.shape)])) {
const a = soft.shape[k] ?? [0, 0], b = full.shape[k] ?? [0, 0];
// tolerate small count drift (live tables), flag missing kinds and lost text
if (Math.abs(a[0] - b[0]) > Math.max(2, b[0] * 0.25) || (b[1] > 0 && a[1] === 0 && b[0] <= 20 && a[0] > 0))
diffs.push(`shape ${k}: router=${a} full=${b}`);
}
if (soft.viewChildren <= 0 && full.viewChildren > 0) diffs.push("view empty under router");
if (soft.status !== soft.title) diffs.push(`live region: ${soft.status}`);
if (full.foreign.length) injectors.push({ url: url.replace(HOST, ""), foreign: full.foreign });
if (routerErrors.length) diffs.push(`console: ${routerErrors.slice(0, 2).join(" | ").slice(0, 200)}`);
(diffs.length ? divergences : ok).push({ url: url.replace(HOST, ""), diffs });
} catch (e) {
errors.push({ url: url.replace(HOST, ""), error: e.message });
if (/stalled/.test(e.message)) await recover(`walk ${url}`);
}
process.stderr.write(`walk ${url.replace(HOST, "")} ${Date.now() - t0} ms\n`);
}
out.walk = { ok: ok.length, fallbacks, divergences, errors, injectors };
}
/* ---------- timing ---------- */
if (!ONLY || ONLY === "timing") {
const sample = PAGES.filter((u) => !/overview/.test(u)).slice(0, 8);
const rows = [];
for (const url of sample) {
// full load: navigation start → first non-spinner content in #view
const full = [];
for (let i = 0; i < RUNS; i++) {
await fullLoad(p.sessionId, i % 2 ? START : `${HOST}/cgi-bin/luci/admin/system/system`);
await fullLoad(p.sessionId, url);
const t = await evaljs(p.sessionId, "window.__viewReady");
if (t != null) full.push(t);
}
// router, cold (fresh document, module never required) then warm
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const cold = await spaNavigate(p.sessionId, url);
const warm = [];
for (let i = 0; i < RUNS; i++) {
await spaNavigate(p.sessionId, START);
const r = await spaNavigate(p.sessionId, url);
if (!r.error && r.sameDoc) warm.push(r.ms);
}
rows.push({ page: url.replace(`${HOST}/cgi-bin/luci`, ""),
fullLoadMs: +median(full).toFixed(0), spaColdMs: +cold.ms.toFixed(0),
spaWarmMs: +median(warm).toFixed(0), n: [full.length, warm.length] });
}
out.timing = rows;
}
/* ---------- soak ---------- */
if (!ONLY || ONLY === "soak") {
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const laps = 5, ring = PAGES.slice(0, 12);
const samples = [];
const measure = async () => {
await evaljs(p.sessionId, "typeof gc === 'function' && gc()");
await sleep(200);
const m = await send("Performance.getMetrics", {}, p.sessionId).catch(() => null);
const g = (n) => m?.metrics.find((x) => x.name === n)?.value ?? null;
const inpage = JSON.parse(await evaljs(p.sessionId, `JSON.stringify({
pollQueue: L.Poll.queue.length, viewIds: document.querySelectorAll('[id="view"]').length,
sheets: document.querySelectorAll('style, link[rel~=stylesheet]').length })`));
samples.push({ heapMB: +((g("JSHeapUsedSize") ?? 0) / 1048576).toFixed(2), nodes: g("Nodes"),
listeners: g("JSEventListeners"), ...inpage });
};
await send("Performance.enable", {}, p.sessionId);
await measure();
for (let lap = 0; lap < laps; lap++) {
for (const url of ring) { const r = await spaNavigate(p.sessionId, url); if (!r.sameDoc) break; }
await spaNavigate(p.sessionId, START);
await measure();
}
out.soak = { navigations: laps * (ring.length + 1), samples };
}
/* ---------- back ---------- */
if (!ONLY || ONLY === "back") {
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
// Two alias/firstchild URLs (from the menu tree, resolved client-side by
// the router) interleaved with two view URLs.
const redirecting = JSON.parse(await evaljs(p.sessionId, `(async () => {
const tree = await L.require('ui').then(ui => ui.menu.load());
const out = [];
(function walk(node, segs) {
for (const [name, child] of Object.entries(node.children ?? {})) {
const path = [...segs, name];
const t = child.action?.type;
if (child.satisfied && child.title && (t === 'alias' || t === 'firstchild') && path[0] === 'admin' && path.length >= 3)
out.push(L.url(...path));
walk(child, path);
}
})(tree, []);
return JSON.stringify(out.map(u => new URL(u, location.href).href));
})()`, true)).filter((u) => PAGES.includes(u) && /\/admin\/(status|network)\//.test(u)).slice(0, 2);
const views = PAGES.filter((u) => !redirecting.includes(u) && u !== START).slice(0, 2);
const chain = [redirecting[0], views[0], redirecting[1], views[1]].filter(Boolean);
process.stderr.write(`back chain: ${chain.map((u) => u.replace(HOST, "")).join(" → ")}\n`);
if (redirecting.length < 2) process.stderr.write("back: fewer than 2 alias/firstchild pages found\n");
for (const url of chain) {
const r = await spaNavigate(p.sessionId, url);
process.stderr.write(` ${url.replace(HOST, "")} sameDoc=${r.sameDoc}\n`);
}
const steps = [];
for (let i = chain.length - 1; i >= 0; i--) {
let r;
try {
r = JSON.parse(await evaljs(p.sessionId, `(async () => {
const t0 = performance.now();
try { await navigation.back().finished; } catch (e) { return JSON.stringify({ error: String(e) }); }
return JSON.stringify({ ms: performance.now() - t0, sameDoc: window.__sameDocMarker === 1,
url: location.pathname, page: document.body.dataset.page });
})()`, true));
} catch (e) {
if (!/navigated or closed/.test(e.message)) throw e;
// cross-document traversal: a bfcache restore fires no load event, so
// poll until the (restored or new) document answers
for (let t = 0; t < 100; t++) {
try { if (await evaljs(p.sessionId, "document.readyState") === "complete") break; } catch {}
await sleep(100);
}
await waitViewSettled(p.sessionId);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
r = { sameDoc: false, url: await evaljs(p.sessionId, "location.pathname"), page: await evaljs(p.sessionId, "document.body.dataset.page") };
}
const expected = i > 0 ? chain[i - 1] : START;
steps.push({ ...r, ok: r.sameDoc && `${HOST}${r.url}` === expected.split("?")[0] });
}
out.back = { chain: chain.map((u) => u.replace(HOST, "")), redirectingInChain: redirecting.length,
steps: steps.length, allSameDocument: steps.every((s) => s.sameDoc),
allCorrect: steps.every((s) => s.ok), detail: steps };
}
/* ---------- poison gate ---------- */
if (!ONLY || ONLY === "poison") {
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const [a, b] = PAGES.filter((u) => u !== START).slice(0, 2);
const before = await spaNavigate(p.sessionId, a);
// what a foreign view does: an unlayered <style> straight into <head>
await evaljs(p.sessionId, `document.head.appendChild(Object.assign(document.createElement('style'),
{ textContent: '.cbi-button-save{display:none!important}' })).id = 'poison'`);
const poisoned = await spaNavigate(p.sessionId, b); // must be a full load
const stillPoisoned = await evaljs(p.sessionId, "!!document.getElementById('poison')");
const after = await spaNavigate(p.sessionId, a); // fresh document → router again
out.poison = { beforeSameDoc: before.sameDoc, poisonedFullLoad: !poisoned.sameDoc,
styleGoneAfterFullLoad: !stillPoisoned, afterSameDoc: after.sameDoc,
ok: before.sameDoc && !poisoned.sameDoc && !stillPoisoned && after.sameDoc };
}
/* ---------- foreign sheets ---------- */
if (!ONLY || ONLY === "sheets") {
const FOREIGN = `[...document.querySelectorAll('style, link[rel~="stylesheet"]')].filter(l => !document.getElementById('view')?.contains(l) && !l.hasAttribute('data-luci-shell') && !l.hasAttribute('data-luci-patch') && !l.hasAttribute('data-luci-node-css')).length`;
const injecting = out.walk?.injectors?.map((i) => HOST + i.url) ?? [];
if (!injecting.length && ONLY === "sheets")
for (const url of PAGES.filter((u) => u !== START)) {
await fullLoad(p.sessionId, url);
if (await evaljs(p.sessionId, FOREIGN)) injecting.push(url);
if (injecting.length >= 3) break;
}
const cases = [];
for (const a of injecting.slice(0, 3)) {
const [b, c] = PAGES.filter((u) => u !== START && u !== a);
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const arrive = await spaNavigate(p.sessionId, a);
// A page the router does not serve (Lua, call) is a full load either way.
if (!arrive.sameDoc) { cases.push({ a: a.replace(HOST, ""), skipped: "not a view page" }); continue; }
await waitViewSettled(p.sessionId); await sleep(300);
const foreign = await evaljs(p.sessionId, FOREIGN);
const leave = await spaNavigate(p.sessionId, b); // poison gate → full load
const after = await spaNavigate(p.sessionId, c); // fresh document → router again
// Landing on the page itself: its modules insert before the router boots;
// the markers, not a snapshot, must still tell those sheets apart.
await fullLoad(p.sessionId, a);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const fromBoot = await spaNavigate(p.sessionId, b);
cases.push({ a: a.replace(HOST, ""), foreign, arriveSameDoc: arrive.sameDoc, leaveFullLoad: !leave.sameDoc, afterSameDoc: after.sameDoc,
leaveFromBootFullLoad: !fromBoot.sameDoc,
ok: arrive.sameDoc && foreign > 0 && !leave.sameDoc && after.sameDoc && !fromBoot.sameDoc });
}
const tested = cases.filter((c) => !c.skipped);
out.sheets = tested.length ? { cases, ok: tested.every((c) => c.ok) } : { skipped: "no walked view page inserts a stylesheet", cases };
}
/* ---------- hygiene ---------- */
if (!ONLY || ONLY === "hygiene") {
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const b = PAGES.find((u) => u !== START);
const nav = await spaNavigate(p.sessionId, b);
await waitViewSettled(p.sessionId); await sleep(300);
// Back on Overview, which polls, so the visibility gate has a timer to stop.
const home = await spaNavigate(p.sessionId, START);
await waitViewSettled(p.sessionId); await sleep(1500);
const r = JSON.parse(await evaljs(p.sessionId, `(async () => {
const barGone = !document.getElementById('luci-nav-progress');
const status = document.getElementById('luci-nav-status')?.textContent;
const wasActive = L.Poll.active();
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
document.dispatchEvent(new Event('visibilitychange'));
const hiddenActive = L.Poll.active();
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
document.dispatchEvent(new Event('visibilitychange'));
const shownActive = L.Poll.active();
return JSON.stringify({ barGone, status, title: document.title, wasActive, hiddenActive, shownActive });
})()`, true));
out.hygiene = { ...r, sameDoc: nav.sameDoc && home.sameDoc,
ok: nav.sameDoc && home.sameDoc && r.barGone && r.status === r.title && r.wasActive && !r.hiddenActive && r.shownActive };
}
/* ---------- menu.d node css ---------- */
if (!ONLY || ONLY === "nodecss") {
const LINKS = `JSON.stringify([...document.querySelectorAll('link[data-luci-node-css]')].map(l => [l.getAttribute('data-luci-node-css'), !l.disabled]))`;
let styled = null;
for (const url of PAGES.filter((u) => u !== START)) {
await fullLoad(p.sessionId, url);
if (JSON.parse(await evaljs(p.sessionId, LINKS)).length) { styled = url; break; }
}
if (!styled) out.nodecss = { skipped: "no installed menu.d node declares css" };
else {
const other = PAGES.find((u) => u !== START && u !== styled);
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const arrive = await spaNavigate(p.sessionId, styled);
const onArrival = JSON.parse(await evaljs(p.sessionId, LINKS));
const leave = await spaNavigate(p.sessionId, other);
const afterLeave = JSON.parse(await evaljs(p.sessionId, LINKS));
const back = await spaNavigate(p.sessionId, styled);
const onReturn = JSON.parse(await evaljs(p.sessionId, LINKS));
out.nodecss = { page: styled.replace(HOST, ""), onArrival, afterLeave, onReturn,
ok: arrive.sameDoc && leave.sameDoc && back.sameDoc &&
onArrival.length === 1 && onArrival[0][1] === true &&
afterLeave.length === 1 && afterLeave[0][1] === false &&
onReturn.length === 1 && onReturn[0][1] === true };
}
}
/* ---------- session expiry ---------- */
if (!ONLY || ONLY === "expiry") {
await fullLoad(p.sessionId, START);
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
const [a, b] = PAGES.filter((u) => u !== START).slice(0, 2);
const before = await spaNavigate(p.sessionId, a);
// What a real expiry looks like from inside the document: the session is
// gone server-side and the next RPC comes back -32002; luci-base probes
// session.access, then shows its modal and stops polling.
const seen = JSON.parse(await evaljs(p.sessionId, `(async () => {
await fetch(L.url('admin/logout'), { credentials: 'same-origin' }).catch(() => {});
const rpc = await L.require('rpc');
await rpc.declare({ object: 'system', method: 'board' })().catch(() => {});
await new Promise(r => setTimeout(r, 500));
return JSON.stringify({ modal: !!document.querySelector('.modal'), pollActive: L.Poll.active() });
})()`, true));
const nav = await spaNavigate(p.sessionId, b); // must be a full load
const login = await evaljs(p.sessionId, `!!document.querySelector('input[name="luci_username"]')`);
out.expiry = { beforeSameDoc: before.sameDoc, modalShown: seen.modal, pollStopped: !seen.pollActive,
expiredFullLoad: !nav.sameDoc, landedOnLogin: login,
ok: before.sameDoc && seen.modal && !nav.sameDoc && login };
}
console.log(JSON.stringify(out, null, 2));
ws.close();
if (chrome.exitCode == null) { const exited = once(chrome, "exit"); chrome.kill(); await Promise.race([exited, sleep(2000)]); }
await sleep(300);
try { rmSync(profile, { recursive: true, force: true }); } catch {}
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* On-device performance bench (see this skill's references/measuring.md, and
* references/aurora-budgets.md for the budget table).
*
* Measures TTFB / total time / transferred bytes against the OpenWrt device
* configured in .env (median of N runs), plus uhttpd RSS and theme flash
* usage over SSH when reachable. Output is a Markdown report — paste it into
* a PR or archive it under the skill's git-ignored baselines/ directory.
*
* Run directly from .dev/ — the .env is located relative to the working
* directory (.dev/.env), not this script's own path, so the harness keeps
* working if the skill is later moved or packaged as a plugin.
*
* Usage: node ../.claude/skills/aurora-performance/scripts/bench.mjs
* BENCH_RUNS=20 node ../.claude/skills/aurora-performance/scripts/bench.mjs
*/
import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { promisify } from "node:util";
import {
parseCurlMetrics,
summarize,
parseEnv,
parseVmRss,
formatMarkdownTable,
assertOkStatus,
} from "./bench-lib.js";
const execFileAsync = promisify(execFile);
let env;
try {
env = parseEnv(readFileSync(resolve(process.cwd(), ".env"), "utf-8"));
} catch (err) {
console.error("bench: .env not found in the current directory — run from .dev/ (copy .env.example and set VITE_OPENWRT_HOST first).");
process.exit(1);
}
const HOST = env.VITE_OPENWRT_HOST || "http://192.168.1.1:80";
const SSH_HOST = env.VITE_OPENWRT_SSH_HOST || "";
const SSH_KEY = env.VITE_OPENWRT_SSH_KEY || "";
const RUNS = Number(process.env.BENCH_RUNS) || 10;
const CURL_FORMAT = "%{time_starttransfer}\\t%{time_total}\\t%{size_download}\\t%{http_code}";
// The login page is the honest server-side render target: it is what an
// unauthenticated bench can measure, and TTFB comparisons must compare like
// with like (spec: error handling / edge cases).
const TARGETS = [
{
label: "GET /cgi-bin/luci (login page)",
path: "/cgi-bin/luci",
allowedStatuses: [403],
},
{ label: "main.css (identity)", path: "/luci-static/aurora/main.css" },
{ label: "login.css (identity)", path: "/luci-static/aurora/login.css" },
{ label: "menu-aurora.js (identity)", path: "/luci-static/resources/menu-aurora.js" },
{
label: "Lato regular font (identity)",
path: "/luci-static/aurora/fonts/lato-v24-latin-regular.woff2",
},
{ label: "logo.svg (identity)", path: "/luci-static/aurora/images/logo.svg" },
];
async function measureOnce(target) {
const args = [
"-o", "/dev/null",
"-s",
"-w", CURL_FORMAT,
"--max-time", "30",
];
args.push(`${HOST}${target.path}`);
const { stdout } = await execFileAsync("curl", args);
return assertOkStatus(
parseCurlMetrics(stdout),
target.allowedStatuses ?? [],
);
}
async function measureTarget(target) {
const samples = [];
for (let i = 0; i < RUNS; i++) samples.push(await measureOnce(target));
return { label: target.label, ...summarize(samples) };
}
function sshArgs(command) {
const args = ["-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5"];
if (SSH_KEY) args.push("-i", SSH_KEY);
args.push(SSH_HOST, command);
return args;
}
async function deviceReport() {
if (!SSH_HOST) return "⚠ VITE_OPENWRT_SSH_HOST not set — HTTP metrics only.";
try {
const { stdout: status } = await execFileAsync(
"ssh",
sshArgs('cat /proc/$(pidof uhttpd | cut -d" " -f1)/status'),
);
const { stdout: du } = await execFileAsync(
"ssh",
sshArgs("du -sk /www/luci-static/aurora /www/luci-static/resources/menu-aurora.js"),
);
const rss = parseVmRss(status);
return [
"## Device",
"",
`- uhttpd VmRSS: ${rss !== null ? `${rss} kB` : "unavailable"}`,
"- Theme flash usage:",
"",
"```",
du.trim(),
"```",
].join("\n");
} catch (err) {
return `⚠ SSH unreachable (${err?.code ?? "error"}) — HTTP metrics only.`;
}
}
const rows = [];
try {
for (const target of TARGETS) {
process.stderr.write(`bench: ${target.label} ×${RUNS}\n`);
rows.push(await measureTarget(target));
}
} catch (err) {
console.error(`bench: aborted — ${TARGETS[rows.length].label} failed (${err.message})`);
process.exit(1);
}
console.log(`# Bench — ${HOST} (median of ${RUNS} runs)`);
console.log("");
console.log(formatMarkdownTable(rows));
console.log("");
console.log(await deviceReport());
@@ -0,0 +1,61 @@
---
name: luci-change
description: Change this theme with two gates — the user's approval of a written plan before any edit, and a vertical agent review after implementation. Use for any request that touches .dev/src, ucode/, htdocs/, root/, Makefile, vite.config.ts or CI.
argument-hint: <what to change>
disable-model-invocation: true
---
# /luci-change $ARGUMENTS
Gate 1 is the user's explicit approval; gate 2 is the `luci-vertical-review`
workflow. No edit to a shipped or source path happens before gate 1, and the
`guard-edits` hook refuses it mechanically.
0. **Scope.** If the fix belongs in luci-base, uhttpd, rpcd or the feed, stop
and run `/upstream-rfc "<topic>"` instead. If it only touches `.dev/docs/`,
skip to step 4 (docs are not guarded).
1. **Research (read-only).** Load `luci-knowledge`; read
`references/aurora-contract.md` and the section relevant to the files
involved; `git log -8 --oneline -- <files>`; the tests and budgets that
cover them. If the request names a page, a package, a DOM element or
shows a page screenshot, load `luci-dom-compat` and complete its steps
1–2 (locate the page, read `.dev/compat/pages/` for the files and
selectors about to change) before planning. If the request adds or
changes motion (transition, animation, View Transition), load
`aurora-motion`; the plan names the state, its effect from the table
and the measured main.css byte delta.
2. **Plan.** Call `EnterPlanMode`. The plan states: files to change; the
approach in ≤ 10 lines; budget impact (bytes per `tests/build-performance.test.js`,
per-request ubus/uci/fs calls, dispatches per navigation); floor it
depends on (OpenWrt/browser); risk and rollback; verification
(`pnpm test`, `pnpm build`, which bench if a device is reachable); that
it is one logical commit. Call `ExitPlanMode` — the user's selection is
gate 1. If plan mode is unavailable, put the same plan in
`AskUserQuestion` with options "Approve" / "Change the plan" and proceed
only on "Approve".
3. **Arm the guard.** `printf '%s\n' <approved path prefixes> > .claude/.change-approved`
(one prefix per line, exactly the paths in the approved plan).
4. **Implement.** Minimal diff, no unrelated cleanup, comments of one or
two lines. `htdocs/` only through `cd .dev && pnpm build`.
5. **Verify.** `cd .dev && pnpm test && pnpm build`; if budgets moved, update
the number in `tests/build-performance.test.js` **and**
`.claude/skills/aurora-performance/references/aurora-budgets.md` in the
same change with the measured value. Run the relevant bench when
`.dev/.env` names a reachable device; otherwise write "not measured on
device" in the report.
6. **Gate 2.** Run the workflow `luci-vertical-review` with
`{ base: "HEAD", scope: ".claude/.change-approved" }`. Fix every
`blocker`/`major` it confirms; list `minor` ones. If a fix changed
behaviour, run it once more (two rounds maximum).
7. **Report and disarm.** What changed (files), findings and what was done
with each, measurements or the explicit "not measured", anything left
out and why. Write the same report as a decision record to
`.dev/decisions/<YYYY-MM-DD>-<slug>.md` (git-ignored; header lines
`Request`, `Decision: implemented | reverted | deferred`, `Commit:` once
one exists) so the ledger outlives the session. A page-scoped change also
appends its entry to `.dev/compat/pages/<data-page prefix>.md` (and a
fixture under `.dev/compat/fixtures/` when the symptom is geometric —
`luci-dom-compat` step 5) in the same commit. `rm -f
.claude/.change-approved`. Commit only when the user asks: one logical
change, short conventional subject, body carries the reasoning, no
session trailers. Never push.
@@ -0,0 +1,78 @@
---
name: luci-dom-compat
description: Use whenever a request is about how one LuCI page renders under this theme — a cell that wraps or overflows, misaligned controls, a mobile card layout, a screenshot of a page, a data-page value, a package name (luci-app-*), or an issue about a plugin. Stock LuCI pages count as much as third-party apps. Reads the DOM compat ledger before any CSS is touched and writes the entry (and a geometry fixture) after.
argument-hint: <page, package, symptom or issue>
---
# luci-dom-compat $ARGUMENTS
The ledger lives in `.dev/compat/` (format in its README). It answers "why
is this rule here" for every page-scoped rule in the theme, and
`tests/compat.test.js` runs the fixtures that guard the geometric ones.
This skill does not replace `/luci-change`: the edit itself still goes
through that gate; this is what happens before and after.
## 1. Locate — write the quadruple down before touching CSS
| what | where to get it |
|---|---|
| `data-page` | `body[data-page]` on the page; or the URL segments after `/cgi-bin/luci/` joined with `-` |
| package | on the device: `apk list -I \| grep -i <name>` or `opkg list-installed \| grep -i <name>`; stock pages are `luci-mod-*`/`luci-app-*` from openwrt/luci |
| menu path | `/usr/share/luci/menu.d/*.json` (`grep -o '"admin/[^"]*"'` + the `title` fields) or the breadcrumb on the page |
| DOM element | the selector, plus any inline `style`/attribute the layout depends on (`table-layout:fixed`, `white-space:nowrap`, `width:`) — that is usually the cause |
## 2. Read the ledger first
```
ls .dev/compat/pages/ | grep <prefix> # this page
grep -rln "<file you will edit>" .dev/compat/pages/
grep -rln "<selector you will touch>" .dev/compat/pages/
```
For every hit, state in the plan whether the change keeps, changes or
removes that entry's behaviour. A rule that looks pointless usually has an
entry; if it has none, that is the first thing to add.
## 3. Evidence
Device reachable (`VITE_OPENWRT_HOST` in `.dev/.env`, empty root password
over http on the dev routers): `node .claude/skills/luci-dom-compat/scripts/compat-probe.mjs`.
It logs in, opens each page in headless Chrome, serves the *before* CSS
through `Fetch` interception, measures, hot-swaps the *after* CSS into the
same document, measures again, and prints per-element computed-style
diffs on `.table` subtrees, wrap/overflow counts for `SELECTOR`, and
screenshots. Nothing on the device is written. Env: `HOST`, `PAGES`
(comma-separated `admin/...` paths), `WIDTHS`, `BEFORE`/`AFTER` (css files;
default before = the file the device serves, after = `htdocs/.../main.css`),
`SELECTOR`, `OUT`, `PRESET_LS` (a `localStorage` key=value the page needs,
e.g. the selected disk).
Device unreachable: a `.dev/mocks/` snapshot through `pnpm dev`
(`/mocks/`), or an existing fixture. Compare against luci-theme-bootstrap
(`../luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css`) when
the question is "what does upstream do here" — serve it as `AFTER` to the
same page.
Wrap detection that survives inline swatches and checkboxes: walk the
cell's text nodes, collect client-rect tops, and treat "text starts below
the inline indicator" as a wrap (`nowrap` in `tests/compat.test.js`).
## 4. Fix
Through `/luci-change`. Prefer the component partial over a `patches/`
file when the cause is a theme rule blocking what the page asked for
(#65: a font size on `td` blocked the table's own); a patch is for markup
the theme cannot reasonably absorb.
## 5. Sink — same commit as the fix
1. Append the entry to `.dev/compat/pages/<data-page prefix>.md` in the
README format. `verified:` carries the numbers from step 3.
2. Symptom geometric (wrap, overflow, width, height)? Trim the DOM to a
fixture in `.dev/compat/fixtures/<data-page>--<slug>.html`: the block,
the page's own injected `<style>`, a `max-width` wrapper matching the
measured content width if the wrap depends on it, and the `#compat`
checks. The theme font is injected by the runner. Confirm the fixture
**fails against the pre-fix CSS** (rebuild from the previous commit, or
swap the utility back) before committing it.
3. `mirror: pending` until the same change lands in luci-theme-shadcn.
@@ -0,0 +1,182 @@
#!/usr/bin/env node
/**
* compat-probe.mjs — before/after a theme CSS change on real device pages,
* in one page load per page: log in, open the page in headless Chrome,
* serve BEFORE as the theme's main.css through Fetch interception, measure,
* hot-swap AFTER into the same document, measure again. Writes nothing on
* the device. Raw CDP, no npm deps, node >= 22.
*
* env:
* HOST http://192.168.8.1 (default: VITE_OPENWRT_HOST from .dev/.env)
* PAGES comma-separated paths after /cgi-bin/luci/ (default: a stock set)
* WIDTHS viewport widths, default 1280,390
* BEFORE css file served as main.css first (default: fetch the device's own)
* AFTER css file swapped in (default: htdocs/luci-static/aurora/main.css)
* SELECTOR elements whose wrap/overflow are counted (default: .table td, .table .td)
* PRESET_LS key=value stored in localStorage before load (e.g. the disk a plugin remembers)
* OUT output directory for screenshots + report (default: ./compat-probe-out)
* USER/PASS login (default root / empty)
* CHROME_BIN
*/
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
const root = resolve(import.meta.dirname, "../../../..");
const envFile = join(root, ".dev/.env");
const dotenv = existsSync(envFile)
? Object.fromEntries(readFileSync(envFile, "utf8").split("\n").filter((l) => /^[A-Z_]+=/.test(l)).map((l) => l.split("=", 2)))
: {};
const HOST = (process.env.HOST ?? (dotenv.VITE_OPENWRT_HOST ? `http://${dotenv.VITE_OPENWRT_HOST}` : "http://192.168.1.1")).replace(/\/+$/, "");
const PAGES = (process.env.PAGES ?? "admin/status/overview,admin/network/network,admin/network/firewall,admin/network/dhcp,admin/system/mounts").split(",");
const WIDTHS = (process.env.WIDTHS ?? "1280,390").split(",").map(Number);
const AFTER = readFileSync(process.env.AFTER ?? join(root, "htdocs/luci-static/aurora/main.css"));
const BEFORE = process.env.BEFORE ? readFileSync(process.env.BEFORE) : null;
const SELECTOR = process.env.SELECTOR ?? ".table td, .table .td";
const OUT = resolve(process.env.OUT ?? "compat-probe-out");
const CHROME = process.env.CHROME_BIN ?? (process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "google-chrome");
mkdirSync(OUT, { recursive: true });
const login = await fetch(`${HOST}/cgi-bin/luci/`, {
method: "POST",
body: new URLSearchParams({ luci_username: process.env.USER_NAME ?? "root", luci_password: process.env.PASS ?? "" }),
redirect: "manual",
});
const cookie = (login.headers.get("set-cookie") ?? "").match(/(sysauth[^=]*)=([^;]+)/);
if (!cookie) throw new Error(`login failed (${login.status})`);
let before = BEFORE;
if (!before) {
const r = await fetch(`${HOST}/luci-static/aurora/main.css`, { headers: { cookie: `${cookie[1]}=${cookie[2]}` } });
if (!r.ok) throw new Error(`device main.css: ${r.status}`);
before = Buffer.from(await r.arrayBuffer());
}
const profile = mkdtempSync(join(tmpdir(), "cdp-compat-probe-"));
const chrome = spawn(CHROME, ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "about:blank"], { stdio: "ignore" });
let port = null;
for (let i = 0; i < 100 && !port; i++) {
await sleep(100);
const f = join(profile, "DevToolsActivePort");
if (existsSync(f)) port = +readFileSync(f, "utf8").split("\n")[0];
}
if (!port) throw new Error("chrome: no DevToolsActivePort");
const ws = new WebSocket((await (await fetch(`http://127.0.0.1:${port}/json/version`)).json()).webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.addEventListener("open", res, { once: true }); ws.addEventListener("error", () => rej(new Error("CDP connect failed")), { once: true }); });
let mid = 0; const pending = new Map(); const handlers = new Set();
ws.addEventListener("message", (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); }
else for (const h of handlers) h(m);
});
const send = (method, params = {}, sessionId) => new Promise((res, rej) => { const id = ++mid; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params, sessionId })); });
// Computed font-size/line-height per element under every .table, plus the
// wrap/overflow state of SELECTOR — the two things a table change moves.
const MEASURE = `(() => {
const pick = (el, pseudo) => { const s = getComputedStyle(el, pseudo); return s.fontSize + '/' + s.lineHeight + ' ' + s.whiteSpace; };
const path = (el) => { const p = []; for (let e = el; e && e !== document.body; e = e.parentElement) p.unshift(e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + (e.classList[0] ? '.' + e.classList[0] : '')); return p.slice(-4).join('>'); };
const styles = [];
[...document.querySelectorAll('table.table, .table')].forEach((t, ti) => {
styles.push(['T' + ti, path(t), pick(t)]);
t.querySelectorAll('*').forEach((el, i) => {
if (el.closest('.table') !== t) return;
const txt = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
styles.push(['T' + ti + '#' + i + (txt ? ' txt' : ''), path(el), pick(el)]);
});
});
const wrapped = [], overflow = [];
for (const el of document.querySelectorAll(${JSON.stringify(SELECTOR)})) {
const tops = []; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let n; (n = w.nextNode());) { if (!n.textContent.trim()) continue; const r = document.createRange(); r.selectNodeContents(n); for (const x of r.getClientRects()) if (x.width > 0) tops.push(x.top); }
const ind = el.querySelector('input, img, svg, [class*=indicator]');
if (tops.some(t => Math.abs(t - tops[0]) > 3) || (ind && tops.length && tops[0] >= ind.getBoundingClientRect().bottom - 1)) wrapped.push(el.textContent.trim().slice(0, 30));
if (el.scrollWidth > el.clientWidth + 1) overflow.push(el.textContent.trim().slice(0, 30));
}
return { page: document.body.dataset.page, styles, wrapped, overflow, pageOverflow: document.documentElement.scrollWidth > innerWidth };
})()`;
const report = [];
try {
for (const width of WIDTHS) {
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
await send("Page.enable", {}, sessionId);
await send("Network.enable", {}, sessionId);
await send("Network.setCacheDisabled", { cacheDisabled: true }, sessionId);
await send("Network.setCookie", { name: cookie[1], value: cookie[2], domain: new URL(HOST).hostname, path: "/" }, sessionId);
await send("Emulation.setDeviceMetricsOverride", { width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId);
if (process.env.PRESET_LS) {
const [k, v] = process.env.PRESET_LS.split("=", 2);
await send("Page.addScriptToEvaluateOnNewDocument", { source: `localStorage.setItem(${JSON.stringify(k)}, ${JSON.stringify(v)})` }, sessionId);
}
await send("Fetch.enable", { patterns: [{ urlPattern: "*/luci-static/aurora/main.css*", requestStage: "Request" }] }, sessionId);
handlers.add((m) => {
if (m.sessionId !== sessionId || m.method !== "Fetch.requestPaused") return;
const body = m.params.request.url.includes("variant=after") ? AFTER : before;
send("Fetch.fulfillRequest", { requestId: m.params.requestId, responseCode: 200, responseHeaders: [{ name: "Content-Type", value: "text/css" }, { name: "Cache-Control", value: "no-store" }], body: body.toString("base64") }, sessionId);
});
const evaljs = async (expression) => {
const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, sessionId);
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
return r.result.value;
};
const shot = async (name) => {
const s = await send("Page.captureScreenshot", { format: "png" }, sessionId);
writeFileSync(join(OUT, name), Buffer.from(s.data, "base64"));
};
for (const page of PAGES) {
await send("Page.navigate", { url: `${HOST}/cgi-bin/luci/${page}` }, sessionId);
for (let i = 0; i < 80; i++) {
await sleep(250);
const ready = await evaljs(`document.readyState === 'complete' && !document.querySelector('#view > .spinning') && !!document.querySelector('#view > :not(script)')`).catch(() => false);
if (ready) break;
}
await sleep(2000);
if (await evaljs(`!!document.querySelector('input[name="luci_username"]')`)) throw new Error(`landed on the login form for ${page}`);
// Device on another theme: swap its shell stylesheets for aurora's so the
// content region can still be measured. Header/nav markup stays foreign,
// so only in-view numbers mean anything then.
const foreign = await evaljs(`!document.querySelector('link[rel=stylesheet][href*="/luci-static/aurora/main.css"]')`);
if (foreign) {
console.log(` (device is not on aurora for ${page}: aurora main.css injected over the active theme; compare the content region only)`);
await evaljs(`new Promise(r => {
document.querySelectorAll('link[rel=stylesheet][href*="/luci-static/"]').forEach(l => { if (!/\\/resources\\//.test(l.href)) l.remove(); });
const l = Object.assign(document.createElement('link'), { rel: 'stylesheet', href: '/luci-static/aurora/main.css?probe=before', onload: r });
document.head.append(l);
})`);
await sleep(400);
}
const b = await evaljs(MEASURE);
const slug = page.replaceAll("/", "-");
await shot(`${slug}-${width}-before.png`);
await evaljs(`new Promise(r => { const l = [...document.querySelectorAll('link[rel=stylesheet]')].find(l => l.href.includes('/aurora/main.css')); const n = l.cloneNode(); n.href = '/luci-static/aurora/main.css?variant=after'; n.onload = () => { l.remove(); r(); }; l.after(n); })`);
await sleep(400);
const a = await evaljs(MEASURE);
await shot(`${slug}-${width}-after.png`);
const diffs = [];
const n = Math.min(b.styles.length, a.styles.length);
for (let i = 0; i < n; i++) {
if (b.styles[i][0] !== a.styles[i][0]) { diffs.push(`structure changed at ${i}`); break; }
if (b.styles[i][2] !== a.styles[i][2]) diffs.push(`${b.styles[i][0]} ${b.styles[i][1]}: ${b.styles[i][2]} -> ${a.styles[i][2]}`);
}
const textDiffs = diffs.filter((d) => / txt /.test(d) || d.startsWith("structure"));
report.push({ width, page, dataPage: b.page, elements: b.styles.length, diffs: diffs.length, textDiffs: textDiffs.length, wrapped: [b.wrapped.length, a.wrapped.length], overflow: [b.overflow.length, a.overflow.length], pageOverflow: [b.pageOverflow, a.pageOverflow] });
console.log(`[${width}] ${page} (${b.page}) els=${b.styles.length} styleDiffs=${diffs.length} textDiffs=${textDiffs.length} wrapped ${b.wrapped.length}->${a.wrapped.length} overflow ${b.overflow.length}->${a.overflow.length} pageOverflow ${b.pageOverflow}->${a.pageOverflow}`);
for (const d of textDiffs.slice(0, 6)) console.log(" ", d);
const uniq = [...new Set(diffs.filter((d) => !textDiffs.includes(d)).map((d) => d.replace(/^T\d+#\d+ /, "")))];
for (const d of uniq.slice(0, 4)) console.log(" (no text)", d);
if (a.wrapped.length) console.log(" still wrapped:", a.wrapped.slice(0, 8).join(" | "));
}
await send("Target.closeTarget", { targetId });
}
writeFileSync(join(OUT, "report.json"), JSON.stringify(report, null, 2));
console.log(`report: ${join(OUT, "report.json")}`);
} finally {
ws.close();
chrome.kill();
await sleep(300);
rmSync(profile, { recursive: true, force: true });
}
@@ -0,0 +1,52 @@
---
name: luci-knowledge
description: Verified facts about luci-base, uhttpd and this theme's own contract — boot order, template scope, L.require semantics, dispatch costs, caching headers, packaging rewrites, router invariants, budgets. Load before reviewing, changing or benchmarking anything in ucode/, .dev/src/, htdocs/ or before proposing a change to luci-base/uhttpd.
user-invocable: false
---
# LuCI knowledge (Aurora)
Facts only, each with a `file:line` in a local checkout. Nothing here is a
recommendation; the workflows decide what to do with the facts.
| Touching | Read |
|---|---|
| `.ut` templates, `<head>` order, anything before `luci.js` exists | references/luci-runtime.md §1–2, references/aurora-contract.md |
| `menu-aurora.js`, `router-aurora.js`, page JS patches, polling | references/luci-runtime.md §3–4, references/spa-pitfalls.md |
| TTFB, per-navigation cost, "is this worth a dispatch" | references/dispatch-cost.md |
| caching, compression, CGI vs mod-ucode, HTTP-only constraints | references/uhttpd.md, references/luci-runtime.md §5 |
| `?v=`, `{# PKG_VERSION #}`, Makefile, feed, apk/opkg | references/packaging.md |
| any claim that sounds familiar | references/traps.md — refute before repeating |
## Source checkouts (`$LUCI_SRC`, `$UHTTPD_SRC`)
The references cite files inside two upstream checkouts. Their location is
machine-specific and lives only in the git-ignored `.dev/.env`:
```
LUCI_SRC=/path/to/openwrt-luci # git clone https://github.com/openwrt/luci
UHTTPD_SRC=/path/to/uhttpd # git clone https://git.openwrt.org/project/uhttpd.git
```
Resolve them before reading any source: `grep -E '^(LUCI|UHTTPD)_SRC=' .dev/.env`.
If a variable is unset or the directory is missing, shallow-clone into
`${TMPDIR:-/tmp}/luci-src/<name>` (`git clone --depth 1`) and say so in the
output. For a branch comparison the checkout may lack the release branches
(a fork's remote): fetch them without touching its config —
`git -C "$LUCI_SRC" fetch --depth 1 https://github.com/openwrt/luci
openwrt-23.05:refs/remotes/upstream/openwrt-23.05` (same for 24.10, 25.12)
— then read with `git show upstream/<branch>:<path>`. Never write a
machine-specific path into a repository file, a report or a proposal — cite
as `openwrt/luci@<branch> modules/luci-base/ucode/http.uc:499` or
`uhttpd file.c:344`.
Rules for every agent using this skill:
1. Never assert luci-base/uhttpd behaviour from memory. Open the file, quote
it, cite `path:line`, and name the branch (`git -C "$LUCI_SRC"
branch --show-current`, or `git show <branch>:<path>`).
2. Mark each claim `verified` (read the source) or `inferred` (reasoned).
3. Numbers come from `references/dispatch-cost.md` or a fresh bench run;
never invent a round figure.
4. The theme floor is OpenWrt 23.05 + Chrome 111 / Safari 16.4 / Firefox 128;
the Navigation API is optional. Say which floor a finding depends on.
@@ -0,0 +1,80 @@
# Aurora's own contract (what a change must keep intact)
## Layout
`.dev/src/media/**` → `htdocs/luci-static/aurora/{main,login}.css` + `patches/*.css`;
`.dev/src/resource/**` → `htdocs/luci-static/resources/*.js` + `aurora/patches/*.js`
(Terser, `compress.directives:false` keeps the `'require …'` pragmas);
`.dev/public/aurora/**` copied. `htdocs/` is committed output — never hand-edit.
Templates: `ucode/template/themes/aurora/{header,footer,sysauth}.ut`. Version: `Makefile`.
## Head order (`header.ut`) — do not reorder without a reason
inline theme script (`setTheme`/`syncSwitchers` globals; `data-darkmode` before first paint)
→ `main.css` `[data-luci-shell]` → matching `patches/<prefix>.css` `[data-luci-patch]`
→ `patches/<prefix>.js` (defer) → font preload + inline `@font-face` → icons/manifest
→ `dispatched.css` `[data-luci-node-css]` → `{{ css }}` → UCI token overrides
(`:root{--light_*→--*}` / `[data-darkmode=true]{--dark_*}`)
→ `admin/translations/<lang>` (sync) → `cbi.js` (sync). Nothing in `<head>` may use `L`.
Footer: `L.require('menu-aurora').then(() => window.navigation && L.require('router-aurora'))`.
Both sync tags must stay synchronous: `_()` is defined by `cbi.js` and `menu-aurora.js`
calls it in `__init__` (12 call sites, first at `:59`); with `defer` the cached module
chain can run first → `ReferenceError`, and with only the catalog deferred the 12
strings render untranslated (see `.dev/decisions/translations-catalog-caching.md`).
## DOM hooks shared by templates, CSS and JS
`body[data-page]` (request segments joined by `-`), `body[data-nav-type]`
(`mega-menu|dropdown|sidebar`), `body[data-asset-version]`, `body[data-patches]`,
`body[data-bg]`, `body[data-nav-stamp]`; `#maincontent[tabindex=-1]`, `#tabmenu`, `#topmenu`, `#sidebar-list`,
`#sidebar-footer`, `#header-crumb`, `#mobile-nav-list`, `#mobile-nav-footer-action`,
`#indicators`, `#modemenu`, `#cmdk-trigger`, `.desktop-menu-container > .desktop-menu-sheet
> .desktop-menu-canvas`, `.desktop-menu-board` (template cloned per panel),
`.theme-switcher .theme-option[data-theme]`, `#sidebar-list[data-active-path]`
(dispatch segments 1–2), `[data-restored]` on replayed nav surfaces.
Pre-paint nav: `menu-aurora.js` `cacheNav()` writes `sessionStorage["aurora.nav"]`
= `[body[data-nav-stamp], {id: innerHTML}]` after render (stamp: nav type, lang,
user, version); header.ut replays it after the nav markup, inert, only while the
stamp matches and luci-base's `luci-session-store` still holds `menu` (sidebar:
recomputes active marks, then the global `auroraCrumb()`, which `menu-aurora.js`
also calls); `menu-aurora.js` lifts `inert` once `ui.menu.load()` settles; the
login page clears the cache. A collapsed sidebar is restored first in `<body>`: its column
transitions, so the class must land before any style resolution.
Renaming any of these is a
cross-file change (grep all three layers + tests).
## Router invariants (`router-aurora.js`, spec in `.dev/docs/router.md`)
`contract()` lists every luci-base surface it needs — a missing one means MPA, not
a broken page. `menu-aurora.js` must keep `syncRoute()` and `closeSurfaces()`. Page
JS patches expose `window.luciPatches[stem] = { mount, unmount }` and mount once at
eval. Same-URL navigations, hash changes, forms, downloads, expired sessions and a
poisoned `<head>` are full loads. Teardown order: `poll.queue.length = 0; poll.stop();
poll.start(); hideIndicator('poll-status'); clear view intervals/listeners; hideModal;
closeSurfaces; unmountPatches`. Departed regions go through `L.dom.content(n, null)`.
## CSS rules
`@apply` + CSS Nesting everywhere except `media/patches/*.css` (plain CSS; theme values
only through `:root` vars: `--surface`, `--hairline`, `--radius-base` (radii are
`calc(var(--radius-base) * n)`), `--app-shadow-*`; dark = `[data-darkmode="true"]`).
No `@layer` wrappers in partials; `main.css` import order is cascade order; tokens come
from `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` (change colours in that repo).
Budgets (`tests/build-performance.test.js`, mirrored in
`.claude/skills/aurora-performance/references/aurora-budgets.md`): main.css ≤ 193 KB,
login.css ≤ 12 KB, menu-aurora.js ≤ 23 KB, router-aurora.js ≤ 16 KB, admin cold set
≤ 268.5 KB, login set ≤ 55 KB, SVG data URLs ≤ 17 KB and unique.
## Patches contract (third-party compatibility)
File name = the page's `data-page` prefix; prefix matching on segment boundaries; both
CSS and JS discovered per render by `lsdir()` in `header.ut:30`; any package may drop a
file into `/www/luci-static/aurora/patches/`. Every page-scoped rule — patches and
the page selectors inside the component partials alike — has an entry in
`.dev/compat/pages/<data-page prefix>.md` (page, package, menu path, DOM, cause, fix,
verification); grep it for a file or selector before changing either
(`luci-dom-compat` skill). Geometric ones are guarded by `.dev/compat/fixtures/` via
`tests/compat.test.js`.
## Verification available
`cd .dev && pnpm test` (140 tests, ~1 s) · `pnpm build` · on-device:
`.claude/skills/aurora-performance/scripts/{bench.mjs,bench-router.mjs,bench-fullload.mjs,bench-dispatch.sh}`
(need `.dev/.env` + a reachable router; say so when they were not run).
## Commit rules
One logical change per commit; short conventional subject, reasoning in the body;
never `Claude-Session:`/session URLs; never push without an explicit instruction.
@@ -0,0 +1,71 @@
# What a request costs the router
Numbers measured on the maintainer's test router (OpenWrt SNAPSHOT, plain HTTP) with
`.claude/skills/aurora-performance/scripts/bench-dispatch.sh` and
`bench-fullload.mjs`, 2026-08; reproduced in `.dev/docs/router.md` §"Why it
pays". Treat the **ratios** as stable and the digits as device-specific —
re-measure before quoting them for another box.
## One request, on the router (no network in the number)
| request | ms | bytes | note |
|---|--:|--:|---|
| page HTML, a `view` node | 75.4 | 18,583 | dispatch #1 |
| `admin/translations/en` | 62.7 | **13** | dispatch #2 — the control: 13 bytes still pay a full dispatch |
| `admin/translations/zh-cn` | 60.3 | 229,503 | same dispatch, re-sent every navigation (`no-cache`, no validators) |
| `admin/menu` | 68.2 | 45,022 | once per **session** (`ui.menu.load` caches it) |
| static `main.css` | **0.8** | 191,899 | uhttpd `file.c`, identity bytes |
## Inside one dispatch
| phase every dispatch pays | ms |
|---|--:|
| fork + ucode VM start | 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 |
Reading: the cost is **process bootstrap, not work**. A persistent handler
(`uhttpd-mod-ucode` + `luci-base/ucode/uhttpd.uc`) pays the 37.2 ms import
once per uhttpd process instead of once per request — see `uhttpd.md` §3.
## A full MPA page load, end to end (browser clock)
| stage | ms | what it is |
|---|--:|---|
| dispatch #1 — page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut` → theme `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 were cache hits |
209 of 321 ms pass before anything page-specific happens. The same-document
router removes both dispatches (median 91 ms warm, data calls start at 2 ms
instead of 227 ms). On browsers without the Navigation API (Safari < 26.2,
Firefox < 147) every click still pays the full column.
## Wire cost without compression (uhttpd sends identity)
| asset | raw | gzip -9 |
|---|--:|--:|
| aurora `main.css` | 192,154 | 23,439 |
| aurora `menu-aurora.js` | 21,809 | 6,665 |
| aurora `router-aurora.js` | 14,844 | 5,552 |
| luci-base `luci.js` | 99,444 | 24,817 |
| luci-base `ui.js` | 167,889 | 37,856 |
| luci-base `form.js` | 184,155 | 37,454 |
| luci-base `network.js` | 125,286 | 27,263 |
A form page's JS + CSS (`luci.js`, `ui.js`, `form.js`, `uci.js` 28,922,
`rpc.js` 15,344, `validation.js` 44,031, plus the theme's `main.css`,
`menu-aurora.js`, `router-aurora.js`) is 768,592 B identity and 157,618 B
gzip -9 (fonts and images excluded). The lever is uhttpd's, not any
theme's.
## Budget rules that follow
- Count dispatches per navigation, not just bytes: one avoided dispatch
≈ 60–75 ms on this class of SoC, more than the entire theme CSS transfer.
- A new per-request `ubus`/`uci`/`fs` call in a template is a measurable
fraction of the 6–14 ms rows above; name the reason in the PR.
- Static files cost ~1 ms regardless of size on the server; their cost is
on the wire (identity) and in parse time on the phone.
@@ -0,0 +1,175 @@
# luci-base runtime contract
Verified against openwrt/luci master (31748dcc2a, 2026-06-17); paths are
relative to that checkout (`$LUCI_SRC`, see SKILL.md). Every fact cites a
file; re-verify on the branch you target before relying on it, and cite
`file:line` in any finding you derive from this page.
## 1. Boot order of an admin page
1. `dispatcher.uc` resolves the node and runs its action. A `view` node
includes `modules/luci-base/ucode/template/view.ut`:
```
{% include('header') %}
<div id="view">
<div class="spinning">{{ _('Loading view…') }}</div>
<script>L.require('ui').then(function(ui){ ui.instantiateView('{{ view }}'); });</script>
</div>
{% include('footer') %}
```
2. luci-base `ucode/template/header.ut` first includes the **theme** header
(`include(\`themes/${theme}/header\`)`, same scope — the theme renders the
whole `<head>` and opens `<body>`), then emits (header.ut:10-31):
```
<script src="{{ resource }}/luci.js?v={# PKG_VERSION #}-{{ pkgs_update_time }}"></script>
<script>L = new LuCI({ media, resource, scriptname, pathinfo, documentroot,
requestpath, dispatchpath, pollinterval, ubuspath, sessionid, token,
nodespec, apply_rollback, apply_holdoff, apply_timeout, apply_display,
rollback_token })</script>
```
Consequences: the theme `<head>` is parsed **before** `luci.js` exists;
`luci.js` itself sits in the body region after `#tabmenu`; the `?v=` of
`luci.js` (and therefore `L.env.resource_version`, used for every
`L.require` URL) changes whenever any package is installed, because
`pkgs_update_time` is the mtime of `/lib/apk/db/installed` or
`/usr/lib/opkg/status` (`ucode/runtime.uc:182`). On `openwrt-23.05` the
tag is a bare `luci.js` (preceded by `promis.min.js`) and
`pkgs_update_time` does not exist (`23.05 header.ut:10-11`,
`runtime.uc:179-182`), so `resource_version` is undefined and module
URLs are unversioned there.
`_()` is **not** in `luci.js`: it is defined by `cbi.js`
(`resources/cbi.js:154-157`), reads `window.TR` lazily at call time and
falls back to the source string. The catalog script and `cbi.js` must
therefore have executed before any module that calls `_()` runs — and
this theme's `menu-aurora.js` calls `_()` synchronously in `__init__`.
3. `view.ut`'s inline `L.require('ui')` runs synchronously in parser order
right after `luci.js`. A wrapper installed from the theme **footer** is too
late for that call, but early enough for `ui.instantiateView(view)` →
`L.require(view)` and for `L.require('menu-aurora')`.
4. luci-base `ucode/template/footer.ut` includes the theme footer and, when
an apply/revert/rollback is pending, an inline script bound to the
`luci-loaded` event.
## 2. Template scope the theme can use
Globals set by `runtime.uc` / `dispatcher.uc` and visible inside
`themes/<theme>/header.ut`: `media`, `resource`, `theme`, `dispatcher`
(`.lang`, `.build_url()`, `.lookup()`), `dispatched` (the node: `title`,
`css`, …), `ctx.request_path`, `ctx.path`, `config`, `version`,
`pkgs_update_time`, `http`, `ubus`, `_()`, `striptags()`, `entityencode()`.
Callers may add scope: `sysauth.ut` passes `blank_page`, and the theme adds
its own `prefetched_*` (aurora `sysauth.ut:17-21`).
`{{ expr }}` prints raw — the shipped templates call `entityencode(v, true)`
for attributes and `striptags()` for text explicitly. Do the same.
## 3. Module loader — `L.require`
- URL: `${env.base_url}/${name.replace(/\./g,'/')}.js` + `?v=${env.resource_version}`
(`luci.js:2474`); both env values are parsed from the `luci.js` script src
at boot (`luci.js:2257`).
- Loads the source, lexes the leading string literals `'require x [as y]'`
(works on a minified one-line head), then loads dependencies **serially per
level** — a dependency depth of *n* costs *n* round trips.
- Returns the **instance**: `__init__` has already run, so re-requiring a
view returns the rendered singleton. To render again use
`new instance.constructor()` (aurora `router-aurora.js:713`).
- Six names are seeded in memory (`luci.js:2237-2244`) and have **no file**
on disk: `baseclass`, `dom`, `poll`, `request`, `session`, `view`.
Prefetchers must skip them or they 404 on every page.
## 4. Menu, session store, polling
- `ui.menu.load()` (`ui.js:3733`): `session.getLocalData('menu')` first —
sessionStorage key `luci-session-store`, namespaced by session id
(`luci.js:1883-1932`) — else one `GET admin/menu` per **session**, not per
page. `ui.menu.flushCache()` (`ui.js:3748`) has no caller in luci-base or
the bootstrap theme.
- `L.Poll` is a singleton inside `luci.js` (`luci.js:1067`): `queue`
(`:1070`), `start()` (`:1158`, dispatches `poll-start` at `:1167`),
`stop()` (`:1183`, dispatches `poll-stop`), `active()`, `tick`. Core's
`setupDOM` shows the `poll-status` indicator on `poll-start`
(`luci.js:2743`) and flips it to "Paused" on `poll-stop` (`:2749`); nothing
in luci-base hides it.
- `dom.content(node, children)` (`luci.js:1442-1449`) deletes the
`data-idref` registry entries under `node` — the only call that does;
without it a departed subtree and its class instances stay referenced.
- Session expiry is signalled two ways (`luci.js:2715-2733`): a ubus error
`-32002` on anything but `session.access` triggers a `session.access`
probe, and an HTTP 403 carrying `X-LuCI-Login-Required: yes` is the
login-required path.
- `hasViewPermission()` is `!env.nodespec.readonly` (`luci.js:3263-3267`);
the form footer's Save/Apply state reads it (`luci.js:2195`).
- `env.pollinterval` defaults to 5 s; a hidden tab keeps polling unless a
theme stops it.
## 5. Dispatcher responses and caching
- `http.uc` `write_headers()` (`http.uc:497-512`): unless the action set
its own, every dispatcher response gets `Cache-Control: no-cache` and
`Expires: 0`, plus `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection`,
`X-Content-Type-Options: nosniff`. No `ETag`, no `Last-Modified` — nothing
to revalidate against, so browsers refetch every dispatcher response.
- `admin/translations/<lang>` (`ucode/controller/admin/index.uc:124-136`,
`action_translations`) streams `window.TR={"%08x":"…",…};`. It is a full
CGI dispatch per document and uncacheable by the rule above. Themes link it
as a synchronous `<head>` script (bootstrap `header.ut:44`, aurora
`header.ut:222`), so it is parser-blocking on every MPA navigation. Its
menu node is `type: function` with `auth: {}` (`menu.d/luci-base.json:62-69`),
which only skips the login redirect: `resolve_page()` still runs
`is_authenticated()` for the `admin` node's cookie methods
(`dispatcher.uc:650-651`, `:533-551`), so the dispatch pays the
`session.get` + `session.access` round-trip like any page.
- An action can override the default: `http.header()` stores keys
lower-cased (`http.uc:466-469`), so a `Cache-Control` set by the action
suppresses the `no-cache` default. No `.lmo` exists for `en`
(`luci.mk:10`, `src/lib/lmo.c:237`), so a file-mtime cache key is
undefined for the English catalog; `pkgs_update_time` is not.
- A query string never changes which node is dispatched: uhttpd splits it
off before path lookup and the dispatcher reads `PATH_INFO` only
(`dispatcher.uc:929`; `QUERY_STRING` is parsed separately, `http.uc:606`).
`?v=` on a dispatcher URL is therefore safe.
- Menu tree: `dispatcher.uc:19,366-374` — index cache
`/tmp/luci-indexcache.<hash>.json`, hashed over the `menu.d` file list; each
dispatch stats the `menu.d` files and parses the cached JSON.
- ACL: `dispatcher.uc` folds every `depends.acl` on the dispatch path into a
single `check_acl_depends()`; `env.nodespec.readonly` is what
`hasViewPermission()` reads, which is what disables Save/Apply.
- Alias / firstchild are resolved server-side **without a redirect**: the
URL stays, `requestpath`/`dispatchpath`/`nodespec`/`ctx.path` already point
at the leaf. `resolve_firstchild()` / `node_weight()` semantics are ported
line for line in `router-aurora.js:25-98`.
## 6. Branch differences (23.05 / 24.10 / 25.12 / master)
Source-checked 2026-08 (see `.dev/docs/router.md` §Compatibility):
| Surface | 23.05 | 24.10 | 25.12 | master |
|---|---|---|---|---|
| `node.css` in the page tree | — | — | — | yes (7c6d8ff, 2026-08) |
| `wildcardaction` + descend-into-satisfied-child rule | — | — | yes | yes (df90c60a7, 2026-01-17) |
| `pkgs_update_time` template global; `luci.js?v=PKG-mtime` (so `L.env.resource_version` and versioned `L.require` URLs) | — (`runtime.uc:179-182`, `header.ut:11`) | yes | yes | yes |
| `action_translations` body, `http.uc` `no-cache` default | same | same | same | same |
| everything else the router uses (`L.require` instance cache, `dom.content`, `Poll`, `ui.menu.load` session cache, interceptors, `-32002` probe, `view.ut` shell) | same | same | same | same |
The theme targets 23.05+ (ucode templates); anything relying on a newer
surface must degrade to the older behaviour, and the finding must name the
branch.
## 7. Where to look
- `$LUCI_SRC/modules/luci-base/ucode/{dispatcher,http,runtime}.uc`
- `$LUCI_SRC/modules/luci-base/ucode/template/{header,footer,view}.ut`
- `$LUCI_SRC/modules/luci-base/ucode/controller/admin/index.uc`
- `$LUCI_SRC/modules/luci-base/ucode/uhttpd.uc` (in-process handler entry)
- `$LUCI_SRC/modules/luci-base/htdocs/luci-static/resources/{luci,ui,rpc,uci,form,network}.js` (`Poll`, `dom`, `session`, `view` live inside `luci.js`)
- `$LUCI_SRC/luci.mk`, `$LUCI_SRC/themes/luci-theme-bootstrap/`
- Other branches: `git -C $LUCI_SRC show <branch>:<path>` (fetch
`openwrt-23.05`, `openwrt-24.10`, `openwrt-25.12` first if missing).
@@ -0,0 +1,35 @@
# Packaging facts (luci.mk, Makefile, feed)
## luci.mk rewrites at package build (`$LUCI_SRC/luci.mk:287-298`, `SubstituteVersion`)
Applied to every `*.ut` / `*.htm` under the installed template dir:
1. `{# PKG_VERSION #}` → the package version (aurora `header.ut:227` uses it
for `body[data-asset-version]`; luci-base `header.ut:10` for `luci.js?v=`).
2. Quoted `"{{ media }}…/x.css"` and `"{{ resource }}…/x.js"` links get
`?v=$(PKG_VERSION)` appended. The regex requires the literal `.css"`/`.js"`
at the end of the quoted string, so `{{ resource }}/{{ dispatched.css }}`
is **not** rewritten (same as bootstrap) while
`{{ media }}/patches/{{ patch }}.css` **is**.
Never judge versioning from the source `.ut`; inspect the installed file or
the live HTML.
## Theme Makefile
- `PKG_VERSION` / `PKG_RELEASE` (date) in `Makefile`. `LUCI_MINIFY_CSS:=`
blanks luci.mk's default `LUCI_MINIFY_CSS?=1` (`luci.mk:18`), so the
csstidy pass (`luci.mk:274-280`) is skipped for the already-minified
`htdocs/`. `LUCI_MINIFY_JS` is **not** blanked: with `CONFIG_LUCI_JSMIN`
the package build runs jsmin over `htdocs/**/*.js` (`luci.mk:261-268`),
so on-device JS sizes can differ from the repo's — measure on the device
when a JS budget is the question.
- `root/etc/uci-defaults/30_luci-theme-aurora` sets `luci.themes.Aurora` and
`luci.main.mediaurlbase` on fresh installs only (`PKG_UPGRADE != 1`).
- The OpenWrt buildbot has no Node: `htdocs/luci-static/` is the committed
output of `pnpm build`; the package copies it verbatim.
- Floors: OpenWrt 23.05 (ucode templates); Chrome 111 / Safari 16.4 /
Firefox 128; the Navigation API is an optional enhancement.
- CI: `.github/workflows/build-theme-package.yml` builds ipk + apk on tags,
manual dispatch, and branch pushes whose head commit message contains
`[build]`.
@@ -0,0 +1,29 @@
# SPA pitfalls that apply to this router
Distilled from luci-theme-footstrap's router work (public:
https://github.com/VizzleTF/luci-theme-footstrap/blob/main/docs/spa-router.md)
and re-checked against this theme's implementation; Aurora's handling is
specified in `.dev/docs/router.md`. Self-contained — no other note is needed.
| pitfall | why it bites | aurora |
|---|---|---|
| `L.require()` returns the rendered **instance** | re-requiring a view paints nothing; `new` on the instance's class without teardown = double render + double pollers, invisible to the eye | `new instance.constructor()` only after teardown, `router-aurora.js:713` |
| the class rendered by the full load must be marked "seen" | otherwise the first return to that page takes the cold path and renders twice | `initialRoute()` |
| generation check at **paint** time, and a superseded render must never resolve | an empty node blanks the live page, a throw lets luci-base paint an error box into the new page | `navigate()` gen gate |
| poll teardown is three steps: `queue.length = 0`, `stop()`, `start()` | `start()` resets the tick that arms the incoming view's first poll | `teardown()` |
| the `poll-status` indicator outlives polling | core shows it on `poll-start` and never hides it | `hideIndicator('poll-status')` |
| bare `setInterval` in apps survives a same-document swap | a full load killed it; the router must | `hookIntervals()` |
| `alias` / `firstchild` are the most-clicked links and resolve server-side **without a redirect** | `requestpath`/`dispatchpath`/`nodespec` already point at the leaf while the URL stays; port `resolve_firstchild()`/`node_weight()` literally | `resolve()` |
| `env.nodespec.readonly` is not decoration | `hasViewPermission()` reads it → Save/Apply enabled for read-only users if lost | `readonlyAlong()` |
| links with `?query` / `#hash` stay full loads | views read `location.search`; a hash-only change is not a navigation | `onNavigate()` |
| content lands outside `#view` | `<h2>` siblings from server templates, `ui.addNotification()` inserted into `#maincontent` — a full load cleared them, a swap must | `region()`, `commit()` |
| `body[data-page]` and `document.title` | restamp `data-page` from the resolved leaf (page-scoped CSS keys on it); cache the title host once, third-party views overwrite `document.title` | `setEnvironment()` |
| view-injected `<style>`/`<link>` never die, and must never be deleted | ACE's sheet is imported once per document; deleting it leaves a 2-million-pixel black box on the next visit | poison gate: foreign sheet ⇒ next navigation is a full load (`poisoned()`, `knownSheets`) |
| pragma scan must handle a minified one-line head | `/^'require …'$/m` matches nothing on the router's files — silently | `pragmaDeps()` |
| six in-memory names have no file | `baseclass`, `dom`, `poll`, `request`, `session`, `view` 404 on every page if prefetched | `warm()` skips names without a dot |
| prefetch the transitive closure, stop after the click, await in-flight fetches | otherwise the same module downloads twice at full latency | `warm()` |
| hidden tabs keep polling | 24/7 ubus on a low-power router for a page nobody sees | `hookVisibility()` |
| any require/instanceof failure ⇒ full load **with** `console.error` | a silent fallback makes every regression look like "slow page" | `navigate()` catch |
Not applicable here: History API / `pushState` / scroll-restoration pitfalls —
Aurora uses the Navigation API with `scroll: 'after-transition'`.
@@ -0,0 +1,21 @@
# False beliefs to refute on sight
Each line: the belief · the fact · where to check.
- "`cbi.js` bootstraps `luci.js`." · No — `cbi.js` is 26 KB of legacy CBI globals; `luci.js` is emitted by luci-base `header.ut:10` **after** the theme header. · `$LUCI_SRC/modules/luci-base/ucode/template/header.ut`
- "`?v=` makes an asset cache forever." · No — uhttpd sends only `ETag`/`Last-Modified`; browsers use heuristic freshness, then 304. · `uhttpd/file.c:344`
- "uhttpd can serve `.gz` sidecars / negotiates gzip." · No — `Accept-Encoding` is never read. · `uhttpd/file.c`
- "`/admin/menu` is fetched on every page." · No — once per session via `luci-session-store`. · `ui.js:3733`
- "The translation catalog is cached." · No — `Cache-Control: no-cache`, no validators; refetched every document. · `http.uc:499`, `controller/admin/index.uc:124`
- "`{{ expr }}` HTML-escapes." · No — call `entityencode()`/`striptags()`. · any shipped `.ut`
- "`L.require()` returns a class." · No — an instance whose `__init__` already ran. · `luci.js` require; `router-aurora.js:713`
- "`baseclass.js` / `dom.js` / `poll.js` / `request.js` / `session.js` / `view.js` exist on disk." · No — seeded in memory. · `luci.js`
- "`uhttpd-mod-ucode` handles requests in-process without forking." · No — VM + handler top level run at startup, then one fork per request. · `uhttpd/ucode.c:224-300,378`
- "23.05/24.10 have `node.css` / `wildcardaction`." · No — master only / 25.12+. · `.dev/docs/router.md` §Compatibility
- "The built CSS is OKLCH." · Only if browserslist allows it; today `vite.config.ts:33` (`last 4 versions`) lowers tokens to hex + `lab()` fallbacks. · `htdocs/luci-static/aurora/main.css` `:root{--bg:#f7fafc`
- "main.css can be reviewed from the PR diff." · It is one 192 KB line; review the source partials and re-measure the build. · `tests/build-performance.test.js`
- "A theme can set headers on dispatcher responses." · No — only the action (upstream) can; the theme controls the tags, not the headers.
- "A dispatcher action can answer 304 with ETag/Last-Modified." · No — uhttpd never exports `If-None-Match`/`If-Modified-Since` to CGI or the ucode handler. · `uhttpd proc.c:56-76`
- "`cbi.js` is legacy and can be deferred or dropped." · `_()` is defined only there (`cbi.js:154-157`); `menu-aurora.js` calls `_()` synchronously at init → `ReferenceError` without it. · `resources/cbi.js`, `.dev/src/resource/menu-aurora.js:59`
- "23.05 versions `luci.js` and module URLs too." · No — the `?v=PKG-pkgs_update_time` key starts with 24.10; on 23.05 `resource_version` is undefined. · `openwrt-23.05 header.ut:10-11`, `runtime.uc:179-182`
- "Service Worker / speculation rules will work on the router." · Not over `http://` (not a secure context).
@@ -0,0 +1,74 @@
# uhttpd — what the server actually does
Verified against `$UHTTPD_SRC` (f682cca, 2026-07-24). Stock
OpenWrt serves LuCI with this server; every "cache forever" or "gzip"
assumption must be checked here first.
## 1. Static files (`file.c`)
- Validators only: `ETag` (from inode/size/mtime, `uh_file_mktag`) and
`Last-Modified` (`file.c:344-345`). Conditional requests
(`If-None-Match`, `If-Modified-Since`, `If-Range`, …) are honoured.
- **No `Cache-Control`, no `Expires`.** A versioned URL (`?v=1.2.5`) is
therefore *not* "download once": the browser applies heuristic freshness
(≈10 % of the file's age since `Last-Modified`) and revalidates with a 304
after that. Fresh installs (young mtimes) revalidate almost every click.
- **No content negotiation.** `Accept-Encoding` is never read; there is no
gzip/brotli, and no `.gz` sidecar lookup. Raw bytes are wire bytes.
- The query string is separated from the path and otherwise ignored, which
is what makes `luci.mk`'s `?v=` rewrite safe.
## 2. Dynamic handlers
| handler | process model | used for |
|---|---|---|
| CGI (`cgi.c`) | `uh_create_process` (fork) + `execl` of the script per request (`cgi.c:63-65`, `:91`) | `/cgi-bin/luci` — the default LuCI dispatch |
| `uhttpd-mod-ubus` (`ubus.c`) | in-process, libubus; no fork, no exec | `/ubus/` JSON-RPC — every `L.rpc` call |
| `uhttpd-mod-ucode` (`ucode.c`) | VM created and handler **executed at startup** (`uh_ucode_state_init`, `ucode.c:224-300`: `uc_compile` + `uc_vm_execute` of the handler file), then `create_process` (fork, no exec) per request calling `handle_request(env)` (`ucode_handle_request`, `ucode.c:378-400`; `ucode_main`, `ucode.c:320`) | `ucode_prefix` mappings |
Request headers reach CGI (and the ucode handler, `ucode.c:348` region)
only through the whitelist `proc_header_env[]` (`proc.c:56-76`, applied at
`:166-173`): Accept*, Authorization, Connection, Cookie, Host, Origin,
Referer, User-Agent, X-Http-Method-Override, Auth-User/Pass, Content-Type,
Content-Length. `If-None-Match` and `If-Modified-Since` are **not** in it —
a dispatcher action can never see a validator and can never answer 304;
only the static-file path does (`file.c:66,426`). Response headers written
by the action are replayed verbatim (`proc.c:207-226`); uhttpd consumes
`Status` and only notes `Content-Length`/`Transfer-Encoding`.
So with `mod-ucode`, top-level code of the handler — including
`import dispatch from 'luci.dispatcher'` in luci-base's
`ucode/uhttpd.uc` — runs **once** in the parent; each request inherits the
warmed VM through fork (copy-on-write). The 37.2 ms `import luci.dispatcher`
line in `dispatch-cost.md` is exactly the cost this model amortises.
Configuration (not the OpenWrt default; `uhttpd-mod-ucode` must be
installed):
```
uci add_list uhttpd.main.ucode_prefix='/cgi-bin/luci=/usr/share/ucode/luci/uhttpd.uc'
```
Open questions to measure before recommending it widely: per-request fork
cost of the larger warmed process, memory (VmRSS) of the parent, behaviour
under concurrent requests (uhttpd is a single-threaded event loop; forked
children run in parallel), and index-cache reparse (`dispatcher.uc:366`) which
is still per request unless memoised in module scope.
## 3. The three server-side changes that matter for this theme
| gap | what nginx/Caddy do | smallest uhttpd change |
|---|---|---|
| no compression (main.css 192 KB → 23 KB gzip; luci-base JS ≈700 KB → ≈150 KB) | `gzip_static` / `precompressed`: serve `<path>.gz` when `Accept-Encoding` allows, `Vary: Accept-Encoding` | `file.c`: after path lookup, if `<phys>.gz` exists and gzip is accepted, serve it with `Content-Encoding: gzip` + `Vary` (≈60–80 lines). Sidecars built at package time; zero router CPU |
| no `Cache-Control` → 304 per click | `Cache-Control: public, max-age=31536000, immutable` on fingerprinted URLs (RFC 8246) | `file.c`: emit it when the query carries `v=` (≈10 lines). Every LuCI `?v=` URL is already a real fingerprint |
| CGI fork+exec per dispatch (37 ms import) | FastCGI / PHP-FPM persistent workers | already shipped: `mod-ucode` + `luci-base/ucode/uhttpd.uc` (§2); needs default config + measurement |
A router reached over plain `http://` on the LAN is not a secure context:
no Service Worker, no speculation rules, no PWA install prompt unless
`luci-ssl` is in use. Baseline designs assume plain HTTP.
## 4. Where to look
- `$UHTTPD_SRC/{file,cgi,ucode,lua,ubus,client,main}.c`
- OpenWrt package: `package/network/services/uhttpd` (Makefile, `files/uhttpd.config`, `files/uhttpd.init`) in an OpenWrt tree
- LuCI side: `$LUCI_SRC/modules/luci-base/ucode/uhttpd.uc`
@@ -0,0 +1,109 @@
export const meta = {
name: 'luci-vertical-review',
description: 'Second review of a change in this repository by the two Aurora-vertical reviewers (templates/CSS/packaging and client runtime); every finding is adversarially verified against luci-base/uhttpd sources before it is reported',
whenToUse: 'After an approved change is implemented (step 6 of /luci-change), or on any branch: /luci-vertical-review [base-ref] (default HEAD = uncommitted work). args may also be {base, scope}.',
phases: [
{ title: 'Review', detail: 'static reviewer + runtime reviewer, in parallel' },
{ title: 'Verify', detail: 'one verifier per finding, refute-first' },
],
}
const base = (typeof args === 'string' && args.trim()) || args?.base || 'HEAD'
const scope = args?.scope ?? ''
const MAX_PER_DIM = 10
const FINDING = {
type: 'object',
required: ['file', 'line', 'severity', 'category', 'claim', 'failure_scenario', 'evidence'],
properties: {
file: { type: 'string' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['blocker', 'major', 'minor'] },
category: { type: 'string' },
claim: { type: 'string' },
failure_scenario: { type: 'string' },
evidence: { type: 'string' },
},
}
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: { findings: { type: 'array', items: FINDING } },
}
const VERDICT = {
type: 'object',
required: ['verdict', 'reason'],
properties: {
verdict: { type: 'string', enum: ['confirmed', 'corrected', 'refuted'] },
reason: { type: 'string' },
corrected_claim: { type: 'string' },
evidence: { type: 'string' },
},
}
const DIMENSIONS = [
{
key: 'static',
agentType: 'aurora-static-reviewer',
paths: 'ucode/ .dev/src/media/ .dev/public/ Makefile root/ .dev/vite.config.ts .dev/package.json .github/',
},
{
key: 'runtime',
agentType: 'aurora-runtime-reviewer',
paths: '.dev/src/resource/ .dev/tests/ ucode/template/themes/aurora/*.ut (inline scripts only)',
},
]
const reviewPrompt = (d) => `Review the change against base "${base}" for the Aurora theme.
Diff: \`git diff ${base} --stat\` then \`git diff ${base} -- ${d.paths}\`. If ${base} is HEAD, also
include untracked files (\`git status --porcelain\`).
${scope ? `Approved scope file: ${scope} — apply the Spec axis: flag any changed path outside the listed prefixes and any behaviour beyond the approved plan.` : 'No approved scope given: Standards axis only.'}
Return at most ${MAX_PER_DIM} findings, most severe first, each with file, 1-based line
in the NEW file, failure scenario and file:line evidence. No style nits.`
const results = await pipeline(
DIMENSIONS,
(d) => agent(reviewPrompt(d), { label: `review:${d.key}`, phase: 'Review', agentType: d.agentType, schema: FINDINGS }),
(r, d) => {
// A reviewer that returned nothing did not review: fail this dimension
// (pipeline turns the throw into null) instead of reporting "no findings".
if (!r || !Array.isArray(r.findings)) throw new Error(`review:${d.key} returned no result`)
const found = r.findings.slice(0, MAX_PER_DIM)
if (r.findings.length > MAX_PER_DIM) log(`review:${d.key} returned ${r.findings.length} findings; verifying the first ${MAX_PER_DIM}`)
return parallel(
found.map((f) => () =>
agent(
`Refute this review finding about a change in the Aurora theme (base ${base}).
Finding: ${JSON.stringify(f)}
Check the diff yourself (\`git diff ${base} -- ${f.file}\`), then the luci-base/uhttpd
source the claim depends on, and whether the failure scenario is reachable from this
theme's code paths. Default to refuted without decisive evidence.`,
{ label: `verify:${f.file}:${f.line}`, phase: 'Verify', agentType: 'luci-claim-verifier', schema: VERDICT },
).then((v) => (v ? { ...f, dimension: d.key, verdict: v } : null)),
),
)
},
)
// Gate semantics: a dimension that did not complete fails the whole review.
const failed = DIMENSIONS.filter((d, i) => results[i] === null).map((d) => d.key)
if (failed.length) throw new Error(`review incomplete — reviewer(s) did not run: ${failed.join(', ')}; nothing can be reported as clean`)
const all = results.flat().filter(Boolean)
const rank = { blocker: 0, major: 1, minor: 2 }
const confirmed = all
.filter((f) => f.verdict.verdict !== 'refuted')
.map((f) => ({
...f,
claim: f.verdict.verdict === 'corrected' && f.verdict.corrected_claim ? f.verdict.corrected_claim : f.claim,
}))
.sort((a, b) => rank[a.severity] - rank[b.severity])
const refuted = all.filter((f) => f.verdict.verdict === 'refuted')
log(`${confirmed.length} findings confirmed, ${refuted.length} refuted`)
return {
base,
scope: scope || null,
confirmed,
refuted: refuted.map((f) => ({ file: f.file, line: f.line, claim: f.claim, reason: f.verdict.reason })),
}
@@ -0,0 +1,196 @@
export const meta = {
name: 'upstream-rfc',
description: 'Investigate one luci-base/uhttpd problem against the local sources and draft an upstream RFC with diagrams into .dev/decisions/<slug>.md (never touches theme code)',
whenToUse: '/upstream-rfc "<topic>" — for a problem whose fix lives in openwrt/luci, uhttpd, rpcd or the feed. Aborts when both analysts place the fix inside this theme (use /luci-change then). An existing proposal on the same slug is revised, not overwritten.',
phases: [
{ title: 'Investigate', detail: 'server-side and client-side analysts, every claim with file:line' },
{ title: 'Refute', detail: 'one verifier per claim, refute-first' },
{ title: 'Draft', detail: 'RFC with mermaid diagrams from confirmed claims' },
{ title: 'Critique', detail: 'gap check, one revision' },
],
}
const topic = typeof args === 'string' ? args : args?.topic
if (!topic) throw new Error('usage: /upstream-rfc <topic> (or args: {topic, slug, hints})')
const slug = String(args?.slug ?? topic)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60)
const out = `.dev/decisions/${slug}.md`
const hints = args?.hints ? `\nHints from the requester:\n${args.hints}\n` : ''
const MAX_CLAIMS = 12
const EVIDENCE = {
type: 'object',
required: ['file', 'lines', 'quote'],
properties: {
file: { type: 'string' },
lines: { type: 'string' },
quote: { type: 'string' },
},
}
const CLAIMS = {
type: 'object',
required: ['claims', 'unknowns', 'fix_location'],
properties: {
fix_location: { type: 'string', enum: ['upstream', 'theme', 'both'] },
claims: {
type: 'array',
items: {
type: 'object',
required: ['claim', 'evidence', 'confidence'],
properties: {
claim: { type: 'string' },
evidence: { type: 'array', items: EVIDENCE },
confidence: { type: 'string', enum: ['verified', 'inferred'] },
measurement: { type: 'string' },
},
},
},
unknowns: { type: 'array', items: { type: 'string' } },
},
}
const VERDICT = {
type: 'object',
required: ['verdict', 'reason', 'evidence'],
properties: {
verdict: { type: 'string', enum: ['confirmed', 'corrected', 'refuted'] },
reason: { type: 'string' },
corrected_claim: { type: 'string' },
evidence: { type: 'array', items: EVIDENCE },
},
}
const DRAFT = {
type: 'object',
required: ['path', 'unfilled'],
properties: {
path: { type: 'string' },
unfilled: { type: 'array', items: { type: 'string' } },
},
}
const GAPS = {
type: 'object',
required: ['gaps'],
properties: {
gaps: {
type: 'array',
items: {
type: 'object',
required: ['section', 'issue'],
properties: { section: { type: 'string' }, issue: { type: 'string' } },
},
},
},
}
const investigate = (lens, focus) =>
agent(
`Problem under investigation: "${topic}".${hints}
Lens: ${lens}. ${focus}
Trace the exact request path through the sources and return every fact the
proposal will rest on (max 8 claims, most load-bearing first). Each claim
needs verbatim evidence with file:line and a verified/inferred mark; attach
a measurement only from references/dispatch-cost.md or a bench you ran.
List unknowns with the command that would settle each. Set fix_location to
where the actual fix has to land: "upstream" (openwrt/luci, uhttpd, rpcd,
feed), "theme" (this repository alone can fix it), or "both".`,
{ label: `analyst:${lens}`, phase: 'Investigate', agentType: 'luci-runtime-analyst', schema: CLAIMS },
)
phase('Investigate')
const analysesRaw = await parallel([
() => investigate('server', 'Cover dispatcher.uc / http.uc / controller actions, uhttpd handlers (cgi.c, ucode.c, file.c) and response headers.'),
() => investigate('client', 'Cover what the browser does with it: luci.js loader, ui.js, the theme templates (ucode/template/themes/aurora) and caching behaviour.'),
])
const analyses = analysesRaw.filter(Boolean)
const analystsFailed = analysesRaw.length - analyses.length
if (analystsFailed) log(`${analystsFailed} analyst(s) returned nothing — the proposal rests on the remaining lens only`)
if (analyses.length && analyses.every((a) => a.fix_location === 'theme'))
throw new Error(`both analysts place the fix inside this theme — no upstream proposal to write; use /luci-change "${topic}"`)
let claims = analyses.flatMap((a) => a.claims ?? [])
const unknowns = analyses.flatMap((a) => a.unknowns ?? [])
if (claims.length > MAX_CLAIMS) {
log(`${claims.length} claims collected; verifying the first ${MAX_CLAIMS} only (rest dropped)`)
claims = claims.slice(0, MAX_CLAIMS)
}
if (!claims.length) throw new Error('analysts returned no claims — nothing to verify')
log(`${claims.length} claims to verify, ${unknowns.length} unknowns`)
phase('Refute')
const verifiedRaw = await parallel(
claims.map((c, i) => () =>
agent(
`Refute this claim about "${topic}". Default to refuted without decisive evidence.
Claim ${i + 1}: ${c.claim}
Stated evidence: ${JSON.stringify(c.evidence)}
Confidence stated: ${c.confidence}${c.measurement ? `\nMeasurement quoted: ${c.measurement}` : ''}`,
{ label: `verify:${i + 1}`, phase: 'Refute', agentType: 'luci-claim-verifier', schema: VERDICT },
).then((v) => (v ? { ...c, verdict: v } : null)),
),
)
// A claim whose verifier returned nothing is unverified, not refuted: it is
// kept out of the document and reported separately.
const unverified = claims.filter((c, i) => verifiedRaw[i] === null).map((c) => c.claim)
const verified = verifiedRaw.filter(Boolean)
const kept = verified
.filter((c) => c.verdict.verdict !== 'refuted')
.map((c) => ({
claim: c.verdict.verdict === 'corrected' && c.verdict.corrected_claim ? c.verdict.corrected_claim : c.claim,
verdict: c.verdict.verdict,
evidence: [...(c.evidence ?? []), ...(c.verdict.evidence ?? [])],
measurement: c.measurement ?? '',
}))
const refuted = verified.filter((c) => c.verdict.verdict === 'refuted')
log(`${kept.length} claims kept (${kept.filter((c) => c.verdict === 'corrected').length} corrected), ${refuted.length} refuted`)
if (!kept.length) throw new Error('every claim was refuted — no proposal can be written')
phase('Draft')
const draft = await agent(
`Write the upstream proposal for "${topic}" to ${out}, following
.dev/decisions/TEMPLATE.md. If ${out} already exists, revise it in place:
keep sections whose claims are still in the list below, replace the rest,
and note the revision date in the Status line. Use only these verified claims:
${JSON.stringify(kept, null, 2)}
Unknowns to list honestly: ${JSON.stringify(unknowns)}
Refuted (do not mention): ${JSON.stringify(refuted.map((c) => c.claim))}${hints}`,
{ label: 'rfc:draft', phase: 'Draft', agentType: 'upstream-rfc-writer', schema: DRAFT },
)
if (!draft?.path) throw new Error('writer returned no path')
phase('Critique')
const critique = await agent(
`Read ${draft.path} and treat every factual sentence in it as a claim to
refute, section by section. Report as a gap: a fact you can refute or that
carries no file:line; a number absent from references/dispatch-cost.md and
from the claims supplied to the writer; a diagram that lists boxes instead
of showing the request mechanism; a missing 23.05/24.10/25.12/master
statement in the change sketch; an "Interim in Aurora" step a theme cannot
actually perform. Ignore style. Return only real gaps.`,
{ label: 'rfc:critique', phase: 'Critique', agentType: 'luci-claim-verifier', schema: GAPS },
)
let revised = false
if (critique?.gaps?.length) {
log(`${critique.gaps.length} gaps found; one revision pass`)
await agent(
`Revise ${draft.path} in place to close these gaps, without adding
unverified claims (mark anything unknowable as "not measured yet"):
${JSON.stringify(critique.gaps, null, 2)}`,
{ label: 'rfc:revise', phase: 'Critique', agentType: 'upstream-rfc-writer', schema: DRAFT },
)
revised = true
}
return {
topic,
path: draft.path,
claims: { kept: kept.length, corrected: kept.filter((c) => c.verdict === 'corrected').length, refuted: refuted.length, unverified: unverified.length },
unverified,
analystsFailed,
unknowns,
unfilled: draft.unfilled ?? [],
gaps: critique?.gaps ?? [],
revised,
}
+18
View File
@@ -0,0 +1,18 @@
# Optional — run `pnpm setup:router` once and it writes/updates every value below.
# OpenWrt router address — bare IP/hostname; the web proxy target and the
# .ut-sync ssh target both derive from it (ssh key selection etc. belongs
# in ~/.ssh/config)
# VITE_OPENWRT_HOST=192.168.1.1
# Dev server bind / port
VITE_DEV_PORT=5173
# 0.0.0.0 = LAN; 127.0.0.1 = local only
VITE_DEV_HOST=0.0.0.0
# Optional — local source checkouts the .claude agents read to verify claims
# against luci-base / uhttpd (see .claude/skills/luci-knowledge/SKILL.md).
# Unset = the agents shallow-clone into a temp dir on demand.
# LUCI_SRC=/path/to/openwrt-luci # git clone https://github.com/openwrt/luci
# UHTTPD_SRC=/path/to/uhttpd # git clone https://git.openwrt.org/project/uhttpd.git
+4
View File
@@ -0,0 +1,4 @@
# Fail `pnpm install` early with a clear message on unsupported Node versions
# (the range in package.json `engines` mirrors Vite 7's requirement) instead
# of letting the install die somewhere deep inside a dependency.
engine-strict=true
+4
View File
@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}
+91
View File
@@ -0,0 +1,91 @@
# DOM compat ledger
Every page-scoped rule in the theme exists because some page's DOM did not
sit right under Aurora. This directory records which page, which package,
where in the menu, which element, what went wrong, why the rule is written
the way it is, and how it was verified — so the next edit to `_table.css`
can see what it is about to undo.
"compat" means page DOM ↔ theme. Stock LuCI pages (Software, Mount Points,
Wireless) count as much as third-party apps.
```
pages/<data-page prefix>.md one file per page prefix; entries appended in time order
fixtures/<data-page>--<slug>.html trimmed DOM + checks, run by tests/compat.test.js
```
The page prefix follows the same rule as `media/patches/`: the file
`admin-system-diskman.md` also covers `admin-system-diskman-partition-…`.
Rules that are not tied to one page but were added for one (`.td.cbi-value-field`
for Dockerman) go under the page that motivated them, and the `dom:` line
names the selector so a grep for the selector finds the entry.
## Entry format
```
## <slug> — <one line: what the rule does>
- page: <data-page> · <Menu → Path as shown in the UI>
- package: <luci-app-x (author)> | stock <luci-mod-x>
- dom: <selector / element, plus the inline style or attribute the fix depends on>
- symptom: <what the user sees>
- cause: <why, one or two lines> | unknown (<what the record lacks>)
- fix: <file> `<selector>` <utilities> · <commit> · <version>
- verified: <device/fixture, method, numbers before → after> | not measured
- fixture: fixtures/<name>.html | none (<why>)
- issue: #n | — · mirror: shadcn <commit> | pending | n/a · status: active | removed <commit>
```
Facts only. A cause that cannot be recovered from history is written as
`unknown`, never guessed. `status: removed` entries stay: a removed rule is
the one most likely to be re-added blindly.
Before editing a component partial or a patch, grep here for the file and
every selector you touch:
```
grep -rn "_table.css" .dev/compat/pages/
grep -rln "status_leases" .dev/compat/pages/
```
## Fixtures
A fixture is a trimmed copy of the real page: `<body data-page="…">`,
`#maincontent > #view`, only the offending block (three table rows are
enough), any `<style>` the page injects itself, and the theme CSS by relative
path. Keep the viewport meta: without it a mobile viewport lays out at 980px
and the `max-md:` card rules never apply. When the wrap depends on the
content width, put the width measured on the device as `max-width` on the
block's own container (not on `.cbi-section`, whose padding eats into it).
The runner injects the theme font itself.
```html
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../../htdocs/luci-static/aurora/main.css">
<!-- add ../../../htdocs/luci-static/aurora/patches/<prefix>.css when the page has one -->
<script type="application/json" id="compat">
{ "ledger": "#65", "viewports": [1280, 390],
"checks": [ { "nowrap": "table[id^=partition-table] td" },
{ "fits": "table[id^=partition-table]" } ] }
</script>
```
Checks:
| check | passes when |
|---|---|
| `nowrap: <sel>` | every matched element's text sits on one line, and no text starts below an inline checkbox/indicator inside it |
| `fits: <sel>` | `scrollWidth ≤ clientWidth + 1` and the element's right edge is inside its parent's |
| `js: "<expr>"` | the expression is truthy in the page — escape hatch; the ledger entry says why |
A check may carry its own `"viewports": [...]` (a mobile card layout wraps on
purpose). Capture the DOM from a device running this theme (`.dev/mocks/`
snapshot, DevTools `copy(el.outerHTML)`, or the probe script in
`.claude/skills/luci-dom-compat/scripts/`), strip it down, and confirm the
check fails against the pre-fix CSS before committing it.
## Running
`cd .dev && pnpm build && pnpm test` — `tests/compat.test.js` opens each
fixture in headless Chrome over CDP (`CHROME_BIN`, then the macOS app path,
then `google-chrome`/`chromium` on PATH). Without Chrome the test skips with
a hint; with `CI` set it fails.
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>compat: Dockerman overview info table (#96)</title>
<link rel="stylesheet" href="../../../htdocs/luci-static/aurora/main.css">
<script type="application/json" id="compat">
{
"ledger": "#96",
"viewports": [1440, 1024, 390],
"checks": [
{ "nowrap": "td.cbi-value-field[data-name=entry]" },
{ "fits": "table.table" }
]
}
</script>
</head>
<body data-page="admin-services-dockerman-overview">
<div id="maincontent">
<div id="view">
<div class="cbi-map">
<div class="cbi-section cbi-tblsection">
<table class="table cbi-section-table"><thead class="thead cbi-section-thead"></thead><tbody class="tbody cbi-section-tbody"><tr id="cbi-json-ib12" class="tr cbi-section-table-row" data-sid="ib12" data-section-id="ib12"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib12-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib12.entry"><output for="cbid.json.ib12.entry">Plugins</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib12-value" data-index="1" data-depends="[]" data-field="cbid.json.ib12.value"><output for="cbid.json.ib12.value">{"Volume":["local"],"Network":["bridge","host","ipvlan","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","local","splunk","syslog"]}</output></div></td></tr><tr id="cbi-json-ib13" class="tr cbi-section-table-row" data-sid="ib13" data-section-id="ib13"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib13-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib13.entry"><output for="cbid.json.ib13.entry">MemoryLimit</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib13-value" data-index="1" data-depends="[]" data-field="cbid.json.ib13.value"><output for="cbid.json.ib13.value">true</output></div></td></tr><tr id="cbi-json-ib24" class="tr cbi-section-table-row" data-sid="ib24" data-section-id="ib24"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib24-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib24.entry"><output for="cbid.json.ib24.entry">CgroupDriver</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib24-value" data-index="1" data-depends="[]" data-field="cbid.json.ib24.value"><output for="cbid.json.ib24.value">cgroupfs</output></div></td></tr><tr id="cbi-json-ib39" class="tr cbi-section-table-row" data-sid="ib39" data-section-id="ib39"><td class="td cbi-value-field" data-title="Entry" data-description="" data-name="entry" data-widget="CBI.DummyValue"><div id="cbi-json-ib39-entry" data-index="0" data-depends="[]" data-field="cbid.json.ib39.entry"><output for="cbid.json.ib39.entry">Runtimes</output></div></td><td class="td cbi-value-field" data-title="Value" data-description="" data-name="value" data-widget="CBI.DummyValue"><div id="cbi-json-ib39-value" data-index="1" data-depends="[]" data-field="cbid.json.ib39.value"><output for="cbid.json.ib39.value">{"io.containerd.runc.v2":{"path":"runc","status":{"org.opencontainers.runtime-spec.features":"{\"ociVersionMin\":\"1.0.0\",\"ociVersionMax\":\"1.2.1\",\"hooks\":[\"prestart\",\"createRuntime\",\"createContainer\",\"startContainer\",\"poststart\",\"poststop\"],\"mountOptions\":[\"async\",\"atime\",\"bind\",\"defaults\",\"dev\",\"diratime\",\"dirsync\",\"exec\",\"iversion\",\"lazytime\",\"loud\",\"mand\",\"noatime\",\"nodev\",\"nodiratime\",\"noexec\",\"noiversion\",\"nolazytime\",\"nomand\",\"norelatime\",\"nostrictatime\",\"nosuid\",\"nosymfollow\",\"private\",\"ratime\",\"rbind\",\"rdev\",\"rdiratime\",\"relatime\",\"remount\",\"rexec\",\"rnoatime\",\"rnodev\",\"rnodiratime\",\"rnoexec\",\"rnorelatime\",\"rnostrictatime\",\"rnosuid\",\"rnosymfollow\",\"ro\",\"rprivate\",\"rrelatime\",\"rro\",\"rrw\",\"rshared\",\"rslave\",\"rstrictatime\",\"rsuid\",\"rsymfollow\",\"runbindable\",\"rw\",\"shared\",\"silent\",\"slave\",\"strictatime\",\"suid\",\"symfollow\",\"sync\",\"tmpcopyup\",\"unbindable\"],\"linux\":{\"namespaces\":[\"cgroup\",\"ipc\",\"mount\",\"network\",\"pid\",\"time\",\"user\",\"uts\"],\"capabilities\":[\"CAP_CHOWN\",\"CAP_DAC_OVERRIDE\",\"CAP_DAC_READ_SEARCH\",\"CAP_FOWNER\",\"CAP_FSETID\",\"CAP_KILL\",\"CAP_SETGID\",\"CAP_SETUID\",\"CAP_SETPCAP\",\"CAP_LINUX_IMMUTABLE\",\"CAP_NET_BIND_SERVICE\",\"CAP_NET_BROADCAST\",\"CAP_NET_ADMIN\",\"CAP_NET_RAW\",\"CAP_IPC_LOCK\",\"CAP_IPC_OWNER\",\"CAP_SYS_MODULE\",\"CAP_SYS_RAWIO\",\"CAP_SYS_CHROOT\",\"CAP_SYS_PTRACE\",\"CAP_SYS_PACCT\",\"CAP_SYS_ADMIN\",\"CAP_SYS_BOOT\",\"CAP_SYS_NICE\",\"CAP_SYS_RESOURCE\",\"CAP_SYS_TIME\",\"CAP_SYS_TTY_CONFIG\",\"CAP_MKNOD\",\"CAP_LEASE\",\"CAP_AUDIT_WRITE\",\"CAP_AUDIT_CONTROL\",\"CAP_SETFCAP\",\"CAP_MAC_OVERRIDE\",\"CAP_MAC_ADMIN\",\"CAP_SYSLOG\",\"CAP_WAKE_ALARM\",\"CAP_BLOCK_SUSPEND\",\"CAP_AUDIT_READ\",\"CAP_PERFMON\",\"CAP_BPF\",\"CAP_CHECKPOINT_RESTORE\"],\"cgroup\":{\"v1\":true,\"v2\":true,\"systemd\":true,\"systemdUser\":true,\"rdma\":true},\"seccomp\":{\"enabled\":true,\"actions\":[\"SCMP_ACT_ALLOW\",\"SCMP_ACT_ERRNO\",\"SCMP_ACT_KILL\",\"SCMP_ACT_KILL_PROCESS\",\"SLine truncated
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>compat: mini-diskmanager partition table (#65)</title>
<link rel="stylesheet" href="../../../htdocs/luci-static/aurora/main.css">
<!-- The rule the plugin injects into <head> for the colour swatch in front of
the filesystem name; the wrap in #65 is "swatch on one line, name below". -->
<style>
.partition-color-indicator {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid;
border-radius: 3px;
margin-right: 8px;
vertical-align: middle;
opacity: 0.85;
}
/* The 1153px below was measured without a scrollbar; the theme's stable
gutter would take 11px of it (see the ledger's scrollbar-gutter entry). */
html { scrollbar-gutter: auto; }
</style>
<script type="application/json" id="compat">
{
"ledger": "#65",
"viewports": [1280, 390],
"checks": [
{ "nowrap": "#partition-table-mmcblk0 tr.tr:not(.table-titles) td" },
{ "fits": "#partition-table-mmcblk0" },
{ "js": "getComputedStyle(document.querySelector('#partition-table-mmcblk0 td')).fontSize === '12px'" }
]
}
</script>
</head>
<body data-page="admin-services-mini-diskmanager-minidiskmanager">
<div id="maincontent">
<div id="view">
<div class="cbi-section">
<!-- 1153px is the table width measured on the device at a 1280px viewport;
max-width keeps the mobile viewport unconstrained. -->
<div class="partition-table-container" style="max-width: 1153px">
<table class="table" id="partition-table-mmcblk0" style="border:1px solid var(--border-color-medium)!important; table-layout:fixed; border-collapse:collapse; width:100%; font-size:12px;"><tr class="tr table-titles"><th class="th left" style="width: 180px; min-width: 180px; text-align: left; cursor: default;" data-sortable-row="true"><span style="display: inline-block;"><input type="checkbox" id="wipeall-checkbox" title="Select all partitions for wiping" style="margin-right: 8px; vertical-align: middle; cursor: pointer;" disabled=""></span><span style="pointer-events: none; user-select: none;">Partition</span></th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Type / Filesystem</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Mount Point</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Label</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Size</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Used</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Unused</th><th class="th left" style="width:12.50%; min-width: 90px; text-align: left; cursor: default; pointer-events: none;" data-sortable-row="true">Flags</th></tr><tr class="tr cbi-rowstyle-2"><td class="td left" data-title="Partition"><label data-tooltip="Click to select" style="cursor: pointer; display: inline-flex; align-items: center; gap:6px;"><input type="checkbox" class="partition-select-checkbox" name="partition_select" value="mmcblk0p2" data-partition="mmcblk0p2" aria-label="/dev/mmcblk0p2"><span>/dev/mmcblk0p2</span></label></td><td class="td left" data-title="Type / Filesystem"><span class="partition-color-indicator 0bootconfig"></span> 0BOOTCONFIG</td><td class="td left" data-title="Mount Point">-</td><td class="td left" data-title="Label">-</td><td class="td left" data-title="Size">256.00 KiB</td><td class="td left" data-title="Used">-</td><td class="td left" data-title="Unused">-</td><td class="td left" data-title="Flags">-</td></tr><tr class="tr cbi-rowstyle-1"><td class="td left" data-title="Partition"><label data-tooltip="Click to select" style="cursor: pointer; display: inline-flex; align-items: center; gap:6px;"><input type="checkbox" class="partition-select-checkbox" name="partition_select" value="mmcblk0p18" data-partition="mmcblk0p18" aria-label="/dev/mmcblk0p18"><span>/dev/mmcblk0p18</span></label></td><td class="td left" data-title="Type / Filesystem"><span class="partition-color-indicator squashfs"></span> SQUASHFS</td><td class="td left" data-title="Mount Point">/rom</td><td class="td left" data-title="Label">-</td><td class="td left" data-title="Size">2.00 GiB</td><td class="td left" data-title="Used">110.0 MiB</td><td class="td left" data-title="Unused">0</td><td class="td left" data-title="Flags">-</td></tr><tr class="tr cbi-rowstyle-2"><td class="td left" data-title="Partition"><label data-tooltip="Click to select" style="cursor: pointer; display: inline-flex; align-items: center; gap:6px;"><input type="checkbox" class="partition-select-checkbox" name="partition_select" value="mmcblk0p6" data-partition="mmcblk0p6" aria-label="/dev/mmcblk0p6"><span>/dev/mmcblk0p6</span></label></td><td class="td left" data-title="Type / Filesystem"><span class="partition-color-indicator ext4"></span> Ext4</td><td class="td left" data-title="Mount Point">/mnt/mmcblk0p6</td><td class="td left" data-title="Label">-</td><td class="td left" data-title="Size">113.26 GiB</td><td class="td left" data-title="Used">2.0 MiB</td><td class="td left" data-title="Unused">105.2 GiB</td><td class="td left" data-title="Flags">storage</td></tr></table>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,25 @@
# admin-dashboard
Patch file: `.dev/src/media/patches/admin-dashboard.css` (scope `[data-page="admin-dashboard"] .Dashboard > .section-content`, line 8-10). Route: main.css `@layer luci-mod-dashboard` (7f25128) → `_patches.css` (aedf3dc, 7d9f6a1) → own on-demand file with `@reference`/`@apply` (1c924cf, PR #82) → native CSS (e14cfcd). Page package: stock luci-mod-dashboard (menu.d "admin/dashboard" "Dashboard"); it ships its own `view/dashboard/css/custom.css`.
## dashboard-cards-and-dark — card decoration on `.dashboard-bg`, dark-mode svg invert, title rule, wifi device cell
- page: admin-dashboard · Dashboard
- package: stock luci-mod-dashboard
- dom: `.dashboard-bg` (card), `.dashboard-bg.tr` (row variant, no radius), `img[src*=".svg"]`, `.title h3`, `.router-status-wifi .wifi-info .devices-info .tr .td:nth-child(3)`
- symptom: not recorded beyond the subjects ("dark mode display issue in luci-mod-dashboard"; 56db83c: "Adapt for missing data-page="admin-dashboard" on default login redirect to dashboard", "Remove border-radius on .dashboard-bg for mobile view")
- cause: unknown for the card/wifi/h3 rules (7f25128 has subject only). For the scope: 56db83c body says the `[data-page="admin-dashboard"]` attribute was missing when the login redirect landed on the dashboard, so the scope was dropped; 1c924cf restored it when the patch became on-demand (PR #82: "Backfilled missing data-page scoping in the original patches (… dashboard)"), and header.ut now computes the page name from `ctx.path` when `request_path` is empty (DEVELOPMENT.md §On-Demand Third-Party Patches, item 1)
- fix: patches/admin-dashboard.css — `.dashboard-bg { background: var(--surface-overlay) }` + `&.tr { border-radius: 0 }` + `&:not(.tr) { border: 1px solid var(--hairline); box-shadow: var(--app-shadow-lg); &:hover {…} }` (line 11-27); `img[src*=".svg"] { [data-darkmode="true"] & { filter: invert(100%) } }` (line 38-42); `.title h3 { color: var(--text); border: 0; padding-bottom: 1rem; @media (width < 48rem) { margin-inline: 0; padding-bottom: 0.5rem } }` (line 93-102); `.router-status-wifi .wifi-info .devices-info .tr .td { &:nth-child(3) { position: relative; top: 0; width: 100%; padding-inline: 0 } @media (width < 48rem) { flex: 1 1 50% } }` (line 104-117) · 7f25128 2025-09-28 · 0.5.1_beta (first tag v0.5.5_beta); 56db83c 2025-10-12 · 0.5.9_beta (first tag v0.6.0_beta) dropped the scope and added `.tr { rounded-none }`; the original `.router-status-lan .devices-info tr th { max-md:flex-[1_1_50%] }` from 7f25128 was removed by c3fae55 2025-09-29 "fix: mobile display issue in luci-mod-dashboard th" (subject only)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## dashboard-dark-text-and-inline-tables — force theme text colour in dark mode, compact assoclist cells, undecorate inline info tables
- page: admin-dashboard · Dashboard (issue #66 names the System, DHCP Devices and Wireless panels)
- package: stock luci-mod-dashboard
- dom: `.dashboard-bg span/td/th` (dark mode), `.dashboard-bg .td/.th` (assoclist cells carry `.td/.th` classes), `.dashboard-bg .table:not(.assoclist)` with its `tr` and `td` (the DNS-style inline info tables)
- symptom: issue #66: "Using luci-mod-dashboard with aurora in dark mode results in multiple UI readability and layout issues … Some elements are rendered with very low contrast (almost black on dark background), and spacing/padding in certain tables and panels (System, DHCP Devices, Wireless) appears inconsistent." (OpenWrt 25.12.4, two screenshots)
- cause: the patch's own comment: "Dark mode: override custom.css hardcoded light-mode colors on all text nodes" — luci-mod-dashboard's `custom.css` sets text colours the theme's dark palette does not reach; the spacing/inline-table rules have no recorded cause (944e7c5 has subject only)
- fix: patches/admin-dashboard.css — `& span, & td, & th { [data-darkmode="true"] & { color: var(--text) !important } }` (line 30-36); `& .td { padding-block: 0.5rem; @media (width < 48rem) { padding-block: 0.25rem } }` and `& .th { padding-block: 0.5rem }` (line 45-54); `& .table:not(.assoclist) { margin-bottom: 0; border-radius: 0; border: 0; background: transparent; box-shadow: none; & tr { &:hover { background: transparent } @media (width < 48rem) { display: table-row; border: 0; padding: 0 } } & td { border: 0; padding: 0.125rem 0; @media (width < 48rem) { display: table-cell } &:first-child { padding-right: 0.5rem; white-space: nowrap; opacity: 0.7 } } }` (line 57-91) · 944e7c5 2026-05-31 (in `_patches.css`) · 0.11.10 (first tag v0.12.0)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: #66 (still open) · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-modem-modemdata-modempreview
## modemdata-badge-width — let signal badges inside info cards size to content
- page: admin-modem-modemdata-modempreview · Modem → Modem Data → (preview) (menu path inferred from the page name; not in the local LuCI checkouts)
- package: luci-app-modemdata (third-party; named in the patch header and PR #80)
- dom: `.ifacebox-body .ifacebadge` on the "General Information" cards (PR #80 text)
- symptom: PR #80 body: "Aurora's default ifacebox styles spread modemdata rows apart, wrap signal badges vertically, and leave cards at uneven heights."
- cause: unknown beyond the PR wording — the merged diff (910c85d, 12 lines) contains only the badge width override; the row-spacing and card-height parts of the PR description have no rule in the commit
- fix: patches/admin-modem-modemdata-modempreview.css line 8-12 `[data-page="admin-modem-modemdata-modempreview"] { & .ifacebox-body .ifacebadge { width: auto !important; } }` · 910c85d 2026-06-28 (author CJay D Acopra, PR #80) · 1.0.2 (first tag v1.1.0) — added to `_patches.css` as `@apply w-auto!`, split into its own file by 1c924cf, native CSS by e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: PR #80 · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-modem-qmodem
## qmodem-theming — colours, borders, hover states, tab and table styling across the QModem pages
- page: admin-modem-qmodem (prefix; covers `-overview`, `-sms`, `-sms-conversation-<contact>`, `-sms_sim`, `-config_advanced`, `-settings`, `-network_config`) · Modem → QModem → … (menu paths inferred from the page names; the app is not in the local LuCI checkouts)
- package: luci-app-qmodem (third-party; named in the patch header and PR #92)
- dom: `[data-page^="admin-modem-qmodem"]`: `.copyright-section`, `fieldset.cbi-section > legend`, `fieldset.cbi-section`, `.cbi-section-table-titles.named::before` (line 9-42); `[data-page^="admin-modem-qmodem-sms-conversation"]`: `#sms-messages-area`, `.sms-message-bubble`, `.sms-message-content`, `.sms-message-meta` (line 44-64); `[data-page="admin-modem-qmodem-config_advanced"]`: `.cbi-tabmenu li` and `.cbi-tab` (line 66-96; the file's comment: "targets <li> directly (not li > a) because qmodem's tab markup has no <a> inside tabs"); `[data-page="admin-modem-qmodem-sms"]`: `.td > div`, `.td > span[style*="margin-left"]`, `.sms-conversations` (+ `> .cbi-section`, `fieldset.cbi-section`, `.table.cbi-section-table`), `.cbi-section-table-row.tr` (+ hover), `.sms-unread-conversation`, `.sms-unread-badge` (line 98-175); `[data-page="admin-modem-qmodem-sms_sim"]`: `.cbi-section-table-row.tr` (+ hover) (line 177-197); `[data-page="admin-modem-qmodem-settings"]`: `.cbi-section-table-descr.named::before` (line 201-206)
- symptom: not recorded per rule. PR #92 body: "Styles qmodem overview, SMS, conversation, advanced config, network config, and settings pages with consistent theming (borders, colors, hover states, table formatting, and tab styling)."
- cause: unknown (commit 7066351 has subject only; the PR body lists scope, not what each page did wrong)
- fix: patches/admin-modem-qmodem.css (whole file) · 7066351 2026-07-25 (author shiraneko, PR #92 by cjayacopra) · 1.1.2 (first tag v1.2.0) — written with `@reference`/`@apply`, rewritten natively by e14cfcd 2026-07-27 (1.1.8; radii became `calc(var(--radius-base) * n)`)
- verified: not measured (no record)
- fixture: none (backfill; DOM snapshots exist in `.dev/mocks/admin-modem-qmodem*.html` — overview, sms, sms_sim, config_advanced, settings, network_config)
- issue: PR #92 · mirror: pending · status: active
@@ -0,0 +1,47 @@
# admin-network-network
## bridge-vlan-descr-hidden — hide the table description row on mobile
- page: admin-network-network · Network → Interfaces → Devices → Bridge VLAN Filtering (efa07d8 body: "Network -> Interfaces -> Devices -> Bridge VLAN Filtering")
- package: stock luci-mod-network
- dom: `.table tr.cbi-section-table-descr` — global selector, added for this page
- symptom: not recorded beyond "to improve layout" (efa07d8 body)
- cause: unknown (efa07d8 body states the action, not what the descr row did wrong)
- fix: components/_table.css `&.table-titles, &.cbi-section-table-titles, &.cbi-section-table-descr { max-md:hidden }` (line 58-62) — `.cbi-section-table-descr` was added to the existing list · efa07d8 2025-09-18 · 0.3.7_alpha (first tag v0.4.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## ifacebox-cells-full-row — interface status/box cells stack full-width on mobile
- page: admin-network-network · Network → Interfaces (menu.d title "Interfaces")
- package: stock luci-mod-network
- dom: `td[data-name="_ifacebox"]`, `td[data-name="_ifacestat"]` — DummyValue cells of the `interface` GridSection (interfaces.js:296-297 query `[data-name="_ifacestat"] > div` and `[data-name="_ifacebox"] .ifacebox-body`)
- symptom: not recorded (bec6830 subject: "feat: enhance ifacebox display in td")
- cause: unknown (commit bec6830 has subject only)
- fix: components/_table.css `[data-page="admin-network-network"] & { &[data-name="_ifacebox"], &[data-name="_ifacestat"] { … } }` nested under `& td, & .td` (line 214-219) `max-md:basis-[100%]` · bec6830 2025-09-23 · 0.4.3_alpha (first tag v0.5.0_beta). The same commit added the `td &` variants of `.ifacebox` that 397e46b later moved under `#cbi-network-interface` (next entry)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## interface-cards-floor — equal-width interface cards, horizontal card layout on mobile
- page: admin-network-network · Network → Interfaces
- package: stock luci-mod-network
- dom: `#cbi-network-interface` (id of `form.Map('network')` + GridSection `'interface'`, interfaces.js:497-501) — five descendant rules on `.ifacebox`, `.ifacebox-head`, `.ifacebox-body`, `.ifacebox-body > div`, `.ifacebox-body small`
- symptom: 397e46b body: "The interfaces overview sizes every card by its content, so a bridge carrying seven device icons rendered far wider than a single-port one."
- cause: 397e46b body: "The shared `.ifacebox` min-width had been masking half of that until 1.2.7 lowered it to stop the port status grid overlapping. The floor now sits on the overview's own section instead — `#cbi-network-interface`, the scope luci-theme-bootstrap already uses for this page — at 152px, and the five `td &` rules that only ever applied there move under it too rather than reaching every `.ifacebox` inside any table cell."
- fix: components/_card.css `#cbi-network-interface &` at line 72-74 (`.ifacebox`: `max-md:flex-row md:min-w-38`), 83-85 (`.ifacebox-head`: `max-md:flex max-md:w-auto max-md:shrink-0 max-md:items-center max-md:justify-center max-md:rounded-l-3xl max-md:rounded-tr-none max-md:border-r max-md:border-b-0`), 107-109 (`.ifacebox-body`: `max-md:flex-row max-md:items-center max-md:rounded-r-3xl max-md:rounded-bl-none max-md:py-2 max-md:pr-2 max-md:pl-4`), 144-146 (`.ifacebox-body > div`: `max-md:w-auto max-md:flex-1 max-md:space-y-1`), 163-165 (`.ifacebox-body small`: `max-md:mt-0`) · 397e46b 2026-08-30 · 1.2.10 (first tag v1.3.0). The mobile rules were first written as `td &` in bec6830 (2025-09-23)
- verified: no before/after measurement of the card widths recorded; the body states the size changes it made: "icons 24px -> 20px, the gap between adjacent ones 8px -> 4px, card padding 16px -> 12px and the label line spacing 16px -> 4px", floor 152px, `.ifacebox-body > img` / badge icons "intrinsic 32px" → 20px, zone swatch 33px tall → vertical padding 2px
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## bridge-vlan-dropdown-clip — let the Bridge VLAN table's dropdowns escape their wrapper on mobile
- page: admin-network-network · Network → Interfaces → Devices → Configure → Bridge VLAN filtering (path from efa07d8's wording of the same table; c5a2dff itself only says "bridge-vlan table")
- package: stock luci-mod-network
- dom: `[data-name="bridge-vlan"] > div` — the SectionValue option `bridge-vlan` wrapping the Bridge VLAN TableSection (tools/network.js:1354); patch rule under `@media (width < 48rem)`
- symptom: dropdown clipped on mobile (c5a2dff subject: "fix(mobile): resolve clipped dropdown in bridge-vlan table")
- cause: unknown (commit c5a2dff has subject only; the diff shows only the override, not which rule clipped the dropdown)
- fix: patches/admin-network-network.css line 8-12 `@media (width < 48rem) { [data-name="bridge-vlan"] > div { overflow: visible !important; } }` · c5a2dff 2025-10-19 · 0.6.1_beta (first tag v0.7.0_beta) — written as `@layer patches > admin-network-network { … @apply max-md:!overflow-visible }` in main.css, moved to `_patches.css` by aedf3dc, split into its own on-demand file by 1c924cf (#82, `@reference` + `@apply`), rewritten as native CSS by e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
See also: e86b70f lists "Network => (Interfaces, Wireless, Firewall)" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`.
@@ -0,0 +1,14 @@
# admin-network-wireless
## assoclist-full-row-cells — Associated Stations cells take a full row on mobile
- page: admin-network-wireless · Network → Wireless, "Associated Stations" table (wireless.js:2667 creates `#wifi_assoclist_table`, h3 at :2680). The same id is also created by luci-mod-status 60_wifi.js:243 on Status → Overview; 1dbd95a does not say which page it was written against
- package: stock luci-mod-network (and luci-mod-status for the overview copy)
- dom: `#wifi_assoclist_table td`, `#wifi_assoclist_table .td` (class `table assoclist`); rule also moves the `data-title` ::before label (`before:max-md:mb-1.5`)
- symptom: not recorded (1dbd95a subject: "Ensure each table cell in Associated Stations takes up a full row on mobile")
- cause: unknown (commit 1dbd95a has subject only)
- fix: components/_table.css `#wifi_assoclist_table &` nested under `& td, & .td` (line 156-158) `max-md:flex-[0_0_100%] max-md:px-3 max-md:py-2 before:max-md:mb-1.5` · 1dbd95a 2025-10-17 · 0.5.14_beta (first tag v0.6.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
See also: 50c2b84 lists "Network => Wireless host: Fix overflow when td text is too long" as one motivation for `.td.cbi-value-field { break-all }` — that lineage is recorded in `admin-services-dockerman.md` (the rule's current form comes from a2d110a). e86b70f lists "Network => (Interfaces, Wireless, Firewall)" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`.
@@ -0,0 +1,13 @@
# admin-services-dockerman
## info-table-entry-column — value cells break only words that do not fit
- page: admin-services-dockerman-overview · Services → Dockerman JS → Overview
- package: luci-app-dockerman (openwrt/luci, the JS rewrite)
- dom: `.cbi-section.cbi-tblsection > table.table.cbi-section-table > tr.cbi-section-table-row > td.td.cbi-value-field[data-name=entry|value]` — `docker info` rendered as two-column TableSections; the value column holds multi-KB JSON with no spaces (Runtimes, Plugins)
- symptom: the entry column collapsed to ~42px and showed one character per line (`MemoryLimit` over 7–8 lines) beside the JSON values
- cause: `.td.cbi-value-field` carried `word-break: break-all` at every width (added in 50c2b84 as an overflow guard). `break-all` drops every cell's min-content to about one character; auto table layout then hands spare width out by max-content, so the JSON took nearly all of it. bootstrap sets no word-break on desktop cells — its labels stay whole but the JSON stretches the table to ~3.5k px and the page scrolls sideways.
- fix: `components/_table.css` `& td, & .td { &.cbi-value-field { wrap-anywhere md:max-w-prose } }` — `wrap-anywhere` keeps break-all's min-content (same overflow guard) but only breaks a word that does not fit; `md:max-w-prose` caps each cell's intrinsic contribution so one huge value cannot starve the other columns (the used width may still exceed it: 944px measured) · a2d110a · 1.3.2. Lineage: 50c2b84 2025-10-12 (0.5.8_beta) added `&.cbi-value-field { break-all }` for "System => (Software, Mount Points): Fix overflow when td text is too long" and "Network => Wireless host: Fix overflow when td text is too long", replacing the global `td max-w-94` it removed for passwall (see `admin-services-passwall.md`); 031283c 2026-07-09 added the mobile-card utilities on the same rule (`max-md:mb-1 max-md:basis-full max-md:self-start max-md:text-left` and the `[data-widget=…] max-md:basis-1/2` sub-rule)
- verified: device, Chromium, Environment table before → after: entry column 42 → 260px at 1440, 42 → 196 at 1024, 41 → 152 at 800; short words split across lines 41 → 0 / 0 / 2; mobile cards unchanged; section height 8301 → 3797px at 1440; firewall zone and rule GridSections kept identical column widths at 1440/1024/800/390; a file:// fixture gave the same result in Chromium, WebKit and Firefox. Side effect noted, not measured: on admin-system-mounts the GridSection value cells take the 65ch cap instead of the page's `max-w-104` (same specificity, later rule).
- fixture: fixtures/admin-services-dockerman-overview--info-table.html (entry column `nowrap` at 1440/1024/390 — at 800 two short words still split, so that width is not asserted)
- issue: #96 · mirror: shadcn f4d7219 · status: active
@@ -0,0 +1,25 @@
# admin-services-mini-diskmanager
## partition-table-font — the table's own font-size reaches its cells
- page: admin-services-mini-diskmanager-minidiskmanager · Services → Disk Manager
- package: luci-app-mini-diskmanager (4IceG/luci-app-mini-diskmanager)
- dom: `table#partition-table-<dev> > tr.tr > td.td` — the `<table>` carries inline `table-layout:fixed; width:100%; font-size:12px`; first `th` 180px, the other seven `width:12.5%; min-width:90px`; the type cell is `<span class="partition-color-indicator"> NAME`
- symptom: type/filesystem and mount-point cells wrap — swatch on one line, name below; long names break mid-word (`0BOOTCONFIG` / `1`, `/mnt/mmcblk0` / `p6`)
- cause: `td, .td` set `text-base` (16px) directly, so the table's inline 12px never reached the cells; fixed columns sized for 12px got 16px text and `wrap-break-word` split what did not fit. bootstrap puts its font size on `.table` and lets cells inherit.
- fix: `components/_table.css` `table.table, .table` `text-base max-md:text-sm` (moved off `td, .td`, which now inherit) · dd7c64b · 1.3.3
- verified: device 192.168.8.1, CDP before/after in one page load — cells 16 → 12px; wrapped cells 5 → 1 at 1280 (the remaining `0BOOTCONFIG1` wraps under bootstrap at that width too), 5 → 0 at 1920, 0 → 0 at 390; eleven stock pages (overview, routes, processes, interfaces, firewall zones/rules, DHCP, wireless, mounts, startup, SSH keys) showed zero font-size/line-height changes on any text-bearing table element at 1280 and 390
- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html
- issue: #65 · mirror: shadcn 4f1c7f4 · status: active
## partition-table-gutter — the stable scrollbar gutter leaves the type cell 0.9px short at 1280
- page: admin-services-mini-diskmanager-minidiskmanager · Services → Disk Manager
- package: luci-app-mini-diskmanager (4IceG/luci-app-mini-diskmanager)
- dom: `table#partition-table-<dev>` (inline `table-layout:fixed; width:100%`); type cell `<span class="partition-color-indicator"> 0BOOTCONFIG`
- symptom: with classic scrollbars at a 1280px viewport the swatch and `0BOOTCONFIG` split onto two lines; overlay scrollbars are unaffected
- cause: `_elements.css` `html` `[scrollbar-gutter:stable]` (added so short pages and open modals stop shifting the layout) reserves the 11px thin scrollbar on every page, which is what a scrolling page already paid; the #65 fit had 0.6px to spare
- fix: none on the page; the fixture pins `html { scrollbar-gutter: auto }` because its 1153px width was measured without a scrollbar, so it keeps guarding #65's inherited 12px
- verified: headless Chrome, fixture at 1280 — table 1152.7 → 1142.1px, cell content 114.9 → 113.4px against a 114.3px line (fits → wraps); `pnpm test` compat fails without the pin and passes with it
- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html
- issue: — · mirror: shadcn 50c53a9 · status: active
@@ -0,0 +1,12 @@
# admin-services-openclash-config
## openclash-subinfo-icons — fix the subscription-info icon size
- page: admin-services-openclash-config · Services → OpenClash → Config Manage (controller openclash.lua:84 entry title "Config Manage")
- package: luci-app-openclash (vernesong; the ImmortalWrt feed copy was checked)
- dom: `.sub_div img` — `.sub_div` comes from `luasrc/view/openclash/sub_info_show.htm`, the template used by config.lua:191
- symptom: not recorded (87522d8 subject: "feat: add openclash patch and remove btn spacing")
- cause: unknown (commit 87522d8 has subject only)
- fix: patches/admin-services-openclash-config.css line 8-13 `[data-page="admin-services-openclash-config"] { .sub_div img { height: 1.25rem; width: 1.25rem; } }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha) — written as `@layer patches > luci-app-openclash { [data-page=…] .sub_div img { @apply h-5 w-5 } }` in main.css; `_patches.css` via aedf3dc; own file via 1c924cf; native via e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-services-openclash-log
## openclash-log-dark-bg — dark page background on the Server Logs page
- page: admin-services-openclash-log · Services → OpenClash → Server Logs (controller openclash.lua:85 entry title "Server Logs")
- package: luci-app-openclash (vernesong)
- dom: `[data-page="admin-services-openclash-log"]` (the body element itself)
- symptom: not recorded (87522d8 subject only)
- cause: unknown (commit 87522d8 has subject only)
- fix: (historic, main.css `@layer patches > luci-app-openclash`) `[data-page="admin-services-openclash-log"] { @apply dark:bg-aurora-night-teal; }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 30f922d (2025-11-09 "chore: remove patch styles and adjust DOM indentation" — body: "Removed temporary patch styles … Test case: openclash → Server Logs (dark mode background)"; 0.7.9_beta, first tag v0.8.0_beta)
@@ -0,0 +1,23 @@
# admin-services-openclash-settings
## openclash-settings-select-full — selects fill the value field on mobile
- page: admin-services-openclash-settings · Services → OpenClash → Plugin Settings (controller openclash.lua:71 entry title "Plugin Settings")
- package: luci-app-openclash (vernesong)
- dom: `.cbi-value-field .cbi-input-select` under `@media (width < 48rem)`
- symptom: not recorded (a667f02 subject: "feat: add openclash cbi-input-select style patch")
- cause: unknown (commit a667f02 has subject only; its diff is a whole-file rewrite of main.css, the rule as first written was `[data-page="admin-services-openclash-settings"] .cbi-value-field .cbi-input-select { @apply max-md:!w-full }`)
- fix: patches/admin-services-openclash-settings.css line 9-13 `@media (width < 48rem) { .cbi-value-field .cbi-input-select { width: 100% !important; } }` · a667f02 2025-09-26 · 0.4.6_alpha (first tag v0.5.0_beta) — `_patches.css` via aedf3dc; own file via 1c924cf; native via e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## openclash-diag-grid — stack the Debug Logs diagnostic controls in one column on mobile
- page: admin-services-openclash-settings · Services → OpenClash → Plugin Settings → Debug Logs tab (settings.lua:81 tab "debug", :1308 DummyValue rendering `debug.htm`)
- package: luci-app-openclash (vernesong)
- dom: `.diag-style` and `.diag-style > div` — from `luasrc/view/openclash/debug.htm` (which itself styles `.diag-style` for `[data-darkmode="true"]` at its line 12-17)
- symptom: not recorded (87522d8 subject only)
- cause: unknown (commit 87522d8 has subject only)
- fix: patches/admin-services-openclash-settings.css line 15-33 `.diag-style { & > div { display: flex; flex-direction: column; align-items: center; gap: 0.5rem } @media (width < 48rem) { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 1rem; & > div { width: 100% !important; gap: 0.25rem } } }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha) — first written unscoped under `@layer plugins > luci-app-openclash` (applied on every page); 7d9f6a1 merged `_plugins.css` into `_patches.css`; 1c924cf scoped it to this page (PR #82: "openclash `.diag-style` → settings"); native via e14cfcd
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,14 @@
# admin-services-passwall
## td-global-max-width — a global `max-w-94` on every table cell
- page: admin-services-passwall-node_list · Services → PassWall → Node List (controller passwall.lua:39 entry title "Node List") — the page that got the rule removed; the page it was added for is not recorded
- package: luci-app-passwall (xiaorouji; the ImmortalWrt feed copy was checked)
- dom: `.table td, .table .td` (global) — on Node List the `td.cbi-section-actions` cell holds the row's action buttons
- symptom: on removal — 50c2b84 subject: "Services => passwall => Node List cbi-section-actions: Fix overflow beyond td max width" (the action buttons ran past the capped cell). On addition — not recorded
- cause: addition: unknown (efa07d8's body describes two other changes — "Removed conflicting styling that prevented setting `overflow-auto`, which was necessary to maintain the sticky positioning of `th` elements" and hiding `.cbi-section-table-descr` on the Bridge VLAN page — and says nothing about `max-w-94`, which the diff adds to td alongside `max-md:max-w-full` and col caps). Removal: the fixed cap could not hold the Node List action cell (50c2b84 subject); d276fd3 later restates the lesson: "the fix avoids applying a global maximum width to table data cells (`td`) to prevent regressions in CBI section actions (e.g., in Services -> passwall -> Node List)"
- fix: (historic, main.css `& td, & .td`) `max-w-94` · efa07d8 2025-09-18 · 0.3.7_alpha (first tag v0.4.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 50c2b84 (2025-10-12 "fix: table content overflow", 0.5.8_beta, first tag v0.6.0_beta). The same commit added `.td.cbi-value-field { break-all }` as the replacement overflow guard for "System => (Software, Mount Points)" and "Network => Wireless host" — see `admin-services-dockerman.md`; per-page caps came back scoped: `[data-page="admin-system-mounts"]` (d276fd3, `admin-system-mounts.md`) and `[id*="status_leases"]` (80be54c, `admin-status-overview.md`)
See also: bbb355b 2025-10-26 "fix: resolve tooltip flicker and add footer spacing" — body: "added top margin to the footer to avoid overlap with main content in plugins that lack proper view containers (e.g., PassWall)"; a global `footer` rule (currently _layout.css line 644), not traced further here.
@@ -0,0 +1,23 @@
# admin-services-samba4
## samba4-cell-padding — tighter th/td padding inside the Network Shares form
- page: admin-services-samba4 · Services → Network Shares (title from luci-app-samba4 menu.d; 1dbd95a writes it as "NAS => Network Shares => Shared Directories")
- package: stock luci-app-samba4
- dom: `#cbi-samba4` (id of `form.Map('samba4')`, samba4.js:23) containing the `sambashare` TableSection "Shared Directories" (samba4.js:93); rule hits every `th/.th` and `td/.td` under it
- symptom: not recorded (1dbd95a subject: "Reduce cell padding in NAS => Network Shares => Shared Directories")
- cause: unknown (commit 1dbd95a has subject only; no symptom or measurement)
- fix: components/_table.css `#cbi-samba4 &` (nested under `& th, & .th`, line 114-116, and under `& td, & .td`, line 148-150) `px-0.5 py-1.5` · 1dbd95a 2025-10-17 · 0.5.14_beta (first tag v0.6.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## samba4-section-overflow — horizontal scroll on the samba4 card
- page: admin-services-samba4 · Services → Network Shares (menu path inferred from the page name)
- package: stock luci-app-samba4
- dom: `#cbi-samba4 .cbi-section`
- symptom: not recorded (2e1d18a subject: "table overflow issue on samba4, package, and autoreboot pages")
- cause: unknown (commit 2e1d18a has subject only). The diff shows the section went from `overflow-x-auto` to `overflow-visible` globally and got `#cbi-samba4 & { @apply overflow-x-auto; }` back for this page only, alongside dropping `table-fixed` from `.table`
- fix: (historic, main.css `.cbi-section`) `#cbi-samba4 & { @apply overflow-x-auto; }` · 2e1d18a 2025-08-31 · 0.1.3_alpha (first tag v0.2.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 6b9b328 (2025-09-03 "feat: make dropdown fill based on content, simplify table" — `.cbi-section` went back to global `overflow-x-auto`, so the per-page override was deleted with it)
@@ -0,0 +1,12 @@
# admin-statistics-graphs
## rrd-graphs-dark — invert and hue-rotate the rendered graph images in dark mode
- page: admin-statistics-graphs · Statistics → Graphs (menu.d title "Graphs"; edf378d: "Affected pages: Statistics => Graphs")
- package: stock luci-app-statistics
- dom: `[data-plugin] img` — the per-plugin tab containers built in graphs.js:75 (`'data-plugin': plugin`) holding the rrdtool PNGs; dark scope is `[data-darkmode="true"]` on `<html>`
- symptom: not recorded beyond "Adapt img background color for dark mode" (edf378d body)
- cause: edf378d body: "Adapt img background color for dark mode" — the graphs are server-rendered images whose light background does not follow the theme; nothing further recorded
- fix: patches/admin-statistics-graphs.css line 8-10 `[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img { filter: hue-rotate(150deg) invert(100%); }` · edf378d 2025-10-17 · 0.5.16_beta (first tag v0.6.0_beta) — written as `@layer luci-app-statistics { [data-page=…] [data-plugin] img { @apply dark:hue-rotate-150 dark:invert } }` in main.css; `_plugins.css` after aedf3dc, merged into `_patches.css` by 7d9f6a1, own file via 1c924cf, native via e14cfcd (the e14cfcd body cites this file: "the two-rule statistics patch alone went 1,609 B → 110 B")
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-status-logs
## log-viewer — parse the read-only `#syslog` textarea into a column-aligned, colour-coded view
- page: admin-status-logs (prefix; covers `admin-status-logs-syslog`, `admin-status-logs-dmesg` and the bare `/logs` alias) · Status → System Log / Kernel Log (menu.d: "admin/status/logs" "System Log" alias → "…/syslog" "System Log", "…/dmesg" "Kernel Log")
- package: stock luci-mod-status
- dom: `textarea#syslog` inside `#content_syslog`; the patch inserts `div.syslog-view` (rows `.log-row.lv-*` with `.log-time/.log-sev/.log-tag/.log-msg`) after the textarea and adds `.log-enhanced` to the container. Styling lives in components/_syslog.css in main.css, not in a patch
- symptom: none — this is an enhancement, not a breakage fix (a5b44da: "The first payload reworks the System/Kernel Log pages")
- cause: a5b44da body: "the read-only #syslog textarea stays in place as LuCI's polled data source while a dirty-checked viewer parses each line (classic logread and the bracketed RPC format, whitelist-gated) into aligned time/level/source columns with per-severity colors, whole lines scrolling sideways, and a flash-free takeover via an insertion observer. Unknown formats trip a parse gate and the stock textarea simply shows again. One admin-status-logs name covers both tabs on every supported release — 23.05, 24.10 and master all mount the pages under admin/status/logs/*." Why the CSS is not a patch: "The viewer's CSS is core-page styling, not third-party compat, so it lives in components/_syslog.css inside main.css where the full utility pipeline applies"
- fix: src/resource/patches/admin-status-logs.js (whole file; router lifecycle `window.luciPatches["admin-status-logs"] = { mount, unmount }` at line 292-295, `mount()` at line 296) · a5b44da 2026-07-27 · 1.1.7 (first tag v1.2.0). Follow-ups: ee16953 2026-07-29 (1.1.9) "contain select-all, follow the tail, leave dmesg unsplit" — Ctrl+A grabbed the whole page, tail-follow when pinned to the bottom, dmesg rendered verbatim because "kernel lines have no uniform tag grammar"; e681ecb 2026-07-29 (1.1.10) "render every log token verbatim, copies byte-identical" — timestamps/colons were being rewritten, now "joining the rendered tokens with single spaces reproduces the source line byte for byte, and a test pins that round-trip"; 664c795 2026-08-16 (1.2.1) and e748f7c 2026-08-26 (1.2.6) wired the mount/unmount lifecycle for the same-document router (subjects only read)
- verified: a5b44da: "Node tests pin the parser against each format family and the payload's home" (`.dev/tests/log-patch.test.js`); e681ecb: round-trip test. No rendering measurement recorded
- fixture: none (backfill; DOM snapshot exists in `.dev/mocks/admin-status-logs-syslog.html`)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,34 @@
# admin-status-overview
## leases-td-max-width — cap DHCP lease cells so DUID/IAID columns cannot widen the table
- page: admin-status-overview · Status → Overview, "Active DHCP Leases" / "Active DHCPv6 Leases" tables (luci-mod-status 40_dhcp.js:110 `#status_leases`, :169 `#status_leases6`; `[id*=]` matches both). The commit subject says "DHCP > Leases"; the ids exist only in this status include
- package: stock luci-mod-status
- dom: `.table[id*="status_leases"] .td` (class `table leases` / `table leases6`, div-based `.td` cells)
- symptom: table overflow from the DUID/IAID columns (80be54c subject)
- cause: unknown (commit 80be54c has subject only; no DOM or widths recorded)
- fix: components/_table.css `&[id*="status_leases"] { & .td { … } }` (line 137-141) `max-w-62 max-md:max-w-full` · 80be54c 2025-12-12 · 0.9.1_beta (first tag v0.10.0)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## section-actions-mobile — action-button cell spans the mobile card, buttons share the row
- page: admin-status-overview · Status → Overview → Active DHCP Leases; also System → Mount Points and Network → Interfaces / Wireless / Firewall (all five listed in e86b70f)
- package: stock luci-mod-status / luci-mod-system / luci-mod-network (per the pages listed)
- dom: `.table td.cbi-section-actions` (`> div` wrapper, `> div > .cbi-button`) and `.table td > .cbi-button`
- symptom: not recorded (e86b70f subject: "cbi-button display on mobile for cell view")
- cause: unknown (commit e86b70f has subject only)
- fix: components/_table.css `&.cbi-section-actions` nested under `& td, & .td` (line 190-200) `max-md:flex-[0_0_100%]`, `& > div` `max-md:w-full`, `& > div > .cbi-button` `max-md:flex-1`; plus `& > .cbi-button` (line 202-204) `max-md:w-full` · e86b70f 2025-10-17 · 0.5.15_beta (first tag v0.6.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
## section-actions-mobile-v0 — original scaffold rule for the actions cell on mobile
- page: unknown (076281f is the initial scaffold commit; no page recorded)
- package: unknown
- dom: `.table td.cbi-section-actions` (`& div`, `& .btn, & .cbi-button`) and a sibling `.td.cbi-section-actions { p-2 max-md:p-1 … }` block in the button layer
- symptom: not recorded
- cause: unknown (076281f "chore: setup luci-theme-aurora with Vite and TailwindCSS" has subject only)
- fix: (historic, main.css) `&.cbi-section-actions { @apply max-md:mt-3 max-md:border-t max-md:pt-3; & div { max-md:flex max-md:flex-wrap max-md:justify-start max-md:gap-2 } & .btn, & .cbi-button { max-md:min-w-0 max-md:flex-1 … } }` · 076281f 2025-08-25 · 0.1.0 (first tag v0.2.0-alpha)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: n/a · status: removed 386dec5 (2025-08-30 "feat: optimize mobile display for table" rewrote the mobile table block and dropped it; the `.td.cbi-section-actions` button-layer block was dropped by 4c99ac8 2025-09-05 "chore: adjust button nesting style structure", which replaced it with the generic `.cbi-section-actions > div { flex flex-row … }`). e86b70f re-added a different rule seven weeks later — see the entry above
@@ -0,0 +1,12 @@
# admin-system-diskman
## diskman-partition-bar — keep the injected partition bar inside the card on small screens, clip segment labels
- page: admin-system-diskman (prefix; also `-partition-<dev>`, `-btrfs-<uuid>`) · System → Disk Man (controller diskman.lua:24 entry title "Disk Man", alias to `disks`; issue #58 calls it the storage area on the home page)
- package: luci-app-diskman (lisaac; the ImmortalWrt feed copy was checked)
- dom: `td[colspan]:has(> div[title])` and `td[colspan] > div[title]`, `.tr > div[style*="nowrap"] > div[title]` — disk_info.htm:34/88 emit `<tr width="100%" style="white-space:nowrap;"><td style="… white-space:nowrap;" colspan="15">` and :36 `<div class="tr cbi-section-table-row …"><div style="white-space:nowrap; position:absolute; width:100%">`, with one inline-block `div[title]` per partition sized in percent
- symptom: issue #58: "首页的存储区域会超出宽度,导致页面可以左右滑动" (the storage area exceeds the width; page scrolls sideways), Safari 26 / iOS 26, OpenWrt 24.10.5, theme 0.11; 49fff90 body: "drags the page ~300px wide"
- cause: 49fff90 body: "luci-app-diskman injects the partition bar as a full-span row whose cell carries an inline white-space:nowrap. Once .tr becomes a flex row on mobile, that cell's automatic minimum size is its min-content — the whole unwrapped bar — so it cannot shrink and drags the page ~300px wide (reported in #58 with the page DOM). … clip each segment so its label stops spilling onto the neighbouring one (visible on desktop too)". The file adds: "The segments are inline-blocks sized in percent: any whitespace between them adds a word space on top of the 100% they already sum to" (hence `font-size: 0` on the cell)
- fix: patches/admin-system-diskman.css line 9-35 `[data-page^="admin-system-diskman"] { td[colspan]:has(> div[title]) { flex: 0 0 100%; min-width: 0; overflow: hidden; border-radius: var(--radius-base); font-size: 0 } td[colspan] > div[title], .tr > div[style*="nowrap"] > div[title] { overflow: hidden; font-size: 0.75rem; line-height: 1.5rem; text-overflow: ellipsis } }` · 49fff90 2026-08-02 · 1.1.13 (first tag v1.2.0)
- verified: not measured (the body states the before width, "~300px wide", from the reporter's DOM; no after-number recorded)
- fixture: none (backfill; DOM snapshot exists in `.dev/mocks/admin-system-diskman.html`)
- issue: #58 · mirror: pending · status: active
@@ -0,0 +1,12 @@
# admin-system-filemanager
## filemanager-container-scroll — scrollable file-manager container, flat status bar
- page: admin-system-filemanager · System → File Manager (menu.d title "File Manager")
- package: luci-app-filemanager (in openwrt/luci applications)
- dom: `#file-manager-container` (the app's own `<style>` block targets it, filemanager.js:269-272) and its `#status-bar`
- symptom: not recorded (347d645 subject: "feat: optimize mobile h3 display and add layer plugins")
- cause: unknown (commit 347d645 has subject only)
- fix: patches/admin-system-filemanager.css line 8-17 `[data-page="admin-system-filemanager"] { #file-manager-container { overflow: auto; #status-bar { background: var(--surface); border: 0; } } }` · 347d645 2025-09-12 · 0.2.1_alpha (first tag v0.3.0-alpha) — first written unscoped as `@layer plugins > luci-app-filemanager { #file-manager-container { @apply overflow-auto; #status-bar { @apply border-0 dark:bg-slate-900 } } }` (applied on every page); `_plugins.css` after aedf3dc, merged into `_patches.css` by 7d9f6a1; 1c924cf scoped it to the page (PR #82: "Backfilled missing data-page scoping … filemanager"); native via e14cfcd (the dark-only `bg-slate-900` became an always-on `var(--surface)`)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
@@ -0,0 +1,14 @@
# admin-system-mounts
## mounts-td-max-width — cap cells on Mount Points so a long mount path cannot widen the table
- page: admin-system-mounts · System → Mount Points → "Mounted file systems" (menu.d title "Mount Points"; d276fd3: "Tests: System -> Mount Points -> Mounted file systems")
- package: stock luci-mod-system (page depends on block-mount)
- dom: every `td/.td` under `.table` on the page. The "Mounted file systems" table is a plain `E('table', { class: 'table' })` (mounts.js:168) with no id, so the page scope is the only handle; the rule also reaches the `mount`/`swap` GridSections
- symptom: a long docker directory path breaks the Mount Points layout (issue #24: "docker目录比较长,mounts下显示问题", Chrome 142, OpenWrt 24.10, screenshot only)
- cause: d276fd3 body: "the fix avoids applying a global maximum width to table data cells (`td`) to prevent regressions in CBI section actions (e.g., in Services -> passwall -> Node List)" — i.e. the cap is page-scoped because the earlier global `max-w-94` on td was removed in 50c2b84 for passwall (see `admin-services-passwall.md`). Why the path overflowed rather than wrapped is not recorded
- fix: components/_table.css `[data-page="admin-system-mounts"] &` nested under `& td, & .td` (line 152-154) `max-w-104 max-md:max-w-full` · d276fd3 2025-11-28 · 0.8.6_beta (first tag v0.9.0_beta)
- verified: d276fd3 says "Tests: System -> Mount Points -> Mounted file systems"; no numbers. a2d110a (2026-09-13) notes: "On admin/system/mounts the GridSection value cells now take the 65ch cap instead of the page's 26rem one (same specificity, later rule). That only shifts width distribution and was not measured: the test device has no block-mount, so the page is absent."
- fixture: none (backfill)
- issue: #24 · mirror: pending · status: active
See also: 50c2b84 lists "System => (Software, Mount Points): Fix overflow when td text is too long" for `.td.cbi-value-field { break-all }` — recorded in `admin-services-dockerman.md`. e86b70f lists "System => Mount Points" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`.
@@ -0,0 +1,17 @@
# admin-system-package-manager
## progress-title-10px — shrink the progress bar's title text below the sm breakpoint
- page: admin-system-package-manager · System → Software (menu.d title "Software")
- package: stock luci-app-package-manager
- dom: `.cbi-progressbar[title]` (package-manager.js:1253 `E('div', { class: 'cbi-progressbar', title })`); the theme prints the title through `before:content-[attr(title)]` with `before:whitespace-nowrap` (_progress.css line 2)
- symptom: progress text clipping on the package page (cdcc680 subject)
- cause: unknown (commit cdcc680 has subject only). What the diff shows: the title is a nowrap ::before centred in the bar; the fix only lowers its font size under the sm breakpoint. 386dec5 (2025-08-30) had earlier given every progressbar `max-md:before:text-[10px]`; when that left is not traced here
- fix: components/_progress.css `[data-page="admin-system-package-manager"] &` nested under `.cbi-progressbar` (line 3-5) `max-sm:before:text-[10px]` · cdcc680 2025-12-05 · 0.8.10_beta (first tag v0.9.0_beta)
- verified: not measured (no record)
- fixture: none (backfill)
- issue: — · mirror: pending · status: active
See also (global rules motivated by this page, not page-scoped):
- 2e1d18a 2025-08-31 "fix: table overflow issue on samba4, package, and autoreboot pages" — dropped `table-fixed` from `.table` globally (see `admin-services-samba4.md`).
- 50c2b84 2025-10-12 "System => (Software, Mount Points): Fix overflow when td text is too long" — `.td.cbi-value-field { break-all }`, now `wrap-anywhere md:max-w-prose` (see `admin-services-dockerman.md`).
- edf378d 2025-10-17 "Adjust .controls button layout — Affected pages: Statistics => Graphs, System => Software (filter form and paginator)" — `.controls { m-2 flex flex-wrap items-center gap-3 … }` in main.css at the time; current location not traced here.
@@ -0,0 +1,64 @@
# <Title: one line, the change, not the complaint>
**Status:** draft · **Decision:** pending | submitted <PR link> | interim in Aurora | dropped <why> · **Target:** openwrt/luci `<branch>` | uhttpd | rpcd | eamonxg feed · **Date:** <YYYY-MM-DD>
## Problem
Two or three sentences: what happens today, on which request, with the
measured cost (from `dispatch-cost.md` or a named bench run). One sentence on
who pays (every navigation / every session / every install).
## Evidence
| # | fact | where |
|---|---|---|
| 1 | … | `openwrt/luci@<branch> modules/luci-base/ucode/http.uc:499` |
| 2 | … | `uhttpd file.c:344` |
Unknowns (not yet verified) listed here, each with the command that settles it.
## Today
```mermaid
sequenceDiagram
participant B as Browser
participant U as uhttpd
participant D as CGI dispatcher (ucode)
B->>U: GET …
U->>D: fork + exec (2 ms) + import (37 ms)
D-->>B: 200, Cache-Control: no-cache
```
Label edges with cost (ms, bytes, RTT). One diagram per mechanism.
## Proposal
```mermaid
flowchart LR
A[before] -->|change| B[after]
```
Change sketch: exact file and function, approximate size in lines, the new
headers/config/API. Behaviour on 23.05 / 24.10 / 25.12 / master. What stays
byte-identical for users who do not opt in.
## Alternatives considered
Each in one line with why it loses (measured or structural).
## Measurement plan
Before/after commands (`bench-dispatch.sh`, `bench.mjs`, `bench-fullload.mjs`),
the numbers expected to move, the device class. "not measured yet" is a
valid entry; an invented number is not.
## Interim in Aurora
What this theme (or the eamonxg feed) can do without upstream, and what it
cannot; link the `/luci-change` plan if one exists.
## Submission
Repository and branch, commit granularity (one logical change), cover-letter
contents: problem, measurement before/after with device, compatibility
statement, test performed.
+395
View File
@@ -0,0 +1,395 @@
<h4 align="right"><strong>English</strong> | <a href="DEVELOPMENT_zh.md">简体中文</a></h4>
# Development Guide
This guide covers the complete development workflow for the Aurora theme, from environment setup to building production packages.
## Prerequisites
- **[Node.js 20.19+ / 22.12+](https://nodejs.org/en/download)** - JavaScript runtime (mirrors Vite 7's support range — odd releases like 21.x don't qualify; enforced at `pnpm install` time via `engines` + `engine-strict`, so an unsupported Node fails fast with a clear message)
- **pnpm** - Package manager (managed via [Corepack](https://github.com/nodejs/corepack))
- **Tailwind CSS knowledge** - Required for styling. See [Tailwind CSS Documentation](https://tailwindcss.com/docs)
- **Network access** - Development machine must be on the same network as your OpenWrt router
## Environment Setup
### 1. Clone and Install
```bash
# Clone the repository
git clone git@github.com:eamonxg/luci-theme-aurora.git
cd luci-theme-aurora/.dev/
# Enable Corepack to manage pnpm version
corepack enable && corepack prepare
# Install dependencies
pnpm install
```
### 2. Configure Environment
```bash
# One-shot wizard: asks for every .env value (router IP, dev-server host/port,
# current .env entries as defaults), generates/installs an SSH key (one
# router-password prompt), verifies template sync end-to-end, and writes .env
pnpm setup:router
# Non-interactive: pass the IP directly (no prompts; dev-server values keep
# their saved .env entries or defaults)
pnpm setup:router 192.168.2.1
```
> The script is named `setup:router` (not `setup`) because `pnpm setup` resolves to pnpm's own built-in setup command and would never run a package script.
**What it does** (`scripts/setup.js`), in order:
1. **Collects the `.env` values.** With no argument it prompts for each variable in turn, showing the current `.env` entry as the default (an empty answer keeps it). With an IP argument it takes that as `VITE_OPENWRT_HOST` non-interactively and leaves the dev-server values alone. Nothing is written yet — `.env` is only updated once every step below has succeeded.
2. **Pre-flights the connection.** A raw TCP probe of `<host>:22` with a 2s timeout, so an unreachable device or one with SSH disabled fails immediately with a clear message instead of hanging inside ssh.
3. **Finds or generates an SSH key.** It looks for `~/.ssh/id_ed25519.pub`, `id_rsa.pub`, `id_ecdsa.pub` in that order and reuses the first one present — an existing key is never overwritten. **If you have no key at all, it generates one** (`ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519`). The empty passphrase is deliberate: `.ut` sync has to run unattended for the whole `pnpm dev` session.
4. **Installs the public key on the router.** It first tests whether passwordless SSH already works (`ssh -o BatchMode=yes … echo ok`) and skips the install if so — after a full reflash that wipes the key, the test fails and the install simply runs again. Otherwise it appends the key to `/etc/dropbear/authorized_keys` over one interactive SSH session; the append is guarded by `grep -qxF`, so re-running never duplicates the entry. **This is the only step that asks for the router's root password, and it asks once.** Afterwards it re-tests passwordless auth and classifies any failure (wrong password / host unreachable / other). On a device running openssh rather than dropbear the key belongs in `/root/.ssh/authorized_keys` instead — install it manually there and this step detects it and skips.
5. **Verifies the sync end-to-end.** Rather than trusting `echo ok`, it pushes `ucode/template/themes/aurora/` through the exact `tar -cf - | ssh … tar -xf -` pipeline the `ut-sync` plugin uses (see [Template (`.ut`) Live Sync](#template-ut-live-sync)). If this passes, `pnpm dev`'s template sync will too.
6. **Writes `.env`.** Managed keys are rewritten in place so surrounding comments keep their meaning, missing ones are appended, and any other line passes through untouched.
> The password prompt in step 4 comes from the **local** ssh client, which reads it straight from `/dev/tty` rather than stdin — so it shows up normally even though the script pipes ssh's stderr in order to classify errors. It does mean the step needs a controlling terminal **on the development machine** (the router side is unaffected): run it from a normal shell, not from CI, `nohup`, or an editor task pane, where ssh has no way to ask and the run fails at authentication. Check with `tty` if unsure; in such an environment, install the key by hand instead.
If the router is at the default `192.168.1.1` and passwordless SSH already works, no `.env` is needed at all — every value below has a working default.
**Environment Variables** (all optional):
- `VITE_OPENWRT_HOST` - bare router address, e.g. `192.168.1.1` (default; `host:port` and full-URL forms also accepted). The web proxy target and the `.ut`-sync SSH target (`root@<hostname>`) both derive from it; anything fancier (a dedicated key, a jump host, a non-standard ssh port) belongs in a `Host` block in `~/.ssh/config`, which ssh picks up automatically.
- `VITE_DEV_HOST` - Development server host (code default: `127.0.0.1`, `.env.example` sets `0.0.0.0` for LAN access)
- `VITE_DEV_PORT` - Development server port (default: `5173`)
## Development Workflow
### Start Development Server
```bash
cd luci-theme-aurora/.dev/
pnpm dev
```
The development server will start at `http://127.0.0.1:5173` and proxy requests to your OpenWrt device.
**How Vite Proxy Works:**
The Vite development server uses middleware to rewrite local requests to serve CSS/JS resources from your development environment instead of the router. This enables live editing without deploying to the router. For detailed implementation, see `vite.config.ts`.
**Key proxy behaviors:**
1. Proxies `/cgi-bin` and `/luci-static` requests to OpenWrt device
2. Uses middleware (`createLocalServePlugin`) to rewrite request paths for CSS and JS files
3. CSS requests to `/luci-static/aurora/main.css` and `/luci-static/aurora/login.css` are rewritten to serve from `.dev/src/media/main.css` and `.dev/src/media/login.css` respectively
4. JS file requests are served directly from `.dev/src/resource/` with middleware reading and returning file content
5. Injects Vite HMR client into proxied HTML responses for live reload support
6. Redirects `/` to `/cgi-bin/luci` for proper routing
### Code Style and Formatting
This project uses **Prettier** for code formatting with automatic formatting on save.
**Prettier Configuration:**
- Located in `.prettierrc`
- VS Code settings in `.vscode/settings.json` enable format-on-save for CSS and JS files
- Uses `prettier-plugin-tailwindcss` to sort Tailwind CSS classes
### CSS Nesting Support
Thanks to **lightningcss**, you can freely use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) in your stylesheets. The build process automatically compiles nested CSS into flat, browser-compatible format.
This will be compiled to standard CSS that works in all browsers.
### CSS Architecture
The theme has two independent Tailwind CSS v4 entry points, both sourced from `.dev/src/media/`:
- **`main.css`** — the LuCI admin UI. It is an import manifest that disables Tailwind's automatic source scan (`source(none)`) and pulls in (in order) `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` (OKLCH theme tokens, mapped via `@theme inline`), shared `_icons.css`, `_base.css`, `_elements.css`, `_layout.css`, every file in `components/` (one partial per UI component — buttons, cards, modals, tables, etc.), and `_utilities.css`.
- **`login.css`** — the standalone login page (`sysauth.ut`). Self-contained: imports Tailwind theme/utilities with `source(none)`, omits full Preflight in favor of a tiny local reset, and imports `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` directly. At build time the `login-css-prune` plugin (`vite.config.ts`) strips every custom property the page's var() chains never reach, so the admin-sized token sheet ships login-sized.
Third-party compatibility patches are **not** bundled into `main.css` — they are split into per-page files under `media/patches/` and loaded on demand (see [On-Demand Third-Party Patches](#on-demand-third-party-patches) below).
**Adding new styles:**
- New UI component → create `components/_<name>.css` and add an `@import` line to `main.css`. Each file is its own organizational unit — don't add `@layer` wrappers: theme partials stay unlayered, so they outrank Tailwind's layered base/utilities regardless of specificity.
- Compatibility fix for a third-party LuCI app/page → add a new file under `media/patches/` (see below).
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties. The one deliberate exception is `media/patches/*.css`, which is plain native CSS by design (see below).
### On-Demand Third-Party Patches
> Page-scoped JS patches must expose `window.luciPatches[stem] = { mount, unmount }` (and mount themselves once at eval) so the client-side router can drive them across same-document navigations — see `router.md`.
Some third-party LuCI apps ship markup that doesn't adapt to the theme and needs a narrow compatibility override. Instead of bundling every such patch into `main.css` (which would ship them to **every** page), each patch is a standalone CSS file loaded **only on the page it targets**.
**How it works:**
1. **One file per page, named by `data-page`.** Each patch lives at `media/patches/<page>.css`, where `<page>` is the value of `<body data-page="…">` for the target page — i.e. the request path segments joined by `-` (e.g. `admin-services-openclash-config`). `header.ut` computes the same string at render time from `ctx.request_path`, falling back to `ctx.path` when `request_path` is empty (`join('-', length(ctx.request_path) ? ctx.request_path : ctx.path)`) so default landings reached without an explicit path still resolve their patch.
2. **Build splits, not bundles.** `vite.config.ts` adds every `media/patches/*.css` as its own Rollup entry, so each compiles to `htdocs/luci-static/aurora/patches/<page>.css`. They are no longer part of `main.css`.
3. **Plain CSS, not `@apply`.** Patches are the one place that writes native declarations instead of Tailwind utilities. Each patch is its own build entry, and the old `@reference "../main.css";` + `@apply` setup made every file carry its own `@property`/helper boilerplate. Rewriting the *same rules* natively — measured on the v1.1.7 → v1.1.8 conversion, built output, identical selectors and values:
| patch | `@reference` + `@apply` | native CSS | saved |
| --- | ---: | ---: | ---: |
| admin-dashboard | 6,940 B | 2,639 B | −62% |
| admin-modem-modemdata-modempreview | 162 B | 96 B | −41% |
| admin-modem-qmodem | 9,483 B | 4,497 B | −53% |
| admin-network-network | 160 B | 86 B | −46% |
| admin-services-openclash-config | 187 B | 87 B | −53% |
| admin-services-openclash-settings | 638 B | 509 B | −20% |
| admin-statistics-graphs | 1,609 B | 110 B | −93% |
| admin-system-filemanager | 576 B | 184 B | −68% |
| **total** | **19,755 B** | **8,208 B** | **−58%** |
The overhead is per-entry and fixed-ish (`@property` registrations, `--tw-*` helper chains), so the smaller the patch, the worse the ratio — the two-rule statistics patch paid 15× its own weight. uhttpd serves identity bytes (no gzip), so raw size is wire size. Note the runtime `:root` exposes the raw color tokens plus `--radius-base` and `--app-shadow-*` — the `--radius-3xl`-style names only exist inside the Tailwind build, so a patch writes `calc(var(--radius-base) * 3)` for radii, never `var(--radius-3xl)`. The old Tailwind mode is still *supported* for theme-repo patches — a file starting with `@reference "../main.css";` and using `@apply` continues to compile — and it has real upsides worth weighing against the table above: **build-time validation** (a typo'd utility or color name fails the build, while a typo'd `var()` fails silently at runtime — exactly how the `--radius-3xl` regression slipped into the native rewrite), the **same vocabulary** as the component partials with the `dark:`/`md:`/`hover:` variant shorthands, and **automatic tracking** when the theme remaps a utility (a radius-chain or shadow-token change recompiles into Tailwind patches for free; native patches need a hand edit). Native is the default convention for the size numbers, not a hard gate. App-shipped patches (installed straight into the device's `patches/` directory) have never had a choice: they bypass the build entirely, so `@apply` would reach the browser as dead text — plain CSS only, as always. It also means third-party patch authors need no Tailwind knowledge. Reach theme values through the `:root` custom properties (`var(--surface-sunken)`, `var(--hairline)`, `var(--radius-3xl)`, `var(--shadow-lg)`, …) rather than hardcoding colors or radii; the dark variant is a plain selector (`[data-darkmode="true"] & { … }`), breakpoints are plain media queries (`@media (width < 48rem)`), and CSS Nesting still works — the build (lightningcss) minifies and lowers it for the supported browsers.
4. **`header.ut` discovers patches at render time.** On each (non-login) page render, `header.ut` lists `/www/luci-static/aurora/patches/` with ucode's `fs.lsdir()` (a readdir of a dozen entries — microseconds, dwarfed by the template's existing `ubus` call) and matches the installed `*.css` filenames against the **cumulative path-segment prefixes** of the request: a patch matches its exact page and any subpage, but only on real segment boundaries — `admin-services-wol.css` covers `admin/services/wol/plus`, yet never a sibling app whose own segment merely starts the same way (`admin/services/wol-plus`). Every matching patch is linked right after `main.css`, in lexical order — so a general patch loads before a more specific one and the specific one cascades on top. Pages with no match get nothing — no extra request, no 404. If the directory is missing or unreadable, the list is empty and the page renders unpatched.
5. **The patches directory is a drop-in extension point.** Because discovery is at render time, patches don't have to ship with the theme: **any package may install a `<page-prefix>.css` into `/www/luci-static/aurora/patches/`** and the theme will load it on matching pages. Install/uninstall lifecycle is automatic — the file appears and disappears with the package, no registration or allow-list rebuild. (Patches shipped this way are plain CSS served as-is; the theme's own patches are written the same way and only pass through the build for minification.)
6. **Dynamically generated pages are covered by their fixed prefix.** Some apps mint a page per entity — e.g. QModem's SMS conversations render as `admin-modem-qmodem-sms-conversation-<contact>`. Name the patch after the fixed prefix (`admin-modem-qmodem-sms-conversation.css`) and the prefix match loads it for every conversation page, regardless of the contact name. No wildcard syntax is needed (and `*` in a filename is not supported).
**Adding a patch:**
1. Open the target page in the browser and read `document.body.dataset.page` — that exact string is your filename (for a family of dynamic per-entity pages, use their fixed prefix instead — see point 5 above).
2. Create `media/patches/<that-string>.css`:
```css
/* PATCH: <page> (luci-app-foo) */
[data-page="<page>"] {
/* narrow, selector-scoped overrides — native CSS + CSS Nesting,
theme values via var(--surface), var(--hairline), … */
}
```
3. Run `pnpm build`. There is no allow-list to regenerate — the loader discovers whatever `.css` files are installed under `patches/` at render time.
4. Verify `htdocs/luci-static/aurora/patches/<page>.css` is small (just your rules).
> Removing a patch is symmetric: delete the file and rebuild — the loader stops linking it because it no longer exists.
**Shipping a patch with a third-party app** (no theme release needed): build or hand-write a plain CSS file named after your page's `data-page` prefix and install it from your package's Makefile:
```makefile
define Package/luci-app-foo/install
...
$(INSTALL_DIR) $(1)/www/luci-static/aurora/patches
$(INSTALL_DATA) ./htdocs/aurora-patch.css \
$(1)/www/luci-static/aurora/patches/admin-services-foo.css
endef
```
The theme loads it automatically on `admin-services-foo` and all its subpages whenever both packages are installed. Note app-shipped patches bypass the theme's Tailwind build — write plain CSS (you can still target the theme's CSS custom properties, e.g. `var(--surface)`), and scope every rule under your own `[data-page^="…"]` selector.
**Naming.** The filename is the page's `data-page` string; matching by prefix means broader targets also just work:
| You want to patch… | File to create | Then it loads on |
| --- | --- | --- |
| One specific page, `admin/services/foo/general` | `admin-services-foo-general.css` | that page (and any subpage under it) |
| A whole app, all pages under `admin/services/foo/…` | `admin-services-foo.css` | `foo`, `foo/general`, `foo/rules`, … |
| Dynamic per-entity pages, e.g. QModem SMS `…/sms/conversation/<contact>` | `admin-modem-qmodem-sms-conversation.css` (the fixed prefix — no wildcard needed) | every conversation page, whatever the contact |
Two rules of thumb that follow from prefix matching:
- **A patch applies to its page and all subpages by default.** `admin-services-foo.css` loads on every page under `admin/services/foo/…`. When you need finer targeting, narrow it in either of two ways: scope individual rules inside the file (`[data-page="admin-services-foo-general"] { … }` only affects that one page), or ship an additional, longer-named file (`admin-services-foo-rules.css`) for page-specific rules — on a page matching both, **both load**, shorter name first, so the more specific file wins the cascade.
- **Matching respects path-segment boundaries**, so a prefix never leaks onto a lookalike sibling: `admin-services-wol.css` covers `admin/services/wol/plus` but not a different app at `admin/services/wol-plus`. The one unavoidable collision is two paths joining to the same `data-page` string (`wol/plus` vs `wol-plus`) — such a patch loads on both pages. If that matters, key rules to your app's own class names/ids so an accidental load matches nothing.
> Unlike the `_`-prefixed partials (which are `@import`-only fragments), patch filenames have no `_` prefix — each is a real build entry that ships to `htdocs/`. This also holds inside `media/patches/` itself: a `_`-prefixed file there is a shared fragment for other patches to `@import`, skipped by the entry scan and never shipped.
**JS payloads.** The mechanism is not CSS-specific: the same `lsdir()` sweep also collects `patches/<page>.js` files, emitted as `<script defer src>` right after the patch stylesheet links — same per-page prefix matching, same drop-in lifecycle for third-party packages (ship a plain script; it must not assume LuCI modules are loadable via `L.require` at parse time — run after DOM ready or poll for your target element). Theme-owned JS patches live at `src/resource/patches/<page>.js` and build through the same Terser pass as other resource JS, but land under `aurora/patches/` so one directory listing serves both payload types. First user: the log-viewer enhancement on `admin-status-logs` (parses the read-only `#syslog` textarea into a colored, column-aligned view, with a parse-success gate that falls back to the stock textarea on unknown log formats; the one prefix covers the System Log tab, the Kernel Log tab and the bare `/logs` alias on every supported release). Its CSS is deliberately **not** a patch: the log pages are stock LuCI, so their styling lives in `components/_syslog.css` inside `main.css`, where the full Tailwind token pipeline (reactive radii, shadows, config-app overrides) applies — patches/ stays reserved for third-party compatibility.
**Aliases — one payload, several pages.** When two unrelated page names need the same payload, naming the file after their shared prefix would over-match (an `admin-status` patch would load on *every* status subpage, including the busiest overview page). For that case `PATCH_ALIASES` in `vite.config.ts` duplicates the built output (CSS and JS) under each alias name, and the dev server resolves alias requests to the shared source. Duplicate CSS *entries* would not work as an alternative: Rollup deduplicates identical-content assets into a single file, so one of the two names would silently never ship. (The map is currently empty — the log viewer turned out to need only `admin-status-logs`, since every supported release mounts both log pages under `admin/status/logs/*`.)
### Mock Pages
Style a third-party app's page — write or adjust its `patches/*.css`, or check a `main.css`/component change against it — **without having the app (or a device) installed**. Save the page's rendered HTML once, then develop against it with the theme live and hot-reloading.
- **Where snapshots live:** `.dev/mocks/*.html` (git-ignored — snapshots are large, device/fork-specific and go stale, so they stay local). The directory need not exist in a fresh clone; it's created on first capture.
- **The mock bar:** every HTML page this dev server hands out — proxied device pages and served snapshots alike — gets `scripts/mock-bar.client.js` (served at `/mocks/__bar.js`), a floating bar in the bottom-left corner. It lives in a Shadow DOM, so theme and patch CSS can neither restyle it nor be polluted by it, and the theme's own floating toolbar keeps the bottom-right corner. On a device page it lists what `.dev/mocks/` holds, so the workflow is reachable without typing the `/mocks/` URL: `◆` appears when this page's `data-page` matches a snapshot and opens it in one click, `⊕` captures the open page, and an empty `.dev/mocks/` shrinks the bar to a lone `⊕`. Inside a snapshot it names the open one, steps through the rest, and `↩` goes back to the same page on the device. `✕` collapses it to a dot, remembered in `localStorage['aurora.mockbar.collapsed']`. The snapshot list is injected inline next to the script; both tags carry `data-aurora-mock`, which is how a capture strips them back out — a snapshot must never bake in a list that is re-injected, current, on every serve.
- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and hit `⊕` on the mock bar — or press <kbd>Alt/Option+Shift+S</kbd>, or call `__auroraMockCapture()` in the console. It POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — named after the page's `data-page`, doctype included, dev-only script tags stripped. (The endpoint only accepts requests carrying the helper's custom header, which cross-origin pages can't send without a CORS preflight this server never approves.) Manual capture still works: run `copy(document.documentElement.outerHTML)` in the DevTools console and save the paste as `.dev/mocks/<name>.html` — the filename is free; the page's real identity is the `data-page` attribute already in its `<body>`, which patch selectors match.
- **Capture it from a device running _this_ theme — snapshots are not portable between themes.** A snapshot is a verbatim copy of a rendered page, so it hard-codes the theme that rendered it in three places: the stylesheet links (`/luci-static/aurora/main.css` plus that page's patch), the device's stored UCI token overrides in an inline `<style>`, and the theme's own header/nav markup. Drop a snapshot captured under `luci-theme-shadcn` in here (or vice versa) and the page renders **completely unstyled**, because a dev server only serves its own `/luci-static/<theme>/` prefix. The giveaway is a terminal line naming the other theme's stylesheet:
```
[Mocks] miss /luci-static/shadcn/main.css → 404 (mirror it at .dev/mocks/static/… to serve it)
```
Mirroring, which that generic hint suggests, is the wrong fix here — re-capture the page from a device running this theme. If you must reuse a foreign snapshot anyway, only the app's own content region means anything: point its stylesheet links at this theme, and delete the inline `<style>` block, or the captured device's colors override this checkout's tokens.
- **View:** `pnpm dev`, then open <http://localhost:5173/mocks/> — an auto-generated index lists every snapshot with its `data-page` and age. The `mock-pages-plugin` (in `vite.config.ts`) serves each page with the Vite HMR client injected, so editing any theme source (`main.css`, a component, a `patches/*.css`, or served JS) triggers the usual full reload (see [Live Reload Behavior](#live-reload-behavior)). The snapshot keeps its absolute `/luci-static/…` links; theme CSS/JS, fonts and images resolve locally and compile on the fly. Serving prepends the `<!doctype html>` that `outerHTML` captures drop, so mocks render in standards mode exactly like the real page.
- **Navigate between snapshots in place:** inside a mock the bar also takes over clicks on the snapshot's own LuCI links (`/cgi-bin/luci/…`), resolving them against the captured snapshots by `data-page` (exact match) and jumping straight to the matching mock — an app's tab bar or the sidebar works just like on the device. Uncaptured targets are blocked with a hint naming the missing snapshot instead of falling through to the proxy. The bar lists every snapshot, cycles with <kbd>[</kbd>/<kbd>]</kbd> (or its ‹/› buttons) and links back to the index. `↩` leaves for the real page: its target is the `requestpath` from LuCI's own inline bootstrap, but only when that agrees with the snapshot's `data-page` (a hand-assembled mock can carry the segments of the page it was built from); otherwise it falls back to splitting `data-page`, which is lossy whenever a path segment contains a dash of its own (`admin-status-disks-info`), then to the last device page visited in this tab.
- **Third-party assets:** an app's own css/js the snapshot references (e.g. `qmodem-next.css`, or a device-only custom logo) isn't in this repo. To serve it, mirror its URL under `.dev/mocks/static/` (e.g. `.dev/mocks/static/luci-static/resources/qmodem/qmodem-next.css`); files there are served as-is (no HMR). Misses requested by a mock page 404 immediately — never proxied to the router, so mocks stay fully offline-capable — and each miss prints a one-time terminal hint with the exact mirror path. (CSS-initiated requests, e.g. nav icons, carry the stylesheet's URL as referer and can't be attributed to the mock page; any `/luci-static` request that falls through to the proxy is therefore bounded to 5s and answers 504 when the router is unreachable.) The theme still applies without them.
- **No auth, no runtime:** a snapshot is static DOM, so `mock-pages-plugin` strips LuCI's runtime scripts (`luci.js`/`cbi.js`/`xhr.js` and `/cgi-bin/` endpoints) and injects a no-op `L`/`LuCI`/`XHR` stub before serving. Without this, LuCI boots, polls the backend, gets 403 (no session) and pops the "Session expired" modal. The trade-off: framework-dependent theme JS (e.g. `menu-aurora`) no-ops in mocks — the captured DOM is already rendered, so it still looks right. The theme's own inline scripts (dark mode, toolbar state) and any `src/media/` JS still run.
### Design Tokens
There is no local `_tokens.css` and no generation step: `main.css`/`login.css` `@import "@eamonxg/luci-theme-tokens/dist/aurora/tokens.css"` directly, resolved straight out of `node_modules` at build time. The source of truth lives in the standalone [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) npm package, consumed here as a devDependency:
- **`aurora/defaults.js`** — the 10 editable input colors (`bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger`) for light and dark mode, as OKLCH strings.
- **`aurora/spec.js`** — `DERIVATIONS` (how every other token — `text_muted`, `surface_sunken`, `hairline`, `brand_hover`, `brand_subtle`, `focus_ring`, `progress_start`/`progress_end`, `*_surface`, `scrim`, `mega_menu_bg`, …) is computed from the inputs via `mix`/`shade`/`set`/`alpha`/`const` operators, and `FIXED` (mode-specific literals such as shadows that bypass derivation).
- **`engine.js`** — the OKLCH/OKLAB color math behind those operators, via [colorjs.io](https://colorjs.io/).
- **`resolve.js`** — `createResolver` walks a `DERIVATIONS` spec and returns a flat `{token: oklchString}` map with no `color-mix()`/`var()` left in it; `aurora/index.js` pre-binds this to Aurora's own spec as `resolveMode(mode)`, exported from the package's `/aurora` entry point.
- **`dist/aurora/tokens.css`** — built by the package's own `build.mjs` (its `prepublishOnly`) and shipped in the published tarball; nothing in this repo regenerates it.
**Changing a color:**
1. Edit `aurora/spec.js`/`aurora/defaults.js` in the [`luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) repo (derivation rules, fixed literals, base input colors), tag a release so CI tests, builds `dist/`, and publishes the package, then bump the `@eamonxg/luci-theme-tokens` devDependency version here and run `pnpm install`. For unreleased iteration against a local checkout, run `pnpm link ../../luci-theme-tokens` from `.dev` instead of bumping/publishing.
2. Run `pnpm build` — Vite resolves `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` straight from `node_modules`, so a version bump (or `pnpm link`) is all a color change needs on this side.
3. Run `pnpm test` to check the color-math and derived-token invariants (`tests/resolve.test.js`, `tests/surfaces.test.js`, both importing `resolveMode` from `@eamonxg/luci-theme-tokens/aurora`) — e.g. hue families, lightness ordering between `bg`/`surface_sunken`/`surface`, and translucency of menu backgrounds.
**Runtime overrides from UCI:** `header.ut` reads `uci get_all aurora.theme` on each render and re-emits stored tokens as CSS custom-property overrides in an inline `<style>` after `main.css`. Keys are namespaced by prefix — `light_*` and `struct_*` land in `:root`, `dark_*` in `[data-darkmode="true"]` — with the prefix stripped and `_` mapped to `-` (e.g. `light_surface_sunken` → `--surface-sunken`). The template flattens all keys in a single pass into two pre-joined declaration strings (rather than per-key template loops), which halves the iteration work and keeps the emitted `<style>` compact. This is the hook `luci-app-aurora-config` writes through.
### LuCI JavaScript API
For LuCI-specific JavaScript development, refer to the official API documentation:
- [LuCI JavaScript API Reference](http://openwrt.github.io/luci/jsapi/index.html)
### Live Reload Behavior
- **CSS changes**: Trigger full page reload via custom HMR handler
- **JS changes**: Trigger full page reload via custom HMR handler
- **Template changes** (`.ut` files): Auto-synced to router over SSH and trigger full page reload (one-time `pnpm setup:router` required, see below)
### Template (`.ut`) Live Sync
The `.ut` template files are rendered server-side on the OpenWrt device, so unlike CSS/JS they can't be served locally — the dev server pushes them to the router instead. Run `pnpm setup:router` once to configure passwordless SSH; after that it's fully automatic:
- **On startup**, the whole template directory is pushed (as one tarball over ssh stdin — Dropbear has no SFTP server for scp), so edits made while the dev server was down never leave the router stale.
- **On save**, changes are debounced and the directory is pushed again, then the browser reloads.
- **On page load**, requests to `/cgi-bin` wait for any in-flight push, so a proxied render never uses a stale template.
**Troubleshooting** — sync errors are printed with the fix:
- **Host key mismatch** (device was reflashed): Run `ssh-keygen -R <device-ip>`, then restart the dev server
- **Authentication failed** (public key not on device, e.g. after a reflash): Run `pnpm setup:router`
- **Connection refused/timed out**: Check that the device is online and SSH is enabled
A failed sync is retried on the next `.ut` change; CSS/JS dev features work normally without SSH.
## Building for Production
### Build Command
```bash
cd luci-theme-aurora/.dev/
pnpm build
```
This compiles all assets to the production directory `htdocs/luci-static/`, which is used by LuCI during OpenWrt package compilation.
**Build Output:**
```
htdocs/luci-static/
├── aurora/
│ ├── main.css # Minified admin UI CSS (via lightningcss)
│ ├── login.css # Minified login page CSS (via lightningcss)
│ ├── fonts/ # Web fonts (Lato)
│ └── images/ # Logo assets + PWA icons
└── resources/
└── menu-aurora.js # Menu configuration (minified via Terser)
```
**Build Process:**
1. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`), resolving `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css` straight from `node_modules` (see [Design Tokens](#design-tokens)) and keeping Tailwind's native `@layer` structure
2. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser
3. Static assets copied from `.dev/public/aurora/`
## Package Compilation
### Via GitHub Actions
**Build frontend assets:**
1. Manually trigger the `frontend-assets-build` workflow
2. It runs `pnpm build`, then auto-commits the output to `htdocs/` if anything changed
**Build `.ipk`/`.apk` packages:**
1. Push a version tag (`v*`), push to `master`/`feat/**` with `[build]` in the commit message, or manually trigger the workflow
2. The `build-theme-package` workflow compiles both `.ipk` and `.apk` OpenWrt packages
**PR review:**
Pull requests that touch `.dev/`, `htdocs/`, `ucode/`, or `root/` are automatically reviewed by the `claude-pr-review` workflow — it posts inline comments on the source diff (generated `htdocs/` output is excluded) plus a summary comment. Mention `@claude` in a PR comment to request a follow-up review or ask a question.
**Issue triage:**
New issues are handled by the `claude-issue-bot` workflow — it checks for spam/duplicates, applies labels, and posts a deep technical analysis comment. Mention `@claude` in an issue comment to get a response.
**Workflow Files:** `.github/workflows/`
- `frontend-assets-build.yml` — Build assets and auto-commit (manual trigger)
- `build-theme-package.yml` — Compile `.ipk`/`.apk` packages
- `claude-pr-review.yml` — AI code review for PRs (inline + summary comments)
- `claude-issue-bot.yml` — AI issue triage and analysis
## Directory Structure
```
luci-theme-aurora/
├── .dev/ # Development environment
│ ├── docs/ # Project documentation
│ │ └── DEVELOPMENT.md # Development guide (this file)
│ ├── mocks/ # Local page snapshots for /mocks/ (git-ignored, see Mock Pages)
│ ├── public/aurora/ # Public static assets
│ │ ├── fonts/ # Web fonts (Lato)
│ │ └── images/ # Theme images + PWA icons
│ ├── scripts/ # Build scripts + dev-server client helpers
│ │ ├── clean.js # Build cleanup utility
│ │ ├── mock-bar.client.js # Injected into device pages and /mocks/ — snapshot bar, capture, link takeover
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
│ │ ├── media/ # CSS source (Tailwind CSS v4)
│ │ │ ├── main.css # Admin UI entry point (import manifest; tokens via @eamonxg/luci-theme-tokens)
│ │ │ ├── login.css # Login page entry point
│ │ │ ├── _base.css # Document foundation (html/body viewport bg)
│ │ │ ├── _elements.css # Base element styles (headings, links, …)
│ │ │ ├── _layout.css # Page layout/structure
│ │ │ ├── _utilities.css # Custom utility classes
│ │ │ ├── components/ # One partial per UI component
│ │ │ └── patches/ # Per-page third-party patches (on-demand, one file per data-page)
│ │ └── resource/ # JavaScript resources
│ │ └── menu-aurora.js # Menu logic
│ ├── tests/ # All test suites (pnpm test)
│ │ ├── resolve.test.js # Resolved token invariants (against @eamonxg/luci-theme-tokens/aurora)
│ │ ├── surfaces.test.js # Surface/hue layering invariants
│ │ ├── overlay.test.js # Overlay/layout CSS assertions
│ │ └── navigation-*.test.js # Navigation model/rendering/styles
│ ├── .env.example # Environment variables template
│ ├── .prettierrc # Prettier configuration
│ ├── package.json # Node.js dependencies
│ ├── pnpm-lock.yaml # pnpm lock file
│ └── vite.config.ts # Vite configuration with custom plugins
├── .github/ # GitHub configuration
│ ├── ISSUE_TEMPLATE/ # Issue templates
│ ├── workflows/ # GitHub Actions workflows
│ └── renovate.json # Renovate dependency update config
├── .vscode/ # VS Code workspace settings
│ └── settings.json # Auto-format on save settings
├── htdocs/luci-static/ # Build output (generated by Vite)
│ ├── aurora/ # Theme CSS and assets
│ │ ├── fonts/ # Built font files
│ │ ├── images/ # Built images + PWA icons
│ │ ├── main.css # Compiled admin UI CSS
│ │ ├── login.css # Compiled login page CSS
│ │ └── patches/ # Compiled per-page patches (linked on demand by header.ut)
│ └── resources/ # Built JavaScript modules
│ └── menu-aurora.js # Minified menu logic
├── root/etc/uci-defaults/ # OpenWrt system integration
│ └── 30_luci-theme-aurora # Theme auto-setup script
├── ucode/template/themes/aurora/ # LuCI ucode templates
│ ├── header.ut # Header template
│ ├── footer.ut # Footer template
│ └── sysauth.ut # Login page template
├── LICENSE # Apache License 2.0
├── Makefile # OpenWrt package Makefile
├── README.md # English documentation
└── README_zh.md # Chinese documentation
```
## Tools and Technologies
- **[Tailwind CSS v4](https://tailwindcss.com/)** - Utility-first CSS framework
- **[Vite](https://vitejs.dev/)** - Build tool and development server
- **[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager
- **[lightningcss](https://lightningcss.dev/)** - CSS minifier
- **[colorjs.io](https://colorjs.io/)** - OKLCH/OKLAB color math for design token generation (used by [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens))
- **[Terser](https://terser.org/)** - JavaScript minifier
- **[Prettier](https://prettier.io/)** - Code formatter
- **[prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)** - Tailwind class sorting
- **[tw-animate-css](https://github.com/Wombosvideo/tw-animate-css)** - Animation utilities for Tailwind CSS
- **[tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar)** - Custom scrollbar styling plugin
@@ -0,0 +1,395 @@
<h4 align="right"><a href="DEVELOPMENT.md">English</a> | <strong>简体中文</strong></h4>
# 开发指南
本指南覆盖 Aurora 主题从环境搭建到构建生产包的完整开发流程。
## 前置条件
- **[Node.js 20.19+ / 22.12+](https://nodejs.org/en/download)** —— JavaScript 运行时(与 Vite 7 的支持范围一致 —— 21.x 这类奇数版本不符合要求;在 `pnpm install` 时通过 `engines` + `engine-strict` 强制执行,因此不受支持的 Node 会立刻失败并给出清晰提示)
- **pnpm** —— 包管理器(通过 [Corepack](https://github.com/nodejs/corepack) 管理版本)
- **Tailwind CSS 知识** —— 写样式必备。参见 [Tailwind CSS 文档](https://tailwindcss.com/docs)
- **网络连通** —— 开发机必须与你的 OpenWrt 路由器处在同一网络
## 环境搭建
### 1. 克隆与安装
```bash
# 克隆仓库
git clone git@github.com:eamonxg/luci-theme-aurora.git
cd luci-theme-aurora/.dev/
# 启用 Corepack 来管理 pnpm 版本
corepack enable && corepack prepare
# 安装依赖
pnpm install
```
### 2. 配置环境
```bash
# 一站式向导:询问每一个 .env 值(路由器 IP、开发服务器 host/port,
# 以当前 .env 条目为默认值),生成/安装 SSH 密钥(只提示一次路由器密码),
# 端到端验证模板同步,然后写入 .env
pnpm setup:router
# 非交互式:直接传入 IP(不再提问;开发服务器相关的值保持
# 已保存的 .env 条目或默认值)
pnpm setup:router 192.168.2.1
```
> 脚本名叫 `setup:router` 而不是 `setup`,因为 `pnpm setup` 会解析到 pnpm 自带的内置 setup 命令,永远不会执行包脚本。
**它做了什么**(`scripts/setup.js`),按顺序:
1. **收集 `.env` 的值。** 不带参数时会逐个变量提问,并把当前 `.env` 条目作为默认值展示(回车留空即保持原值)。带 IP 参数时则非交互地把它当作 `VITE_OPENWRT_HOST`,开发服务器相关的值原样不动。此时还什么都没写 —— 只有下面每一步都成功后,`.env` 才会被更新。
2. **预检连接。** 对 `<host>:22` 做一次超时 2 秒的原始 TCP 探测,这样不可达的设备、或关闭了 SSH 的设备会立刻带着清晰提示失败,而不是卡在 ssh 里面。
3. **找到或生成 SSH 密钥。** 它按顺序查找 `~/.ssh/id_ed25519.pub`、`id_rsa.pub`、`id_ecdsa.pub`,复用第一个存在的 —— 已有密钥绝不会被覆盖。**如果你完全没有密钥,它会生成一个**(`ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519`)。空口令是刻意的:`.ut` 同步必须在整个 `pnpm dev` 会话期间无人值守地运行。
4. **把公钥装到路由器上。** 它先测试免密 SSH 是否已经可用(`ssh -o BatchMode=yes … echo ok`),可用就跳过安装 —— 而在一次抹掉密钥的完整刷机之后,该测试会失败,安装便会重新执行。否则它会在一次交互式 SSH 会话中把密钥追加到 `/etc/dropbear/authorized_keys`;追加由 `grep -qxF` 守卫,所以重复运行不会产生重复条目。**这是唯一一个会询问路由器 root 密码的步骤,而且只问一次。** 之后它会重新测试免密认证,并对任何失败进行归类(密码错误 / 主机不可达 / 其他)。如果设备跑的是 openssh 而不是 dropbear,密钥应该放在 `/root/.ssh/authorized_keys` —— 手动装到那里,本步骤会检测到并跳过。
5. **端到端验证同步。** 它不满足于 `echo ok`,而是用 `ut-sync` 插件所使用的完全相同的 `tar -cf - | ssh … tar -xf -` 管道把 `ucode/template/themes/aurora/` 推上去(见 [模板(`.ut`)实时同步](#模板ut实时同步))。这一步通过了,`pnpm dev` 的模板同步也就通过了。
6. **写入 `.env`。** 受管理的键在原位重写,周围的注释因此保持意义;缺失的键追加到末尾;其他任何行原样透传。
> 第 4 步的密码提示来自**本地**的 ssh 客户端,它直接从 `/dev/tty` 读取而不是 stdin —— 所以即便脚本为了归类错误而管道化了 ssh 的 stderr,提示仍会正常显示。但这也意味着该步骤需要**开发机上**有一个控制终端(路由器一侧不受影响):请在普通 shell 里运行,而不要在 CI、`nohup` 或编辑器任务面板里运行,那些环境下 ssh 无从提问,运行会在认证处失败。不确定就用 `tty` 检查;在这类环境下,请改为手动安装密钥。
如果路由器就在默认的 `192.168.1.1` 上、且免密 SSH 已经可用,那么完全不需要 `.env` —— 下面每一个值都有可用的默认值。
**环境变量**(全部可选):
- `VITE_OPENWRT_HOST` —— 路由器裸地址,例如 `192.168.1.1`(默认值;也接受 `host:port` 和完整 URL 形式)。Web 代理目标和 `.ut` 同步的 SSH 目标(`root@<hostname>`)都由它推导。更花哨的需求(专用密钥、跳板机、非标准 ssh 端口)应写进 `~/.ssh/config` 的 `Host` 块,ssh 会自动读取。
- `VITE_DEV_HOST` —— 开发服务器 host(代码默认值 `127.0.0.1`,`.env.example` 里设为 `0.0.0.0` 以便局域网访问)
- `VITE_DEV_PORT` —— 开发服务器端口(默认 `5173`)
## 开发流程
### 启动开发服务器
```bash
cd luci-theme-aurora/.dev/
pnpm dev
```
开发服务器会在 `http://127.0.0.1:5173` 启动,并把请求代理到你的 OpenWrt 设备。
**Vite 代理的工作方式:**
Vite 开发服务器用中间件重写本地请求,使 CSS/JS 资源由你的开发环境提供,而不是由路由器提供。这样就能在不部署到路由器的前提下实时编辑。具体实现见 `vite.config.ts`。
**代理的关键行为:**
1. 把 `/cgi-bin` 和 `/luci-static` 请求代理到 OpenWrt 设备
2. 用中间件(`createLocalServePlugin`)重写 CSS 和 JS 文件的请求路径
3. 对 `/luci-static/aurora/main.css` 和 `/luci-static/aurora/login.css` 的 CSS 请求分别被重写为由 `.dev/src/media/main.css` 和 `.dev/src/media/login.css` 提供
4. JS 文件请求直接由 `.dev/src/resource/` 提供,中间件读取文件内容并返回
5. 向被代理的 HTML 响应注入 Vite HMR 客户端以支持实时重载
6. 把 `/` 重定向到 `/cgi-bin/luci` 以正确路由
### 代码风格与格式化
本项目使用 **Prettier** 进行代码格式化,并开启保存时自动格式化。
**Prettier 配置:**
- 位于 `.prettierrc`
- `.vscode/settings.json` 中的 VS Code 设置为 CSS 和 JS 文件启用保存时格式化
- 使用 `prettier-plugin-tailwindcss` 排序 Tailwind CSS 类名
### CSS 嵌套支持
得益于 **lightningcss**,你可以在样式表中自由使用 [CSS 嵌套语法](https://drafts.csswg.org/css-nesting/)。构建过程会自动把嵌套 CSS 编译成扁平的、浏览器兼容的形式。
它会被编译成在所有浏览器中都能工作的标准 CSS。
### CSS 架构
主题有两个彼此独立的 Tailwind CSS v4 入口,均来自 `.dev/src/media/`:
- **`main.css`** —— LuCI 管理界面。它是一份 import 清单,禁用了 Tailwind 的自动源码扫描(`source(none)`),并按顺序引入 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`(OKLCH 主题 token,经 `@theme inline` 映射)、共享的 `_icons.css`、`_base.css`、`_elements.css`、`_layout.css`、`components/` 下的每一个文件(每个 UI 组件一个片段 —— 按钮、卡片、弹窗、表格等等),以及 `_utilities.css`。
- **`login.css`** —— 独立的登录页(`sysauth.ut`)。自包含:用 `source(none)` 引入 Tailwind 的 theme/utilities,不用完整 Preflight 而改用一份极小的本地 reset,并直接引入 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`。构建时 `login-css-prune` 插件(`vite.config.ts`)会剥掉该页 var() 链条永远够不到的每一个自定义属性,于是管理端尺寸的 token 表以登录页的尺寸发布。
第三方兼容补丁**不会**被打进 `main.css` —— 它们被拆成 `media/patches/` 下的按页文件并按需加载(见下文 [按需第三方补丁](#按需第三方补丁))。
**新增样式:**
- 新的 UI 组件 → 创建 `components/_<name>.css` 并在 `main.css` 中加一行 `@import`。每个文件都是自己的组织单元 —— 不要加 `@layer` 包裹:主题片段保持不分层,因此无论特异性如何都压过 Tailwind 分层的 base/utilities。
- 针对第三方 LuCI 应用/页面的兼容修复 → 在 `media/patches/` 下新建一个文件(见下文)。
所有规则都用 `@apply` 配合 Tailwind 工具类和 CSS 嵌套 —— 不写裸 CSS 属性。唯一刻意的例外是 `media/patches/*.css`,它按设计就是原生 CSS(见下文)。
### 按需第三方补丁
> 页面级 JS 补丁必须暴露 `window.luciPatches[stem] = { mount, unmount }`(并在求值时自行挂载一次),这样客户端路由才能在同文档导航之间驱动它们 —— 见 `router_zh.md`。
有些第三方 LuCI 应用发布的标记结构无法适配主题,需要一处狭窄的兼容覆盖。与其把每一个这样的补丁都打进 `main.css`(那会把它们发到**每一个**页面),不如让每个补丁成为一个独立 CSS 文件,**只在它所针对的页面上加载**。
**工作方式:**
1. **一页一文件,以 `data-page` 命名。** 每个补丁位于 `media/patches/<page>.css`,其中 `<page>` 是目标页面 `<body data-page="…">` 的值 —— 也就是请求路径各段用 `-` 连接的结果(例如 `admin-services-openclash-config`)。`header.ut` 在渲染时从 `ctx.request_path` 计算出同样的字符串,`request_path` 为空时回退到 `ctx.path`(`join('-', length(ctx.request_path) ? ctx.request_path : ctx.path)`),这样不带显式路径抵达的默认落地页也能解析出自己的补丁。
2. **构建是拆分,不是打包。** `vite.config.ts` 把每个 `media/patches/*.css` 都加为独立的 Rollup 入口,因此各自编译成 `htdocs/luci-static/aurora/patches/<page>.css`。它们不再是 `main.css` 的一部分。
3. **原生 CSS,不用 `@apply`。** 补丁是唯一写原生声明而非 Tailwind 工具类的地方。每个补丁都是独立构建入口,而旧的 `@reference "../main.css";` + `@apply` 方案会让每个文件各自携带一份 `@property`/辅助样板。把*同样的规则*改写成原生 —— 基于 v1.1.7 → v1.1.8 的转换实测,构建产物,选择器和取值完全相同:
| 补丁 | `@reference` + `@apply` | 原生 CSS | 节省 |
| --- | ---: | ---: | ---: |
| admin-dashboard | 6,940 B | 2,639 B | −62% |
| admin-modem-modemdata-modempreview | 162 B | 96 B | −41% |
| admin-modem-qmodem | 9,483 B | 4,497 B | −53% |
| admin-network-network | 160 B | 86 B | −46% |
| admin-services-openclash-config | 187 B | 87 B | −53% |
| admin-services-openclash-settings | 638 B | 509 B | −20% |
| admin-statistics-graphs | 1,609 B | 110 B | −93% |
| admin-system-filemanager | 576 B | 184 B | −68% |
| **合计** | **19,755 B** | **8,208 B** | **−58%** |
这份开销是按入口计的、且大体固定(`@property` 注册、`--tw-*` 辅助链),所以补丁越小、比例越难看 —— 只有两条规则的 statistics 补丁付出了自身重量 15 倍的代价。uhttpd 按原样字节提供服务(不做 gzip),所以原始体积就是传输体积。注意运行时的 `:root` 暴露的是原始颜色 token 加上 `--radius-base` 和 `--app-shadow-*` —— `--radius-3xl` 这类名字只存在于 Tailwind 构建内部,所以补丁要写 `calc(var(--radius-base) * 3)` 来取圆角,绝不要写 `var(--radius-3xl)`。对主题仓库自己的补丁而言,旧的 Tailwind 模式仍然*受支持* —— 以 `@reference "../main.css";` 开头并使用 `@apply` 的文件照样能编译 —— 而且它有几个值得与上表权衡的实在好处:**构建期校验**(写错的工具类或颜色名会让构建失败,而写错的 `var()` 只会在运行时静默失效 —— `--radius-3xl` 那次回归正是这样溜进原生改写版的)、与组件片段**同一套词汇**以及 `dark:`/`md:`/`hover:` 变体简写,还有主题重新映射某个工具类时的**自动跟随**(圆角链或阴影 token 的改动会免费重新编译进 Tailwind 补丁;原生补丁则需要手动改)。原生是为了上面那组体积数字而定的默认约定,不是硬性门槛。由应用自带的补丁(直接装进设备的 `patches/` 目录)从来就没有选择余地:它们完全绕过构建,`@apply` 到浏览器就是一段无效文本 —— 一如既往,只能写原生 CSS。这也意味着第三方补丁作者不需要懂 Tailwind。请通过 `:root` 自定义属性取用主题值(`var(--surface-sunken)`、`var(--hairline)`、`var(--radius-3xl)`、`var(--shadow-lg)` 等),不要硬编码颜色或圆角;深色变体是一个普通选择器(`[data-darkmode="true"] & { … }`),断点是普通媒体查询(`@media (width < 48rem)`),CSS 嵌套依旧可用 —— 构建(lightningcss)会为受支持的浏览器压缩并降级它。
4. **`header.ut` 在渲染时发现补丁。** 每次(非登录页)渲染时,`header.ut` 用 ucode 的 `fs.lsdir()` 列出 `/www/luci-static/aurora/patches/`(读取十来个条目的 readdir —— 微秒级,被模板已有的 `ubus` 调用完全盖过),并把已安装的 `*.css` 文件名与请求的**累积路径段前缀**匹配:一个补丁匹配它的精确页面和任意子页面,但只在真正的段边界上 —— `admin-services-wol.css` 覆盖 `admin/services/wol/plus`,却绝不会覆盖某个自身段只是恰好以同样字符开头的兄弟应用(`admin/services/wol-plus`)。每个匹配到的补丁都会紧跟在 `main.css` 之后链接,按字典序排列 —— 于是通用补丁先加载、更具体的后加载,具体的那个在层叠中压在上面。没有匹配的页面什么都不会拿到 —— 没有额外请求,也没有 404。如果目录缺失或不可读,列表为空,页面就以未打补丁的状态渲染。
5. **补丁目录是一个即插即用的扩展点。** 由于发现发生在渲染时,补丁不必随主题发布:**任何包都可以把一个 `<page-prefix>.css` 装进 `/www/luci-static/aurora/patches/`**,主题就会在匹配的页面上加载它。安装/卸载的生命周期是自动的 —— 文件随包出现和消失,无需注册,也无需重建白名单。(以这种方式发布的补丁按原样作为纯 CSS 提供;主题自己的补丁也是同样写法,只是额外过一遍构建做压缩。)
6. **动态生成的页面由它们的固定前缀覆盖。** 有些应用为每个实体铸造一个页面 —— 例如 QModem 的短信会话渲染为 `admin-modem-qmodem-sms-conversation-<contact>`。把补丁按固定前缀命名(`admin-modem-qmodem-sms-conversation.css`),前缀匹配就会为每个会话页面加载它,与联系人名字无关。不需要通配符语法(文件名里的 `*` 也不受支持)。
**添加一个补丁:**
1. 在浏览器里打开目标页面并读取 `document.body.dataset.page` —— 那个精确字符串就是你的文件名(对于一族动态的按实体页面,改用它们的固定前缀 —— 见上文第 5 点)。
2. 创建 `media/patches/<那个字符串>.css`:
```css
/* PATCH: <page> (luci-app-foo) */
[data-page="<page>"] {
/* 狭窄的、限定在选择器内的覆盖 —— 原生 CSS + CSS 嵌套,
主题值通过 var(--surface)、var(--hairline)、… 取用 */
}
```
3. 运行 `pnpm build`。没有白名单需要重新生成 —— 加载器会在渲染时发现 `patches/` 下安装了哪些 `.css` 文件。
4. 确认 `htdocs/luci-static/aurora/patches/<page>.css` 很小(只有你写的规则)。
> 移除补丁是对称的:删掉文件并重新构建 —— 加载器不再链接它,因为它不存在了。
**随第三方应用一起发布补丁**(无需主题发版):构建或手写一个以你页面 `data-page` 前缀命名的纯 CSS 文件,并在你的包 Makefile 中安装它:
```makefile
define Package/luci-app-foo/install
...
$(INSTALL_DIR) $(1)/www/luci-static/aurora/patches
$(INSTALL_DATA) ./htdocs/aurora-patch.css \
$(1)/www/luci-static/aurora/patches/admin-services-foo.css
endef
```
只要两个包都安装了,主题就会在 `admin-services-foo` 及其所有子页面上自动加载它。注意应用自带的补丁绕过了主题的 Tailwind 构建 —— 请写纯 CSS(你仍然可以引用主题的 CSS 自定义属性,例如 `var(--surface)`),并把每条规则都限定在你自己的 `[data-page^="…"]` 选择器之下。
**命名。** 文件名就是页面的 `data-page` 字符串;因为按前缀匹配,更宽的目标也自然生效:
| 你想打补丁的对象… | 要创建的文件 | 它会在哪些页面加载 |
| --- | --- | --- |
| 某个具体页面,`admin/services/foo/general` | `admin-services-foo-general.css` | 该页面(及其下任意子页面) |
| 整个应用,`admin/services/foo/…` 下所有页面 | `admin-services-foo.css` | `foo`、`foo/general`、`foo/rules`、… |
| 动态的按实体页面,例如 QModem 短信 `…/sms/conversation/<contact>` | `admin-modem-qmodem-sms-conversation.css`(固定前缀 —— 无需通配符) | 每一个会话页面,不论联系人是谁 |
由前缀匹配自然引出两条经验法则:
- **补丁默认作用于它的页面和所有子页面。** `admin-services-foo.css` 会在 `admin/services/foo/…` 下的每个页面加载。需要更精确的目标时,有两种收窄方式:在文件内部限定单条规则的作用域(`[data-page="admin-services-foo-general"] { … }` 只影响那一个页面),或者为页面专属规则再发布一个名字更长的文件(`admin-services-foo-rules.css`)—— 在两者都匹配的页面上,**两个都会加载**,名字短的在前,因此更具体的那个赢得层叠。
- **匹配遵守路径段边界**,所以前缀绝不会泄漏到长得像的兄弟上:`admin-services-wol.css` 覆盖 `admin/services/wol/plus`,但不覆盖位于 `admin/services/wol-plus` 的另一个应用。唯一无法避免的冲突是两条路径拼接出同一个 `data-page` 字符串(`wol/plus` vs `wol-plus`)—— 这样的补丁会在两个页面上都加载。如果这有影响,就把规则挂到你自己应用的类名/id 上,这样意外加载也匹配不到任何东西。
> 与 `_` 前缀的片段(那些是只供 `@import` 的碎片)不同,补丁文件名没有 `_` 前缀 —— 每一个都是会发布到 `htdocs/` 的真实构建入口。这一点在 `media/patches/` 内部同样成立:那里以 `_` 开头的文件是供其他补丁 `@import` 的共享片段,会被入口扫描跳过,永不发布。
**JS 负载。** 这套机制并不限于 CSS:同一次 `lsdir()` 扫描也会收集 `patches/<page>.js` 文件,作为 `<script defer src>` 输出在补丁样式表链接之后 —— 同样的按页前缀匹配,同样对第三方包即插即用的生命周期(发布一个纯脚本;它不能假设解析时就能通过 `L.require` 加载 LuCI 模块 —— 请在 DOM ready 之后运行,或轮询你的目标元素)。主题自有的 JS 补丁放在 `src/resource/patches/<page>.js`,和其他资源 JS 一样经过 Terser 处理,但落到 `aurora/patches/` 下,这样一次目录列举就能同时服务两种负载类型。第一个使用者:`admin-status-logs` 上的日志查看器增强(把只读的 `#syslog` textarea 解析成带颜色、按列对齐的视图,并带一道解析成功闸门 —— 遇到未知日志格式时回退到原生 textarea;这一个前缀覆盖了系统日志标签页、内核日志标签页以及每个受支持版本上的裸 `/logs` alias)。它的 CSS 刻意**不是**补丁:日志页面是原生 LuCI,所以它们的样式放在 `main.css` 内的 `components/_syslog.css` 里,那里完整的 Tailwind token 流水线(响应式圆角、阴影、配置应用的覆盖)都适用 —— patches/ 保持只服务于第三方兼容。
**别名 —— 一份负载,多个页面。** 当两个不相关的页面名需要同一份负载时,按它们的共同前缀命名会过度匹配(一个 `admin-status` 补丁会加载到*每一个* status 子页面上,包括最繁忙的概览页)。为此,`vite.config.ts` 里的 `PATCH_ALIASES` 会把构建产物(CSS 和 JS)复制成每个别名的名字,开发服务器则把别名请求解析回共享的源文件。用重复的 CSS *入口* 作为替代方案行不通:Rollup 会把内容相同的资源去重成一个文件,于是两个名字里的一个会悄无声息地永不发布。(这张映射表目前是空的 —— 日志查看器最后只需要 `admin-status-logs`,因为每个受支持版本都把两个日志页挂在 `admin/status/logs/*` 之下。)
### Mock 页面
给第三方应用的页面做样式 —— 编写或调整它的 `patches/*.css`,或拿它来检验 `main.css`/组件的改动 —— **无需安装那个应用(甚至无需设备)**。把页面渲染后的 HTML 保存一次,之后就能在主题实时热重载的情况下对着它开发。
- **快照放在哪:** `.dev/mocks/*.html`(被 git 忽略 —— 快照体积大、与设备/分支强相关、还会过期,所以只留在本地)。全新克隆里这个目录不必存在;首次捕获时会自动创建。
- **Mock 工具条:** 这个开发服务器交出的每一个 HTML 页面 —— 无论是代理的设备页面还是提供的快照 —— 都会带上 `scripts/mock-bar.client.js`(在 `/mocks/__bar.js` 提供),它是左下角的一个浮动工具条。它活在 Shadow DOM 里,因此主题和补丁 CSS 既不能重新给它上样式、也不会被它污染,而主题自己的浮动工具栏继续占据右下角。在设备页面上它会列出 `.dev/mocks/` 里有什么,这样不用手敲 `/mocks/` URL 也能进入该工作流:当本页 `data-page` 匹配到某个快照时会出现 `◆`,一键打开它;`⊕` 捕获当前打开的页面;`.dev/mocks/` 为空时工具条会缩成一个孤零零的 `⊕`。在快照内部,它会显示当前打开的是哪一个、可以逐个切换其余快照,而 `↩` 回到设备上的同一页面。`✕` 把它收起为一个点,状态记在 `localStorage['aurora.mockbar.collapsed']`。快照列表被内联注入在脚本旁边;两个标签都带 `data-aurora-mock`,捕获时正是靠它把这些标签剥掉 —— 快照绝不能把一份列表烤进去,因为每次提供服务时都会重新注入一份最新的。
- **捕获一个:** 让开发服务器代理一台装有该页面的设备,通过代理打开页面并点 mock 工具条上的 `⊕` —— 或者按 <kbd>Alt/Option+Shift+S</kbd>,或者在控制台里调用 `__auroraMockCapture()`。它会把实时 DOM POST 到 `/mocks/__save`,后者写出 `.dev/mocks/<data-page>.html` —— 以页面的 `data-page` 命名,包含 doctype,仅开发用的 script 标签被剥除。(该端点只接受带有辅助脚本自定义头的请求,跨源页面在没有本服务器绝不批准的 CORS 预检的情况下发不出这个头。)手动捕获同样可行:在 DevTools 控制台运行 `copy(document.documentElement.outerHTML)`,把粘贴内容存成 `.dev/mocks/<name>.html` —— 文件名随意;页面真正的身份是它 `<body>` 里已有的 `data-page` 属性,补丁选择器匹配的正是它。
- **必须从运行着*本*主题的设备上捕获 —— 快照在主题之间不可移植。** 快照是渲染后页面的逐字拷贝,所以它在三个地方硬编码了渲染它的那个主题:样式表链接(`/luci-static/aurora/main.css` 加上该页的补丁)、内联 `<style>` 里设备存储的 UCI token 覆盖,以及主题自己的头部/导航标记。把一个在 `luci-theme-shadcn` 下捕获的快照丢进来(或者反过来),页面会**完全没有样式**,因为开发服务器只提供它自己的 `/luci-static/<theme>/` 前缀。破绽是终端里一行点名了另一个主题样式表的输出:
```
[Mocks] miss /luci-static/shadcn/main.css → 404 (mirror it at .dev/mocks/static/… to serve it)
```
那条通用提示建议的「镜像」在这里是错误的修法 —— 请从运行着本主题的设备上重新捕获该页面。如果你无论如何都要复用一个外来快照,那么只有该应用自己的内容区还有意义:把它的样式表链接指向本主题,并删掉那个内联 `<style>` 块,否则被捕获设备的颜色会覆盖当前检出的 token。
- **查看:** `pnpm dev`,然后打开 <http://localhost:5173/mocks/> —— 一个自动生成的索引会列出每个快照及其 `data-page` 和存在时长。`mock-pages-plugin`(在 `vite.config.ts` 中)在提供每个页面时注入 Vite HMR 客户端,因此编辑任何主题源码(`main.css`、某个组件、某个 `patches/*.css`、或被提供的 JS)都会触发常规的整页重载(见 [实时重载行为](#实时重载行为))。快照保留它绝对的 `/luci-static/…` 链接;主题的 CSS/JS、字体和图片都在本地解析并即时编译。提供服务时会补上 `outerHTML` 捕获会丢掉的 `<!doctype html>`,所以 mock 会像真实页面一样以标准模式渲染。
- **在快照之间原地导航:** 在 mock 内部,工具条还会接管快照自身 LuCI 链接(`/cgi-bin/luci/…`)的点击,按 `data-page`(精确匹配)把它们解析到已有快照上并直接跳转 —— 应用的标签栏或侧边栏用起来就跟在设备上一样。未捕获的目标会被拦下,并给出点名缺失快照的提示,而不是穿透到代理。工具条列出每一个快照,用 <kbd>[</kbd>/<kbd>]</kbd>(或它的 ‹/› 按钮)循环切换,并链接回索引。`↩` 离开去往真实页面:它的目标来自 LuCI 自己内联引导代码里的 `requestpath`,但仅在该值与快照的 `data-page` 一致时才用(手工拼装的 mock 可能带着它所依据页面的路径段);否则回退到拆分 `data-page`,而只要某个路径段自身含有连字符(`admin-status-disks-info`),这种拆分就是有损的;再不行就回退到本标签页最后访问过的设备页面。
- **第三方资源:** 快照引用的应用自有 css/js(例如 `qmodem-next.css`,或某个只存在于设备上的自定义 logo)不在本仓库里。要提供它们,就把它的 URL 镜像到 `.dev/mocks/static/` 之下(例如 `.dev/mocks/static/luci-static/resources/qmodem/qmodem-next.css`);那里的文件按原样提供(无 HMR)。mock 页面请求的未命中会立即 404 —— 绝不代理到路由器,因此 mock 完全可以离线使用 —— 而每次未命中都会打印一次性的终端提示,给出准确的镜像路径。(由 CSS 发起的请求,例如导航图标,携带的 referer 是样式表的 URL,无法归因到 mock 页面;因此任何穿透到代理的 `/luci-static` 请求都被限制在 5 秒内,路由器不可达时回答 504。)没有这些资源,主题依然生效。
- **无认证,无运行时:** 快照是静态 DOM,所以 `mock-pages-plugin` 会剥掉 LuCI 的运行时脚本(`luci.js`/`cbi.js`/`xhr.js` 以及 `/cgi-bin/` 端点),并在提供服务前注入一个空操作的 `L`/`LuCI`/`XHR` 桩。没有这一步,LuCI 会启动、轮询后端、拿到 403(没有会话)并弹出「会话已过期」弹窗。代价是:依赖框架的主题 JS(例如 `menu-aurora`)在 mock 里变成空操作 —— 但捕获的 DOM 本来就已经渲染好了,所以看上去仍然是对的。主题自己的内联脚本(深色模式、工具栏状态)以及任何 `src/media/` 下的 JS 仍然会运行。
### 设计 Token
这里没有本地的 `_tokens.css`,也没有生成步骤:`main.css`/`login.css` 直接 `@import "@eamonxg/luci-theme-tokens/dist/aurora/tokens.css"`,在构建时直接从 `node_modules` 解析。真正的源头在独立的 [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) npm 包里,本仓库以 devDependency 方式消费它:
- **`aurora/defaults.js`** —— 浅色和深色模式下 10 个可编辑的输入色(`bg`、`surface`、`text`、`brand`、`on_brand`、`link`、`info`、`warning`、`success`、`danger`),以 OKLCH 字符串表示。
- **`aurora/spec.js`** —— `DERIVATIONS`(其余每一个 token —— `text_muted`、`surface_sunken`、`hairline`、`brand_hover`、`brand_subtle`、`focus_ring`、`progress_start`/`progress_end`、`*_surface`、`scrim`、`mega_menu_bg` 等等 —— 如何通过 `mix`/`shade`/`set`/`alpha`/`const` 运算符从输入色计算出来),以及 `FIXED`(绕过推导的、按模式区分的字面量,例如阴影)。
- **`engine.js`** —— 这些运算符背后的 OKLCH/OKLAB 色彩数学,基于 [colorjs.io](https://colorjs.io/)。
- **`resolve.js`** —— `createResolver` 遍历一份 `DERIVATIONS` 规格并返回一张扁平的 `{token: oklchString}` 映射,其中不留任何 `color-mix()`/`var()`;`aurora/index.js` 把它预绑定到 Aurora 自己的规格上,导出为 `resolveMode(mode)`,从包的 `/aurora` 入口暴露。
- **`dist/aurora/tokens.css`** —— 由该包自己的 `build.mjs`(其 `prepublishOnly`)构建并随发布的 tarball 一起分发;本仓库不会重新生成它。
**修改一个颜色:**
1. 在 [`luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) 仓库里编辑 `aurora/spec.js`/`aurora/defaults.js`(推导规则、固定字面量、基础输入色),打一个 release tag 让 CI 跑测试、构建 `dist/` 并发布包,然后在这里 bump `@eamonxg/luci-theme-tokens` 这个 devDependency 的版本并运行 `pnpm install`。若要对着本地检出做未发版的迭代,改为在 `.dev` 下运行 `pnpm link ../../luci-theme-tokens`,而不必 bump/发布。
2. 运行 `pnpm build` —— Vite 直接从 `node_modules` 解析 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`,所以在本侧,一次颜色改动所需要的就只是一次版本 bump(或 `pnpm link`)。
3. 运行 `pnpm test` 检查色彩数学与派生 token 的不变量(`tests/resolve.test.js`、`tests/surfaces.test.js`,两者都从 `@eamonxg/luci-theme-tokens/aurora` 引入 `resolveMode`)—— 例如色相族、`bg`/`surface_sunken`/`surface` 之间的明度顺序,以及菜单背景的半透明性。
**来自 UCI 的运行时覆盖:** `header.ut` 在每次渲染时读取 `uci get_all aurora.theme`,并把存储的 token 以 CSS 自定义属性覆盖的形式,在 `main.css` 之后重新输出到一个内联 `<style>` 中。键按前缀分命名空间 —— `light_*` 和 `struct_*` 落到 `:root`,`dark_*` 落到 `[data-darkmode="true"]` —— 前缀被剥掉,`_` 映射为 `-`(例如 `light_surface_sunken` → `--surface-sunken`)。模板在一次遍历中把所有键扁平化成两个预先拼好的声明字符串(而不是逐键的模板循环),这把迭代工作量减半,并让输出的 `<style>` 保持紧凑。这正是 `luci-app-aurora-config` 写入的那个钩子。
### LuCI JavaScript API
LuCI 相关的 JavaScript 开发,请参考官方 API 文档:
- [LuCI JavaScript API Reference](http://openwrt.github.io/luci/jsapi/index.html)
### 实时重载行为
- **CSS 改动**:通过自定义 HMR 处理器触发整页重载
- **JS 改动**:通过自定义 HMR 处理器触发整页重载
- **模板改动**(`.ut` 文件):经 SSH 自动同步到路由器并触发整页重载(需一次性运行 `pnpm setup:router`,见下)
### 模板(`.ut`)实时同步
`.ut` 模板文件是在 OpenWrt 设备上服务端渲染的,所以不像 CSS/JS 那样能本地提供 —— 开发服务器改为把它们推送到路由器。运行一次 `pnpm setup:router` 配置好免密 SSH;此后全自动:
- **启动时**,整个模板目录被推送一次(通过 ssh stdin 传一个 tarball —— Dropbear 没有给 scp 用的 SFTP 服务),这样开发服务器停机期间做的编辑绝不会让路由器上的文件陈旧。
- **保存时**,改动经防抖后再次推送整个目录,然后浏览器重载。
- **页面加载时**,对 `/cgi-bin` 的请求会等待任何进行中的推送,因此被代理的渲染绝不会用到陈旧模板。
**排障** —— 同步错误会连同修复方法一起打印:
- **主机密钥不匹配**(设备被刷过机):运行 `ssh-keygen -R <device-ip>`,然后重启开发服务器
- **认证失败**(公钥不在设备上,例如刷机之后):运行 `pnpm setup:router`
- **连接被拒绝/超时**:检查设备是否在线、SSH 是否启用
失败的同步会在下一次 `.ut` 改动时重试;没有 SSH 时 CSS/JS 的开发功能照常工作。
## 构建生产版本
### 构建命令
```bash
cd luci-theme-aurora/.dev/
pnpm build
```
它把所有资源编译到生产目录 `htdocs/luci-static/`,OpenWrt 打包编译时 LuCI 会使用该目录。
**构建产物:**
```
htdocs/luci-static/
├── aurora/
│ ├── main.css # 压缩后的管理界面 CSS(经 lightningcss)
│ ├── login.css # 压缩后的登录页 CSS(经 lightningcss)
│ ├── fonts/ # Web 字体(Lato)
│ └── images/ # Logo 资源 + PWA 图标
└── resources/
└── menu-aurora.js # 菜单配置(经 Terser 压缩)
```
**构建过程:**
1. Vite 构建两个 CSS 入口(`src/media/main.css` 和 `src/media/login.css`),直接从 `node_modules` 解析 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`(见[设计 Token](#设计-token)),并保留 Tailwind 原生的 `@layer` 结构
2. 自定义 Vite 插件(`luci-js-compress`)经 Terser 压缩 JS 文件
3. 从 `.dev/public/aurora/` 复制静态资源
## 打包编译
### 通过 GitHub Actions
**构建前端资源:**
1. 手动触发 `frontend-assets-build` 工作流
2. 它运行 `pnpm build`,若有变化则把产物自动提交到 `htdocs/`
**构建 `.ipk`/`.apk` 包:**
1. 推送版本 tag(`v*`)、以带 `[build]` 的提交信息推到 `master`/`feat/**`,或手动触发工作流
2. `build-theme-package` 工作流会同时编译 `.ipk` 和 `.apk` 两种 OpenWrt 包
**PR 审查:**
触及 `.dev/`、`htdocs/`、`ucode/` 或 `root/` 的 Pull Request 会由 `claude-pr-review` 工作流自动审查 —— 它在源码 diff 上发布行内评论(生成的 `htdocs/` 产物被排除),外加一条总结评论。在 PR 评论中 `@claude` 可请求追加审查或提问。
**Issue 分诊:**
新 issue 由 `claude-issue-bot` 工作流处理 —— 它检查垃圾/重复内容、打标签,并发布一条深入的技术分析评论。在 issue 评论中 `@claude` 即可获得回复。
**工作流文件:** `.github/workflows/`
- `frontend-assets-build.yml` —— 构建资源并自动提交(手动触发)
- `build-theme-package.yml` —— 编译 `.ipk`/`.apk` 包
- `claude-pr-review.yml` —— PR 的 AI 代码审查(行内 + 总结评论)
- `claude-issue-bot.yml` —— AI issue 分诊与分析
## 目录结构
```
luci-theme-aurora/
├── .dev/ # 开发环境
│ ├── docs/ # 项目文档
│ │ └── DEVELOPMENT.md # 开发指南(本文件的英文版)
│ ├── mocks/ # /mocks/ 用的本地页面快照(被 git 忽略,见 Mock 页面)
│ ├── public/aurora/ # 公共静态资源
│ │ ├── fonts/ # Web 字体(Lato)
│ │ └── images/ # 主题图片 + PWA 图标
│ ├── scripts/ # 构建脚本 + 开发服务器客户端辅助脚本
│ │ ├── clean.js # 构建清理工具
│ │ ├── mock-bar.client.js # 注入到设备页面和 /mocks/ —— 快照工具条、捕获、链接接管
│ │ └── setup.js # pnpm setup:router —— .env 向导 + 到路由器的免密 SSH
│ ├── src/ # 源码
│ │ ├── assets/icons/ # SVG 图标
│ │ ├── media/ # CSS 源码(Tailwind CSS v4)
│ │ │ ├── main.css # 管理界面入口(import 清单;token 来自 @eamonxg/luci-theme-tokens)
│ │ │ ├── login.css # 登录页入口
│ │ │ ├── _base.css # 文档基座(html/body 视口背景)
│ │ │ ├── _elements.css # 基础元素样式(标题、链接等)
│ │ │ ├── _layout.css # 页面布局/结构
│ │ │ ├── _utilities.css # 自定义工具类
│ │ │ ├── components/ # 每个 UI 组件一个片段
│ │ │ └── patches/ # 按页的第三方补丁(按需加载,一个 data-page 一个文件)
│ │ └── resource/ # JavaScript 资源
│ │ └── menu-aurora.js # 菜单逻辑
│ ├── tests/ # 全部测试套件(pnpm test)
│ │ ├── resolve.test.js # 解析后 token 的不变量(对着 @eamonxg/luci-theme-tokens/aurora)
│ │ ├── surfaces.test.js # 表面/色相分层的不变量
│ │ ├── overlay.test.js # 遮罩/布局 CSS 断言
│ │ └── navigation-*.test.js # 导航模型/渲染/样式
│ ├── .env.example # 环境变量模板
│ ├── .prettierrc # Prettier 配置
│ ├── package.json # Node.js 依赖
│ ├── pnpm-lock.yaml # pnpm 锁文件
│ └── vite.config.ts # 带自定义插件的 Vite 配置
├── .github/ # GitHub 配置
│ ├── ISSUE_TEMPLATE/ # Issue 模板
│ ├── workflows/ # GitHub Actions 工作流
│ └── renovate.json # Renovate 依赖更新配置
├── .vscode/ # VS Code 工作区设置
│ └── settings.json # 保存时自动格式化设置
├── htdocs/luci-static/ # 构建产物(由 Vite 生成)
│ ├── aurora/ # 主题 CSS 与资源
│ │ ├── fonts/ # 构建后的字体文件
│ │ ├── images/ # 构建后的图片 + PWA 图标
│ │ ├── main.css # 编译后的管理界面 CSS
│ │ ├── login.css # 编译后的登录页 CSS
│ │ └── patches/ # 编译后的按页补丁(由 header.ut 按需链接)
│ └── resources/ # 构建后的 JavaScript 模块
│ └── menu-aurora.js # 压缩后的菜单逻辑
├── root/etc/uci-defaults/ # OpenWrt 系统集成
│ └── 30_luci-theme-aurora # 主题自动配置脚本
├── ucode/template/themes/aurora/ # LuCI ucode 模板
│ ├── header.ut # 头部模板
│ ├── footer.ut # 底部模板
│ └── sysauth.ut # 登录页模板
├── LICENSE # Apache License 2.0
├── Makefile # OpenWrt 包 Makefile
├── README.md # 英文文档
└── README_zh.md # 中文文档
```
## 工具与技术
- **[Tailwind CSS v4](https://tailwindcss.com/)** —— 工具类优先的 CSS 框架
- **[Vite](https://vitejs.dev/)** —— 构建工具与开发服务器
- **[pnpm](https://pnpm.io/)** —— 快速、节省磁盘空间的包管理器
- **[lightningcss](https://lightningcss.dev/)** —— CSS 压缩器
- **[colorjs.io](https://colorjs.io/)** —— 设计 token 生成所用的 OKLCH/OKLAB 色彩数学(由 [`@eamonxg/luci-theme-tokens`](https://github.com/eamonxg/luci-theme-tokens) 使用)
- **[Terser](https://terser.org/)** —— JavaScript 压缩器
- **[Prettier](https://prettier.io/)** —— 代码格式化工具
- **[prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)** —— Tailwind 类名排序
- **[tw-animate-css](https://github.com/Wombosvideo/tw-animate-css)** —— Tailwind CSS 的动画工具类
- **[tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar)** —— 自定义滚动条样式插件
+665
View File
@@ -0,0 +1,665 @@
<h4 align="right"><strong>English</strong> | <a href="router_zh.md">简体中文</a></h4>
# The client-side router
> The router now ships from `@eamonxg/luci-theme-devkit` (`runtime/router.js`,
> page-scoped patches in `runtime/patches.js`), built into
> `resources/router-aurora.js` by its Vite plugin; this theme keeps only the
> markers, the `luci-navigate` listener in `menu-aurora.js` and the CSS. The
> design below is unchanged; the devkit copy is the maintained one.
How the theme turns a menu click into an in-document view swap instead of a
full page load, where it deliberately does not, and the invariants a router
inside LuCI has to keep. Source: `.dev/src/resource/router-aurora.js`,
loaded from `footer.ut` next to `menu-aurora.js`. **No changes to luci-base
or to any view** — the router is additive theme JS plus three small template
hooks (a patch manifest, a `<footer>` boundary, and `data-aurora-*` markers
on the stylesheets header.ut itself renders).
![One LuCI navigation: what the device does, what the browser does, and which of those steps the same-document router deletes](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.svg)
## Prior art
[luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)
solves the same problem, and reading it informed two pieces here: pausing
`L.Poll` on a hidden tab, and folding a view's read-only state along its
dispatch path (both below). The rest is independent, and one choice diverges
deliberately. footstrap drives navigation through the **History API**
(`pushState`/`popstate`) with its own scroll bookkeeping and a
`prototype.render` guard to repair stale renders; this router is built on
the **Navigation API** instead (see "Kernel"), which hands scroll, history
and supersession to the browser and needs none of that — at the cost of
running only on newer browsers, where the theme falls back to the plain MPA
it already is. On top of that shared base this router also adds a
session-expiry gate, reproduces `template` pages from the server's own shell
rather than hand-porting them, and cross-fades the swap with a view
transition — each its own section below.
## Why it pays, measured
**Cudy TR3000** (mediatek/filogic, ARMv8), OpenWrt SNAPSHOT r0-20d94d5, this
branch deployed, plain HTTP, warm cache, 2026-08-18. Both paths are measured
in one loop (`bench-fullload.mjs`, RUNS=10, medians over the 8 pages below)
so they see the same device state. Run-to-run spread is ±40 ms on a full
load: the ratios are the claim, the digits are not.
### Where a full load's time goes
| stage | ms | what it is |
|---|--:|---|
| dispatch #1 — the page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut` → `header.ut` |
| dispatch #2 — `admin/translations/<lang>` | 124→209 | a *second* CGI process, parser-blocking, uncacheable |
| DOMContentLoaded | 215 | the shell is back, byte-identical to the one just discarded |
| view module + ubus data + render | 215→321 | static assets are already cache hits |
**209 of the 321 ms passes before anything page-specific has happened.** Both
dispatches re-derive a shell the browser already had on screen; the view's own
ubus calls do not start until 227 ms. A same-document swap deletes both
dispatches and keeps the last row — the same 8 pages land at a median of
**91 ms** warm, with the data calls starting at 2 ms instead of 227 ms.
### What one dispatch costs the device
Measured on the device itself, so no network is in the number
(`bench-dispatch.sh`).
| request | ms | bytes |
|---|--:|--:|
| page HTML, a `view` node | 75.4 | 18,583 |
| `admin/translations/en` | 62.7 | **13** |
| `admin/translations/zh-cn` | 60.3 | 229,503 |
| `admin/menu` — once per *session*, not per navigation | 68.2 | 45,022 |
| static `main.css` | **0.8** | 191,899 |
| inside one dispatch, per process | ms |
|---|--:|
| fork + ucode VM | 2.2 |
| `import luci.dispatcher` (runtime, http, ubus, uci, core, authplugins) | 37.2 |
| menu tree: stat 8 `menu.d` files + parse the 28,307 B index cache | 13.8 |
| `session.get` + `session.access` over ubus | 6.0 |
The `en` row is the control: a **13-byte** response still costs 62.7 ms, while
a 191,899-byte static file is served in 0.8 ms. The cost is the dispatch, not
the payload — and a full page load pays that block **twice**. (`zh-cn` clients
additionally re-transfer 229,503 B every navigation: `write_headers()` in
`http.uc` sets `Cache-Control: no-cache` and `Expires: 0` with no `ETag` or
`Last-Modified`, so there is nothing to revalidate against.)
### End to end, click → view painted
Median of 10 per page, full load = navigation start → first non-spinner child
of `#view`; router = click → the navigation's `finished` promise.
| page | full load | router (warm) | faster |
|---|--:|--:|--:|
| status/routesj | 326 | 92 | 72 % |
| status/nftables | 316 | 90 | 72 % |
| status/logs | 281 | 100 | 64 % |
| status/processes | 457 | 228 | 50 % |
| status/channel_analysis | 401 | 54 | 87 % |
| status/realtime | 211 | 37 | 82 % |
| system/system | 496 | 132 | 73 % |
| system/admin | 231 | 40 | 83 % |
Median **73 % faster**, range 50–87 %. `bench-router.mjs timing`, an
independent harness, was run twice the same day and landed at 72 % and
74.5 % — all of that is inside the device's own spread, so treat the range,
not the digit, as the result.
Speculation-rules prefetch cannot reach it: the API is secure-context only,
so it is inert on HTTP, and on HTTPS a document prefetch hides only the
first dispatch — the catalog is a subresource, fetched after the document
arrives.
## Why it is possible
For a `view` node the dispatcher renders `view.ut`: the theme header, then
`<div id="view">` with an inline `L.require('ui').then(ui =>
ui.instantiateView('<path>'))`, then the theme footer. The server decides
*which* view; the client renders it. The router repeats what `view.ut` does
without the reload: resolve the path against the menu tree the client
already holds (`ui.menu.load()` serves it from `sessionStorage`), swap the
content region, re-instantiate the view, and let the browser own the URL.
## Kernel: the Navigation API, and only that
`navigation.addEventListener('navigate', …)` + `event.intercept()`. One
event covers link clicks, `location.assign`, back/forward traversals to
same-document entries, and our own `navigation.navigate()`; the browser
writes the URL and history entry, exposes `event.signal` for supersession,
and (with `scroll: 'after-transition'`) restores scroll on traversal /
scrolls to top on push, so the router carries no `pushState`/`popstate`
code, no scroll bookkeeping, and no fragment-vs-navigation heuristics.
Why this API rather than the History API footstrap uses:
- **The browser owns URL, history and scroll.** `pushState` puts the
router in charge of all three and of keeping them consistent with what
it rendered; here it only ever renders.
- **Supersession is built in.** A newer navigation aborts the older one's
`event.signal`; the generation gate below is a check, not a state
machine, and no `render` guard is needed to repair a stale paint.
- **Every navigation kind arrives at one listener** — link click,
`location.assign`, back/forward to a same-document entry, our own
`navigation.navigate()` — so there is exactly one path to keep correct.
- **The fallback is free.** Where the API is missing, the theme is the
MPA it already was; nothing has to be polyfilled or feature-forked.
**Browsers without the API stay MPA.** `footer.ut` only requires the module
when `window.navigation` exists, and `__init__` re-checks the surface it
actually uses: `navigation.addEventListener`, `NavigateEvent`, and
`intercept` on its prototype. Chrome/Edge **105+**, Safari 26.2+, Firefox
147+ get the router — 105, not the 102 that first shipped the Navigation
API, because the method was called `transitionWhile()` until Chrome 108 and
`canIntercept` was `canTransition`; gating on `intercept` is what makes the
floor 105. The theme's declared floor (Chrome 111 / Safari 16.4 /
Firefox 128) keeps working as it does today. This is a deliberate trade:
one code path, correct by construction, over a second history-API path that
would double the surface of everything below.
## Compatibility
### Browsers — per platform feature
| Feature | Used for | Required? | Chrome / Edge | Safari | Firefox | Without it |
|---|---|---|---|---|---|---|
| Navigation API (`navigation.addEventListener('navigate')`, `NavigateEvent.intercept()`, `event.destination/signal`, `navigation.navigate()/back()`) | the whole router | **yes — gate** | 105+ (2022) | 26.2+ (2026-01) | 147+ (2026-01) | `router-aurora.js` is not even loaded (`footer.ut` checks `window.navigation`); the theme is the plain MPA it was before |
| `document.startViewTransition()` (same-document) | crossfade at the swap | no | 111+ | 18+ | 144+ | swap without animation; also off under `prefers-reduced-motion` |
| `fetch(url, { priority: 'low' })` | hover module prewarm | no | 101+ | 17.2+ | 132+ | the option is ignored, the fetch still runs at default priority |
| `MutationObserver`, `DOMParser`, `WeakSet`, `URL`, `Element.replaceWith`, `:scope`, `matchMedia`, optional chaining / `??=` | render completion, template shells, poison gate, staging | yes | ≥ 85 | ≥ 14 | ≥ 79 | all inside the theme's declared floor (Chrome 111 / Safari 16.4 / Firefox 128) |
So the router's effective floor is Chrome/Edge 105, Safari 26.2, Firefox 147;
everything older keeps the theme's existing floor and behaviour. Verified
live in Chrome 151 (headless, `bench-router.mjs`); Safari/Firefox by feature
detection only — the gate is the same API surface, not a UA sniff.
### OpenWrt / LuCI
The theme already requires OpenWrt 23.05+ (ucode templates). Except for the
two version-scoped items called out below, the router touches only luci-base
surfaces that are identical in the `openwrt-23.05`, `openwrt-24.10`,
`openwrt-25.12` and `master` branches of `openwrt/luci` (checked against the
branch sources, 2026-08): `L.require` with instance caching and
`prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref`
registry, `L.env.{scriptname, base_url, resource_version, media,
requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`,
`L.Poll.{queue, start, stop, active, timer}` (and `start()`'s reset of
`tick`, which is what re-arms an incoming view's first poll),
`ui.menu.load()`'s session-cached tree with `satisfied` /
`firstchild_ineligible` / `wildcard` / `action.type` (`view`, `alias`,
`firstchild`, `template`), the `poll-status` indicator id `setupDOM`'s
`poll-start` handler registers (the teardown hides it by that name),
`ui.instantiateView`, `ui.hideIndicator`,
`ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`,
`network.js`'s uci-backed state, `Request.addInterceptor` /
`rpc.addInterceptor` and the `-32002` → `session.access` probe in
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding, `view.ut`'s `#view` +
inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
`node_weight` / alias re-dispatch semantics (ported line for line).
**Two surfaces are not the same across those branches**, and the resolver is
written against the newer one:
- **`node.css`** entered `build_pagetree`'s schema in master only
(7c6d8ff, 2026-08). 23.05, 24.10 and 25.12 carry no `css` on any node, so
`nodeCss()` returns `null` and the feature is simply inert there.
- **Wildcard descent.** `wildcardaction` exists in 25.12 and master, not in
23.05 or 24.10 — an absent key just falls back to `node.action`, which is
what those releases do anyway, so that part is safe. The *resolution rule*
around it is not: 25.12 and master descend into a matching `satisfied`
child before treating trailing segments as args, while 23.05 and 24.10
capture every remaining segment the moment a `wildcard` node is reached.
The router ports the 25.12/master rule. On 23.05 or 24.10 a tree that has
both `foo/*` and a real `foo/bar` child would therefore resolve differently
in the router than in the dispatcher — the exact "click opens one page, F5
opens another" failure this resolver exists to avoid. Both the rule and
`wildcardaction` came in as one commit (df90c60a7, 2026-01-17) whose stated
purpose is to let `path/*` carry an action distinct from the bare path, so
the shape had no defined behaviour before it and a tree written for
23.05/24.10 is unlikely to use it — but that is an argument, not a survey
of every installed `menu.d`, and the router has not been run on either
release. Treat 23.05/24.10 as inspected, not verified.
Live verification so far: OpenWrt SNAPSHOT r0-20d94d5 (2026-08,
mediatek/filogic) and an earlier SNAPSHOT on ipq60xx. 23.05 / 24.10 / 25.12
by branch source only, not on device.
That list is also executable: `contract()` in `router-aurora.js` looks every
one of those surfaces up at boot (`L.view`, `L.require`, `L.dom.content`,
`L.env.{base_url,resource,media}`, `L.Request.addInterceptor`,
`L.uci.{load,unload,state}`, `rpc.addInterceptor`,
`poll.{queue,start,stop,active}`, `ui.menu.load`, `ui.hideModal`,
`ui.hideIndicator`, `E`) and, if any is missing, logs which and does not
activate — the theme is the MPA it was, not a broken router, on a luci-base
that moved.
## What is intercepted
A `navigate` event is intercepted only when **all** hold:
- `event.canIntercept` (same-origin, not cross-document-only), not
`hashChange`, no `downloadRequest`, no `formData`, `navigationType !== 'reload'`;
- the destination is **not the document's own URL** (fragment aside). A
same-URL navigation arrives as `navigationType: 'replace'`, not
`'reload'`, yet it is a reload by another name: luci-base's
`ui.changes.apply/revert` end in `window.location =
window.location.href.split('#')[0]` (and the expiry modal's button in the
same) precisely so the server re-renders the shell — a theme switched
under System → Language and Style, a new language, a new hostname, a
changed menu tree — and intercepting it left the swap showing the old
shell until F5. A click on the current page's own link is the same reload
it is in the MPA;
- the destination path (minus `L.env.scriptname`) resolves in the menu tree
to a **serviceable node** (below);
- the document is not **poisoned** (below) and its session is not known to
be **expired** (below);
- the router **activated** in this document: it does so only when the page
it booted on is itself serviceable. A `call`/`cbi`/`function` page carries
scripts (legacy `XHR.poll`, inline timers) that only a document death
retires; the first click away from one is always a full load.
Anything else falls through untouched: the browser performs the ordinary
full navigation, i.e. exactly what the theme did before. Modifier-clicks and
`target=_blank` never reach the event.
### Serviceable nodes
Resolved with a port of the dispatcher's own rules, not a paraphrase:
- `alias` → jump to `action.path` from the root and continue;
- `firstchild` → the same `resolve_firstchild()` / `node_weight()` the
dispatcher runs: candidates are `satisfied` children with a `title` and an
object `action`; weight `min(order ?? 9999, 9999)` +10000 for
`auth.login`; a `firstchild` candidate counts only if it resolves further;
`firstchild_ineligible` excluded; ties keep key order. The ACL check is
skipped because `/admin/menu` is already filtered for the session;
- `wildcard` nodes are descended into first — a segment that matches a
`satisfied` child wins over arg capture — and only the remainder becomes
request args; with args present the node's `wildcardaction` (the `path/*`
entry's own action) runs, and `action` for the bare path. This is the
25.12/master rule; 23.05 and 24.10 capture at the first `wildcard` node
instead — see "OpenWrt / LuCI" above;
- a hop counter (32) breaks cycles in a foreign `menu.d`;
- **any segment that does not match a `satisfied` child ends the attempt.**
The dispatcher would fall back to the deepest satisfied ancestor and
re-resolve from there; the router returns `null` and hands the navigation
to the server. Deliberate: the fallback costs one full load, guessing the
ancestor wrong costs the wrong page.
Two tracks are kept, as a full load keeps them: **requested** segments →
`L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved**
segments → `L.env.dispatchpath`, `L.env.nodespec`, the menu highlight, the
title. Pick a different child than the dispatcher would and a click opens
one page while F5 opens another — that is why the resolver is a port.
| node | served |
|---|---|
| `view` | yes — `view.<path>` |
| `alias`, `firstchild` | yes — resolved to a leaf, recursively |
| `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below |
| Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load |
`rewrite` is deliberately not resolved. The node and its action *are* in the
tree, but following it means re-implementing `dispatcher.uc`'s
`splice(request_path, 0, action.remove)` and re-dispatching from the result;
an off-by-one there opens the wrong page, which is worse than the reload it
falls back to.
### Template nodes: the server's own shell, never a hand port
`admin/status/overview` is a `template` whose server side defines page
globals (`progressbar`, `renderBox`, `renderBadge`), emits an `<h2>` and a
`div.includes` (server-rendered Lua includes), and then instantiates
`view.status.index`. A first version re-implemented those helpers in the
router and drifted on the first real page (the network badges lost their
labels: upstream's `renderBadge` takes extra `L.itemlist` arguments the port
did not know about). So the router does not port anything: when a link to a
template node is hovered or focused, its page is **fetched once per
document** (the in-flight request is shared by every intent event that
arrives before it resolves), parsed with `DOMParser`, and the content region between `#tabmenu` and
`<footer>` is kept as the page's *shell* — every node cloned, `#view`
replaced by an empty div, the inline `instantiateView('…')` script read for
the class name, the remaining inline scripts (the helpers) replayed into
global scope on staging. luci-base's own bootstrap (`luci.js` and
`L = new LuCI(env)`) also lives in that region and is filtered out. If the
document *is* that template (the session started on Overview), the shell is
taken from the live region and no fetch happens. A template node is only
intercepted once its shell is known — so a Lua template page (no
`instantiateView` call) is remembered as unservable after one hover fetch
and never enters the router's error path, and a template clicked without a
prior hover is a plain full load that seeds the shell for the rest of the
document. Its status include modules are singletons carrying
`oneshot`/`hide` state that a full load would reset — verified against a
real full load, not against expectation.
## The navigation procedure
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,
handler in order:
1. **Generation.** `const gen = ++this.gen`; every later DOM write is gated
on it.
`event.signal` aborts our own awaits, but it cannot cancel a LuCI XHR
(`L.Request` hands back a bare promise; the `XMLHttpRequest` only surfaces
on the *resolved* `Response`, too late to abort) or a `View.__init__`
chain already running, so the generation is the correctness mechanism and
the signal is hygiene.
2. **Teardown of the departing document state**, i.e. what a document
death would have done for free:
- `Poll`: `queue.length = 0; stop(); start()` — three steps. The flush
drops the old view's pollers; `stop()` drops the tick; `start()` on an
empty queue re-arms `tick = 0` so the incoming view's `poll.add()`
auto-starts and fires immediately instead of waiting up to `interval`
seconds for the surviving tick to align. Upstream's `initDOM()` does
the same `Poll.start()` on an empty queue before the first view.
- `uci`: `unload()` every package present in `state.values` **or**
`uci.loaded` (documents start with an empty cache; four shipped apps
read `load()`'s return as an existence check and draw an error over
the page when the cache answers `[]`; and `uci.loaded` keeps a
package's request promise — a rejected one included — until
`unload()`, so a failed load left there would be handed to every later
view). Then, if `L.network` has been loaded,
`load(['network','luci'])` — plus `'wireless'` when
`L.hasSystemFeature('wifi')` — is re-issued and **awaited** —
and a rejection propagates to the hard-load fallback rather than
leaving `network.js` on an empty config: `network.js` fills its `_state` once and
from then on answers out of the uci cache (`getWifiDevices()` *is*
`uci.sections('wireless','wifi-device')`), so dropping those without
refilling hands every consumer an empty config for the rest of the
document. Unsaved local edits die with the page as they would on a
full load; saved changes live on the server and the Unsaved-changes
indicator is unaffected.
- bare `setInterval`s registered since the router booted are cleared
(`setInterval`/`clearInterval` are hooked in `__init__`, i.e. when
`L.require('router-aurora')` instantiates the class; `poll.timer`, the
one interval `L.Poll` owns, is skipped). `setTimeout` and rAF are
**not** touched: the
core keeps tooltips, notification timeouts and a request timeout on
`setTimeout`, and there is no self-rescheduling timeout in any
shipped view.
- `window`/`document` listeners a view registered **while it rendered**
are removed. Several shipped views add them per render (statistics
graphs: an anonymous `resize` that later throws against detached DOM;
nlbwmon: `tooltip-open`/`touchstart`; the core's own dropdown widgets:
one `window` click/touchstart per instance), so they accumulate and
act on pages that are gone. The hook records registrations inside the
render window only. A **warm** render evaluates no module, so its
registrations are per-render by construction and go on the next
teardown. A **cold** render also runs the module's top level, whose
registrations must survive (removing them is one-way — an editor's
module-eval listeners never come back), so cold registrations are only
credited to the class and released when a later warm render of the
same class registers the same target/type, which proves them
per-render.
- `ui.hideIndicator('poll-status')` — luci-base leaves a
*Refreshing* / *Paused* indicator behind that a document death would
have taken with it.
- `ui.hideModal()`, the theme's own surfaces (mega
menu, mobile drawer, palette) close.
- page-scoped patch CSS is disabled and its JS patch unmounted (below).
The uci flush is the one part of this that is awaited rather than
fire-and-forget, so it is a separate step after `teardown()` returns.
3. **Environment.** `L.env.requestpath/dispatchpath/pathinfo/nodespec`,
`body[data-page]`, `document.title`. An alias is re-dispatched
server-side, so `requestpath` and `data-page` carry the alias target while
`pathinfo` keeps the URL as requested; a `firstchild` keeps the requested
path in both. The title suffix (` - hostname`) is read off the initial
document, so it matches whatever the template emitted. `nodespec` drives
`L.hasViewPermission()` and therefore the Save/Apply footer's readonly
state — and its `readonly` is **folded down the dispatch path** the way
`dispatcher.uc` does it: `ctx_append` collects every node's
`depends.acl` and one `check_acl_depends()` over the union is writable as
soon as *any* group is writable, so a page is readonly only when every
acl-bearing node on its path is. The tree's per-node flag
(`apply_tree_acls`) covers that node's own acl alone; handing the leaf
node over as-is gave a read-only user a live Save & Apply on every page
under a read-only group. The tree object is not mutated (`nodespec` is a
copy). `data-page` keys `ui.tabs` session state and the theme's
page-scoped CSS.
4. **Chrome.** `menu-aurora.js` exposes `syncRoute()`: it re-marks
`is-active-page`/`aria-current` from `L.env.dispatchpath` across every
nav surface, expands the active sidebar/mobile group and collapses the
rest, rebuilds the header crumb and re-renders `#tabmenu` for the new
section. Menus are **not** rebuilt — the mega menu measures and binds on
construction and the palette index is a flat array of the same model —
only their state changes.
5. **Staging.** A fresh `<div id="view" class="view-staging">` is inserted
right after `#tabmenu`, i.e. **first in tree order** —
`getElementById('view')` returns the first match, so everything LuCI's
view chain writes goes into the staged element while the outgoing page
stays on screen (dimmed, `.view-leaving`). The stage is invisible but
**laid out** (`visibility:hidden; height:0; overflow:hidden`, never
`display:none`): the realtime graphs size themselves from
`#view.offsetWidth` inside `render()`, and a `display:none` stage handed
them a 0-wide canvas. Nothing is removed yet.
6. **Patches.** `header.ut` emits the installed on-demand patch stems as
`body[data-patches]`; the router applies the same segment-prefix rule the
template applies at render time: matching `patches/<stem>.css` links are
ensured (`<link data-luci-patch>`, enabled for the page on screen,
`disabled` — not removed — for the rest, so a return costs nothing);
matching `patches/<stem>.js` files are loaded once and their
`window.luciPatches[stem]` `{ mount, unmount }` pair is driven per
visit (the list of stems to mount belongs to the navigation that computed
it, so a superseded one mounts nothing later); URLs the router adds carry
the same `?v=PKG_VERSION` luci.mk stamps on the template's own links,
read from `body[data-asset-version]`, so they hit the same cache entry — a JS patch that registers nothing is simply executed once,
MPA-style. A patch script mounts itself when it evaluates; if the user
has navigated on before it arrives, its `load` handler checks whether
the current page still wants that stem and unmounts it otherwise (a
same-stem page reached meanwhile keeps it mounted).
A menu.d node's own `css` (`header.ut` links `<resource>/<node.css>` for
the dispatched node, marked `data-luci-node-css`) is kept the same
way: one `<link>` per stylesheet, enabled for the page whose resolved leaf
declares it, `disabled` for every other page, never removed. Both
attributes are exempt from the poison gate.
7. **View.**
- **cold** (`view.<path>` never required in this document):
`window.L.require(className)` — the require *is* the render (LuCI
instantiates on first require) and it must go through `window.L`, the
runtime instance, never the prototypal `L` a module factory receives
(`ui` hangs `itemlist`/`showModal` on `window.L`; a view required
through the wrong `L` dies three modules down on `L.itemlist is not a
function`, and because `require()` caches by name the binding is fixed
by the *first* requirer);
- **warm**: `require()` hands back the cached instance whose `__init__`
already ran; LuCI's class system sets `prototype.constructor`, so
`new instance.constructor()` runs a fresh `__init__` → `load()` →
`render()` → `dom.content('#view')`, exactly what a full load starts
from. Either way the required value is checked with
`instanceof L.view`; anything else throws into the hard-load path
rather than staging a non-view.
- **completion** is observed, not assumed: a `MutationObserver` on the
staged element resolves when a non-spinner child lands (or the spinner
is removed for an empty render). Not completing within 15 s is a
**failure**, not a completion: committing the spinner and releasing the
serialization would let the still-running chain paint into a later
navigation's `#view`, so the timeout rejects and the catch path
hard-loads the destination. On completion — and only if this navigation
is still the latest — the outgoing region
(everything between `#tabmenu` and `<footer>` except the staged
element) is removed and the staged view is unhidden inside
`document.startViewTransition()` when available and reduced motion is
off; the navigation's `finished` promise resolves after that swap.
Each departing element goes through `L.dom.content(el, null)` before
`remove()`: that is what drops its `data-idref` registry entries, which
would otherwise hold the detached subtree and its class instances
alive — which is what the soak test below measures.
- **Renders are serialized.** Neither an in-flight LuCI XHR nor a running
`View.__init__` chain can be cancelled (same reason), and every chain
paints into *whichever* `#view` is first at paint time. So a navigation
first awaits the previous one's completion
(bounded by the same timeout) before it tears anything down or stages
anything — the previous chain finishes into its own staged element,
which is then discarded. Rapid A→B→C therefore never interleaves:
B is skipped when C arrives before B ran (`event.signal` /
generation), and C waits for whichever render is actually in flight.
The document's initial LuCI-rendered view is tracked the same way, so a
click during the first load cannot be painted over by it — and a first
render that never completes rejects that wait, so the first navigation
takes the hard-load fallback instead of staging next to a chain that
may still paint. That first render also runs inside a render window
(opened after the router's own listeners are registered), so the
listeners it adds are credited to its class like a cold render's; the
ones it registered before the router loaded are out of reach. The cost
is that a click during a slow load waits for that load; the alternative —
wrapping `prototype.render` per class and repairing stale cold renders
by re-navigating — leaves a real window open and needs three mechanisms
where one suffices.
8. **Focus and announcement.** `#maincontent` (`tabindex=-1`) with
`preventScroll`; the new `document.title` is written into
`#luci-nav-status` (`role=status`, `aria-live=polite`), since a
same-document swap fires no load a screen reader would announce. The
landmark carries `outline-none`: iOS WebKit (Safari and Chrome for iOS
alike) paints its focus ring for programmatic focus, and the ring's top
edge just under the sticky header was reported as "a progress bar that
never goes away" — it was never the bar.
9. **Progress.** A navigation that outlives 150 ms gets `#luci-nav-progress`
inserted — Turbo Drive's bar, in shape: a hairline at the top whose
`width` is driven inline and **trickles** in ever-smaller steps
(`+ (100 - w) / 30` every 300 ms) until commit, so a slow render keeps
visibly moving instead of looking stuck; on commit it fills to 100 %,
fades (`data-state="done"`) and is **removed from the DOM**. Shorter
navigations stay silent, overlapping ones share the bar. The browser's
own progress bar is no help here: it only shows for document loads,
which is exactly what a same-document swap is not — hence GitHub,
YouTube, Turbo/HEY and every nprogress user draw their own. Reduced
motion drops the transitions, not the bar.
10. Any exception → `console.error` (a silent fallback makes every router
regression look like "the page is just slow") → `location.href =
destination` — a hard full load, never a stuck page. A `bypass` flag is
set first so the `navigate` event that write produces passes straight
through instead of being intercepted back into the failing path, and the
handler then parks on a never-settling promise so nothing else runs
against a document that is on its way out.
## The expiry gate
luci-base answers a dead session with `notifySessionExpiry()`: `Poll.stop()`
plus a modal whose only button is a hard reload. A same-document swap would
`hideModal()` and `Poll.start()` right through it and browse on, every page
erroring in turn (measured: `bench-router.mjs expiry` against the previous
router — `expiredFullLoad: false`). So the router listens for the same two
signals luci-base acts on — a `403` with `X-LuCI-Login-Required: yes` on any
`L.Request`, and the `session.access` probe luci-base fires after a
`-32002` coming back denied or errored — and from then on intercepts nothing:
the next click is a full load, which the dispatcher turns into the login
page. A denied call on any other object is an ACL matter and is ignored.
Nothing is reset: the flag dies with the document, as the session did. The
same flag keeps the visibility gate (below) from restarting a poll the
expiry stopped.
## Hidden tabs
luci-base keeps polling in a background tab. The router stops `Poll` on
`visibilitychange` → hidden when it was active and starts it again on
return — unless the user had paused it, or the session died meanwhile. On
a weak router that is RPC work nobody is looking at.
## The poison gate
A `<style>`/`<link rel=stylesheet>` a view writes into `<head>` dies with
the document on a full load and **survives** a same-document swap, painting
every page after it (a shipped file manager hides Save/Reset on every config
page with one unlayered `!important` rule). Removing it is not an option: a
library that imports CSS at module eval never runs again, so deletion is
one-way (an editor page came back as a black rectangle two million pixels
tall). Hence a gate, not a sweep: before intercepting, any sheet outside `#view`
that is not one of the theme's own marks the document **poisoned** and the
navigation is a full load — the fresh document carries no view CSS, so the
router resumes immediately. "Own" means *marked*: header.ut stamps everything
it renders (`data-luci-shell` on `main.css`, the font, custom and token
`<style>`s; `data-luci-patch` on patches; `data-luci-node-css` on the
menu.d node css). The boot snapshot the gate compares against is filtered by
those markers, so a sheet the boot page's own modules inserted before the
router loaded still counts as foreign instead of being grandfathered in for
the rest of the document. Correctness over speed, never the other way.
An owner-based refinement (stamp each sheet with the inserting module off
the call stack, enable it for pages whose dependency closure holds that
module, `disabled` for the rest) was built, verified on the device and
**removed again**: on this device one view page inserts its own CSS, the
saving is one reload when leaving it, and the price was three monkeypatches
plus an inline template script whose failure mode — a page silently missing
a shared library's CSS — is worse than the reload it avoids. Revisit only
with a real corpus of self-styling view pages.
## Module prewarm on hover
Entering (`pointerover`/`focusin`/`pointerdown`) a link to a serviceable
node `fetch()`es its view module with `priority: 'low'` — not `require()`,
which would render it. The URL is built byte-for-byte as `LuCI.require()`
builds it (`<base_url>/<name with . → />.js?v=<resource_version>`) or it
misses the HTTP cache. The walk is transitive: the fetched body's **first
4 KB** is scanned for the leading run of `'require x'` string literals with
a regex that is **not** line-anchored (shipped files are minified onto one
line), and dotted
names are warmed the same way; dotless names are either luci-base's file-less
built-ins (`view`, `baseclass`, `dom`, `poll`, `request`, `session`) or flat
libraries the chrome has already loaded, so they are declined outright.
Deduplicated per class name; stops once a navigation to that link has
committed. Cold navigations are the only place this shows; warm ones are
already 0-byte cache hits.
## What is deliberately not done
- **No history-API path.** See "Kernel".
- **No document prefetch while the router is active.** The
`speculationrules` script is removed at boot when the router takes over —
a hover prefetch of a document the router will never load is pure router
CPU. Browsers without the Navigation API keep the rules and the MPA path.
- **No `unload`/`beforeunload`**, ever (bfcache).
- **No cancellation of in-flight XHR** — there is no handle to cancel with
(see step 1); the generation gate makes it a waste, not a bug.
Upstream-only.
- **No sweeping of a view's global listeners or timeouts** — one-way
deletions of module-eval registrations. If a per-render offender ever
appears, the answer is a targeted teardown, not a global hook.
- **`ui.changes.confirm/revert` and `awaitReconnect`** keep their hard
`window.location` writes — a rollback/reboot boundary *should* be a fresh
document.
## Verification matrix
- Unit (`.dev/tests/router.test.js`): resolver against a fixture tree
(alias chain, nested firstchild, weights, ineligible, unsatisfied,
wildcard args, cycle); URL → segments; patch prefix matching; pragma scan
on a minified head; readonly folding; expiry signals; the same-URL reload
rule; node css of the resolved leaf; the contract check.
- Device (`.claude/skills/aurora-performance/scripts/bench-router.mjs`, CDP):
1. full walk of every clickable node in each nav mode, each compared
against a real full load of the same URL — `data-page`,
`dispatchpath`, URL, title, tab count, footer presence, console clean;
2. click → view painted, median of N, router vs full load, warm and cold;
3. soak: 60 navigations over 12 pages, heap / DOM nodes / listeners /
poll queue length flat after the first pass;
4. back/forward chain through alias and firstchild URLs — no reload;
5. poison gate: a foreign `<style>` in `<head>` makes the next
navigation a full load, the one after is a same-document swap again;
5b. sheets: the same, on every walked view page that really inserts its
own sheets (found on the walk) instead of an injected one — reached
same-document and landed on directly (its modules insert before the
router boots), leaving is a full load either way;
5c. hygiene: no progress bar left in the DOM after a swap, live region
present and carrying the title, a hidden tab stops polling and a
visible one resumes it;
6. nodecss: a page whose menu.d node declares `css` — link enabled on
arrival, disabled after leaving, re-enabled without a duplicate on
return (skipped when no installed node declares one);
7. expiry (last, destroys the session): logout fetched from inside the
document, one failing RPC → luci-base's modal and `Poll.stop()`; the
next navigation is a full load landing on the login form.
The walk also compares `nodespec.readonly`, `L.hasViewPermission()`, the
set of enabled node-css links and the live-region text against the full
load, and reports which pages carry sheets that are not the theme's.
- Device (`bench-fullload.mjs`, CDP): where one full load's time goes —
dispatch #1, the parser-blocking catalog, DOMContentLoaded, the view's own
ubus window — and the same page over the router, both in one loop so the
two are subtractable.
- Device (`bench-dispatch.sh`, run on the router): what one CGI dispatch costs
before any page-specific work — process, module graph, menu tree, session
probe — plus the loopback cost and size of each response a navigation pulls.
The `en`-catalog row is the control that separates dispatch cost from payload.
- The perf skill (`.claude/skills/aurora-performance/`) documents all three
harnesses in `references/measuring.md`; the server-side cost this router
removes is the S1/S2 budget in `references/server.md`.
+221
View File
@@ -0,0 +1,221 @@
<h4 align="right"><a href="router.md">English</a> | <strong>简体中文</strong></h4>
# 客户端路由
本文讲清楚三件事:主题如何把一次菜单点击变成文档内的视图替换、而不是整页加载;哪些情况下它刻意不这么做;以及一个跑在 LuCI 里的路由必须守住哪些不变量。源码在 `.dev/src/resource/router-aurora.js`,由 `footer.ut` 与 `menu-aurora.js` 一同加载。**没有改动 luci-base,也没有改动任何 view** —— 路由是纯增量的主题 JS,加上三处很小的模板钩子(补丁清单、`<footer>` 边界,以及 header.ut 自己渲染的样式表上的 `data-aurora-*` 标记)。
![一次 LuCI 导航:设备做了什么、浏览器做了什么,以及同文档路由删掉了其中哪些步骤](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.zh-cn.svg)
## 前人的工作
[luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap) 解决的是同一个问题,读它的代码启发了这里的两处实现:隐藏标签页时暂停 `L.Poll`,以及沿 dispatch 路径折叠 view 的只读状态(两者都在下文)。其余部分是独立实现,且有一处选择是刻意分道扬镳的。footstrap 用 **History API**(`pushState`/`popstate`)驱动导航,需要自己记账滚动位置,并用 `prototype.render` 守卫来修复过期渲染;本路由建立在 **Navigation API** 之上(见「内核」一节),把滚动、历史和取代关系都交还给浏览器,因而完全不需要那些东西 —— 代价是只能跑在较新的浏览器上,在其余浏览器里主题退回它本来就是的普通 MPA。在这个共同的基座之上,本路由还加了会话过期闸门、直接从服务端自己的外壳复制 `template` 页面(而不是手工移植),以及用视图过渡为替换做交叉淡入 —— 每一项在下文各有一节。
## 收益实测
**Cudy TR3000**(mediatek/filogic,ARMv8),OpenWrt SNAPSHOT r0-20d94d5,部署本分支,纯 HTTP,热缓存,2026-08-18。两条路径在同一个循环里测量(`bench-fullload.mjs`,RUNS=10,对下面 8 个页面取中位数),因此它们看到的设备状态相同。整页加载的多次运行波动为 ±40 ms:结论在比例,不在具体数字。
### 一次整页加载的时间都花在哪
| 阶段 | ms | 具体是什么 |
|---|--:|---|
| dispatch #1 —— 页面 HTML | 0→123 | TTFB 118:菜单树、ACL 折叠、`view.ut` → `header.ut` |
| dispatch #2 —— `admin/translations/<lang>` | 124→209 | *第二个* CGI 进程,阻塞解析,不可缓存 |
| DOMContentLoaded | 215 | 外壳回来了,与刚刚被丢弃的那份逐字节相同 |
| view 模块 + ubus 数据 + 渲染 | 215→321 | 静态资源已经是缓存命中 |
**321 ms 里有 209 ms 花在任何页面相关的事情发生之前。** 两次 dispatch 都在重新推导一个浏览器屏幕上本来就有的外壳;view 自己的 ubus 调用要到 227 ms 才开始。同文档替换删掉了这两次 dispatch,只留下最后一行 —— 同样这 8 个页面在热缓存下的中位数是 **91 ms**,数据调用从 227 ms 提前到 2 ms 开始。
### 一次 dispatch 让设备付出多少
在设备本机测量,所以数字里不含网络(`bench-dispatch.sh`)。
| 请求 | ms | 字节 |
|---|--:|--:|
| 页面 HTML,一个 `view` 节点 | 75.4 | 18,583 |
| `admin/translations/en` | 62.7 | **13** |
| `admin/translations/zh-cn` | 60.3 | 229,503 |
| `admin/menu` —— 每 *会话* 一次,不是每次导航 | 68.2 | 45,022 |
| 静态 `main.css` | **0.8** | 191,899 |
| 一次 dispatch 内部,按进程计 | ms |
|---|--:|
| fork + ucode VM | 2.2 |
| `import luci.dispatcher`(runtime、http、ubus、uci、core、authplugins) | 37.2 |
| 菜单树:stat 8 个 `menu.d` 文件 + 解析 28,307 B 的索引缓存 | 13.8 |
| 经 ubus 的 `session.get` + `session.access` | 6.0 |
`en` 那一行是对照组:一个 **13 字节** 的响应仍然要花 62.7 ms,而一个 191,899 字节的静态文件 0.8 ms 就送出去了。成本在 dispatch,不在负载 —— 而一次整页加载要把这一整块付 **两遍**。(`zh-cn` 客户端还要在每次导航时重新传输 229,503 B:`http.uc` 里的 `write_headers()` 设置了 `Cache-Control: no-cache` 和 `Expires: 0`,且没有 `ETag` 或 `Last-Modified`,因此根本没有可供重新校验的依据。)
### 端到端,从点击到视图绘制
每页取 10 次中位数,整页加载 = 导航开始 → `#view` 的第一个非 spinner 子节点;路由 = 点击 → 该次导航的 `finished` promise。
| 页面 | 整页加载 | 路由(热) | 快了 |
|---|--:|--:|--:|
| status/routesj | 326 | 92 | 72 % |
| status/nftables | 316 | 90 | 72 % |
| status/logs | 281 | 100 | 64 % |
| status/processes | 457 | 228 | 50 % |
| status/channel_analysis | 401 | 54 | 87 % |
| status/realtime | 211 | 37 | 82 % |
| system/system | 496 | 132 | 73 % |
| system/admin | 231 | 40 | 83 % |
中位数 **快 73 %**,范围 50–87 %。另一套独立的测量工具 `bench-router.mjs timing` 在同一天跑了两次,结果分别是 72 % 和 74.5 % —— 这些差异都落在设备自身的波动范围内,所以请把范围、而不是某个数字,当作结论。
Speculation Rules 预取够不到这块收益:该 API 只在安全上下文可用,所以在 HTTP 上是失效的;而在 HTTPS 上,文档预取也只能藏起第一次 dispatch —— 语言包是子资源,要等文档到达之后才会去取。
## 为什么这件事做得成
对一个 `view` 节点,dispatcher 渲染的是 `view.ut`:先是主题 header,然后 `<div id="view">` 里带一段内联的 `L.require('ui').then(ui => ui.instantiateView('<path>'))`,最后是主题 footer。服务端决定 *哪一个* view;客户端负责渲染它。路由做的就是在不重新加载的前提下重复 `view.ut` 做的事:把路径拿到客户端已经持有的菜单树里解析(`ui.menu.load()` 从 `sessionStorage` 供给),替换内容区域,重新实例化 view,并把 URL 交给浏览器自己管。
## 内核:只用 Navigation API
`navigation.addEventListener('navigate', …)` + `event.intercept()`。一个事件就覆盖了链接点击、`location.assign`、回退/前进到同文档条目,以及我们自己的 `navigation.navigate()`;浏览器负责写 URL 和历史条目,通过 `event.signal` 暴露取代关系,并且(配合 `scroll: 'after-transition'`)在回退/前进时恢复滚动、在压栈时滚到顶部 —— 所以路由里没有任何 `pushState`/`popstate` 代码,没有滚动记账,也没有「是片段跳转还是导航」的启发式判断。
为什么用这个 API,而不是 footstrap 用的 History API:
- **URL、历史和滚动都归浏览器管。** `pushState` 会把这三者、以及让它们与渲染结果保持一致的责任全塞给路由;而在这里,路由永远只负责渲染。
- **取代关系是内建的。** 更新的一次导航会中止更旧那次的 `event.signal`;下文的世代闸门只是一个检查,不是状态机,也不需要 `render` 守卫去修复过期的绘制。
- **各种导航形式都汇聚到同一个监听器** —— 链接点击、`location.assign`、回退/前进到同文档条目、我们自己的 `navigation.navigate()` —— 因此需要保证正确的只有一条路径。
- **回退方案是免费的。** 在没有该 API 的地方,主题就是它本来的 MPA;不需要 polyfill,也不需要按特性分叉。
**没有该 API 的浏览器保持 MPA。** `footer.ut` 只在 `window.navigation` 存在时才 require 该模块,而 `__init__` 会再次检查它真正用到的接口面:`navigation.addEventListener`、`NavigateEvent`,以及其原型上的 `intercept`。Chrome/Edge **105+**、Safari 26.2+、Firefox 147+ 会启用路由 —— 是 105 而不是首次发布 Navigation API 的 102,因为直到 Chrome 108 之前该方法都叫 `transitionWhile()`,`canIntercept` 也还叫 `canTransition`;正是「以 `intercept` 为闸门」把下限抬到了 105。主题声明的下限(Chrome 111 / Safari 16.4 / Firefox 128)照旧生效。这是一个刻意的取舍:一条构造上就正确的代码路径,胜过再加一条 History API 路径、把下文所有内容的面积翻倍。
## 兼容性
### 浏览器 —— 按平台特性分列
| 特性 | 用途 | 是否必需? | Chrome / Edge | Safari | Firefox | 缺失时 |
|---|---|---|---|---|---|---|
| Navigation API(`navigation.addEventListener('navigate')`、`NavigateEvent.intercept()`、`event.destination/signal`、`navigation.navigate()/back()`) | 整个路由 | **是 —— 闸门** | 105+(2022) | 26.2+(2026-01) | 147+(2026-01) | `router-aurora.js` 根本不会被加载(`footer.ut` 检查 `window.navigation`);主题就是之前那个普通 MPA |
| `document.startViewTransition()`(同文档) | 替换时的交叉淡入 | 否 | 111+ | 18+ | 144+ | 无动画直接替换;在 `prefers-reduced-motion` 下同样关闭 |
| `fetch(url, { priority: 'low' })` | 悬停预热模块 | 否 | 101+ | 17.2+ | 132+ | 该选项被忽略,fetch 仍以默认优先级执行 |
| `MutationObserver`、`DOMParser`、`WeakSet`、`URL`、`Element.replaceWith`、`:scope`、`matchMedia`、可选链 / `??=` | 渲染完成检测、模板外壳、投毒闸门、暂存 | 是 | ≥ 85 | ≥ 14 | ≥ 79 | 全都在主题声明的下限之内(Chrome 111 / Safari 16.4 / Firefox 128) |
所以路由的实际下限是 Chrome/Edge 105、Safari 26.2、Firefox 147;更老的一切都保持主题现有的下限和行为。已在 Chrome 151 上实机验证(headless,`bench-router.mjs`);Safari/Firefox 仅凭特性检测 —— 闸门检的是同一套 API 接口面,不是 UA 嗅探。
### OpenWrt / LuCI
主题本来就要求 OpenWrt 23.05+(ucode 模板)。除下面点名的两处与版本相关的项目外,路由只触碰在 `openwrt/luci` 的 `openwrt-23.05`、`openwrt-24.10`、`openwrt-25.12` 和 `master` 分支上完全一致的 luci-base 接口面(对照分支源码核查,2026-08):带实例缓存和 `prototype.constructor` 的 `L.require`、`L.view`、`L.dom.content` 与 `data-idref` 注册表、`L.env.{scriptname, base_url, resource_version, media, requestpath, dispatchpath, pathinfo, nodespec}`、`L.hasSystemFeature`、`L.Poll.{queue, start, stop, active, timer}`(以及 `start()` 对 `tick` 的重置,正是它让进入的 view 的首次轮询重新上膛)、`ui.menu.load()` 那棵会话缓存的树及其 `satisfied` / `firstchild_ineligible` / `wildcard` / `action.type`(`view`、`alias`、`firstchild`、`template`)、`setupDOM` 的 `poll-start` 处理器注册的 `poll-status` 指示器 id(拆除时按这个名字隐藏它)、`ui.instantiateView`、`ui.hideIndicator`、`ui.hideModal`、`uci.state.values` / `uci.unload()` / `uci.load()`、`network.js` 基于 uci 的状态、`Request.addInterceptor` / `rpc.addInterceptor` 以及 `setupDOM` 里 `-32002` → `session.access` 的探测、`dispatcher.uc` 的 `ctx_append` acl 折叠、`view.ut` 的 `#view` + 内联 `instantiateView` 外壳,还有 `dispatcher.uc` 的 `resolve_firstchild` / `node_weight` / alias 重新分发语义(逐行移植)。
**有两处接口面在这些分支之间并不一致**,解析器是按较新的那一版写的:
- **`node.css`** 只在 master 上进入了 `build_pagetree` 的 schema(7c6d8ff,2026-08)。23.05、24.10 和 25.12 的任何节点上都没有 `css`,所以 `nodeCss()` 返回 `null`,该特性在那里就是单纯的失效状态。
- **通配符下降。** `wildcardaction` 存在于 25.12 和 master,不存在于 23.05 或 24.10 —— 键不存在时会退回 `node.action`,那正是这些版本本来的行为,所以这部分是安全的。不安全的是围绕它的 *解析规则*:25.12 和 master 会先下降到匹配的 `satisfied` 子节点,然后才把剩余段当作参数;而 23.05 和 24.10 一旦到达 `wildcard` 节点,就立刻把剩下的每一段都捕获为参数。路由移植的是 25.12/master 的规则。因此在 23.05 或 24.10 上,一棵同时有 `foo/*` 和真实 `foo/bar` 子节点的树,在路由里和在 dispatcher 里会解析出不同结果 —— 正是「点击打开一个页面、F5 打开另一个页面」这种故障,而这个解析器存在的意义就是避免它。该规则和 `wildcardaction` 是同一个提交引入的(df90c60a7,2026-01-17),其声明目的是让 `path/*` 能携带一个区别于裸路径的 action,所以在此之前这种结构根本没有定义好的行为,一棵为 23.05/24.10 编写的树不太可能用到它 —— 但这是一个论证,不是对每一份已安装 `menu.d` 的普查,而且路由从未在这两个版本上跑过。请把 23.05/24.10 当作「已审阅」,而非「已验证」。
目前的实机验证:OpenWrt SNAPSHOT r0-20d94d5(2026-08,mediatek/filogic),以及 ipq60xx 上一个更早的 SNAPSHOT。23.05 / 24.10 / 25.12 仅凭分支源码,未上设备。
上面这份清单同时也是可执行的:`router-aurora.js` 里的 `contract()` 会在启动时逐一查找其中每一个接口面(`L.view`、`L.require`、`L.dom.content`、`L.env.{base_url,resource,media}`、`L.Request.addInterceptor`、`L.uci.{load,unload,state}`、`rpc.addInterceptor`、`poll.{queue,start,stop,active}`、`ui.menu.load`、`ui.hideModal`、`ui.hideIndicator`、`E`),只要有任何一项缺失,就记录是哪一项并且不激活 —— 在一个已经演进过的 luci-base 上,主题仍然是它原本的 MPA,而不是一个坏掉的路由。
## 什么会被拦截
只有当**全部**条件成立时,`navigate` 事件才会被拦截:
- `event.canIntercept`(同源、并非仅限跨文档),不是 `hashChange`,没有 `downloadRequest`,没有 `formData`,且 `navigationType !== 'reload'`;
- 目标**不是文档自身的 URL**(片段除外)。同 URL 导航到达时的 `navigationType` 是 `'replace'` 而非 `'reload'`,但它其实就是换了个名字的重新加载:luci-base 的 `ui.changes.apply/revert` 以 `window.location = window.location.href.split('#')[0]` 结尾(过期弹窗的按钮也一样),正是为了让服务端重新渲染外壳 —— 在「系统 → 语言和界面」下换了主题、换了语言、换了主机名、菜单树变了 —— 而拦截它会导致替换后仍显示旧外壳,直到 F5。点击当前页面自己的链接,在这里和在 MPA 里一样,就是一次重新加载;
- 目标路径(去掉 `L.env.scriptname` 后)在菜单树中解析到一个**可服务节点**(见下);
- 文档没有被**投毒**(见下),且其会话没有被判定为**已过期**(见下);
- 路由在本文档中**已激活**:只有当它启动时所在的页面本身可服务,它才会激活。`call`/`cbi`/`function` 页面携带的脚本(遗留的 `XHR.poll`、内联定时器)只有文档死亡才能收回,因此从这类页面点出去的第一次点击总是整页加载。
其他一切原样放行:浏览器执行普通的完整导航,也就是主题以前的行为。带修饰键的点击和 `target=_blank` 根本不会到达这个事件。
### 可服务节点
用 dispatcher 自身规则的移植版来解析,而不是照着意思重写:
- `alias` → 从根开始跳到 `action.path` 并继续;
- `firstchild` → 跑 dispatcher 同款的 `resolve_firstchild()` / `node_weight()`:候选是带 `title` 且 `action` 为对象的 `satisfied` 子节点;权重为 `min(order ?? 9999, 9999)`,`auth.login` 再加 10000;一个 `firstchild` 候选只有在能继续解析下去时才算数;`firstchild_ineligible` 被排除;同分时保持键顺序。跳过 ACL 检查,因为 `/admin/menu` 已经按会话过滤过了;
- `wildcard` 节点先被下降进入 —— 匹配到 `satisfied` 子节点的段优先于参数捕获 —— 只有剩下的部分才成为请求参数;有参数时跑节点的 `wildcardaction`(即 `path/*` 条目自己的 action),裸路径时跑 `action`。这是 25.12/master 的规则;23.05 和 24.10 则是在第一个 `wildcard` 节点处就开始捕获 —— 见上文「OpenWrt / LuCI」;
- 一个跳数计数器(32)用来打断外来 `menu.d` 里的环;
- **任何一段匹配不到 `satisfied` 子节点,就终止本次尝试。** dispatcher 会退回到最深的已满足祖先并从那里重新解析;路由则返回 `null`,把这次导航交给服务端。这是刻意的:退回只花一次整页加载,而祖先猜错要付的是打开错误页面。
有两条轨道被同时维护,正如整页加载所做的那样:**请求到的** 段 → `L.env.requestpath`、`L.env.pathinfo`、`body[data-page]`;**解析出的** 段 → `L.env.dispatchpath`、`L.env.nodespec`、菜单高亮、标题。一旦选中的子节点和 dispatcher 选的不一样,就会出现「点击打开一个页面、F5 打开另一个页面」—— 这正是解析器必须是移植而非重写的原因。
| 节点 | 是否服务 |
|---|---|
| `view` | 是 —— `view.<path>` |
| `alias`、`firstchild` | 是 —— 递归解析到叶子 |
| 页面本身是 view 外壳的 `template`(状态 → 概览) | 是 —— 外壳只取一次,见下 |
| Lua `template`、`call`、`function`、`cbi`、`rewrite` | 否 → 整页加载 |
`rewrite` 是刻意不解析的。节点和它的 action *确实* 在树里,但要跟进它就得重新实现 `dispatcher.uc` 的 `splice(request_path, 0, action.remove)` 并从结果重新分发;那里差一位就会打开错误的页面,而这比它退回的那次重新加载糟糕得多。
### template 节点:直接用服务端自己的外壳,绝不手工移植
`admin/status/overview` 是一个 `template`,其服务端定义了页面级全局(`progressbar`、`renderBox`、`renderBadge`),输出一个 `<h2>` 和一个 `div.includes`(服务端渲染的 Lua include),然后实例化 `view.status.index`。第一版曾在路由里重新实现这些辅助函数,结果在第一个真实页面上就跑偏了(网络徽章丢了标签:上游的 `renderBadge` 会接收移植版不知道的额外 `L.itemlist` 参数)。所以路由什么都不移植:当指向 template 节点的链接被悬停或聚焦时,它的页面会**每个文档只取一次**(在它解析完成前到达的所有意图事件共享同一个进行中的请求),用 `DOMParser` 解析,并把 `#tabmenu` 与 `<footer>` 之间的内容区保存为该页面的 *外壳* —— 克隆每一个节点,把 `#view` 替换成一个空 div,从内联的 `instantiateView('…')` 脚本里读出类名,其余的内联脚本(那些辅助函数)在暂存时重放进全局作用域。luci-base 自己的引导代码(`luci.js` 和 `L = new LuCI(env)`)也在这个区域里,会被过滤掉。如果当前文档 *就是* 那个 template(会话从「概览」开始),外壳直接取自实时区域,不发生任何 fetch。template 节点只有在它的外壳已知之后才会被拦截 —— 于是一个 Lua template 页面(没有 `instantiateView` 调用)在一次悬停取回后就被记为不可服务,永远不会进入路由的错误路径;而一个没有先悬停就被点击的 template 就是一次普通整页加载,并顺便为本文档后续的使用播下外壳。它的状态 include 模块是携带 `oneshot`/`hide` 状态的单例,整页加载会重置这些状态 —— 这一点是对着真实的整页加载验证的,不是靠推测。
## 导航流程
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,handler 按顺序:
1. **世代。** `const gen = ++this.gen`;此后每一次 DOM 写入都以它为闸门。
`event.signal` 能中止我们自己的 await,但它无法取消一个 LuCI XHR(`L.Request` 交回的是一个裸 promise;`XMLHttpRequest` 只在 *已解析的* `Response` 上才浮现,那时中止已经太晚),也无法取消一条已经在跑的 `View.__init__` 链,所以世代才是正确性机制,signal 只是卫生措施。
2. **拆除离场文档的状态**,也就是文档死亡本会免费替你做掉的事:
- `Poll`:`queue.length = 0; stop(); start()` —— 三个步骤。清空队列丢掉旧 view 的轮询器;`stop()` 丢掉 tick;在空队列上 `start()` 会把 `tick = 0` 重新上膛,这样进入的 view 的 `poll.add()` 会自动启动并立刻触发,而不是等上最多 `interval` 秒去对齐一个存活下来的 tick。上游的 `initDOM()` 在第一个 view 之前对空队列做的也是同样的 `Poll.start()`。
- `uci`:对出现在 `state.values` **或** `uci.loaded` 中的每个包执行 `unload()`(文档启动时缓存是空的;有四个已发布的应用把 `load()` 的返回值当作存在性检查,当缓存回答 `[]` 时会在页面上盖一个错误提示;而且 `uci.loaded` 会一直保留某个包的请求 promise —— 包括已经 reject 的那个 —— 直到 `unload()`,所以一个失败的加载留在那里会被交给之后的每一个 view)。然后,如果 `L.network` 已被加载,就重新发起 `load(['network','luci'])` —— 当 `L.hasSystemFeature('wifi')` 时再加上 `'wireless'` —— 并且 **await 它**;一旦 reject 就传播到硬加载回退路径,而不是把 `network.js` 留在空配置上:`network.js` 只填充一次它的 `_state`,此后一律从 uci 缓存作答(`getWifiDevices()` *就是* `uci.sections('wireless','wifi-device')`),所以只卸载不重填,会让本文档剩下的时间里每一个消费者都拿到空配置。未保存的本地编辑会随页面消亡,这和整页加载一样;已保存的更改在服务端,「未保存更改」指示器不受影响。
- 路由启动之后注册的裸 `setInterval` 会被清除(`setInterval`/`clearInterval` 在 `__init__` 里被挂钩,也就是 `L.require('router-aurora')` 实例化该类的时候;`poll.timer` 这个 `L.Poll` 自己持有的唯一 interval 会被跳过)。`setTimeout` 和 rAF **不** 动:核心把工具提示、通知超时和一个请求超时都放在 `setTimeout` 上,而且已发布的 view 里没有任何自我重排的 timeout。
- view 在**渲染期间**注册的 `window`/`document` 监听器会被移除。有好几个已发布的 view 每次渲染都加(statistics 图表:一个匿名 `resize`,之后会对着已分离的 DOM 抛错;nlbwmon:`tooltip-open`/`touchstart`;核心自己的下拉组件:每个实例一个 `window` click/touchstart),于是它们会越积越多,并作用在早已消失的页面上。钩子只记录渲染窗口内的注册。一次**热**渲染不会执行任何模块,所以它的注册在构造上就是每次渲染一份,会在下次拆除时移除。一次**冷**渲染还会跑模块的顶层,那些注册必须存活下来(移除它们是单向的 —— 编辑器的模块求值期监听器再也回不来了),所以冷注册只被记在类的账上,等到该类之后的某次热渲染注册了相同的 target/type,证明它确实是每次渲染一份时,才会被释放。
- `ui.hideIndicator('poll-status')` —— luci-base 会留下一个 *正在刷新* / *已暂停* 的指示器,而文档死亡本会把它一并带走。
- `ui.hideModal()`,以及主题自己的各种界面(大菜单、移动端抽屉、命令面板)关闭。
- 页面级的补丁 CSS 被禁用,其 JS 补丁被卸载(见下)。
uci 的清空是这一整步里唯一被 await 而非发射即忘的部分,因此它是 `teardown()` 返回之后的一个独立步骤。
3. **环境。** `L.env.requestpath/dispatchpath/pathinfo/nodespec`、`body[data-page]`、`document.title`。alias 在服务端会被重新分发,所以 `requestpath` 和 `data-page` 承载的是 alias 的目标,而 `pathinfo` 保留请求时的 URL;`firstchild` 则两者都保留请求路径。标题后缀(` - 主机名`)从初始文档里读出,因此它与模板输出的内容一致。`nodespec` 驱动 `L.hasViewPermission()`,进而决定「保存/应用」页脚的只读状态 —— 而它的 `readonly` 是**沿 dispatch 路径折叠**出来的,与 `dispatcher.uc` 的做法一致:`ctx_append` 收集每个节点的 `depends.acl`,对并集做一次 `check_acl_depends()`,只要 *任意* 一个组可写就算可写,所以只有当路径上每一个带 acl 的节点都只读时,页面才是只读的。树里的逐节点标志(`apply_tree_acls`)只覆盖该节点自身的 acl;直接把叶子节点原样递过去,会让一个只读用户在某个只读组下的每个页面上都拿到可用的「保存并应用」。树对象不会被修改(`nodespec` 是一份拷贝)。`data-page` 是 `ui.tabs` 会话状态和主题页面级 CSS 的键。
4. **外壳装饰。** `menu-aurora.js` 暴露了 `syncRoute()`:它依据 `L.env.dispatchpath` 在每一个导航界面上重新标注 `is-active-page`/`aria-current`,展开活动的侧边栏/移动端分组并收起其余的,重建头部面包屑,并为新的分区重新渲染 `#tabmenu`。菜单**不会**重建 —— 大菜单在构造时就完成了测量和绑定,命令面板的索引是同一模型的扁平数组 —— 只有它们的状态在变。
5. **暂存。** 一个全新的 `<div id="view" class="view-staging">` 被插到 `#tabmenu` 之后,也就是**树序上的第一个** —— `getElementById('view')` 返回第一个匹配项,所以 LuCI 的 view 链写入的一切都进了这个暂存元素,而离场的页面仍留在屏幕上(变暗,`.view-leaving`)。这个暂存区不可见但**参与布局**(`visibility:hidden; height:0; overflow:hidden`,绝不用 `display:none`):实时图表在 `render()` 内部按 `#view.offsetWidth` 给自己定尺寸,而 `display:none` 的暂存区会递给它们一个宽度为 0 的画布。此时还什么都没被移除。
6. **补丁。** `header.ut` 把已安装的按需补丁词干输出为 `body[data-patches]`;路由施加与模板在渲染时相同的段前缀规则:匹配的 `patches/<stem>.css` 链接被确保存在(`<link data-luci-patch>`,对屏幕上的页面启用,对其余页面 `disabled` —— 而不是移除,所以回来时不花任何代价);匹配的 `patches/<stem>.js` 文件只加载一次,其 `window.luciPatches[stem]` 的 `{ mount, unmount }` 对按访问驱动(要挂载的词干列表属于计算出它的那次导航,所以一次被取代的导航之后不会挂载任何东西);路由添加的 URL 带着与模板自身链接相同的 `?v=PKG_VERSION` luci.mk 戳记,从 `body[data-asset-version]` 读出,因此它们命中同一个缓存条目 —— 一个什么都不注册的 JS 补丁就只是被执行一次,和 MPA 一样。补丁脚本在求值时自行挂载;如果用户在它到达之前已经导航走了,它的 `load` 处理器会检查当前页面是否仍然需要那个词干,否则就卸载它(期间到达的同词干页面会让它保持挂载)。
menu.d 节点自身的 `css`(`header.ut` 为被分发的节点链接 `<resource>/<node.css>`,标记为 `data-luci-node-css`)用同样的方式维护:每张样式表一个 `<link>`,对解析出的叶子声明了它的那个页面启用,对其余每个页面 `disabled`,永不移除。这两个属性都豁免于投毒闸门。
7. **视图。**
- **冷**(本文档中从未 require 过 `view.<path>`):`window.L.require(className)` —— 这个 require *就是* 渲染(LuCI 在首次 require 时实例化),而且它必须走 `window.L` 这个运行时实例,绝不能走模块工厂拿到的那个原型式 `L`(`ui` 把 `itemlist`/`showModal` 挂在 `window.L` 上;通过错误的 `L` require 进来的 view 会在三层模块之后死在 `L.itemlist is not a function`,而且由于 `require()` 按名字缓存,绑定会被 *第一个* 请求者固定下来);
- **热**:`require()` 交回那个 `__init__` 已经跑过的缓存实例;LuCI 的类系统设置了 `prototype.constructor`,所以 `new instance.constructor()` 会跑一次全新的 `__init__` → `load()` → `render()` → `dom.content('#view')`,与整页加载的起点完全一致。两种情况下,require 到的值都会用 `instanceof L.view` 检查;不是的话就抛进硬加载路径,而不是把一个非 view 暂存起来。
- **完成是被观测的,不是被假定的**:暂存元素上的一个 `MutationObserver` 会在非 spinner 子节点落地时(或空渲染时 spinner 被移除时)解析。15 秒内没有完成算 **失败**,不算完成:提交 spinner 并释放序列化,会让仍在运行的链绘制到后来某次导航的 `#view` 里,所以超时会 reject,catch 路径硬加载目标地址。完成时 —— 并且仅当这次导航仍是最新的一次 —— 离场区域(`#tabmenu` 与 `<footer>` 之间除暂存元素外的一切)被移除,暂存的 view 在 `document.startViewTransition()` 可用且未开启减弱动效时于其内部显现;该次导航的 `finished` promise 在这次替换之后 resolve。每个离场元素在 `remove()` 之前都要走一遍 `L.dom.content(el, null)`:正是它清掉了元素的 `data-idref` 注册表条目,否则那些条目会把已分离的子树及其类实例一直吊着不放 —— 这正是下文浸泡测试所测量的东西。
- **渲染是被串行化的。** 进行中的 LuCI XHR 和正在跑的 `View.__init__` 链都无法取消(原因同上),而每条链都会绘制到绘制时刻*恰好排第一*的那个 `#view` 里。所以一次导航会先等待上一次导航的完成(受同一个超时约束),然后才拆除任何东西或暂存任何东西 —— 上一条链会完成到它自己的暂存元素里,随后被丢弃。因此快速的 A→B→C 绝不会交错:当 C 在 B 还没开跑时就到达,B 会被跳过(`event.signal` / 世代),而 C 会等待真正在飞的那次渲染。文档最初那次由 LuCI 渲染的 view 也按同样方式追踪,所以首次加载期间的一次点击不会被它覆盖绘制 —— 而一次永不完成的首次渲染会让那个等待 reject,于是第一次导航走硬加载回退,而不是在一条可能仍会绘制的链旁边做暂存。那次首渲染同样运行在一个渲染窗口内(该窗口在路由自己的监听器注册之后打开),所以它添加的监听器像冷渲染一样被记在其类的账上;而它在路由加载之前注册的那些则够不着。代价是慢加载期间的点击要等那次加载完成;替代方案 —— 逐类包裹 `prototype.render` 并通过重新导航来修复过期的冷渲染 —— 会留下一个真实的窗口,而且需要三套机制去做一套就够的事。
8. **焦点与播报。** 用 `preventScroll` 聚焦 `#maincontent`(`tabindex=-1`);新的 `document.title` 被写入 `#luci-nav-status`(`role=status`、`aria-live=polite`),因为同文档替换不会触发屏幕阅读器会播报的 load 事件。这个地标带着 `outline-none`:iOS WebKit(Safari 和 iOS 版 Chrome 都一样)会为编程式聚焦绘制焦点环,而这个环紧贴吸顶头部下方的上边缘被用户报告为「一个永远走不完的进度条」—— 它从来就不是进度条。
9. **进度。** 存活超过 150 ms 的导航会插入 `#luci-nav-progress` —— 形态上就是 Turbo Drive 那根条:顶部一条发丝细线,`width` 以内联方式驱动,并以越来越小的步长**涓流**推进(每 300 ms `+ (100 - w) / 30`)直到提交,这样慢渲染看起来一直在动而不是卡住;提交时填到 100 %,淡出(`data-state="done"`)并从 DOM 中**移除**。更短的导航保持沉默,重叠的导航共用同一根条。浏览器自带的进度条在这里帮不上忙:它只在文档加载时显示,而同文档替换恰恰不是文档加载 —— 这也正是 GitHub、YouTube、Turbo/HEY 和每一个 nprogress 用户都要自己画一根的原因。减弱动效只去掉过渡,不去掉这根条。
10. 任何异常 → `console.error`(静默回退会让每一次路由回归都看起来像「页面就是有点慢」)→ `location.href = destination` —— 一次硬性的整页加载,绝不留下卡住的页面。会先设置一个 `bypass` 标志,这样那次写入产生的 `navigate` 事件会直接放行,而不是被拦回失败路径;随后 handler 会停在一个永不落定的 promise 上,这样就不会再有别的东西对着一个正在离开的文档运行。
## 过期闸门
luci-base 用 `notifySessionExpiry()` 应对失效会话:`Poll.stop()` 加上一个唯一按钮是硬重载的弹窗。同文档替换会径直 `hideModal()` 和 `Poll.start()` 穿过它继续浏览,然后每个页面依次报错(实测:`bench-router.mjs expiry` 对着上一版路由 —— `expiredFullLoad: false`)。所以路由会监听 luci-base 依据的同样两个信号 —— 任一 `L.Request` 上带 `X-LuCI-Login-Required: yes` 的 `403`,以及 luci-base 在 `-32002` 之后发起的 `session.access` 探测被拒绝或出错 —— 此后什么都不再拦截:下一次点击是整页加载,dispatcher 会把它变成登录页。其他对象上的一次拒绝属于 ACL 事务,会被忽略。什么都不重置:这个标志随文档一起消亡,就像会话那样。同一个标志也让下文的可见性闸门不会去重启一个被过期停掉的轮询。
## 隐藏的标签页
luci-base 在后台标签页里会继续轮询。路由在 `visibilitychange` → hidden 且轮询原本处于活动状态时停掉 `Poll`,并在回来时重新启动 —— 除非用户自己暂停过,或期间会话已失效。在一台性能孱弱的路由器上,那是没人在看的 RPC 开销。
## 投毒闸门
view 写进 `<head>` 的 `<style>`/`<link rel=stylesheet>` 会在整页加载时随文档消亡,却会在同文档替换中**存活**,然后污染其后的每一个页面(某个已发布的文件管理器用一条未分层的 `!important` 规则在每个配置页上隐藏了保存/重置)。移除它并不是一个选项:在模块求值时导入 CSS 的库永远不会再执行一次,所以删除是单向的(某个编辑器页面回来时变成了一个两百万像素高的黑矩形)。因此这里用的是闸门,不是清扫:在拦截之前,`#view` 之外任何一张不属于主题自己的样式表都会把文档标记为**已投毒**,这次导航就是整页加载 —— 新文档不带任何 view CSS,所以路由会立刻恢复工作。「自己的」意思是*被标记过的*:header.ut 会给它渲染的一切打戳(`main.css`、字体、自定义与 token `<style>` 上的 `data-luci-shell`;补丁上的 `data-luci-patch`;menu.d 节点 css 上的 `data-luci-node-css`)。闸门用来比对的启动快照按这些标记过滤,所以启动页自己的模块在路由加载之前插入的样式表仍然算作外来,而不会被「祖父条款」放行到文档余下的时间里。正确性优先于速度,绝不反过来。
一个基于「归属」的精细化方案(从调用栈上把插入模块的身份戳到每张样式表上,对依赖闭包中包含该模块的页面启用它,对其余页面 `disabled`)曾被实现、在设备上验证过,然后又被**移除**了:在这台设备上只有一个 view 页面插入自己的 CSS,省下的不过是离开它时的一次重载,而代价是三处 monkeypatch 加一段内联模板脚本,它的故障模式 —— 某个页面悄无声息地少了一个共享库的 CSS —— 比它避免掉的那次重载更糟。只有当出现一批真实的自带样式的 view 页面时才值得重新考虑。
## 悬停时的模块预热
进入(`pointerover`/`focusin`/`pointerdown`)一个指向可服务节点的链接时,会用 `priority: 'low'` `fetch()` 它的 view 模块 —— 不是 `require()`,因为那会把它渲染出来。URL 是逐字节按 `LuCI.require()` 的构造方式拼出来的(`<base_url>/<把 . 换成 / 的 name>.js?v=<resource_version>`),否则就会错过 HTTP 缓存。这个遍历是传递性的:取回内容的**前 4 KB** 会被扫描,找出开头连续的那串 `'require x'` 字符串字面量,用的正则**不是**行锚定的(已发布的文件被压缩到了一行上),带点的名字会以同样方式预热;不带点的名字要么是 luci-base 那些没有对应文件的内建(`view`、`baseclass`、`dom`、`poll`、`request`、`session`),要么是外壳早已加载的扁平库,因此直接拒绝。按类名去重;一旦有一次导航到该链接完成提交就停止。这只在冷导航上体现出来;热导航本来就是 0 字节的缓存命中。
## 刻意不做的事
- **不做 History API 路径。** 见「内核」。
- **路由激活期间不做文档预取。** 路由接管时,`speculationrules` 脚本会在启动时被移除 —— 对一个路由永远不会去加载的文档做悬停预取,纯属浪费路由器 CPU。没有 Navigation API 的浏览器保留这些规则和 MPA 路径。
- **永不使用 `unload`/`beforeunload`**(bfcache)。
- **不取消进行中的 XHR** —— 根本没有可用来取消的句柄(见第 1 步);世代闸门让这件事成为浪费,而不是缺陷。这只能由上游解决。
- **不清扫 view 的全局监听器或 timeout** —— 那是对模块求值期注册的单向删除。如果哪天真出现了一个每次渲染都注册的冒犯者,答案是有针对性的拆除,而不是一个全局钩子。
- **`ui.changes.confirm/revert` 和 `awaitReconnect`** 保留它们硬性的 `window.location` 写入 —— 回滚/重启这样的边界*就应该*是一个全新的文档。
## 验证矩阵
- 单元测试(`.dev/tests/router.test.js`):解析器对着一棵固定装置树(alias 链、嵌套 firstchild、权重、ineligible、unsatisfied、通配符参数、环);URL → 段;补丁前缀匹配;对压缩过的 head 做 pragma 扫描;只读折叠;过期信号;同 URL 重载规则;解析出的叶子的 node css;契约检查。
- 设备测试(`.claude/skills/aurora-performance/scripts/bench-router.mjs`,CDP):
1. 在每种导航模式下完整走一遍每个可点击节点,每一个都与同 URL 的真实整页加载对比 —— `data-page`、`dispatchpath`、URL、标题、标签数、footer 是否存在、控制台是否干净;
2. 点击 → 视图绘制,N 次中位数,路由 vs 整页加载,冷热皆测;
3. 浸泡测试:12 个页面上做 60 次导航,第一轮之后堆内存 / DOM 节点 / 监听器 / 轮询队列长度保持平稳;
4. 穿过 alias 和 firstchild URL 的前进/后退链 —— 不发生重载;
5. 投毒闸门:`<head>` 里一个外来 `<style>` 使下一次导航变成整页加载,再下一次又恢复为同文档替换;
5b. 样式表:同上,但作用在遍历时发现的、真正会插入自己样式表的每一个 view 页面上(而不是注入一个假的)—— 分别以同文档方式到达和直接落地(它的模块在路由启动之前就插入了),离开时无论哪种方式都是整页加载;
5c. 卫生检查:替换之后 DOM 里没有残留的进度条,live region 存在且携带标题,隐藏标签页停止轮询、可见时恢复;
6. nodecss:一个 menu.d 节点声明了 `css` 的页面 —— 到达时链接启用,离开后禁用,返回时重新启用且不产生重复(没有已安装节点声明 css 时跳过);
7. 过期(放在最后,会毁掉会话):从文档内部 fetch 登出,一个失败的 RPC → luci-base 的弹窗和 `Poll.stop()`;下一次导航是落在登录表单上的整页加载。
该遍历还会把 `nodespec.readonly`、`L.hasViewPermission()`、启用的 node-css 链接集合以及 live region 文本与整页加载对比,并报告哪些页面带有不属于主题的样式表。
- 设备测试(`bench-fullload.mjs`,CDP):一次整页加载的时间都花在哪 —— dispatch #1、阻塞解析的语言包、DOMContentLoaded、view 自己的 ubus 窗口 —— 以及同一页面走路由的情况,两者在同一个循环里,因此可以相减。
- 设备测试(`bench-dispatch.sh`,在路由器上运行):在任何页面相关工作之前,一次 CGI dispatch 的成本 —— 进程、模块图、菜单树、会话探测 —— 外加一次导航会拉取的每个响应的回环成本与大小。`en` 语言包那一行是把 dispatch 成本和负载成本区分开的对照组。
- 性能 skill(`.claude/skills/aurora-performance/`)在 `references/measuring.md` 中记录了全部三套测量工具;本路由所消除的服务端成本,就是 `references/server.md` 里的 S1/S2 预算。
+102
View File
@@ -0,0 +1,102 @@
# Agent workflows
Two commands, one knowledge skill, five vertical agents, one mechanical
guard. Everything an agent asserts about luci-base or uhttpd must come from
a source checkout (`$LUCI_SRC`, `$UHTTPD_SRC` in the git-ignored `.dev/.env`)
and be cited as `file:line`; the facts they start from live in
`.claude/skills/luci-knowledge/`.
| Command | Use when | Output |
|---|---|---|
| `/luci-change <request>` | anything that edits `.dev/src`, `ucode/`, `htdocs/`, `root/`, `Makefile`, `vite.config.ts`, CI | a reviewed change in the working tree; commit only on request |
| `/upstream-rfc "<topic>"` | the fix belongs in luci-base / uhttpd / rpcd / the feed | `.dev/decisions/<slug>.md` with diagrams; no code change here |
| `/luci-vertical-review [base]` | step 6 of `/luci-change`, or any branch | ranked, verified findings |
## Changing this repository
```mermaid
flowchart LR
R[request] --> S{fix lives here?}
S -- no --> RFC[/upstream-rfc/]
S -- yes --> P[research + plan<br/>files · budget impact · floor · verification]
P --> G1{{gate 1<br/>user approves plan}}
G1 -- no --> P
G1 -- yes --> A[arm guard<br/>.claude/.change-approved]
A --> I[implement<br/>guard allows only approved paths]
I --> V[pnpm test · pnpm build · bench if device]
V --> G2{{gate 2<br/>luci-vertical-review}}
G2 -- blocker/major --> I
G2 -- clean --> D[report · disarm guard<br/>commit only if asked]
```
Gate 1 is plan mode: `ExitPlanMode` needs the user's selection; nothing is
edited before it. Gate 2 is the `luci-vertical-review` workflow:
`aurora-static-reviewer` (templates, CSS, tokens, patches, packaging, CI)
and `aurora-runtime-reviewer` (menu/router JS, teardown, polling, a11y)
review in parallel; every finding then goes to `luci-claim-verifier`, which
tries to refute it against the sources before it can be reported. With a
scope file the reviewers also check that the diff stayed inside the approved
paths.
The guard is `.claude/hooks/guard-edits.sh` (PreToolUse on
`Edit|Write|MultiEdit`): edits to shipped/source paths are refused unless
`.claude/.change-approved` lists a matching prefix. Docs, tests and `.claude/`
are not guarded. Edits made through Bash (`sed -i`, heredocs) are not seen
by the hook — the review's scope check is the second net. To bypass
deliberately, create the marker yourself; to disable, remove the hook from
`.claude/settings.json`.
## Proposing an upstream change
```mermaid
flowchart LR
T[topic] --> I1[analyst: server side<br/>dispatcher · http.uc · uhttpd]
T --> I2[analyst: client side<br/>luci.js · ui.js · theme templates]
I1 --> X{fix location?}
I2 --> X
X -- both say theme --> STOP[abort: use /luci-change]
X -- upstream/both --> C[claims with file:line]
C --> V[one verifier per claim<br/>refute-first]
V --> K[confirmed / corrected]
K --> W[writer: RFC from TEMPLATE.md<br/>mermaid before/after · revises an existing file]
W --> Q[critic: every sentence<br/>treated as a claim]
Q -- gaps --> W2[one revision]
Q -- none --> O[.dev/decisions/slug.md]
W2 --> O
```
Refuted claims never reach the document; unknowns are listed, not filled.
The writer may write only under `.dev/decisions/`. Bounds: at most 12
claims are verified (the rest are logged as dropped), one revision pass.
## Decision records (`.dev/decisions/`, local)
Proposals from `/upstream-rfc` and the closing report of every
`/luci-change` land in `.dev/decisions/`. The directory is git-ignored
except `TEMPLATE.md`: these files are drafts of one maintainer's decision
process — device numbers, half-verified claims, revisions — not
documentation. The durable record lives where the decision is acted on:
the commit body for a change here, the pull request for an upstream one.
Each file keeps a `Decision:` line (pending / submitted / interim /
dropped) so the directory doubles as the ledger.
## Agents
| agent | role | tools |
|---|---|---|
| `luci-runtime-analyst` | traces a request through luci-base/uhttpd sources, returns claims with evidence and where the fix lives | read-only |
| `luci-claim-verifier` | refutes one claim or finding; verdict confirmed / corrected / refuted | read-only |
| `aurora-static-reviewer` | templates, CSS/tokens/budgets, patches, packaging, CI | read-only |
| `aurora-runtime-reviewer` | `menu-aurora.js`, `router-aurora.js`, page JS patches, a11y | read-only |
| `upstream-rfc-writer` | writes/revises one proposal from verified claims | writes `.dev/decisions/` only |
## Where the pattern comes from
| practice | source | here |
|---|---|---|
| orchestrator–workers, parallel reviewers, evaluator–optimizer loop | Anthropic, *Building effective agents* (2024) | workflow scripts; implement → review → fix, two rounds max |
| explore → plan → code; plan mode as the approval point; a second Claude reviews | Anthropic, *Claude Code best practices* | `/luci-change` steps 1–2, gate 2 |
| human-in-the-loop for high-risk actions, mechanical guardrails | OpenAI, *A practical guide to building agents* (2025) | gate 1 + `guard-edits.sh` |
| design doc before a non-trivial change; small, single-purpose commits; reviewers judge correctness, not taste | Google engineering practices | RFC template; one logical change; evidence-only findings |
| fixed proposal sections (motivation, reference, drawbacks, alternatives, unresolved questions) | Rust RFC / ADR | `TEMPLATE.md` |
| one logical patch with a cover letter carrying measurements | Linux kernel / OpenWrt submission norms | RFC "Submission"; bench before/after |
@@ -0,0 +1,10 @@
// Theme identity read by the Vite build (vite.config.ts) and the devkit dev
// layer / bins (@eamonxg/luci-theme-devkit). Everything theme-specific lives
// here; the machinery is generic.
export default {
name: "aurora", // media dir (/luci-static/aurora) + ucode theme dir
css: ["main", "login"], // src/media/<e>.css → /luci-static/aurora/<e>.css
resources: ["menu-aurora"], // src/resource/<m>.js served at /luci-static/resources/<m>.js in dev
assets: { dir: "public/aurora" }, // served under /luci-static/aurora/ in dev
patchAliases: {}, // built patch payloads duplicated under extra page names
};
+30
View File
@@ -0,0 +1,30 @@
{
"name": "luci-theme-aurora",
"description": "Modern OpenWrt LuCI theme built with Vite and TailwindCSS",
"type": "module",
"scripts": {
"dev": "vite",
"setup:router": "luci-theme-setup",
"build": "npm run clean && vite build",
"clean": "luci-theme-clean",
"test": "node --test tests/*.test.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"devDependencies": {
"@eamonxg/luci-theme-devkit": "^0.3.1",
"@eamonxg/luci-theme-tokens": "^2.0.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.0",
"browserslist": "^4.28.2",
"colorjs.io": "^0.6.1",
"lightningcss": "^1.32.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.0",
"tailwindcss": "^4.1.11",
"terser": "^5.43.1",
"vite": "^7.1.1"
},
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c"
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
@@ -0,0 +1,7 @@
@font-face {
font-display: swap;
font-family: "Lato";
font-style: normal;
font-weight: 400;
src: url("/luci-static/aurora/fonts/lato-v24-latin-regular.woff2") format("woff2");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

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

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 326 B

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

After

Width:  |  Height:  |  Size: 539 B

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

After

Width:  |  Height:  |  Size: 495 B

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

After

Width:  |  Height:  |  Size: 1.2 KiB

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

After

Width:  |  Height:  |  Size: 224 B

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

After

Width:  |  Height:  |  Size: 452 B

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

After

Width:  |  Height:  |  Size: 502 B

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

After

Width:  |  Height:  |  Size: 309 B

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

After

Width:  |  Height:  |  Size: 309 B

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

After

Width:  |  Height:  |  Size: 309 B

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

After

Width:  |  Height:  |  Size: 319 B

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

After

Width:  |  Height:  |  Size: 325 B

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

After

Width:  |  Height:  |  Size: 482 B

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

After

Width:  |  Height:  |  Size: 330 B

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

After

Width:  |  Height:  |  Size: 478 B

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

After

Width:  |  Height:  |  Size: 380 B

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

After

Width:  |  Height:  |  Size: 354 B

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

After

Width:  |  Height:  |  Size: 322 B

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

After

Width:  |  Height:  |  Size: 347 B

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

After

Width:  |  Height:  |  Size: 301 B

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

After

Width:  |  Height:  |  Size: 122 B

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

After

Width:  |  Height:  |  Size: 377 B

Loaded 100 of 864 files, more files were not shown because too many files have changed in this diff. Show more