mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-08 05:04:46 +08:00
update 2026-10-31 01:37:59
This commit is contained in:
1 parent
a60cfc3dee
commit
00e269d429
218 files changed
+22189
-205
No files matched your search
@@ -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/`
|
||||
|
||||
+110
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user