🐶 Sync 2026-09-17 00:01:35

This commit is contained in:
github-actions[bot] committed 2026-09-17 00:01:35 +08:00
1 parent d0c748add2
commit 6c6ea07309
106 files changed
+7551 -5770

No files matched your search

@@ -0,0 +1,116 @@
---
name: aurora-motion
description: Use when a change adds, removes or adjusts motion in the Aurora theme (transition, animation, View Transition, hover/press/focus feedback, menu or drawer timing, 动效/动画/过渡/丝滑) or when a request asks "what should this state look like when it changes". Maps each LuCI UI state to one effect (CSS animates, JS only decides when and how far), keeps main.css and the theme JS from growing, and rejects motion that janks a phone or replays on poll.
---
# Aurora motion: which state gets which effect
## First principles
1. **The device is small.** The router has little RAM and flash, and it serves every byte uncompressed. Motion must not grow the theme. Decorative motion must be net ≤ 0 bytes across main.css and the theme JS. Only a functional fix (§Traps) may add bytes, and each must stay under ~100 B.
2. **CSS does the moving; JS decides, in moderation.** Transitions and keyframes animate. JS may decide *when* and *how far* (see §JS). No animation library: `motion/mini` alone is 9,751 B raw (measured 2026-09-16). At HEAD, main.css had 1,270 B left under its 193 KB budget and menu-aurora.js had 306 B left under 22 KB.
3. **Motion explains a state change, nothing else.** If a state is not in the table below, the answer is "no motion". Plain and quiet beats impressive.
4. **Never cause jank.** The client is usually a phone on the router's WiFi. See §Jank.
## State → effect
These timings are the only ones allowed: 150 ms for controls, 250 ms for surfaces, 300–320 ms only for large panels. Curves: `ease-out` for entering elements, `ease-in-out` for symmetric changes. `cubic-bezier(0.34,1.25,0.5,1)` is the float-button's overshoot and nothing else. "Today" describes HEAD.
| State | Hook | Effect | Today |
|---|---|---|---|
| Hover 悬停 (desktop only) | `:hover` | Colour change, instant. Icon-size controls may use `scale-105`. | top nav pills instant; some links still `transition-colors` |
| Focus 键盘聚焦 | `:focus-visible` | A ring appears. No movement. | `ring-2 ring-focus-ring` |
| Press 按下 | `:active` | `scale-95`, only on icon-size controls. | header toggles, switcher, card icons |
| Toggle 开关 | class flip | The thumb slides or the icon morphs. | `.theme-switcher`, hamburger → X |
| Expand / collapse 展开收起 | theme classes | Fade. Grid rows only where they already animate. | mobile submenu |
| Menu / drawer 菜单抽屉 | theme classes | Slide (translate) plus fade. A page scrim only dims; it is never blurred. The desktop sidebar pushes: its grid column eases with the panel's slide, same curve and length. | mega-menu (unblurred curtain), mobile nav, sidebar (push) |
| Tooltip 提示 | hover / focus | Fade plus `scale-95` → 100, after the intent delay. | `_tooltip.css`, `delay-300` |
| Dropdown open 下拉打开 | `.cbi-dropdown[open]` | Opacity .99 → 1 over 120 ms. This is functional: it fires luci-base's focus hand-off. | **missing** (Trap 2) |
| Modal open 弹窗打开 | `body.modal-overlay-active` | Fade in, reusing `aurora-fade-in`. The overlay goes from `display:none` to shown, so the keyframe restarts by itself. | none |
| Modal close 弹窗关闭 | class removed | None. luci-base provides no exit hook. | — |
| Notification in 通知出现 | `.alert-message.fade-in` | Fade in. | `_utilities.css` |
| Notification out 通知消失 | `.fade-out` (dismiss or timer) | Fade out. It must be a *running* transition. | **broken** (Trap 1) |
| View loading 页面加载中 | `#view > .spinning` | Spinner only. It keeps spinning under reduced motion. | `.spinning` |
| Page ready 内容就绪 | direct children of `#view` | At most one fade in, reusing `aurora-fade-in`. No stagger, no rise, and nothing deeper than the direct children. | none |
| Route leave 离开页面 | `#view.view-leaving` | Dim to 60 %. | `_layout.css` |
| Route enter 进入页面 | router swap | Same as "Page ready". Use one mechanism only: the View Transition or the fade, never both stacked. | router root crossfade |
| Tab switch 切换标签 | `[data-tab-active="true"]` | Fade in the incoming pane. Its height snaps. | none |
| Busy action 操作进行中 | `.spinning` on a button | Spinner. | `.spinning` |
| Progress 进度 | `.cbi-progressbar > div` | Width. This is an existing exception; don't copy it. | `_progress.css` |
| Drag 拖拽 | `.dragging` | A static state style, no motion. | `_form.css` |
| Invalid / success / error 校验与结果 | `.cbi-input-invalid`, `.alert-message.*` | Colour plus text. No shake, no bounce. | — |
| Poll refresh 数据刷新 | `LuCI.poll`, every 5 s | **None, ever.** | — |
| Theme switch 主题切换 | header.ut | View Transition crossfade. | header.ut |
| Disabled 禁用 | `:disabled` | None. | — |
The source lines for every hook are in references/luci-hooks.md.
### Never
Blur or mask reveal, parallax, scroll-linked or scroll-triggered motion, stagger on rows, cards or sections, text reveal, tilt, magnetic or cursor-follow effects, hover lift or shadow on `.cbi-section` or `#view`, skeleton screens, layout (FLIP) animation, crossfading tab panes, shake, loops other than `.spinning`, autoplay, and any motion on polled content.
## Bytes
Measured on main.css (raw, Tailwind 4.3.2 plus lightningcss, 2026-09-16). What you write matters more than what the effect is:
| Written as | Cost |
|---|---|
| Add a selector to an existing motion rule's selector list | **+17 B** |
| One shorthand on a new selector: `@apply [transition:opacity_150ms_ease-out]` | +50 B |
| Reuse the keyframe: `@apply animate-[aurora-fade-in_250ms_ease_backwards]` | +59 B |
| New keyframe plus its use (e.g. opacity + scale pop-in) | +141 B |
| Press as shorthand: `[transition:scale_150ms] active:[scale:.95]` | +99 B |
| Split utilities: `transition-opacity duration-150 ease-out` | **+316 B** |
| Split utilities for press: `transition-transform duration-150 active:scale-95` | +415 B |
| `starting:` entry with split utilities (modal) | +508 B |
Tailwind v4's split utilities expand into `--tw-*` fallback chains. So:
- Write motion as **one shorthand declaration**, or reuse `aurora-fade-in`.
- Prefer adding a selector to an existing rule over writing a new one.
- Don't add `@starting-style`: an element that comes back from `display:none` restarts its keyframe anyway.
Check every change. Run `pnpm build`, then compare `wc -c < htdocs/luci-static/aurora/main.css` with `git show HEAD:htdocs/luci-static/aurora/main.css | wc -c`. Put the delta in the commit body. If decorative motion comes out positive, remove bytes elsewhere or drop it.
## Jank
- Animate only `opacity`, `translate`, `scale` and `transform`. Never width, height, margins, `box-shadow`, `filter`, `backdrop-filter` or background. The only existing exceptions are the progress-bar width, the mobile-submenu grid rows, and the desktop sidebar column (user's call, 2026-09-16: a content column that snaps and then slides reads as broken; at 4× CPU the 250 ms push cost ~3 ms layout + style per frame on the overview page).
- Never animate an element that carries `backdrop-blur`, because the blur is re-rasterised every frame. Animate an unblurred child instead. Under software compositing, the blurred mega-menu curtain dropped 3–5 frames per open; without the blur it dropped none (2026-09-16).
- Keep `visibility` out of the tween. Write `[transition:opacity_220ms_…,visibility_0s_220ms]` and put `[transition-delay:0s]` on the shown state. A tweened `visibility` runs on the main thread, and on show it also keeps the paired `opacity` fade off the compositor.
- A `color` or `background-color` transition repaints on the main thread every frame. Swap state colours instantly. Dropping the transition on the nav pills cut repaints by 61 %.
- Always name the transitioned properties. Never use `transition-all`, and never use `will-change`.
- Allow one motion per state change per region, lasting no more than 320 ms. Click, save and error feedback get no delay.
- Under reduced motion, show the end state at once, but keep durations at 1 ms rather than `none` (Trap 1).
- Verify on the Layout track at 4× CPU throttling: aurora-performance runtime.md R1–R3.
## JS
JS is worth its bytes where CSS cannot decide. The mega-menu in `menu-aurora.js` is the reference:
- **Hover intent and aim.** Delay the first open. Hold the open category while the pointer heads into its panel. Every timer is cleared on leave.
- **Measure once, then cache.** Measure after `document.fonts.ready`, in `requestIdleCallback`. Write the result to a custom property (`--mega-menu-height`) and let CSS use it. Invalidate on resize only.
- **Distance-adaptive duration.** Write the computed duration to a custom property as well (`--mega-menu-duration`).
- **Sequencing and cleanup.** Toggle classes (`.active` → `.closing` → removed). A `transitionend` wait always has a fallback timer that reads the real duration back.
- **Reduced motion.** Check `prefers-reduced-motion` before any JS-timed motion, and jump straight to the end state.
Not allowed:
- a `requestAnimationFrame` loop that writes styles every frame;
- scroll or pointer listeners whose only job is an effect;
- layout reads (`offsetHeight`, `getBoundingClientRect`) on every event of a hot handler;
- `el.animate()`, except for a one-off sequence CSS cannot express, cancelled on `luci-navigate`.
## Traps (reproduced on a device, 2026-09-16)
luci-base acts on `transitionend`, which only fires if a transition actually runs.
1. **A dismissed notification never leaves the DOM.** The fill of `.fade-in` (`both`) holds `opacity`, so the `.fade-out` transition never starts. Every Dismiss and every expired notification leaves an invisible 84 px gap. Fix: `both` → `backwards` (+5 B). In `_reduced-motion.css`, replace `transition-none!` with `delay-0! duration-1!` (+57 B).
2. **Arrow keys cannot enter a dropdown.** There is no transition on `.cbi-dropdown > ul`, so focus never moves into the list. Fix: `opacity-99 [transition:opacity_120ms_ease-out]`, with `&.dropdown { opacity-100 }` (+98 B).
Any change touching `.fade-in`/`.fade-out`, dropdowns or `_reduced-motion.css` must pass this, run after `pnpm build`:
```
MAIN_CSS=htdocs/luci-static/aurora/main.css node .claude/skills/aurora-motion/scripts/transitionend-probe.mjs
```
The edit itself goes through /luci-change.
@@ -0,0 +1,56 @@
# Evidence for the state table
luci-base line numbers are from openwrt/luci master (d19bf1358f, 2026-09-14),
through `$LUCI_SRC` (see luci-knowledge). Router line numbers are from
`@eamonxg/luci-theme-devkit` 0.3.1, `runtime/router.js`. Re-check them after
a version bump.
## Hooks luci-base emits
| State | Source | What happens |
|---|---|---|
| Modal open / close | `ui.js:4224`, `ui.js:4242` | `showModal` adds `body.modal-overlay-active`. `hideModal` only removes that class, so there is no exit hook. `#modal_overlay` is `hidden` until the class is present (`_modal.css`). |
| Notification | `ui.js:4359–4390`, `ui.js:4440` | `addNotification` creates `.alert-message.fade-in`. Dismiss adds `.fade-out`, and the node is removed on its `transitionend` (`ui.js:4364`). The time-limited variant adds `.fade-out` on a timer. |
| Dropdown | `ui.js:1300–1306`, `1390–1395`, `1416–1423` | Opening sets `[open]`, and the focus layer gets `.cbi-dropdown-open`. Focus moves into the list only on `transitionend` of the `ul`. |
| Dropdown keys | `ui.js:1881–1930` | ArrowUp/ArrowDown act only when a `li` or the `ul` already has focus. |
| Tabs | `ui.js:4728`, `4882–4887` | Panes get `data-tab-active="true"/"false"`, and the pane fires the `cbi-tab-active` event. In this theme, inactive panes are `invisible h-0` (`_segmented.css`), not `display:none`. |
| Busy button | `ui.js:5683` | Adds `.spinning` while a handler's promise is pending. |
| Drag | `ui.js:2442–2449` | Adds `.dragging` on `dragstart` and removes it on `dragend` (`.cbi-dynlist`). |
| Invalid input | `ui.js:2011` and the validators | `.cbi-input-invalid`. |
| Header indicator | `ui.js:4492` | `#indicators span[data-indicator][data-style=active\|inactive]`. |
| First render | `luci.js:2771` | `luci-loaded` fires after `initDOM()` and `Poll.start()`. |
| Poll | `luci.js:1093`, `luci.js:1165` | Ticks once a second and runs each callback every `pollinterval` seconds: 5 by default (`/etc/config/luci`; luci-base `header.ut:20`). Clicking the `poll-status` indicator pauses it (`luci.js:2742–2750`). |
For reference, bootstrap animates `max-height` over 125 ms on the dropdown
`ul` (`themes/luci-theme-bootstrap/.../cascade.css:1738`). That transition
is what makes the focus hand-off work there.
## Hooks the router emits
| Hook | Source | What happens |
|---|---|---|
| `.view-staging`, `#view.view-leaving` | `router.js:832–848` (`stage()`) | The incoming view renders off-screen, and the outgoing one is marked. |
| `document.startViewTransition(swap)` | `router.js:872` (`commit()`) | Skipped when the API is missing or reduced motion is set. |
| `luci-navigate` / `luci-navigated` | `router.js:733` / `router.js:756` | Document events; `detail` is the route. Neither fires on MPA browsers, so any motion must also work after a full page load. |
| Departed view | `router.js:852–861` | The old view is cleared through `dom.content()` and removed synchronously, so it has no exit animation. |
## Traps: how they were reproduced
`scripts/transitionend-probe.mjs` logs in, renders a stock `ui.Dropdown` and
calls `ui.addNotification` on a real page. It runs with and without
`prefers-reduced-motion` and can serve any build as main.css (`MAIN_CSS`).
| main.css | Dropdown after ArrowDown ×2 | Notification after Dismiss |
|---|---|---|
| HEAD (2026-09-16) | focus stays on the dropdown | still in the DOM, 84 px tall |
| HEAD + reduced motion | same | same |
| + `.fade-in` fill `backwards` + dropdown `ul` opacity transition | focus moves `li a` → `li b` | removed |
| + the above + reduced motion at `duration 1ms` instead of `transition: none` | `li a` → `li b` | removed |
The cause of Trap 1: Chrome does not start a transition on a property that
a filling CSS animation still holds (`.fade-in` uses `… both`). Removing
`.fade-in` before dismissing also made the removal work.
Upstream direction (decision doc only, through /upstream-rfc): luci-base
should not depend on `transitionend` alone, and should fall back when the
computed transition duration is `0s`.
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* transitionend-probe.mjs — luci-base removes a dismissed notification and
* moves dropdown focus into the list only on `transitionend`, so both break
* silently when the theme has no running transition there. Checks both on a
* real device, with and without prefers-reduced-motion. Writes nothing on
* the device. Raw CDP, no npm deps, node >= 22. Exit 1 if a contract fails.
*
* env:
* HOST http://192.168.1.1 (default: VITE_OPENWRT_HOST from .dev/.env)
* PAGE path after /cgi-bin/luci/ (default admin/system/system)
* MAIN_CSS file served as the theme's main.css (default: the device's own),
* e.g. htdocs/luci-static/aurora/main.css after `pnpm build`
* USER_NAME/PASS login (default root / empty)
* CHROME_BIN
*/
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
const root = resolve(import.meta.dirname, "../../../..");
const envFile = join(root, ".dev/.env");
const dotenv = existsSync(envFile)
? Object.fromEntries(readFileSync(envFile, "utf8").split("\n").filter((l) => /^[A-Z_]+=/.test(l)).map((l) => l.split("=", 2)))
: {};
const HOST = (process.env.HOST ?? (dotenv.VITE_OPENWRT_HOST ? `http://${dotenv.VITE_OPENWRT_HOST}` : "http://192.168.1.1")).replace(/\/+$/, "");
const PAGE = process.env.PAGE ?? "admin/system/system";
const MAIN_CSS = process.env.MAIN_CSS ? readFileSync(resolve(process.env.MAIN_CSS)) : null;
const CHROME = process.env.CHROME_BIN ?? (process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "google-chrome");
const login = await fetch(`${HOST}/cgi-bin/luci/`, {
method: "POST",
body: new URLSearchParams({ luci_username: process.env.USER_NAME ?? "root", luci_password: process.env.PASS ?? "" }),
redirect: "manual",
});
const cookie = (login.headers.get("set-cookie") ?? "").match(/(sysauth[^=]*)=([^;]+)/);
if (!cookie) throw new Error(`login failed (${login.status})`);
const profile = mkdtempSync(join(tmpdir(), "cdp-transitionend-"));
const chrome = spawn(CHROME, ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "about:blank"], { stdio: "ignore" });
let port = null;
for (let i = 0; i < 100 && !port; i++) {
await sleep(100);
const f = join(profile, "DevToolsActivePort");
if (existsSync(f)) port = +readFileSync(f, "utf8").split("\n")[0];
}
if (!port) throw new Error("chrome: no DevToolsActivePort");
const ws = new WebSocket((await (await fetch(`http://127.0.0.1:${port}/json/version`)).json()).webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.addEventListener("open", res, { once: true }); ws.addEventListener("error", () => rej(new Error("CDP connect failed")), { once: true }); });
let mid = 0; const pending = new Map(); const handlers = new Set();
ws.addEventListener("message", (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); }
else for (const h of handlers) h(m);
});
const send = (method, params = {}, sessionId) => new Promise((res, rej) => { const id = ++mid; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params, sessionId })); });
async function probe(reduce) {
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId: s } = await send("Target.attachToTarget", { targetId, flatten: true });
const ev = async (expression) => (await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, s)).result.value;
await send("Page.enable", {}, s);
await send("Network.setCookie", { name: cookie[1], value: cookie[2], domain: new URL(HOST).hostname, path: "/" }, s);
await send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }, s);
await send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: reduce ? "reduce" : "no-preference" }] }, s);
if (MAIN_CSS) {
await send("Fetch.enable", { patterns: [{ urlPattern: "*/luci-static/aurora/main.css*", requestStage: "Request" }] }, s);
handlers.add((m) => {
if (m.sessionId !== s || m.method !== "Fetch.requestPaused") return;
send("Fetch.fulfillRequest", { requestId: m.params.requestId, responseCode: 200, responseHeaders: [{ name: "Content-Type", value: "text/css" }, { name: "Cache-Control", value: "no-store" }], body: MAIN_CSS.toString("base64") }, s);
});
}
await send("Page.navigate", { url: `${HOST}/cgi-bin/luci/${PAGE}` }, s);
for (let i = 0; i < 80; i++) {
await sleep(250);
if (await ev(`!!(window.L?.loaded && document.querySelector('#view') && !document.querySelector('#view > .spinning'))`)) break;
}
// A stock single-select ui.Dropdown: open with ArrowDown, then ArrowDown again.
await ev(`L.require('ui').then(ui => {
const el = new ui.Dropdown('a', { a: 'A', b: 'B', c: 'C' }, {}).render();
el.id = 'probe-dd';
document.querySelector('#view').prepend(el);
el.focus();
})`);
const key = async () => {
for (const type of ["rawKeyDown", "keyUp"]) await send("Input.dispatchKeyEvent", { type, key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40, nativeVirtualKeyCode: 40 }, s);
await sleep(500);
};
const focused = `(document.activeElement.closest('#probe-dd li')?.dataset.value ?? 'none')`;
await key(); const first = await ev(focused);
await key(); const second = await ev(focused);
await ev(`document.querySelector('#probe-dd').remove()`);
// ui.addNotification, dismissed after its entrance has finished.
const note = await ev(`L.require('ui').then(ui => new Promise(res => {
const n = ui.addNotification(null, E('p', 'probe'), 'info');
setTimeout(() => {
n.querySelector('button.btn').click();
setTimeout(() => res({ removed: !n.isConnected, gapPx: n.isConnected ? n.offsetHeight : 0 }), 800);
}, 400);
}))`);
await send("Target.closeTarget", { targetId });
const dropdownOk = first === "a" && second === "b";
console.log(JSON.stringify({ reducedMotion: reduce, dropdownFocus: `${first} -> ${second}`, dropdownOk, notification: note, notificationOk: note.removed }));
return dropdownOk && note.removed;
}
let ok = true;
try {
for (const reduce of [false, true]) ok = (await probe(reduce)) && ok;
} finally {
chrome.kill();
ws.close();
await sleep(500);
rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
}
process.exit(ok ? 0 : 1);
@@ -11,13 +11,14 @@ address, so they stay local).
| Metric | Budget | Track | Source |
|---|---|---|---|
| main.css (identity/raw) | ≤ 193 KB | size | production build, 2026-08 (191,899 B; +~2 KB custom-background mode, +~0.5 KB router progress bar / live region; the bar is now Turbo-shaped — width/opacity only, inserted per navigation) |
| main.css (identity/raw) | ≤ 193 KB | size | production build, 2026-09-16 (189,650 B; −0.77 KB sidebar push as two transitions instead of run keyframes; +42 B replayed nav skips the fade; +~2 KB custom-background mode, +~0.5 KB router progress bar / live region; the bar is now Turbo-shaped — width/opacity only, inserted per navigation; −1.24 KB unblurred mega-menu curtain, delayed visibility, instant nav-pill colours) |
| login.css (identity/raw) | ≤ 12 KB | size | production build, 2026-07 (10,935 B, token-pruned) |
| menu-aurora.js (identity/raw) | ≤ 22 KB | size | production build, 2026-08 (21,809 B; +0.8 KB palette recents: record on pick, pure-LRU browse order, storage validation; +0.2 KB ">" logout command) |
| menu-aurora.js (identity/raw) | ≤ 22 KB | size | production build, 2026-09-16 (21,362 B; −0.24 KB sidebar toggle is a class flip; −0.09 KB pre-paint nav cache, its crumb renderer moved into header.ut (the replay itself is per-response HTML, see below); +0.8 KB palette recents: record on pick, pure-LRU browse order, storage validation; +0.2 KB ">" logout command) |
| router-aurora.js (identity/raw, Navigation-API browsers only) | ≤ 15 KB | size | production build, 2026-08 (14,844 B: template shells fetched, per-render listener teardown, timeout-as-failure, expiry gate, readonly folding, node css, wildcard actions, trickling progress bar, same-URL reload rule, visibility gate, contract check) |
| Default logo (identity/raw) | ≤ 16 KB | size | production build, 2026-07 (15,057 B) |
| Core admin cold theme assets (identity/raw) | ≤ 267.5 KB | size | main CSS + menu JS + router JS + default font + logo, 2026-08 (≈267.2 KB; the router is a one-time cost that removes per-click dispatcher work; +1.0 KB palette recents and logout command) |
| Core admin cold theme assets (identity/raw) | ≤ 267.5 KB | size | main CSS + menu JS + router JS + default font + logo, 2026-09-16 (265,420 B; −1.1 KB sidebar push and nav cache; the router is a one-time cost that removes per-click dispatcher work; +1.0 KB palette recents and logout command; −1.24 KB mega-menu curtain) |
| Login cold theme assets, excluding configured background (identity/raw) | ≤ 55 KB | size | login CSS + default font + logo, 2026-07 (49,572 B) |
| Admin HTML inline scripts added per full load (identity, never cached) | — | size | header.ut, 2026-09-16: pre-paint nav replay ≈ +0.5 KB (mega-menu/dropdown) / ≈ +1.9 KB (sidebar, with the shared crumb renderer); login page +0.07 KB. Template shells the router fetches carry it too. |
| Blocking requests before first paint | ≤ 4 | count | current waterfall |
| Repeat-visit asset requests | ≈ 0 | count | target state; package-built CSS/JS URLs are versioned, but long-lived cache headers still need live verification |
| TTFB, login page (device) | proposed: ≤ 130 ms | latency | local device baseline, 2026-07 |
@@ -42,6 +43,10 @@ Budget revisions require a new baseline entry under `../baselines/`.
- Package-root `.DS_Store` metadata removed and covered by a regression test.
- login.css pruned to its reachable custom properties at build time (the
shared token sheet is admin-sized; the login page consumes a fraction).
- Mega-menu curtain (2026-09-16): no backdrop blur, `visibility` delayed
instead of tweened, instant nav-pill colours; main.css −1,244 B. Device
trace, 4× CPU, median of 3: repaints 287 → 112, main-thread animations
33 → 8. With `--disable-gpu`, dropped frames per open went 3–5 → 0.
- Client-side router (`router-aurora.js`, `.dev/docs/router.md`,
2026-08-16): view/alias/firstchild/overview navigations become
@@ -20,7 +20,10 @@ workflow. No edit to a shipped or source path happens before gate 1, and the
cover them. If the request names a page, a package, a DOM element or
shows a page screenshot, load `luci-dom-compat` and complete its steps
1–2 (locate the page, read `.dev/compat/pages/` for the files and
selectors about to change) before planning.
selectors about to change) before planning. If the request adds or
changes motion (transition, animation, View Transition), load
`aurora-motion`; the plan names the state, its effect from the table
and the measured main.css byte delta.
2. **Plan.** Call `EnterPlanMode`. The plan states: files to change; the
approach in ≤ 10 lines; budget impact (bytes per `tests/build-performance.test.js`,
per-request ubus/uci/fs calls, dispatches per navigation); floor it
@@ -23,11 +23,21 @@ strings render untranslated (see `.dev/decisions/translations-catalog-caching.md
## DOM hooks shared by templates, CSS and JS
`body[data-page]` (request segments joined by `-`), `body[data-nav-type]`
(`mega-menu|dropdown|sidebar`), `body[data-asset-version]`, `body[data-patches]`,
`body[data-bg]`; `#maincontent[tabindex=-1]`, `#tabmenu`, `#topmenu`, `#sidebar-list`,
`body[data-bg]`, `body[data-nav-stamp]`; `#maincontent[tabindex=-1]`, `#tabmenu`, `#topmenu`, `#sidebar-list`,
`#sidebar-footer`, `#header-crumb`, `#mobile-nav-list`, `#mobile-nav-footer-action`,
`#indicators`, `#modemenu`, `#cmdk-trigger`, `.desktop-menu-container > .desktop-menu-sheet
> .desktop-menu-canvas`, `.desktop-menu-board` (template cloned per panel),
`.theme-switcher .theme-option[data-theme]`. Renaming any of these is a
`.theme-switcher .theme-option[data-theme]`, `#sidebar-list[data-active-path]`
(dispatch segments 1–2), `[data-restored]` on replayed nav surfaces.
Pre-paint nav: `menu-aurora.js` `cacheNav()` writes `sessionStorage["aurora.nav"]`
= `[body[data-nav-stamp], {id: innerHTML}]` after render (stamp: nav type, lang,
user, version); header.ut replays it after the nav markup, inert, only while the
stamp matches and luci-base's `luci-session-store` still holds `menu` (sidebar:
recomputes active marks, then the global `auroraCrumb()`, which `menu-aurora.js`
also calls); `menu-aurora.js` lifts `inert` once `ui.menu.load()` settles; the
login page clears the cache. A collapsed sidebar is restored first in `<body>`: its column
transitions, so the class must land before any style resolution.
Renaming any of these is a
cross-file change (grep all three layers + tests).
## Router invariants (`router-aurora.js`, spec in `.dev/docs/router.md`)
@@ -18,6 +18,9 @@
vertical-align: middle;
opacity: 0.85;
}
/* The 1153px below was measured without a scrollbar; the theme's stable
gutter would take 11px of it (see the ledger's scrollbar-gutter entry). */
html { scrollbar-gutter: auto; }
</style>
<script type="application/json" id="compat">
{
@@ -11,3 +11,15 @@
- verified: device 192.168.8.1, CDP before/after in one page load — cells 16 → 12px; wrapped cells 5 → 1 at 1280 (the remaining `0BOOTCONFIG1` wraps under bootstrap at that width too), 5 → 0 at 1920, 0 → 0 at 390; eleven stock pages (overview, routes, processes, interfaces, firewall zones/rules, DHCP, wireless, mounts, startup, SSH keys) showed zero font-size/line-height changes on any text-bearing table element at 1280 and 390
- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html
- issue: #65 · mirror: shadcn 4f1c7f4 · status: active
## partition-table-gutter — the stable scrollbar gutter leaves the type cell 0.9px short at 1280
- page: admin-services-mini-diskmanager-minidiskmanager · Services → Disk Manager
- package: luci-app-mini-diskmanager (4IceG/luci-app-mini-diskmanager)
- dom: `table#partition-table-<dev>` (inline `table-layout:fixed; width:100%`); type cell `<span class="partition-color-indicator"> 0BOOTCONFIG`
- symptom: with classic scrollbars at a 1280px viewport the swatch and `0BOOTCONFIG` split onto two lines; overlay scrollbars are unaffected
- cause: `_elements.css` `html` `[scrollbar-gutter:stable]` (added so short pages and open modals stop shifting the layout) reserves the 11px thin scrollbar on every page, which is what a scrolling page already paid; the #65 fit had 0.6px to spare
- fix: none on the page; the fixture pins `html { scrollbar-gutter: auto }` because its 1153px width was measured without a scrollbar, so it keeps guarding #65's inherited 12px
- verified: headless Chrome, fixture at 1280 — table 1152.7 → 1142.1px, cell content 114.9 → 113.4px against a 114.3px line (fits → wraps); `pnpm test` compat fails without the pin and passes with it
- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html
- issue: — · mirror: shadcn 50c53a9 · status: active
@@ -3,7 +3,9 @@
}
html {
@apply [scrollbar-color:var(--text-muted)_var(--surface)] [scrollbar-width:thin];
/* Stable gutter: short pages and open modals (body overflow hidden) would
otherwise drop the classic scrollbar and shift the whole layout. */
@apply [scrollbar-color:var(--text-muted)_var(--surface)] [scrollbar-gutter:stable] [scrollbar-width:thin];
body {
@apply text-text relative flex min-h-dvh flex-col text-sm leading-relaxed font-normal;
+25 -61
View File
@@ -217,7 +217,7 @@ header {
@apply relative;
.menu {
@apply text-text hover:bg-hover-faint block rounded-xl px-3.5 py-1.5 font-medium no-underline transition-colors duration-150;
@apply text-text hover:bg-hover-faint block rounded-xl px-3.5 py-1.5 font-medium no-underline;
/* Unified pill for both dropdown modes — the open/active category is a
filled brand pill, matching the submenu links and the sidebar. */
@@ -325,7 +325,7 @@ header {
}
#indicators {
@apply flex shrink-0 flex-row-reverse gap-5 md:gap-8;
@apply mr-4 ml-auto flex shrink-0 flex-row-reverse gap-5 md:gap-8;
/* Same hover language as .navigation-toggle and .cmdk-trigger: a scale
nudge, never a background plate. */
@@ -369,12 +369,14 @@ body[data-nav-type="sidebar"] {
/* Single source of truth for the sidebar column, so collapsing only has to
zero this one value instead of restating the grid template.
--sidebar-w-full stays constant: the inner panel width and the slide
keyframes read it, and must not see the zeroed collapsed value. */
--sidebar-w-full stays constant: the inner panel width reads it, and
must not see the zeroed collapsed value. */
--sidebar-w-full: 17rem;
--sidebar-w: var(--sidebar-w-full);
@apply md:grid md:grid-cols-[var(--sidebar-w)_minmax(0,1fr)] md:grid-rows-[auto_minmax(0,1fr)] md:overflow-x-clip;
/* The column eases with the panel's slide below (same curve and length),
so the content edge follows the sidebar edge frame by frame. */
@apply md:grid md:grid-cols-[var(--sidebar-w)_minmax(0,1fr)] md:grid-rows-[auto_minmax(0,1fr)] md:overflow-x-clip md:[transition:grid-template-columns_250ms_var(--ease-in-out)];
&.sidebar-collapsed {
@apply md:[--sidebar-w:0rem];
@@ -428,50 +430,20 @@ body[data-nav-type="sidebar"] {
@apply sticky top-0 hidden h-dvh w-full overflow-hidden rounded-none md:block;
}
/* The grid column still snaps (one reflow, never a per-frame layout
animation); visibility removes collapsed controls from the tab order.
The hairline lives on the inner panel, not the frame, so it tracks the
sliding edge during the run animations below. */
/* Hidden (out of the tab order) only once the slide-out has run. The
hairline lives on the inner panel so it tracks the sliding edge. */
&.sidebar-collapsed .sidebar-panel {
@apply invisible;
@apply invisible [transition:visibility_0s_250ms];
}
& .sidebar-panel-inner {
@apply bg-bg border-hairline flex h-full w-(--sidebar-w-full) flex-col overflow-hidden border-r;
@apply bg-bg border-hairline flex h-full w-(--sidebar-w-full) flex-col overflow-hidden border-r [transition:translate_250ms_var(--ease-in-out)];
}
&.sidebar-collapsed .sidebar-panel-inner {
@apply -translate-x-full;
}
/* Coupled slide: the column width snaps in the same frame the
sidebar-anim-* class lands, then the sidebar and the whole content
column (header + #maincontent) slide the same 17rem with the same
curve — compositor-only, so the seam moves as one wall with zero
per-frame reflow. menu-aurora.js swaps the class per direction
(distinct animation-names restart cleanly on rapid toggles) and
clears it after the run. */
&.sidebar-anim-open > header,
&.sidebar-anim-open > #maincontent,
&.sidebar-anim-open .sidebar-panel-inner {
@apply md:animate-[sidebar-run-in_250ms_var(--ease-in-out)_both];
}
&.sidebar-anim-close > header,
&.sidebar-anim-close > #maincontent {
@apply md:animate-[sidebar-run-back_250ms_var(--ease-in-out)_both];
}
/* Closing: the column is already zero, so the panel holds its old spot
as a fixed overlay for one run while its content slides off. */
&.sidebar-anim-close .sidebar-panel {
@apply md:visible md:fixed md:top-0 md:left-0 md:w-(--sidebar-w-full);
}
&.sidebar-anim-close .sidebar-panel-inner {
@apply md:animate-[sidebar-run-out_250ms_var(--ease-in-out)_both];
}
/* Sidebar head: logo + hostname. h-14 matches the header row so its
bottom hairline continues the header's border in one unbroken line
across the viewport. box-content: the header is auto-height (56px
@@ -529,33 +501,13 @@ body[data-nav-type="sidebar"] {
}
}
/* Shared travel distance for the coupled sidebar slide: content column and
panel move the same 17rem, so the seam between them never separates. */
@keyframes sidebar-run-in {
from {
translate: calc(-1 * var(--sidebar-w-full)) 0;
}
}
@keyframes sidebar-run-back {
from {
translate: var(--sidebar-w-full) 0;
}
}
@keyframes sidebar-run-out {
to {
translate: calc(-1 * var(--sidebar-w-full)) 0;
}
}
/* Router progress (router.md) — Turbo Drive's bar: inserted by the router
once a navigation outlives its silent 150 ms, width driven inline and
trickling until commit (so a slow render keeps visibly moving), then
filled, faded and removed from the DOM. Only width and opacity move.
Reduced motion drops the transitions, not the bar. */
#luci-nav-progress {
@apply from-progress-start to-progress-end pointer-events-none fixed top-0 left-0 z-80 h-0.5 w-0 bg-linear-to-r transition-[width] duration-300 ease-out;
@apply from-progress-start to-progress-end pointer-events-none fixed top-0 left-0 z-80 h-0.5 w-0 bg-linear-to-r [transition:width_.3s_var(--ease-out)];
&[data-state="done"] {
@apply opacity-0 [transition:width_.3s_ease-out,opacity_.15s_ease-in_.15s];
@@ -589,7 +541,7 @@ body[data-nav-type="sidebar"] {
/* Outgoing page while the router renders the next one (router.md). */
&.view-leaving {
@apply cursor-progress opacity-60 transition-opacity duration-150;
@apply cursor-progress opacity-60 [transition:opacity_150ms_ease-out];
}
.cbi-title-section {
@@ -639,6 +591,18 @@ body[data-nav-type="sidebar"] {
@apply flex flex-1 flex-col gap-1.5;
}
}
/* Hidden while the view is leaving or loading so its jump to the new page
height is never seen; animate-none restarts its fade-in once it returns. */
&:has(> #view.view-leaving, > #view > .spinning) > footer {
@apply invisible animate-none;
}
}
/* Only the theme toggle captures the page: a router swap would crossfade
two page layouts on top of the content's own fade-in. */
:root:not(:active-view-transition-type(theme)) {
@apply [view-transition-name:none];
}
footer {
@@ -61,6 +61,19 @@
@apply animate-[aurora-fade-in_250ms_ease_both];
}
/* Content that lands after first paint fades in once; none of it is
re-inserted by poll (the dashboard's polled tabs sit under .Dashboard).
A nav header.ut replayed before first paint (data-restored) never fades.
backwards: a filling animation stays a stacking context over dropdowns. */
#view > :not(.spinning),
#view > .cbi-map > .cbi-map-tabbed > [data-tab-active="true"],
.cbi-section-node-tabbed > [data-tab-active="true"],
#topmenu:not([data-restored]),
#sidebar-list:not([data-restored]) > *,
footer {
@apply animate-[aurora-fade-in_250ms_ease_backwards];
}
.fade-out {
@apply pointer-events-none opacity-0 transition-opacity duration-[250ms];
}
@@ -84,29 +84,13 @@ body.mobile-navigation-open > header {
}
[data-nav-type="mega-menu"] {
/* Apple's curtain: the frost is DECLARED here permanently and never
toggled — it fades in and out WITH the dim, so the page blurs
progressively as the sheet descends (apple.com's globalnav curtain runs
its blur exactly like this while the flyout animates). Gating the blur
on a post-transition "settled" state was tried and rejected: the frost
popped in only after the menu landed, visibly off-design.
The cost is per-frame blur re-compositing for the 300ms of fade — but it
is now the ONLY per-frame work: the reveal itself runs entirely on the
compositor (see _layout.css). What janked low-end devices was the old
overlap of main-thread height layout + live blur, not the blur alone.
If the fade still janks a device, reduce the radius or rely on
_reduced-transparency.css, which strips the filter outright.
Closing starts immediately and uses the same 220ms exit as the menu
surface. Delaying the opacity transition left the page frosted after the
sheet had already collapsed into the header, which read as a flash. Once
hidden, visibility:hidden makes the invisible filter cost nothing. */
/* Dim only: a blurred full-screen fade drops frames on weak GPUs. Visibility
flips on a delay instead of tweening, so the opacity fade stays composited. */
& .desktop-menu-overlay {
@apply bg-mega-menu-scrim pointer-events-none invisible fixed inset-0 z-60 opacity-0 backdrop-blur-lg backdrop-saturate-150 transition-[opacity,visibility] duration-[220ms] ease-[cubic-bezier(0.4,0,0.6,1)] max-md:hidden;
@apply bg-mega-menu-scrim pointer-events-none invisible fixed inset-0 z-60 opacity-0 [transition:opacity_220ms_cubic-bezier(0.4,0,0.6,1),visibility_0s_220ms] max-md:hidden;
&.active {
@apply pointer-events-auto visible opacity-100;
@apply pointer-events-auto visible opacity-100 [transition-delay:0s];
}
}
}
@@ -15,15 +15,10 @@
.cmdk-trigger {
/* Hover is a scale nudge with no plate behind it — the header's own
language, set by .navigation-toggle and shared with #indicators. The
size-8 box stays as the touch target; it just never paints. */
@apply text-text relative mr-4 grid size-8 shrink-0 cursor-pointer place-items-center rounded-lg border-0 bg-transparent p-0 shadow-none transition-transform duration-150;
/* Non-sidebar headers have no mr-auto crumb absorbing the slack — pull the
toggle right so it docks next to #indicators instead of landing in the
middle of the justify-between row. */
body:not([data-nav-type="sidebar"]) & {
@apply ml-auto;
}
size-8 box stays as the touch target; it just never paints.
order-last pins it to the bar's right edge: #indicators sits before it
and grows leftward, so a late indicator never shifts the trigger. */
@apply text-text relative order-last grid size-8 shrink-0 cursor-pointer place-items-center rounded-lg border-0 bg-transparent p-0 shadow-none transition-transform duration-150;
/* size-5 (not the button's own size): the same box #indicators gives its
glyphs, so the shared 24×24 Tabler canvas scales identically and the
@@ -6,7 +6,15 @@ const PALETTE_RECENTS_KEY = "aurora.paletteRecents";
return baseclass.extend({
__init__() {
ui.menu.load().then((tree) => this.render(tree));
ui.menu
.load()
.then((tree) => this.render(tree))
.finally(() => {
// header.ut's replay stays inert until the live nav has replaced it
// or failed to; either way it must not stay a dead copy.
for (const el of document.querySelectorAll("[data-restored]"))
el.inert = false;
});
this.initNavigationControls();
this.initUciIndicator();
},
@@ -32,7 +40,6 @@ return baseclass.extend({
const mobileList = overlay.querySelector("#mobile-nav-list");
const desktop = window.matchMedia("(min-width: 768px)");
const SIDEBAR_COLLAPSED_KEY = "aurora.sidebarCollapsed";
let sidebarAnimTimer;
const isDesktopSidebar = () =>
desktop.matches && document.body.dataset.navType === "sidebar";
@@ -85,23 +92,9 @@ return baseclass.extend({
closeMobileNavigation();
const collapsed = !expanded;
const body = document.body;
// Coupled slide (_layout.css): the class must land in the same
// frame as the column snap. Open/close carry distinct
// animation-names, so alternating toggles restart the run without
// a forced reflow; the timer (not animationend — three elements
// animate) clears the class once the 250ms run is over.
body.classList.remove("sidebar-anim-open", "sidebar-anim-close");
body.classList.add(
collapsed ? "sidebar-anim-close" : "sidebar-anim-open",
);
clearTimeout(sidebarAnimTimer);
sidebarAnimTimer = setTimeout(() => {
body.classList.remove("sidebar-anim-open", "sidebar-anim-close");
}, 300);
body.classList.toggle("sidebar-collapsed", collapsed);
// The column and the panel transition together (_layout.css).
document.body.classList.toggle("sidebar-collapsed", collapsed);
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed);
updateToggleState(expanded);
return;
@@ -203,6 +196,25 @@ return baseclass.extend({
this.tree = tree;
this.renderModeMenu(tree);
this.renderTabs(tree);
this.cacheNav();
},
// Snapshot for header.ut's pre-paint replay, under the stamp it renders on
// body; it recomputes the active marks, so one copy serves every page.
cacheNav() {
const html = {};
for (const el of document.querySelectorAll(
"#topmenu, #sidebar-list, #sidebar-footer",
))
if (el.firstChild) html[el.id] = el.innerHTML;
try {
sessionStorage.setItem(
"aurora.nav",
JSON.stringify([document.body.dataset.navStamp, html]),
);
} catch {
// No storage: the next load renders after the module, as before.
}
},
renderTabs(tree) {
@@ -258,7 +270,8 @@ return baseclass.extend({
this.expandActiveNavigationGroup(surface);
}
this.renderCrumb();
// header.ut's, shared with its pre-paint replay (sidebar mode only).
window.auroraCrumb?.();
const tabs = document.querySelector("#tabmenu");
if (tabs) {
@@ -324,6 +337,8 @@ return baseclass.extend({
return ul || E([]);
}
// Drops header.ut's replayed copy, which carries no listeners.
if (ul) ul.innerHTML = "";
if (!ul || !children.length) return E([]);
if (navType === "mega-menu") {
@@ -561,30 +576,7 @@ return baseclass.extend({
});
this.bindNavigationAccordion(list);
this.renderCrumb();
},
renderCrumb() {
const crumbEl = document.querySelector("#header-crumb");
const list = document.querySelector("#sidebar-list");
if (!crumbEl || !list) return;
const crumb = [];
const group = list.querySelector(".is-active-group");
const page = list.querySelector(".is-active-page");
if (group)
crumb.push(group.querySelector(".nav-category-label")?.textContent);
// Same-named group/page pairs ("System › System") collapse to one
// level — the duplicate adds no information.
if (page && page.textContent !== crumb[0]) crumb.push(page.textContent);
crumbEl.innerHTML = "";
crumb.forEach((title, i) => {
if (i) crumbEl.appendChild(E("li", { class: "crumb-sep" }, ["/"]));
crumbEl.appendChild(
E("li", { class: i === crumb.length - 1 ? "current" : "" }, [title]),
);
});
window.auroraCrumb?.();
},
// Command palette (all nav types): a Spotlight-style panel on ⌘K / Ctrl+K
@@ -0,0 +1,242 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import test from "node:test";
const header = readFileSync(
resolve(import.meta.dirname, "../../ucode/template/themes/aurora/header.ut"),
"utf8",
);
// Just enough ucode for these scripts: comments and the nav-type branches.
const renderFor = (source, navType) =>
source
.replace(/\{#[\s\S]*?#\}/g, "")
.replace(
/\{% if \(nav_type == 'sidebar'\): %\}([\s\S]*?)\{% endif %\}/g,
(_, body) => (navType === "sidebar" ? body : ""),
);
const replaySource = header.match(
/<script>\s*(\{% if \(nav_type == 'sidebar'\): %\}\s*\{#[^#]*#\}\s*function auroraCrumb[\s\S]*?)<\/script>/,
)?.[1];
const loginSource = header.match(
/\{% if \(blank_page\): %\}\s*<script>([\s\S]*?)<\/script>/,
)?.[1];
const classes = (...initial) => {
const set = new Set(initial);
return {
set,
contains: (name) => set.has(name),
toggle: (name, on) => (on ? set.add(name) : set.delete(name), on),
};
};
const surface = (extra = {}) => {
const el = { style: { display: "none" }, dataset: {}, html: null, ...extra };
Object.defineProperty(el, "innerHTML", {
set(value) {
el.html = value;
},
});
return el;
};
// A sidebar as the snapshot left it: System was the active group then.
const sidebarFixture = (activePath) => {
const group = (name, label, links) => {
const item = { classList: classes(), lastChild: {} };
if (name === "system")
item.classList.set.add("is-active-group").add("is-expanded");
item.toggle = {
tagName: "BUTTON",
dataset: { section: name },
parentNode: item,
};
item.label = { textContent: label };
item.links = links.map(([page, text]) => ({
pathname: `/cgi-bin/luci/admin/${name}/${page}`,
textContent: text,
classList: classes(
...(name === "system" && page === "system" ? ["is-active-page"] : []),
),
}));
item.querySelector = (sel) =>
sel === ".nav-category-label" ? item.label : null;
return item;
};
const direct = {
tagName: "A",
dataset: { section: "status" },
textContent: "Status",
classList: classes(),
};
const groups = [
group("network", "Network", [
["wireless", "Wireless"],
["network", "Interfaces"],
]),
group("system", "System", [
["system", "System"],
["admin", "Administration"],
]),
];
const links = [direct, ...groups.flatMap((g) => g.links)];
const list = surface({
dataset: { activePath },
querySelectorAll: (sel) =>
sel === "[data-section]"
? [direct, ...groups.map((g) => g.toggle)]
: sel === ".navigation-sublink"
? groups.flatMap((g) => g.links)
: [],
querySelector: (sel) =>
sel === ".is-active-group"
? (groups.find((g) => g.classList.contains("is-active-group")) ?? null)
: sel === ".is-active-page"
? (links.find((l) => l.classList.contains("is-active-page")) ?? null)
: null,
});
return { list, direct, groups };
};
const run = ({
navType,
stamp = "s",
cached = "s",
luciMenu = true,
elements,
}) => {
const store = {
"aurora.nav": JSON.stringify([
cached,
Object.fromEntries(
Object.keys(elements)
.filter((id) => id !== "header-crumb")
.map((id) => [id, `<${id}>`]),
),
]),
"luci-session-store": JSON.stringify({ sid: luciMenu ? { menu: {} } : {} }),
};
const document = {
body: { dataset: { navStamp: stamp } },
getElementById: (id) => elements[id] ?? null,
createElement: () => ({}),
};
new Function("document", "sessionStorage", renderFor(replaySource, navType))(
document,
{
getItem: (key) => store[key] ?? null,
},
);
};
test("header.ut carries the replay and the login-page clear", () => {
assert.ok(replaySource, "replay script not found");
assert.ok(loginSource, "login-page script not found");
});
test("a sidebar replay paints inert and re-marks the current page", () => {
const { list, direct, groups } = sidebarFixture("network/wireless");
const footer = surface();
const crumb = { replaceChildren: (...items) => (crumb.items = items) };
run({
navType: "sidebar",
elements: {
"sidebar-list": list,
"sidebar-footer": footer,
"header-crumb": crumb,
},
});
for (const el of [list, footer]) {
assert.equal(
el.html,
`<${el === list ? "sidebar-list" : "sidebar-footer"}>`,
);
assert.equal(el.inert, true);
assert.equal(el.dataset.restored, "");
}
const [network, system] = groups;
assert.deepEqual(
[...network.classList.set],
["is-active-group", "is-expanded"],
);
assert.deepEqual([...system.classList.set], []);
assert.equal(direct.classList.contains("is-active-page"), false);
assert.deepEqual(
groups
.flatMap((g) => g.links)
.filter((l) => l.classList.contains("is-active-page"))
.map((l) => l.textContent),
["Wireless"],
);
assert.deepEqual(
crumb.items.map((li) => [li.className, li.textContent]),
[
["", "Network"],
["crumb-sep", "/"],
["current", "Wireless"],
],
);
});
test("a direct row is marked on its own section, with no sublink", () => {
const { list, direct, groups } = sidebarFixture("status");
const crumb = { replaceChildren: (...items) => (crumb.items = items) };
run({
navType: "sidebar",
elements: { "sidebar-list": list, "header-crumb": crumb },
});
assert.equal(direct.classList.contains("is-active-page"), true);
assert.ok(groups.every((g) => g.classList.set.size === 0));
assert.ok(
groups
.flatMap((g) => g.links)
.every((l) => !l.classList.contains("is-active-page")),
);
assert.deepEqual(
crumb.items.map((li) => [li.className, li.textContent]),
[["current", "Status"]],
);
});
test("a top menu replay only paints, inert", () => {
const topmenu = surface();
run({ navType: "mega-menu", elements: { topmenu } });
assert.equal(topmenu.html, "<topmenu>");
assert.equal(topmenu.style.display, "");
assert.equal(topmenu.inert, true);
});
test("nothing is replayed for another stamp or after luci-base flushed its menu", () => {
for (const options of [{ cached: "other user" }, { luciMenu: false }]) {
const topmenu = surface();
run({ navType: "mega-menu", elements: { topmenu }, ...options });
assert.equal(topmenu.html, null, JSON.stringify(options));
assert.equal(topmenu.inert, undefined);
}
});
test("the login page drops the cached nav", () => {
const removed = [];
new Function(
"localStorage",
"sessionStorage",
"window",
"document",
renderFor(loginSource),
)(
{ getItem: () => null },
{ removeItem: (key) => removed.push(key) },
{ matchMedia: () => ({ matches: false }) },
{ documentElement: { setAttribute() {} } },
);
assert.deepEqual(removed, ["aurora.nav"]);
});
@@ -6,6 +6,10 @@ const source = await readFile(
new URL("../src/resource/menu-aurora.js", import.meta.url),
"utf8",
);
const header = await readFile(
new URL("../../ucode/template/themes/aurora/header.ut", import.meta.url),
"utf8",
);
const getMethodSource = (name) => {
const start = source.indexOf(` ${name}(`);
@@ -196,6 +200,19 @@ class FakeElement {
}
}
replaceChildren(...children) {
this.children = [];
children.forEach((child) => this.appendChild(child));
}
set className(value) {
this.setAttribute("class", value);
}
set textContent(value) {
this.children = [String(value)];
}
set innerHTML(value) {
assert.equal(value, "");
this.children = [];
@@ -238,6 +255,7 @@ const loadMenuModule = ({
requestpath = [],
translate = (value) => value,
window = {},
sessionStorage = {},
} = {}) => {
const baseclass = {
extend(module) {
@@ -259,8 +277,21 @@ const loadMenuModule = ({
"document",
"window",
"localStorage",
"sessionStorage",
source,
)(baseclass, ui, E, L, translate, document, window, {});
)(baseclass, ui, E, L, translate, document, window, {}, sessionStorage);
};
// header.ut's crumb renderer (shared with menu-aurora.js), on a fake document.
const loadHeaderCrumb = (elements) => {
const fn = header.match(/function auroraCrumb\(\) \{[\s\S]*?\n\t\t\t\}/)?.[0];
assert.ok(fn, "header.ut must define auroraCrumb()");
return new Function("document", `${fn}; return auroraCrumb;`)({
getElementById: (id) => elements[id] ?? null,
createElement: (tagName) => new FakeElement(tagName),
});
};
const textContent = (element) =>
@@ -688,22 +719,22 @@ test("renders mobile items and logout repeatedly without duplicate listeners", (
assert.equal(list.listenerCount("click"), 1);
});
test("renders sidebar items, logout, and translated crumbs without duplication", () => {
test("renders sidebar items and logout without duplication, then the crumb", () => {
const list = new FakeElement("ul", {}, [new FakeElement("li")]);
const footer = new FakeElement("div", {}, [new FakeElement("a")]);
const crumb = new FakeElement("ol", {}, [new FakeElement("li")]);
const document = createFakeDocument({
elements: {
"#header-crumb": crumb,
"#sidebar-footer": footer,
"#sidebar-list": list,
},
navType: "sidebar",
});
let crumbRenders = 0;
const menu = loadMenuModule({
dispatchpath: ["admin", "network", "wireless"],
document,
translate: (value) => `translated:${value}`,
window: { auroraCrumb: () => (crumbRenders += 1) },
});
const tree = {
children: {
@@ -735,28 +766,32 @@ test("renders sidebar items, logout, and translated crumbs without duplication",
assert.equal(footer.children[0].getAttribute("class"), "nav-link");
assert.equal(footer.children[0].getAttribute("href"), "/admin/logout");
assert.equal(textContent(footer.children[0]), "translated:Logout");
assert.equal(crumb.children.length, 3);
assert.equal(crumbRenders, 2);
assert.equal(list.dataset.accordionBound, "true");
assert.equal(list.listenerCount("click"), 1);
const crumb = new FakeElement("ol", {}, [new FakeElement("li")]);
loadHeaderCrumb({ "header-crumb": crumb, "sidebar-list": list })();
assert.deepEqual(
crumb.children.map((child) => textContent(child)),
["translated:Network", "/", "translated:Wireless"],
);
assert.equal(crumb.children[2].getAttribute("class"), "current");
assert.equal(list.dataset.accordionBound, "true");
assert.equal(list.listenerCount("click"), 1);
assert.deepEqual(
crumb.children.map((child) => child.getAttribute("class")),
["", "crumb-sep", "current"],
);
});
test("collapses same-named group/page crumbs to a single level", () => {
const list = new FakeElement("ul");
const crumb = new FakeElement("ol", {}, [new FakeElement("li")]);
const document = createFakeDocument({
elements: {
"#header-crumb": crumb,
"#sidebar-list": new FakeElement("ul"),
},
navType: "sidebar",
});
const menu = loadMenuModule({
dispatchpath: ["admin", "system", "system"],
document,
document: createFakeDocument({
elements: { "#sidebar-list": list },
navType: "sidebar",
}),
translate: (value) => `translated:${value}`,
});
const tree = {
@@ -771,6 +806,7 @@ test("collapses same-named group/page crumbs to a single level", () => {
};
menu.renderSidebar(menu.buildNavigationModel(getMenuChildren(tree), "admin"));
loadHeaderCrumb({ "header-crumb": crumb, "sidebar-list": list })();
assert.deepEqual(
crumb.children.map((child) => textContent(child)),
@@ -779,6 +815,71 @@ test("collapses same-named group/page crumbs to a single level", () => {
assert.equal(crumb.children[0].getAttribute("class"), "current");
});
test("caches the rendered nav under the stamp header.ut replays against", () => {
const stored = new Map();
const document = {
...createFakeDocument(),
body: { dataset: { navStamp: "sidebar:zh_Hans:root:1.3.0" } },
querySelectorAll: () => [
{ id: "sidebar-list", firstChild: {}, innerHTML: "<li>list</li>" },
{ id: "sidebar-footer", firstChild: null, innerHTML: "" },
],
};
const menu = loadMenuModule({
document,
sessionStorage: { setItem: (key, value) => stored.set(key, value) },
});
menu.cacheNav();
assert.deepEqual(JSON.parse(stored.get("aurora.nav")), [
"sidebar:zh_Hans:root:1.3.0",
{ "sidebar-list": "<li>list</li>" },
]);
// Both sides read the one server-rendered stamp (user included).
assert.match(header, /<body [^>]*data-nav-stamp="[^"]*ctx\?\.authuser/);
assert.match(header, /cache\?\.\[0\] !== body\.dataset\.navStamp\) return;/);
// Storage that throws (privacy modes, quota) leaves rendering untouched.
const sealed = loadMenuModule({
document,
sessionStorage: {
setItem() {
throw new Error("sealed");
},
},
});
assert.doesNotThrow(() => sealed.cacheNav());
});
test("releases the inert replay however the menu load settles", () => {
const init = getMethodSource("__init__");
// In finally: a failed load or a throwing render must not leave a dead copy.
assert.match(
init,
/\.finally\(\(\) => \{[\s\S]*querySelectorAll\("\[data-restored\]"\)[\s\S]*inert = false/,
);
});
test("drops the replayed top menu before building the live one", () => {
const ul = E("ul", { id: "topmenu" }, [E("li")]);
const menu = loadMenuModule({
document: createFakeDocument({ elements: { "#topmenu": ul } }),
});
let childrenAtInit = null;
menu.initMegaMenu = (children, url, list) => {
childrenAtInit = list.children.length;
};
menu.renderMainMenu(
{ children: { status: { title: "Status", children: { a: {} } } } },
"admin",
);
assert.equal(childrenAtInit, 0);
});
test("renders an active group expanded when an open mobile list was initially empty", () => {
const list = new FakeElement("ul");
const overlay = new FakeElement("div", { class: "mobile-menu-open" }, [list]);
@@ -204,6 +204,28 @@ test("desktop sidebar styles only provide desktop navigation density", () => {
assert.doesNotMatch(sidebar, /bg-brand-subtle/);
});
test("desktop sidebar toggles move the column and the panel as one", () => {
const sidebar = getBlock(layoutStyles, 'body[data-nav-type="sidebar"]');
const easing = "250ms_var(--ease-in-out)";
// The column width and the panel's translate share one curve and length,
// so the content edge stays on the sidebar edge in every frame.
assert.ok(
sidebar.includes(`md:[transition:grid-template-columns_${easing}]`),
);
assert.ok(
getBlock(sidebar, "& .sidebar-panel-inner {").includes(
`[transition:translate_${easing}]`,
),
);
// Hidden only after the slide-out, shown at once on the way in.
assertIncludesUtilities(
getBlock(sidebar, "&.sidebar-collapsed .sidebar-panel {"),
["invisible", "[transition:visibility_0s_250ms]"],
);
assert.doesNotMatch(layoutStyles, /sidebar-anim|sidebar-run-/);
});
test("mobile drawer styles only provide mobile navigation density", () => {
const drawer = getBlock(overlayStyles, ".mobile-menu-overlay");
const icon = getBlock(drawer, "& .nav-icon");
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import test from "node:test";
const root = resolve(import.meta.dirname, "../..");
const css = readFileSync(resolve(root, "htdocs/luci-static/aurora/main.css"), "utf8");
const header = readFileSync(resolve(root, "ucode/template/themes/aurora/header.ut"), "utf8");
test("only the typed theme toggle captures the page in a view transition", () => {
assert.match(header, /ViewTransitionTypeSet \? \{ update: apply, types: \['theme'\] \} : apply/);
// Standalone rule: merged into a selector list, an engine without
// :active-view-transition-type() would drop its neighbours too.
assert.match(css, /\}:root:not\(:active-view-transition-type\(theme\)\)\{view-transition-name:none\}/);
});
test("late content fades in without a filling animation", () => {
const rule = css.match(/#view>:not\(\.spinning\)[^{]*\{([^}]*)\}/);
assert.ok(rule, "missing page-entry fade rule");
assert.match(rule[1], /\bbackwards\b/);
assert.doesNotMatch(rule[1], /\bboth\b/);
});
test("a collapsed sidebar is restored before anything resolves body's style", () => {
// The sidebar column transitions; a class landing after a style flush
// (the page-bg script's getComputedStyle) would ease it shut on load.
const restore = header.indexOf("aurora.sidebarCollapsed");
assert.ok(restore > header.indexOf("<body"), "restore runs inside body");
assert.ok(
restore < header.indexOf("getComputedStyle"),
"restore precedes the page-bg style read",
);
assert.ok(
restore < header.indexOf("<header>"),
"restore precedes the header markup",
);
});
+12 -7
View File
@@ -111,9 +111,8 @@ test("mega-menu reveal is compositor-only and the frost never overlaps it", () =
sheet?.includes("duration-(--mega-menu-duration,300ms)"),
`sheet must share the adaptive duration: ${sheet}`,
);
// The curtain carries the blur permanently (Apple's globalnav-curtain) and
// fades it with opacity/visibility. Close must start that fade immediately;
// delaying the curtain left blur visible after the sheet had collapsed.
// The curtain only dims: a blurred full-screen fade dropped frames on weak
// GPUs. Opacity fades out at once; visibility waits for it without tweening.
assert.ok(
!overlay.includes("max-md:backdrop-blur-lg"),
"opaque mobile overlay must not blur hidden pixels",
@@ -122,12 +121,18 @@ test("mega-menu reveal is compositor-only and the frost never overlaps it", () =
.split("\n")
.find((l) => l.includes("bg-mega-menu-scrim"));
assert.ok(
curtain?.includes("backdrop-blur-lg"),
`desktop curtain blur: ${curtain}`,
!curtain?.includes("backdrop-"),
`desktop curtain must not blur: ${curtain}`,
);
assert.ok(
curtain?.includes("duration-[220ms]") && !curtain.includes("delay-"),
`desktop curtain exit must be immediate: ${curtain}`,
curtain?.includes("[transition:opacity_220ms_") &&
curtain.includes(",visibility_0s_220ms]") &&
!curtain.includes("delay-"),
`desktop curtain: immediate opacity exit, delayed visibility: ${curtain}`,
);
assert.ok(
/&\.active\s*\{\s*@apply[^;]*\[transition-delay:0s\]/.test(overlay),
"desktop curtain must show without delay",
);
assert.ok(!overlay.includes(".settled"), "settle-gated frost must stay gone");
});
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.3.3
PKG_RELEASE:=80
PKG_VERSION:=1.3.7
PKG_RELEASE:=81
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-content:"";--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1Line truncated
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-content:"";--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-md:12px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (notLine truncated
@@ -1 +1 @@
"use strict";"require baseclass";"require ui";const PALETTE_RECENTS_KEY="aurora.paletteRecents";return baseclass.extend({__init__(){ui.menu.load().then(e=>this.render(e)),this.initNavigationControls(),this.initUciIndicator()},initUciIndicator(){const e=ui.changes?.setIndicator;e&&(ui.changes.setIndicator=function(t){e.call(this,t),document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",t||0)})},initNavigationControls(){const e=document.querySelector("#mobile-menu-overlay"),t=document.querySelector("#navigation-toggle");if(!t||!e)return;const a=e.querySelector("#mobile-nav-list"),n=window.matchMedia("(min-width: 768px)"),i="aurora.sidebarCollapsed";let s;const o=()=>n.matches&&"sidebar"===document.body.dataset.navType,r=e=>{const a=o();t.classList.toggle("is-expanded",e&&!n.matches),t.setAttribute("aria-expanded",e?"true":"false"),t.setAttribute("aria-controls",a?"sidebar-panel":"mobile-menu-overlay");const i=e&&!a?_("Close"):_("Navigation");t.setAttribute("title",i),t.setAttribute("aria-label",i)},l=()=>{e.classList.contains("mobile-menu-open")&&(e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(a),r(!1))};this.closeMobileNavigation=l;const c=t=>{if(o()){l();const e=!t,a=document.body;return a.classList.remove("sidebar-anim-open","sidebar-anim-close"),a.classList.add(e?"sidebar-anim-close":"sidebar-anim-open"),clearTimeout(s),s=setTimeout(()=>{a.classList.remove("sidebar-anim-open","sidebar-anim-close")},300),a.classList.toggle("sidebar-collapsed",e),localStorage.setItem(i,e),void r(t)}if(n.matches)return l(),void r(!1);e.classList.toggle("mobile-menu-open",t),document.body.classList.toggle("mobile-navigation-open",t),document.body.style.overflow=t?"hidden":"",t?this.expandActiveNavigationGroup(a):this.resetNavigationGroups(a),r(t)},d=()=>{if(l(),o()){const e="true"===localStorage.getItem(i);return document.body.classList.toggle("sidebar-collapsed",e),void r(!e)}r(!1)};t.addEventListener("click",t=>{t.stopPropagation(),c(!(o()?!document.body.classList.contains("sidebar-collapsed"):!n.matches&&e.classList.contains("mobile-menu-open")))}),e.addEventListener("click",t=>{t.target===e&&c(!1)}),document.addEventListener("keydown",t=>{"Escape"===t.key&&e.classList.contains("mobile-menu-open")&&c(!1)}),n.addEventListener("change",d),d(),document.addEventListener("click",t=>{const a=t.target?.closest?.(".navigation-direct, .navigation-sublink, .mobile-nav-logout");a&&e.contains(a)&&c(!1)})},renderMobileMenu(e){const t=document.querySelector("#mobile-nav-list"),a=document.querySelector("#mobile-nav-footer-action");t&&(t.innerHTML=""),a&&(a.innerHTML=""),t&&(e.forEach(e=>{e.isLogout?a&&a.appendChild(E("a",{class:"mobile-nav-logout",href:e.href},[e.title])):t.appendChild(this.renderNavigationItem(e,"mobile"))}),this.bindNavigationAccordion(t))},render(e){this.tree=e,this.renderModeMenu(e),this.renderTabs(e)},renderTabs(e){if(L.env.dispatchpath.length<3)return;let t=e,a="";for(let e=0;e<3&&t;e++){const n=L.env.dispatchpath[e];t=t.children?.[n],a+=(a?"/":"")+n}t&&this.renderTabMenu(t,a)},syncRoute(){if(!this.tree)return;const e=new URL(L.url(...L.env.dispatchpath.slice(0,3)),location.href).pathname;document.querySelectorAll("#topmenu a, .desktop-menu-canvas a, #sidebar-list a, #mobile-nav-list a").forEach(t=>{const a=t.pathname===e&&!t.getAttribute("href").startsWith("#");t.classList.toggle("is-active-page",a),a?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),document.querySelectorAll(".navigation-group").forEach(e=>{const t=!!e.querySelector("a.is-active-page"),a=e.querySelector(".navigation-group-toggle");e.classList.toggle("is-active-group",t),t?a?.setAttribute("aria-current","location"):a?.removeAttribute("aria-current")});for(const e of[document.querySelector("#sidebar-list"),document.querySelector("#mobile-nav-list")])this.resetNavigationGroups(e),this.expandActiveNavigationGroup(e);this.renderCrumb();const t=document.querySelector("#tabmenu");t&&(t.innerHTML="",t.style.display="none"),this.renderTabs(this.tree)},closeSurfaces(){this.hideDesktopNav(),this.paletteList&&this.closePalette(),this.closeMobileNavigation?.()},renderTabMenu(e,t,a=0){const n=document.querySelector("#tabmenu"),i=E("ul",{class:"tabs"}),s=ui.menu.getChildren(e);let o=null;return s.forEach(e=>{const n=L.env.dispatchpath[3+a]===e.name;i.appendChild(E("li",{class:`tabmenu-item-${e.name}${n?" active":""}`},[E("a",{href:L.url(t,e.name)},[_(e.title)])])),n&&(o=e)}),i.children.length?(n.appendChild(i),n.style.display="",o&&this.renderTabMenu(o,`${t}/${o.name}`,a+1),i):E([])},renderMainMenu(e,t,a=0,n=null){const i=a?E("ul",{class:"desktop-nav-list"}):document.querySelector("#topmenu"),s=ui.menu.getChildren(e);if(a>1)return E([]);if(0===a){const e=document.body?.dataset?.navType||"mega-menu";if("sidebar"===e)return this.renderSidebar(n||[]),i||E([]);if(!i||!s.length)return E([]);"mega-menu"===e?Line truncated
"use strict";"require baseclass";"require ui";const PALETTE_RECENTS_KEY="aurora.paletteRecents";return baseclass.extend({__init__(){ui.menu.load().then(e=>this.render(e)).finally(()=>{for(const e of document.querySelectorAll("[data-restored]"))e.inert=!1}),this.initNavigationControls(),this.initUciIndicator()},initUciIndicator(){const e=ui.changes?.setIndicator;e&&(ui.changes.setIndicator=function(t){e.call(this,t),document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",t||0)})},initNavigationControls(){const e=document.querySelector("#mobile-menu-overlay"),t=document.querySelector("#navigation-toggle");if(!t||!e)return;const a=e.querySelector("#mobile-nav-list"),n=window.matchMedia("(min-width: 768px)"),i="aurora.sidebarCollapsed",s=()=>n.matches&&"sidebar"===document.body.dataset.navType,o=e=>{const a=s();t.classList.toggle("is-expanded",e&&!n.matches),t.setAttribute("aria-expanded",e?"true":"false"),t.setAttribute("aria-controls",a?"sidebar-panel":"mobile-menu-overlay");const i=e&&!a?_("Close"):_("Navigation");t.setAttribute("title",i),t.setAttribute("aria-label",i)},r=()=>{e.classList.contains("mobile-menu-open")&&(e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(a),o(!1))};this.closeMobileNavigation=r;const l=t=>{if(s()){r();const e=!t;return document.body.classList.toggle("sidebar-collapsed",e),localStorage.setItem(i,e),void o(t)}if(n.matches)return r(),void o(!1);e.classList.toggle("mobile-menu-open",t),document.body.classList.toggle("mobile-navigation-open",t),document.body.style.overflow=t?"hidden":"",t?this.expandActiveNavigationGroup(a):this.resetNavigationGroups(a),o(t)},c=()=>{if(r(),s()){const e="true"===localStorage.getItem(i);return document.body.classList.toggle("sidebar-collapsed",e),void o(!e)}o(!1)};t.addEventListener("click",t=>{t.stopPropagation(),l(!(s()?!document.body.classList.contains("sidebar-collapsed"):!n.matches&&e.classList.contains("mobile-menu-open")))}),e.addEventListener("click",t=>{t.target===e&&l(!1)}),document.addEventListener("keydown",t=>{"Escape"===t.key&&e.classList.contains("mobile-menu-open")&&l(!1)}),n.addEventListener("change",c),c(),document.addEventListener("click",t=>{const a=t.target?.closest?.(".navigation-direct, .navigation-sublink, .mobile-nav-logout");a&&e.contains(a)&&l(!1)})},renderMobileMenu(e){const t=document.querySelector("#mobile-nav-list"),a=document.querySelector("#mobile-nav-footer-action");t&&(t.innerHTML=""),a&&(a.innerHTML=""),t&&(e.forEach(e=>{e.isLogout?a&&a.appendChild(E("a",{class:"mobile-nav-logout",href:e.href},[e.title])):t.appendChild(this.renderNavigationItem(e,"mobile"))}),this.bindNavigationAccordion(t))},render(e){this.tree=e,this.renderModeMenu(e),this.renderTabs(e),this.cacheNav()},cacheNav(){const e={};for(const t of document.querySelectorAll("#topmenu, #sidebar-list, #sidebar-footer"))t.firstChild&&(e[t.id]=t.innerHTML);try{sessionStorage.setItem("aurora.nav",JSON.stringify([document.body.dataset.navStamp,e]))}catch{}},renderTabs(e){if(L.env.dispatchpath.length<3)return;let t=e,a="";for(let e=0;e<3&&t;e++){const n=L.env.dispatchpath[e];t=t.children?.[n],a+=(a?"/":"")+n}t&&this.renderTabMenu(t,a)},syncRoute(){if(!this.tree)return;const e=new URL(L.url(...L.env.dispatchpath.slice(0,3)),location.href).pathname;document.querySelectorAll("#topmenu a, .desktop-menu-canvas a, #sidebar-list a, #mobile-nav-list a").forEach(t=>{const a=t.pathname===e&&!t.getAttribute("href").startsWith("#");t.classList.toggle("is-active-page",a),a?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),document.querySelectorAll(".navigation-group").forEach(e=>{const t=!!e.querySelector("a.is-active-page"),a=e.querySelector(".navigation-group-toggle");e.classList.toggle("is-active-group",t),t?a?.setAttribute("aria-current","location"):a?.removeAttribute("aria-current")});for(const e of[document.querySelector("#sidebar-list"),document.querySelector("#mobile-nav-list")])this.resetNavigationGroups(e),this.expandActiveNavigationGroup(e);window.auroraCrumb?.();const t=document.querySelector("#tabmenu");t&&(t.innerHTML="",t.style.display="none"),this.renderTabs(this.tree)},closeSurfaces(){this.hideDesktopNav(),this.paletteList&&this.closePalette(),this.closeMobileNavigation?.()},renderTabMenu(e,t,a=0){const n=document.querySelector("#tabmenu"),i=E("ul",{class:"tabs"}),s=ui.menu.getChildren(e);let o=null;return s.forEach(e=>{const n=L.env.dispatchpath[3+a]===e.name;i.appendChild(E("li",{class:`tabmenu-item-${e.name}${n?" active":""}`},[E("a",{href:L.url(t,e.name)},[_(e.title)])])),n&&(o=e)}),i.children.length?(n.appendChild(i),n.style.display="",o&&this.renderTabMenu(o,`${t}/${o.name}`,a+1),i):E([])},renderMainMenu(e,t,a=0,n=null){const i=a?E("ul",{class:"desktop-nav-list"}):document.querySelector("#topmenu"),s=ui.menu.getChildren(e);if(a>1)return E([]);if(0===a){const e=document.body?.dataset?.navType||"megaLine truncated
@@ -107,6 +107,8 @@
const theme = localStorage.getItem('aurora.theme') || 'device';
const isDark = theme === 'dark' || (theme === 'device' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-darkmode', isDark);
{# A new session may see a different menu: drop the replayed nav. #}
try { sessionStorage.removeItem('aurora.nav'); } catch (e) {}
})();
</script>
{% else %}
@@ -122,7 +124,8 @@
if (prev !== null && prev !== next && document.startViewTransition && !navOpen &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.startViewTransition(apply);
// Typed: main.css leaves the page uncaptured for any other transition.
document.startViewTransition(window.ViewTransitionTypeSet ? { update: apply, types: ['theme'] } : apply);
} else {
apply();
}
@@ -224,8 +227,16 @@
{% endif %}
</head>
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-nav-type="{{ nav_type }}" data-asset-version="{# PKG_VERSION #}"{% if (length(patch_all)): %} data-patches="{{ entityencode(join(' ', patch_all), true) }}"{% endif %}{% if (blank_page ? tokens.struct_login_bg : tokens.struct_main_bg): %} data-bg{% endif %}>
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-nav-type="{{ nav_type }}" data-asset-version="{# PKG_VERSION #}"{% if (!blank_page): %} data-nav-stamp="{{ entityencode(join(':', [nav_type, dispatcher.lang, ctx?.authuser ?? '']), true) }}:{# PKG_VERSION #}"{% endif %}{% if (length(patch_all)): %} data-patches="{{ entityencode(join(' ', patch_all), true) }}"{% endif %}{% if (blank_page ? tokens.struct_login_bg : tokens.struct_main_bg): %} data-bg{% endif %}>
{% if (!blank_page): %}
{% if (nav_type == 'sidebar'): %}
{# First in body: once anything resolves body's style (the page-bg script does), the column would ease shut on load. #}
<script>
if (localStorage.getItem('aurora.sidebarCollapsed') === 'true') {
document.body.classList.add('sidebar-collapsed');
}
</script>
{% endif %}
{% if (tokens.struct_main_bg): %}
<div class="page-bg" aria-hidden="true"></div>
<script>
@@ -306,16 +317,58 @@
<img class="sidebar-logo" src="{{ media }}/images/{{ logo_svg }}?v={{ icon_cache_version }}" alt="" aria-hidden="true">
<a class="sidebar-brand" href="/">{{ hostname }}</a>
</div>
<ul class="sidebar-list" id="sidebar-list"></ul>
<ul class="sidebar-list" id="sidebar-list" data-active-path="{{ entityencode(join('/', slice(ctx?.path ?? [], 1, 3)), true) }}"></ul>
<div class="sidebar-footer" id="sidebar-footer"></div>
</nav>
</aside>
<script>
if (localStorage.getItem('aurora.sidebarCollapsed') === 'true') {
document.body.classList.add('sidebar-collapsed');
}
</script>
{% endif %}
<script>
{% if (nav_type == 'sidebar'): %}
{# Shared with menu-aurora.js, which calls it after every render and route change. #}
function auroraCrumb() {
const list = document.getElementById('sidebar-list');
const group = list.querySelector('.is-active-group')?.querySelector('.nav-category-label')?.textContent;
const page = list.querySelector('.is-active-page')?.textContent;
const titles = [group, page !== group && page].filter(Boolean);
const li = (className, textContent) => Object.assign(document.createElement('li'), { className, textContent });
document.getElementById('header-crumb').replaceChildren(...titles.flatMap((title, i) =>
i ? [li('crumb-sep', '/'), li('current', title)] : [li(titles[1] ? '' : 'current', title)]));
}
{% endif %}
{# Paints the nav menu-aurora.js cached on its last render before first paint, inert until the module renders over it. #}
(() => {
try {
const body = document.body, cache = JSON.parse(sessionStorage.getItem('aurora.nav'));
{# The stamp (nav type, language, user, version) is rendered on body; a different user sees a different menu. #}
{# luci-base's one-session store loses its menu on ui.menu.flushCache() (package changes): never replay past that. #}
const luci = Object.values(JSON.parse(sessionStorage.getItem('luci-session-store')) ?? {})[0];
if (!luci?.menu || cache?.[0] !== body.dataset.navStamp) return;
for (const id in cache[1]) {
const el = document.getElementById(id);
el.innerHTML = cache[1][id];
el.style.display = '';
el.dataset.restored = '';
el.inert = true;
}
{% if (nav_type == 'sidebar'): %}
{# The marks renderNavigationItem derives from L.env.dispatchpath. #}
const list = document.getElementById('sidebar-list'), [group, page] = list.dataset.activePath.split('/');
for (const row of list.querySelectorAll('[data-section]')) {
const on = row.dataset.section === group, item = row.parentNode;
if (row.tagName === 'A') {
row.classList.toggle('is-active-page', on);
continue;
}
item.classList.toggle('is-active-group', on);
item.classList.toggle('is-expanded', on);
}
for (const link of list.querySelectorAll('.navigation-sublink'))
link.classList.toggle('is-active-page', !!page && link.pathname.endsWith(`/${group}/${page}`));
auroraCrumb();
{% endif %}
} catch (e) {}
})();
</script>
<div class="mobile-menu-overlay" id="mobile-menu-overlay">
<nav class="mobile-nav" aria-label="{{ _('Navigation') }}">