mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-28 03:01:54 +08:00
🏅 Sync 2026-07-06 08:37:58
This commit is contained in:
parent
5753f5171c
commit
7a3baa80e2
61
luci-theme-aurora/.claude/skills/aurora-performance/SKILL.md
Normal file
61
luci-theme-aurora/.claude/skills/aurora-performance/SKILL.md
Normal 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: 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** — 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/`.
|
||||
@ -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.
|
||||
@ -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` |
|
||||
@ -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.
|
||||
@ -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,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 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.** 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` |
|
||||
@ -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");
|
||||
}
|
||||
@ -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 \|/);
|
||||
});
|
||||
@ -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());
|
||||
@ -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?
|
||||
4
luci-theme-aurora/.gitignore
vendored
4
luci-theme-aurora/.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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:=
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user