update 2026-10-31 01:37:59

This commit is contained in:
action committed 2026-10-31 01:37:59 +08:00
1 parent a60cfc3dee
commit 00e269d429
218 files changed
+22189 -205

No files matched your search

+6 -1
View File
@@ -75,7 +75,12 @@ Checks:
|---|---|
| `nowrap: <sel>` | every matched element's text sits on one line, and no text starts below an inline checkbox/indicator inside it |
| `fits: <sel>` | `scrollWidth ≤ clientWidth + 1` and the element's right edge is inside its parent's |
| `js: "<expr>"` | the expression is truthy in the page — escape hatch; the ledger entry says why |
| `js: "<expr>"` | the expression resolves to a truthy value in the page; promises are awaited and rejections fail — escape hatch; the ledger entry says why |
A fixture may opt into `"reducedMotion": [false, true]` to run each viewport
with both motion preferences. The default is `[false]`; the runner sets the
preference before each navigation. Lifecycle checks must await native browser
events, not dispatch synthetic completion events or remove failed nodes.
A check may carry its own `"viewports": [...]` (a mobile card layout wraps on
purpose). Capture the DOM from a device running this theme (`.dev/mocks/`
@@ -0,0 +1,110 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../../htdocs/luci-static/aurora/main.css">
<body data-page="admin-status-nftables">
<div id="maincontent">
<div id="view"><h3>IPv4/IPv6 traffic table "fw4"</h3></div>
</div>
</body>
<script>
const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const require = (condition, message) => {
if (!condition) throw new Error(message);
};
function notification(title) {
const node = document.createElement("div");
node.className = "alert-message fade-in warning";
node.style.display = "flex";
node.innerHTML = '<div style="flex:10"><h4></h4><p>There are legacy iptables rules present on the system. Mixing iptables and nftables rules is discouraged.</p><button class="btn cbi-button">Open iptables rules overview…</button></div><div style="flex:1 1 auto;display:flex"><button class="btn" style="margin-left:auto;margin-top:auto">Dismiss</button></div>';
node.querySelector("h4").textContent = title;
// ui.addNotification keeps fade-in and removes only on transitionend.
node.addEventListener("transitionend", ev => {
const current = ev.currentTarget;
if (current.parentNode && current.classList.contains("fade-out"))
current.parentNode.removeChild(current);
});
node.lastElementChild.firstElementChild.addEventListener("click", ev => {
ev.target.closest(".alert-message").classList.add("fade-out");
});
return node;
}
function removeStartingStyles(sheet) {
let removed = 0;
for (let i = sheet.cssRules.length - 1; i >= 0; i--) {
const rule = sheet.cssRules[i];
if (rule.cssText.startsWith("@starting-style")) {
sheet.deleteRule(i);
removed++;
} else if (rule.cssRules) {
removed += removeStartingStyles(rule);
}
}
return removed;
}
async function checkNotifications(fallback) {
if (fallback) {
const removed = [...document.styleSheets].reduce((sum, sheet) => sum + removeStartingStyles(sheet), 0);
require(removed > 0, "fallback must remove the starting-style enhancement");
}
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const main = document.querySelector("#maincontent");
const view = document.querySelector("#view");
const survivor = notification("Keep this notification");
main.insertBefore(survivor, view);
await frame();
const baseline = view.getBoundingClientRect().top;
try {
for (const early of [true, false]) {
const node = notification("Legacy rules detected");
const events = [];
node.addEventListener("transitionend", ev => events.push({ target: ev.target, property: ev.propertyName }));
main.insertBefore(node, survivor);
await frame();
await frame();
await delay(early ? 50 : 500);
const before = view.getBoundingClientRect().top;
const entryOpacity = Number(getComputedStyle(node).opacity);
events.length = 0;
const removed = await new Promise(resolve => {
const timeout = setTimeout(() => resolve(false), 1200);
node.addEventListener("transitionend", () => {
if (!node.isConnected) {
clearTimeout(timeout);
resolve(true);
}
});
node.lastElementChild.firstElementChild.click();
});
require(removed && !node.isConnected, `${early ? "early" : "settled"} dismissal left an invisible notification in the DOM`);
require(events.some(ev => ev.target === node && ev.property === "opacity"), "removal must follow the notification's own opacity transition");
await frame();
const after = view.getBoundingClientRect().top;
require(after < before && Math.abs(after - baseline) < 1, `notification gap remained: ${before} → ${after}, expected ${baseline}`);
require(survivor.isConnected && !survivor.classList.contains("fade-out"), "dismissal affected another notification");
if (early && !reduced && !fallback)
require(entryOpacity > 0 && entryOpacity < 1, `entry did not fade: opacity=${entryOpacity}`);
if (reduced || fallback)
require(entryOpacity === 1, `notification did not appear immediately: opacity=${entryOpacity}`);
}
return true;
} finally {
survivor.remove();
}
}
</script>
<script type="application/json" id="compat">
{
"ledger": "#103",
"viewports": [390, 1280],
"reducedMotion": [false, true],
"checks": [
{ "js": "checkNotifications(false)" },
{ "js": "checkNotifications(true)" }
]
}
</script>
@@ -0,0 +1,14 @@
# Status → Firewall
## notification-dismissal — dismissed alerts release their layout space
- page: admin-status-nftables · Status → Firewall
- package: stock luci-mod-status; notification lifecycle from luci-base `ui.addNotification()`
- dom: `#maincontent > .alert-message.fade-in.warning[style="display:flex"]` before `#view`; Dismiss adds `fade-out` without removing `fade-in`, and the notification's `transitionend` handler removes the node
- symptom: the legacy iptables warning becomes transparent after Dismiss but remains in the DOM, leaving the rules table below an empty gap; other dismissible LuCI notifications share this contract
- cause: the entry opacity animation blocks the exit opacity transition, including early dismissal with backwards fill; global reduced-motion suppression also prevents the removal event
- fix: `_utilities.css` gives `.fade-in/.fade-out` one opacity transition instead of an entry keyframe, with `@starting-style` for optional entry; `fade-out` supplies opacity zero and a 1 ms reduced-motion transition. Unsupported browsers display immediately and still dismiss. Decorative page/menu/tab/footer entry keyframes were separately removed by request; notification component styles remain unchanged. No production JS changes · v1.4.1
- verified: Chrome fixture, 390/1280 px × normal/reduced motion × early/settled dismissal × enhanced/fallback, 16 cases per theme. Old Aurora and shadcn CSS both fail early dismissal with the node still connected; rebuilt CSS removes it on its own opacity transitionend and restores content position within 1 px of baseline, leaving a second notification intact. Fallback is simulated by removing starting-style rules, not by running an old browser. Device verification is pending: configured device connection timed out
- fixture: fixtures/admin-status-nftables--notification-dismissal.html; async checks are awaited, with an adapter regression against `Promise.resolve(false)`
- issue: #103 · mirror: shadcn dcd26e7 (v0.6.1), same fixture passed against its built CSS · status: active
- footprint: including the requested decorative-entry removal, Aurora main.css 185262 → 184884 B (−378 B); shadcn 129996 → 129851 B (−145 B). Login assets and all pre-existing asset budgets are unchanged
- boundary: these utilities handle newly displayed elements; adding `fade-in` to an already-visible element no longer replays a keyframe. No such theme-owned caller was found; external third-party callers were not exhaustively tested
+11 -19
View File
@@ -51,31 +51,23 @@
}
}
@keyframes aurora-fade-in {
from {
opacity: 0;
}
.fade-in,
.fade-out {
@apply [transition:opacity_250ms_ease-in-out];
}
.fade-in {
@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];
@starting-style {
@apply opacity-0;
}
}
.fade-out {
@apply pointer-events-none opacity-0 transition-opacity duration-[250ms];
@apply pointer-events-none opacity-0;
@media (prefers-reduced-motion: reduce) {
@apply [transition:opacity_1ms]!;
}
}
.spinning {
+34 -21
View File
@@ -82,9 +82,17 @@ const CHECKS = {
}
return bad;
})()`,
js: (expr) => `(() => { const v = (${expr}); return v ? [] : ['expression was ' + JSON.stringify(v)]; })()`,
js: (expr) => `(async () => { const v = await (${expr}); return v ? [] : ['expression was ' + JSON.stringify(v)]; })()`,
};
test("compat js checks await async results", async () => {
const check = (expr) => new Function(`return ${CHECKS.js(expr)}`)();
assert.deepEqual(await check("true"), []);
assert.deepEqual(await check("Promise.resolve(true)"), []);
assert.deepEqual(await check("Promise.resolve(false)"), ["expression was false"]);
await assert.rejects(check("Promise.reject(new Error('fixture failure'))"), /fixture failure/);
});
function readSpec(file) {
const html = readFileSync(join(fixtureDir, file), "utf8");
const m = html.match(/<script[^>]*id="compat"[^>]*>([\s\S]*?)<\/script>/);
@@ -154,26 +162,31 @@ test("compat fixtures keep their geometry", { skip: skipReason ?? false }, async
for (const width of spec.viewports ?? [1280]) {
await send("Emulation.setDeviceMetricsOverride",
{ width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId);
const loaded = new Promise((res) => {
const h = (m) => { if (m.method === "Page.loadEventFired" && m.sessionId === sessionId) { handlers.delete(h); res(); } };
handlers.add(h);
});
await send("Page.navigate", { url: pathToFileURL(join(fixtureDir, file)).href }, sessionId);
await loaded;
// Force the theme font to load before measuring (font-display: swap
// would otherwise measure the fallback), then give layout one frame.
await evaljs(`document.fonts.load('12px "Lato"').then(() => document.fonts.ready)
.then(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))))`);
const fontOk = await evaljs(`!${JSON.stringify(!!fontCss)} || document.fonts.check('12px "Lato"')`);
assert.ok(fontOk, `${file}: theme font did not load from ${output}/fonts`);
const sheets = await evaljs("[...document.styleSheets].map(s => { try { return s.cssRules.length } catch { return -1 } })");
assert.ok(sheets.some((n) => n > 100), `${file}: main.css did not load (rules per sheet: ${sheets}) — run pnpm build`);
for (const check of spec.checks) {
if (check.viewports && !check.viewports.includes(width)) continue;
const [kind, arg] = Object.entries(check).find(([k]) => k in CHECKS) ?? [];
assert.ok(kind, `${file}: unknown check ${JSON.stringify(check)}`);
const bad = await evaljs(CHECKS[kind](arg));
assert.deepEqual(bad, [], `${file} @${width} ${kind} ${JSON.stringify(arg)} (ledger ${spec.ledger}): ${bad.join("; ")}`);
for (const reducedMotion of spec.reducedMotion ?? [false]) {
await send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-motion", value: reducedMotion ? "reduce" : "no-preference" }],
}, sessionId);
const loaded = new Promise((res) => {
const h = (m) => { if (m.method === "Page.loadEventFired" && m.sessionId === sessionId) { handlers.delete(h); res(); } };
handlers.add(h);
});
await send("Page.navigate", { url: pathToFileURL(join(fixtureDir, file)).href }, sessionId);
await loaded;
// Force the theme font to load before measuring (font-display: swap
// would otherwise measure the fallback), then give layout one frame.
await evaljs(`document.fonts.load('12px "Lato"').then(() => document.fonts.ready)
.then(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))))`);
const fontOk = await evaljs(`!${JSON.stringify(!!fontCss)} || document.fonts.check('12px "Lato"')`);
assert.ok(fontOk, `${file}: theme font did not load from ${output}/fonts`);
const sheets = await evaljs("[...document.styleSheets].map(s => { try { return s.cssRules.length } catch { return -1 } })");
assert.ok(sheets.some((n) => n > 100), `${file}: main.css did not load (rules per sheet: ${sheets}) — run pnpm build`);
for (const check of spec.checks) {
if (check.viewports && !check.viewports.includes(width)) continue;
const [kind, arg] = Object.entries(check).find(([k]) => k in CHECKS) ?? [];
assert.ok(kind, `${file}: unknown check ${JSON.stringify(check)}`);
const bad = await evaljs(CHECKS[kind](arg));
assert.deepEqual(bad, [], `${file} @${width} reducedMotion=${reducedMotion} ${kind} ${JSON.stringify(arg)} (ledger ${spec.ledger}): ${bad.join("; ")}`);
}
}
}
await send("Target.closeTarget", { targetId });
@@ -14,11 +14,9 @@ test("only the typed theme toggle captures the page in a view transition", () =>
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("page content appears without decorative entry fades", () => {
assert.ok(!css.includes("aurora-fade-in"), "decorative entry keyframe remains");
assert.ok(!/#view>:not\(\.spinning\)[^{]*\{[^}]*\banimation:/.test(css), "view contents still animate on entry");
});
test("a collapsed sidebar is restored before anything resolves body's style", () => {