🏅 Sync 2026-07-06 08:37:58

This commit is contained in:
github-actions[bot] 2026-07-06 08:37:58 +08:00
parent 5753f5171c
commit 7a3baa80e2
13 changed files with 812 additions and 185 deletions

View File

@ -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: 12 slow cores, 64512 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** — Ship compressed. → 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/`.

View File

@ -0,0 +1,77 @@
# 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 (gzip-transferred) | ≤ 30 KB | size | measured 2026-07 (28 KB) |
| Per-page cold transfer (all theme assets, gzip) | ≤ 60 KB | size | sum of current gzip sizes + headroom |
| 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`)
### Pending
| Item | Principle | Estimated gain |
|---|---|---|
| Precompressed `.gz` assets | S1+L3 | ~260 KB → ~35 KB cold |
| Terser `compress`+`mangle` in `vite.config.ts` | L3 | ~20 KB → ~10 KB |
| Inline `@font-face` + preload woff2 | L1 | 1 blocking RTT |
| SVGO `logo.svg` | L3 | 45 KB → est. < 20 KB |
| 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 |
### 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.
### 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.

View File

@ -0,0 +1,83 @@
# 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 — Ship compressed
**Why.** An uncompressed asset costs both wire time on the slow link (see
The Wire) and CPU on the slow server (see server.md S1) — compression is the
one change that helps both planes at once.
**Do / Don't.** Precompress at build time so `uhttpd` can serve `foo.gz`
verbatim (cross-reference server.md S1). Run shipped SVGs through SVGO
(example: aurora's `logo.svg`, which doubles as the favicon, ships at 45 KB —
an unoptimized SVG is a common place for dead editor metadata to hide).
Choose sane image formats (WebP/AVIF over unoptimized PNG/JPEG) for anything
that isn't vector.
**Verify.** `bench.mjs`'s gzip-negotiated rows; `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, gzip) | `bench.mjs` | `aurora-budgets.md` |
| Individual asset size | `du` / `bench.mjs` | `aurora-budgets.md` |

View File

@ -0,0 +1,97 @@
# 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 5 fixed targets:
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.
2. `main.css`, identity (no `Accept-Encoding`)
3. `main.css`, gzip-negotiated
4. `login.css`, gzip-negotiated
5. the theme's menu script, gzip-negotiated (example: aurora's `menu-aurora.js`)
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).
## 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.

View File

@ -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.

View File

@ -0,0 +1,89 @@
# 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 12 cores and 64512 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.** Precompress assets at build time and let `uhttpd` serve the
precompressed file verbatim when the client sends `Accept-Encoding: gzip`
this makes the request-time cost *decrease*, since `uhttpd` has no dynamic
compression of its own. Generate derived CSS/config/tokens at build time,
never per-request (example: aurora's generated `_tokens.css`, built from
`.dev/tokens/` and never touched at runtime). 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.** `top` on the device shows no request-time compression process;
`curl -H 'Accept-Encoding: gzip' -w '%{size_download}'` against the asset
shows the byte drop versus an uncompressed request.
**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.
Prefer gzip-negotiated transfer over raw bytes wherever the client supports
it (example: aurora's `main.css` is 211,725 B raw but ~28 KB gzip — always
measure and budget the negotiated size, not the raw one).
**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 (gzip) | `bench.mjs` | `aurora-budgets.md` |
| uhttpd VmRSS | `bench.mjs` device report | `aurora-budgets.md` |

View File

