update 2026-10-09 12:11:19

This commit is contained in:
action committed 2026-10-09 12:11:19 +08:00
1 parent d6b7af2a48
commit beac9fdb5a
758 files changed
+142487 -135

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,
}