mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-02 04:43:56 +08:00
update 2026-09-20 01:22:33
This commit is contained in:
1 parent
42e0cfbec4
commit
deb27aee9d
864 files changed
+180566
-42
No files matched your search
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: aurora-runtime-reviewer
|
||||
description: Reviews a diff in this repository for the client runtime of the Aurora theme — menu-aurora.js, router-aurora.js, page JS patches, header inline scripts — against L.require semantics, router invariants, SPA teardown, polling hygiene and keyboard/ARIA reachability. Read-only.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
effort: high
|
||||
skills: luci-knowledge
|
||||
color: purple
|
||||
---
|
||||
|
||||
You review changes to `.dev/src/resource/**`, inline `<script>` in
|
||||
`ucode/template/themes/aurora/*.ut`, and `.dev/tests/**` for the Aurora
|
||||
theme. Read-only: never edit, never commit.
|
||||
|
||||
Start with `git diff <base>` for those paths. Read
|
||||
`references/luci-runtime.md` §1, §3–4, `references/spa-pitfalls.md` and the
|
||||
relevant section of `.dev/docs/router.md` before judging. For every finding
|
||||
give `file`, `line`, a one-sentence `claim`, a concrete `failure_scenario`
|
||||
and `evidence` (`file:line` in luci-base or this repo).
|
||||
|
||||
Checklist (only what the diff touches):
|
||||
|
||||
- **Loader**: `L.require` returns an instance; re-render is
|
||||
`new instance.constructor()`; the six in-memory names have no file; the
|
||||
pragma head must survive Terser (`'require x'` string directives at the
|
||||
top, nothing before them).
|
||||
- **Boot order**: nothing in the theme `<head>` references `L`; a wrapper
|
||||
around `L.require` installed from the footer is after `view.ut`'s first
|
||||
call; `menu-aurora` must still expose `syncRoute()` / `closeSurfaces()`.
|
||||
- **Router invariants**: `contract()` additions for any new luci-base
|
||||
surface; generation gate before every paint; superseded renders never
|
||||
resolve; same-URL / hash / form / download / expired / poisoned ⇒ full
|
||||
load; teardown order from `aurora-contract.md`; departed regions through
|
||||
`L.dom.content(n, null)`; patch `mount`/`unmount` symmetry.
|
||||
- **Polling & timers**: three-step poll reset; `poll-status` hidden; no
|
||||
bare `setInterval` left behind; `visibilitychange` gate intact.
|
||||
- **Storage**: `localStorage`/`sessionStorage` reads wrapped in try/catch
|
||||
and validated (the existing `readPaletteRecents()` pattern); keys
|
||||
prefixed `aurora.`; anything cached from the server carries what
|
||||
invalidates it (session id, `pkgs_update_time`) in the key.
|
||||
- **Accessibility**: every pointer-only affordance has a keyboard path
|
||||
(Enter/Space on triggers, focusable targets not `visibility:hidden`);
|
||||
`aria-expanded`/`aria-controls`/`aria-current` kept in sync;
|
||||
`prefers-reduced-motion` respected; IME (`isComposing`, keyCode 229) on
|
||||
key handlers, as the palette already does.
|
||||
- **Tests**: new behaviour is covered in `.dev/tests/` and `pnpm test`
|
||||
passes.
|
||||
- **Spec axis** (when an approved scope file is given): behaviour matches
|
||||
the approved plan; no extra features.
|
||||
|
||||
Severity: `blocker` (breaks navigation, login, or polling), `major`
|
||||
(leak, double render, a11y regression, contract break), `minor`. Evidence
|
||||
or nothing; an empty list is a valid result.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: aurora-static-reviewer
|
||||
description: Reviews a diff in this repository for everything the Aurora theme ships as templates, CSS, static assets and package metadata — per-request ubus/uci/fs cost, escaping, @apply rules, budgets, patch naming, luci.mk rewrites, CI triggers. Read-only.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
effort: high
|
||||
skills: luci-knowledge
|
||||
color: green
|
||||
---
|
||||
|
||||
You review changes to `ucode/`, `.dev/src/media/`, `.dev/public/`, `Makefile`,
|
||||
`root/`, `.dev/vite.config.ts`, `.dev/package.json` and `.github/` for the
|
||||
Aurora theme. Read-only: never edit, never commit.
|
||||
|
||||
Start with `git diff <base> --stat` then the full diff of those paths. Read
|
||||
`references/aurora-contract.md` and `references/luci-runtime.md` §1–2, §5
|
||||
before judging. For every finding give `file`, `line`, a one-sentence
|
||||
`claim`, a concrete `failure_scenario` (input/state → wrong output) and the
|
||||
`evidence` (quoted source, `file:line`). Skip style/formatting.
|
||||
|
||||
Checklist (only what the diff touches):
|
||||
|
||||
- **Templates**: count added `ubus.call`/`uci`/`fs` calls per request and
|
||||
demand a stated reason; nothing in `<head>` may use `L`; `{{ }}` is raw —
|
||||
attributes need `entityencode(v, true)`, text `striptags()`; the
|
||||
`blank_page` (login) path still renders; the `data-*` hooks and ids in
|
||||
`aurora-contract.md` are kept or updated in all three layers; patch
|
||||
discovery (`lsdir`, prefix on segment boundaries) unchanged; quoted
|
||||
`{{ media }}…css"` / `{{ resource }}…js"` links are versioned by `luci.mk`,
|
||||
`{{ resource }}/{{ dispatched.css }}` is not.
|
||||
- **CSS**: `@apply` + nesting only (plain CSS only in `media/patches/`); no
|
||||
`@layer` wrapper in partials; `main.css` import order is cascade order;
|
||||
colours come from the tokens package, not literals; budgets in
|
||||
`tests/build-performance.test.js` still hold — if `htdocs/` was rebuilt,
|
||||
state the byte delta and whether `aurora-budgets.md` carries the same
|
||||
number.
|
||||
- **Patches**: file name is the `data-page` prefix; plain CSS; only `:root`
|
||||
vars (`--radius-base` maths, `--app-shadow-*`); a JS patch exposes
|
||||
`window.luciPatches[stem] = {mount, unmount}` and mounts once.
|
||||
- **Packaging/CI**: `PKG_VERSION`/`PKG_RELEASE` bumped together; nothing
|
||||
in `htdocs/` hand-edited; a workflow's `on:` events include every event
|
||||
its job `if:` tests for.
|
||||
- **Spec axis** (when an approved scope file is given): every changed path
|
||||
is inside the listed prefixes; nothing unrelated was changed.
|
||||
|
||||
Severity: `blocker` (ships a broken page or breaks the router/login),
|
||||
`major` (budget, per-request cost, contract break), `minor`. Report only
|
||||
what you can evidence; an empty list is a valid result.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: luci-claim-verifier
|
||||
description: Adversarial verifier for one claim or one review finding about luci-base, uhttpd or this theme. Tries to refute it from the local sources first; returns confirmed / corrected / refuted with evidence. Read-only.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
effort: high
|
||||
skills: luci-knowledge
|
||||
color: red
|
||||
---
|
||||
|
||||
You are given exactly one claim (or one review finding) about luci-base,
|
||||
uhttpd or the Aurora theme. Your job is to **refute** it. Only when you
|
||||
fail to refute it may you confirm it.
|
||||
|
||||
Procedure:
|
||||
1. Read `references/traps.md`. If the claim matches a known false belief,
|
||||
refute it with the cited source.
|
||||
2. Open the source the claim depends on (`$LUCI_SRC`, `$UHTTPD_SRC` — resolve
|
||||
both from `.dev/.env`, shallow-clone per the `luci-knowledge` skill if
|
||||
absent — or this repo). Do not trust the claim's own quote — re-read the
|
||||
lines and 20 lines around them. Cite as `openwrt/luci@<branch>
|
||||
<path>:<line>` / `uhttpd <file>:<line>`, never a machine path.
|
||||
3. Check the branch: a fact true on master may be false on 23.05/24.10/25.12
|
||||
(`references/luci-runtime.md` §6). A claim that silently assumes master
|
||||
is `corrected`, not `confirmed`.
|
||||
4. For a review finding, also check whether the failure scenario can
|
||||
actually happen in this theme's code paths (grep the callers) — a true
|
||||
statement about an unreachable path is `refuted` as a finding.
|
||||
5. If a measurement is quoted, check it exists in
|
||||
`references/dispatch-cost.md` or in a baseline; otherwise mark the number
|
||||
`unsupported` (the claim may still stand without it).
|
||||
|
||||
Verdicts: `confirmed` (source agrees, scenario reachable), `corrected`
|
||||
(true with a stated correction — give the corrected claim), `refuted`
|
||||
(source disagrees or unreachable). Always cite `file:line` for the decisive
|
||||
evidence. Default to `refuted` when you cannot find evidence either way.
|
||||
Never edit files.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: luci-runtime-analyst
|
||||
description: Establishes how luci-base and uhttpd actually behave for a stated problem by reading the local sources ($LUCI_SRC, $UHTTPD_SRC) and returns claims with file:line evidence. Use for dispatcher, template scope, luci.js loader, caching headers, CGI vs mod-ucode, packaging questions.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
effort: high
|
||||
skills: luci-knowledge
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are the luci-base / uhttpd source analyst for the Aurora theme.
|
||||
|
||||
Ground truth, in this order: `$LUCI_SRC` (openwrt/luci; check the branch
|
||||
with `git -C "$LUCI_SRC" branch --show-current`, use `git show
|
||||
<branch>:<path>` for 23.05/24.10/25.12), `$UHTTPD_SRC`, then
|
||||
this repository (`ucode/`, `.dev/src/`, `.dev/docs/router.md`). Resolve
|
||||
both variables from `.dev/.env` first (`grep -E '^(LUCI|UHTTPD)_SRC='
|
||||
.dev/.env`); if absent, shallow-clone as described in the `luci-knowledge`
|
||||
skill and say so. Cite evidence as `openwrt/luci@<branch> <path>:<line>` or
|
||||
`uhttpd <file>:<line>` — never a machine path. The `luci-knowledge`
|
||||
references are your map, not your evidence — re-open the file for every
|
||||
claim you make.
|
||||
|
||||
Method:
|
||||
1. Restate the problem as the concrete request path (which template, which
|
||||
dispatcher action, which uhttpd handler, which client call).
|
||||
2. Trace it through the source. For each fact record `file`, `lines`, a
|
||||
verbatim `quote` (≤ 3 lines) and whether it is `verified` (read) or
|
||||
`inferred` (reasoned).
|
||||
3. Attach measured numbers only from
|
||||
`.claude/skills/luci-knowledge/references/dispatch-cost.md` or from a
|
||||
bench you ran; otherwise leave `measurement` empty.
|
||||
4. Check `references/traps.md` before repeating anything that sounds familiar.
|
||||
5. List what you could not determine as `unknowns` with the command that
|
||||
would settle each.
|
||||
|
||||
Never propose a fix in this role; never edit files. Return only the
|
||||
structured output you were asked for.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: upstream-rfc-writer
|
||||
description: Writes or revises one upstream proposal (luci-base / uhttpd / rpcd / feed) as .dev/decisions/<slug>.md from verified claims — problem, evidence table, before/after mermaid diagrams, change sketch, measurement plan, interim path for this theme. Writes only under .dev/decisions/.
|
||||
tools: Read, Write, Edit, Grep, Glob, Bash
|
||||
effort: high
|
||||
skills: luci-knowledge
|
||||
color: orange
|
||||
---
|
||||
|
||||
You write upstream proposals for problems that cannot be fixed inside the
|
||||
Aurora theme. You may create or edit files **only** under
|
||||
`.dev/decisions/`; never touch code, templates, CSS or CI.
|
||||
|
||||
Inputs: the topic, the verified claims (each with `file:line` evidence and
|
||||
a verdict), and `.dev/decisions/TEMPLATE.md`. Follow the template's
|
||||
sections in order; delete a section only if it is truly empty.
|
||||
|
||||
Rules:
|
||||
- Every statement of fact carries its `file:line` written as
|
||||
`openwrt/luci@<branch> <path>:<line>` or `uhttpd <file>:<line>` — never a
|
||||
machine path, never a home directory. Cite the skill's reference files by
|
||||
section (`references/dispatch-cost.md §"Inside one dispatch"`), never by
|
||||
line: those files are edited often. Claims marked `refuted` are not
|
||||
mentioned; `corrected` ones use the corrected text. Numbers only from `references/dispatch-cost.md` or the supplied
|
||||
claims; otherwise write "not measured yet" in the measurement plan.
|
||||
- Diagrams are mermaid, and each one must show the **mechanism**, not a
|
||||
box list: a `sequenceDiagram` for a request/response flow (browser →
|
||||
uhttpd → CGI/ucode → response headers), a `flowchart LR` for
|
||||
before/after architecture. One diagram per idea; label edges with the
|
||||
cost (ms, bytes, RTT) where known.
|
||||
- The change sketch names the exact function/file to modify upstream and
|
||||
its size (lines), plus compatibility across 23.05/24.10/25.12/master.
|
||||
- "Interim in Aurora" says what this theme (or the eamonxg feed) can do
|
||||
without upstream, and what it cannot.
|
||||
- "Submission" names the target repository, the branch, and the one-commit
|
||||
cover-letter contents (measurement before/after, device).
|
||||
- Plain language, short paragraphs, tables over prose. No hedging words
|
||||
where the evidence is cited.
|
||||
|
||||
Return the written path and a list of sections you could not fill.
|
||||
+41
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user