@ -0,0 +1,67 @@
/**
* 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) {
if (!Number.isInteger(sample.status) || sample.status < 200 || sample.status >= 400) {
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");
}

View File

@ -0,0 +1,87 @@
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("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 \|/);
});

View File

@ -0,0 +1,127 @@
#!/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" },
{ label: "main.css (identity)", path: "/luci-static/aurora/main.css" },
{ label: "main.css (gzip negotiated)", path: "/luci-static/aurora/main.css", gzip: true },
{ label: "login.css (gzip negotiated)", path: "/luci-static/aurora/login.css", gzip: true },
{ label: "menu-aurora.js (gzip negotiated)", path: "/luci-static/resources/menu-aurora.js", gzip: true },
];
async function measureOnce(target) {
const args = [
"-o", "/dev/null",
"-s",
"-w", CURL_FORMAT,
"--max-time", "30",
];
if (target.gzip) args.push("-H", "Accept-Encoding: gzip");
args.push(`${HOST}${target.path}`);
const { stdout } = await execFileAsync("curl", args);
return assertOkStatus(parseCurlMetrics(stdout));
}
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());

View File

@ -1,182 +0,0 @@
# Performance — Animation & Runtime
How to verify and quantify the theme's runtime (animation/interaction) performance.
> The essence of an animation optimization is moving work **off the main thread**: from
> per-frame **Layout/Paint** onto the **compositor** (`transform` / `opacity` / `clip-path` /
> `scale`). So the single most important question this guide answers is:
>
> **"Does the main thread still run `Layout` events while the animation plays?"**
## Where this fits
Frontend performance splits into two unrelated buckets — don't conflate them:
| Bucket | Concern | Relevant here? |
|--------|---------|----------------|
| **Load** | First paint, bundle size (LCP, CSS/JS bytes) | Only for `main.css` size |
| **Runtime** | Interaction & animation smoothness (INP, dropped frames) | ✅ This doc |
Two more axes the industry tracks:
- **Lab vs Field***lab* is your local, repeatable profiling (DevTools, Lighthouse).
*Field* is real users (RUM). This doc is lab-only; for field data see
[Preventing regressions](#preventing-regressions).
- **Reality check** — LuCI's assets ship from the router, but **rendering happens in the
user's browser**. The bottleneck is the *client* GPU/CPU. Most users open LuCI on a
**phone**, so always profile under CPU throttle, never on a bare fast desktop.
---
## 0. Setup (one-time)
1. Run the dev server (`cd .dev && pnpm dev`) and open a logged-in page in Chrome/Edge,
e.g. `http://localhost:5173/cgi-bin/luci/admin/status/overview`.
2. Open DevTools (`F12` / `Cmd+Opt+I`).
3. Have two panels ready:
- **Rendering**: `Cmd+Shift+P` → type `Show Rendering`.
- **Performance**: the top tab.
4. Confirm you're serving the optimized CSS: **Network** → reload → click `main.css`
**Response** → search `prefers-reduced-motion`. If present, it's the new build.
---
## 1. Core evidence — Performance recording (the Layout track)
This is where the real numbers come from. Generic recipe, used for every animation:
1. Performance panel ⚙️ → **CPU: 4× slowdown** (emulates a low-end phone/tablet admin device).
2. ● Record → trigger the animation once → let it finish → Stop.
3. Read the timeline:
- **Purple `Layout` / `Recalculate Style` bars** = reflow. Fewer is better.
- **FPS green bar** collapsing = dropped frames.
- **Summary → Rendering** time at the bottom.
> **Verdict rule:** watch the `Layout` row. The ideal result is that **after the first frame,
> the entire animation produces no new `Layout` events** — it runs purely on the compositor.
### Component → technique → what to check
| 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 |
---
## 2. Fastest visual evidence — Paint flashing (no recording)
1. Rendering panel → enable **Paint flashing**.
2. Trigger each animation; repainted regions flash **green**.
| Trigger | Pass criteria |
|---------|---------------|
| Open/close mega-menu | The frosted panel should **not** flash green as a solid block continuously (ideally only a faint flash at the reveal edge) |
| Mobile drawer slide in/out | Drawer body **doesn't** flash green continuously (transform slide is repaint-free) |
| Tooltip appear | Only the small tooltip region flashes once |
| Toggle floating toolbar | Button area doesn't flash green continuously |
> ⚠️ **Honest caveat:** 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.
---
## 3. Accessibility & fallback — `prefers-reduced-motion`
1. Rendering panel → **Emulate CSS media feature prefers-reduced-motion**`reduce`.
2. Trigger animations:
| Check | Pass criteria |
|-------|---------------|
| Mega-menu / drawer / tooltip | Snap into place, **no transition** |
| Theme switch | Instant, no crossfade |
| Page navigation | `#view` has no entry animation |
| **Functionality** | All menus/modals **still open and close** — nothing stuck hidden |
Reset to `No emulation` when done.
---
## 4. View Transitions — theme switch
1. **Chrome/Edge** (supports View Transitions): click the footer theme toggle (device / light / dark).
- Pass: the **whole page** crossfades smoothly (every element transitions together).
2. **No first-paint flash:** reload the page.
- Pass: **no** theme crossfade on load (the first set is guarded by `prev !== null`).
3. **Graceful degradation:** switch theme in **Firefox** / Safari.
- Pass: instant switch, **no console errors**.
---
## 5. Behavior regression (animation rework didn't break function)
- [ ] Mega-menu: hover opens, leave closes; sweeping between menus closes the old and opens the new with no residue.
- [ ] Mega-menu height: submenu content **fully visible, not clipped** (`--mega-menu-height` computed correctly).
- [ ] Mobile drawer: closes via `Esc` / scrim tap / close button; **scrim fades out** on close.
- [ ] Mobile submenu: opening B auto-closes A; reopening the drawer shows all submenus collapsed.
- [ ] Modal (e.g. *Save & Apply*): scrim still blurs the background; modal content stays sharp.
- [ ] Tooltip: appears on hover, disappears on leave, ~150 ms, feels responsive.
---
## 6. Before/after A/B comparison (real numbers)
Absolute numbers on a fast machine prove nothing — **the delta vs `master` is the result.**
```bash
cd /path/to/luci-theme-aurora
git stash # if you have uncommitted work
git checkout master
cd .dev && pnpm build # old assets
# Hard-refresh the browser (Cmd+Shift+R), record the mega-menu open (§1),
# note the Layout event count / Rendering time.
cd .. && git checkout perf/compositor-animations
cd .dev && pnpm build
# Record again — Layout count should drop noticeably.
```
> The dev server re-proxies the new build automatically; **hard-refresh** each time to clear cache.
---
## Pass criteria summary
| Signal | Tool | Pass criteria |
|--------|------|---------------|
| Main-thread reflow during animation | Performance → Main track | Near-zero `Layout` / `Recalculate Style` after frame 1 |
| Dropped frames | Performance → Frames / FPS meter | No frame > 16.7 ms; steady ~60 fps under 4× throttle |
| Repaint area | Rendering → Paint flashing | Minimal green on reflow-class animations |
| Compositor offload | Rendering → Layer borders | Animated elements promoted to their own layer |
| Interaction latency (INP) | Lighthouse / Performance | No regression; improved where `scrollHeight` was removed |
---
## Preventing regressions
Verifying once isn't enough — keep it from drifting back:
- **CSS size budget**: gate `main.css` size in CI (e.g. [`size-limit`](https://github.com/ai/size-limit))
so a PR that bloats the bundle fails.
- **Field data (optional)**: ship the [`web-vitals`](https://github.com/GoogleChrome/web-vitals)
library to report INP/LCP/CLS from real users, and watch the **P75** (not the average).
- **Rule of thumb for new CSS**: animate only `transform` / `opacity` / `clip-path` / `scale`.
Never animate `height` / `width` / `top` / `box-shadow` / `background-color` (they reflow or
repaint). Avoid blanket `transition-all` — list explicit properties.
- **Accepted exception — `.cbi-progressbar`**: 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.
---
## The 3 fastest checks (when short on time)
1. **§1** — record a mega-menu open → is the `Layout` row clear?
2. **§2** — Paint flashing → does the mega-menu / mobile drawer still flash as a solid block?
3. **§3** — emulate `reduce` → are animations fully off *and* everything still functional?

View File

@ -11,5 +11,9 @@ yarn.lock
# Superpowers AI workflow docs
docs/superpowers/
.dev/docs/superpowers/
.superpowers/
.worktrees/
# On-device perf baselines — record device model and LAN address; keep local
/.claude/skills/aurora-performance/baselines/

View File

@ -9,7 +9,7 @@ LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CS
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.0.7
PKG_RELEASE:=41
PKG_RELEASE:=42
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=

View File

@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=natflow
PKG_VERSION:=20260531
PKG_RELEASE:=35
PKG_RELEASE:=36
PKG_SOURCE:=$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=6b24ecd9cd05c0c2fe3c724d29ce3300491afa80
PKG_SOURCE_VERSION:=756fea6d1e541e3d693dea4f82969585ad9262cc
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com>
PKG_LICENSE:=GPL-2.0