🎈 Sync 2026-06-16 01:15:58

This commit is contained in:
github-actions[bot] 2026-06-16 01:15:58 +08:00
parent 26e9df1a69
commit bac0d35f6b
30 changed files with 295 additions and 101 deletions

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede
PKG_VERSION:=1.12.0
PKG_RELEASE:=9
PKG_RELEASE:=10
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)

View File

@ -139,9 +139,9 @@ return view.extend({
]);
};
// run a backgrounded script, stream its log, then toast the result.
// Scripts end their log with "done (rc=N)"; poll until that appears.
const runJob = function(script, arg, btn, logPath, label) {
// run a backgrounded script and stream its log into the log pane until it
// logs "done (rc=N)"; then refresh the badges. Result shows inline (no popup).
const runJob = function(script, arg, btn, logPath) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
@ -149,20 +149,13 @@ return view.extend({
const poll = function() {
return L.resolveDefault(fs.read_direct(logPath, 'text'), '').then(function(c) {
if (c) { logPane.textContent = c; logPane.classList.add('show'); }
const m = c.match(/done \(rc=(\d+)\)/);
if (m) {
const ok = m[1] === '0';
ui.addNotification(null, E('p', label + ' · ' + (ok ? _('done') : _('failed'))), ok ? null : 'danger');
refresh();
return;
}
if (/done \(rc=\d+\)/.test(c)) { refresh(); return; }
if (tries++ > 90) return;
return new Promise(function(r) { setTimeout(r, 2000); }).then(poll);
});
};
return fs.exec('/usr/share/luci-app-daede/' + script, [arg]).then(function(res) {
if (res.code !== 0) { ui.addNotification(null, E('p', label + ' · ' + _('failed to start')), 'danger'); return; }
return poll();
if (res.code === 0) return poll();
}).catch(function() {}).finally(function() {
btn.disabled = false;
btn.textContent = orig;
@ -171,16 +164,12 @@ return view.extend({
// === Data updates (GeoIP / GeoSite) ===
const updateGeo = function(kind, btn) {
return runJob('update-geo.sh', kind, btn,
'/tmp/luci-app-daede.' + kind + '.log', kind === 'geoip' ? 'GeoIP' : 'GeoSite');
return runJob('update-geo.sh', kind, btn, '/tmp/luci-app-daede.' + kind + '.log');
};
// === Package updates (dae|daed / luci-app-daede) ===
const upgradePkg = function(pkg, btn) {
if (!confirm(_('Upgrade %s now? This may restart the active backend.').format(pkg)))
return;
return runJob('update-pkg.sh', pkg, btn,
'/tmp/luci-app-daede.pkg-' + pkg + '.log', pkg);
return runJob('update-pkg.sh', pkg, btn, '/tmp/luci-app-daede.pkg-' + pkg + '.log');
};
const refresh = function() {

View File

@ -10,8 +10,9 @@ fi
: > "$LOCK"
(
# -n: skip if a package op holds the shared apk lock (no need to refresh then)
if command -v apk >/dev/null 2>&1; then
apk update
flock -n /tmp/luci-app-daede.apk.lock apk update
elif command -v opkg >/dev/null 2>&1; then
opkg update
fi

View File

@ -38,15 +38,22 @@ fi
echo "$(date '+%F %T') begin upgrade: $PKG"
if command -v apk >/dev/null 2>&1; then
echo "--- apk update ---"
apk update 2>&1
echo "--- apk add $PKG ---"
# Target only $PKG (not `apk upgrade`, which re-solves the whole world).
apk add "$PKG" 2>&1
# Don't trust apk's exit code: apk-tools 3 returns non-zero whenever ANY
# unrelated installed package has an unavailable .apk in the configured
# feeds, even when $PKG itself upgraded fine. Judge success by state —
# $PKG must be installed and have no pending upgrade left.
# shared apk lock with the bg index refresh (avoid "Unable to lock database")
(
flock 9
apk update 2>&1
# pin exact latest version + --force-broken-world so unrelated broken
# packages can't block this upgrade (cf. clashoo component_update)
ver=$(apk list "$PKG" 2>/dev/null | awk -v p="$PKG" '$1 ~ "^" p "-[0-9]" { v=$1; sub("^" p "-", "", v); print v }' | sort -V | tail -1)
if [ -n "$ver" ]; then
echo "--- apk add $PKG=$ver ---"
apk add "$PKG=$ver" --force-broken-world 2>&1
else
echo "--- apk add $PKG ---"
apk add "$PKG" --force-broken-world 2>&1
fi
) 9>/tmp/luci-app-daede.apk.lock
# apk's exit code is unreliable (broken-world noise); judge by state instead
if ! apk list --installed 2>/dev/null | grep -q "^${PKG}-"; then
echo "result: $PKG is not installed"
rc=1

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg>

After

Width:  |  Height:  |  Size: 224 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M6 4v4" /><path d="M6 12v8" /><path d="M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M12 4v10" /><path d="M12 18v2" /><path d="M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M18 4v1" /><path d="M18 9v11" /></svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" /><path d="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" /><path d="M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" /><path d="M14 7l6 0" /><path d="M17 4l0 6" /></svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6v6h-6l0 -6" /><path d="M14 4h6v6h-6l0 -6" /><path d="M4 14h6v6h-6l0 -6" /><path d="M14 17a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /></svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -12" /><path d="M9 9h6v6h-6l0 -6" /><path d="M3 10h2" /><path d="M3 14h2" /><path d="M10 3v2" /><path d="M14 3v2" /><path d="M21 10h-2" /><path d="M21 14h-2" /><path d="M14 21v-2" /><path d="M10 21v-2" /></svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5" /><path d="M12 12l8 -4.5" /><path d="M12 12l0 9" /><path d="M12 12l-8 -4.5" /><path d="M16 5.25l-8 4.5" /></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 15a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -4" /><path d="M17 17l0 .01" /><path d="M13 17l0 .01" /><path d="M15 13l0 -2" /><path d="M11.75 8.75a4 4 0 0 1 6.5 0" /><path d="M8.5 6.5a8 8 0 0 1 13 0" /></svg>

After

Width:  |  Height:  |  Size: 430 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2" /><path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2" /><path d="M7 8l0 .01" /><path d="M7 16l0 .01" /><path d="M11 8h6" /><path d="M11 16h6" /></svg>

After

Width:  |  Height:  |  Size: 457 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg>

After

Width:  |  Height:  |  Size: 770 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3" /></svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /><path d="M3.6 9h16.8" /><path d="M3.6 15h16.8" /><path d="M11.5 3a17 17 0 0 0 0 18" /><path d="M12.5 3a17 17 0 0 1 0 18" /></svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@ -1,5 +1,5 @@
header {
@apply bg-bg sticky top-0 z-60 mb-2;
@apply bg-bg sticky top-0 z-40 mb-2;
& .header-content {
@apply relative flex h-14 items-center justify-between px-6 py-3 max-md:px-4 max-md:py-2;
@ -16,84 +16,130 @@ header {
/* Category panels live inside the container, not the nav items: the
transformed #topmenu would otherwise become the containing block of
a position:fixed panel and shrink the canvas to the menu's width.
Fixed coordinates shared by every category, so switching panels
never shifts anything: link columns start at 50% - 13rem (just
under the centered nav triggers), the anchor column sits at a
fixed slot left of them. The entering panel floats up 6px
transform + opacity only. */
Each panel is the same centered three-column grid (max-width fixed),
so switching categories is a pure cross-fade with no width shift.
The entering panel floats up 6px transform + opacity only. */
/* The server-rendered board is only a template that menu-aurora.js
clones into each panel's right column the original stays hidden. */
& > .desktop-menu-board {
@apply hidden;
}
& .desktop-nav {
@apply pointer-events-none absolute inset-x-0 top-14 min-h-60 translate-y-1.5 pt-7 pb-8 pl-[calc(50%-13rem)] opacity-0 transition-[opacity,transform] duration-[250ms] ease-out;
/* Centered three-column canvas: anchor / links / device board.
Fixed max-width keeps every category the same width, so switching
is a pure cross-fade. Below lg the side tracks drop and only the
links column remains (tablet just the submenu). The panel scrolls
internally when its content exceeds the viewport below the header. */
@apply pointer-events-none absolute inset-x-0 top-14 mx-auto grid max-h-[calc(100dvh-3.5rem)] max-w-[72rem] translate-y-1.5 grid-cols-[12rem_minmax(0,1fr)_13rem] gap-x-8 overflow-y-auto overscroll-contain px-10 pt-7 pb-8 opacity-0 transition-[opacity,transform] duration-[250ms] ease-out max-lg:max-w-[44rem] max-lg:grid-cols-1 max-lg:px-8;
&.active {
@apply pointer-events-auto translate-y-0 opacity-100;
}
& .desktop-nav-anchor {
@apply absolute top-7 left-[calc(50%-30rem)] w-56 max-lg:hidden;
@apply self-start max-lg:hidden;
& .desktop-nav-title {
@apply text-text text-2xl font-semibold tracking-tight;
@apply text-text flex items-center gap-2.5 text-2xl font-semibold tracking-tight;
/* First-level icon: mask + currentColor so it follows the theme.
--menu-icon is set per node name below; unmapped default. */
&::before {
@apply size-6 shrink-0 bg-current content-[''] [mask:var(--menu-icon,url('@assets/icons/category.svg'))_center/contain_no-repeat];
}
}
/* name icon map (the only maintenance point). Keyed on the LuCI
node name, not the translated title. Add new first-level
sections here; anything unmapped keeps the default icon. */
& .desktop-nav-title[data-section="status"] {
@apply [--menu-icon:url('@assets/icons/activity.svg')];
}
& .desktop-nav-title[data-section="system"] {
@apply [--menu-icon:url('@assets/icons/settings.svg')];
}
& .desktop-nav-title[data-section="services"] {
@apply [--menu-icon:url('@assets/icons/apps.svg')];
}
& .desktop-nav-title[data-section="nas"] {
@apply [--menu-icon:url('@assets/icons/server-2.svg')];
}
& .desktop-nav-title[data-section="control"] {
@apply [--menu-icon:url('@assets/icons/adjustments.svg')];
}
& .desktop-nav-title[data-section="network"] {
@apply [--menu-icon:url('@assets/icons/world.svg')];
}
}
& .desktop-nav-list {
/* Vertical fill: columns of equal height, no orphan rows. Row
count is set per submenu via --menu-rows in menu-aurora.js.
w-max is load-bearing: without it the block-level list fills
the viewport and justify-content:normal stretches the auto
column tracks across all of it. */
@apply grid w-max grid-flow-col grid-rows-[repeat(var(--menu-rows,6),auto)] content-start gap-x-8 gap-y-1;
/* Vertical fill, content-width columns. Row count per submenu via
--menu-rows (menu-aurora.js); more items grow rows, not width,
so the list stays inside the middle track. */
@apply grid grid-flow-col grid-rows-[repeat(var(--menu-rows,6),auto)] content-start justify-start gap-x-8 gap-y-1;
& > li {
@apply m-0 list-none;
& > a {
@apply text-text block w-fit py-2.5 text-sm whitespace-nowrap no-underline hover:underline hover:decoration-2 hover:underline-offset-4;
@apply text-text hover:bg-hover-faint block w-fit rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors duration-150;
&.is-active-page {
@apply bg-brand-subtle text-brand font-medium;
}
}
}
}
}
/* Constant part of the canvas, Apple-flyout style: the device
summary lives once at container level, pinned just below the
fixed category-title slot (header 3.5rem + panel pt 1.75rem +
title line 2rem + 1.25rem gap = top-34). Its position never
depends on an individual panel's height, and switching categories
never re-fades it. */
& .desktop-menu-board {
@apply absolute top-34 left-[calc(50%-30rem)] flex w-56 flex-col gap-2 max-lg:hidden;
/* Right column: device summary, cloned per panel. self-start so it
sits at the top regardless of how tall the links column grows. */
& .desktop-menu-board {
@apply border-hairline flex flex-col gap-2 self-start border-l pl-7 max-lg:hidden;
& .board-line {
@apply text-text-muted flex max-w-full items-center gap-2.5 text-xs;
&::before {
@apply size-4 shrink-0 bg-current content-[''];
& .board-label {
@apply text-text-subtle mb-0.5 text-[11px] font-medium tracking-wide uppercase;
}
& .board-text {
@apply min-w-0 truncate;
& .board-line {
/* items-start keeps the icon aligned to the FIRST text line when a
long value wraps. Icon height (size-4) == text leading (leading-4)
so a single line still reads as vertically centered. */
@apply text-text-muted flex max-w-full items-start gap-2.5 text-xs;
&::before {
@apply size-4 shrink-0 bg-current content-[''];
}
& .board-text {
@apply min-w-0 leading-4 [overflow-wrap:anywhere];
}
}
}
& .board-line-host::before {
@apply [mask:url('@assets/icons/server.svg')_center/contain_no-repeat];
}
& .board-line-host::before {
@apply [mask:url('@assets/icons/router.svg')_center/contain_no-repeat];
}
& .board-line-model::before {
@apply [mask:url('@assets/icons/cpu-chip.svg')_center/contain_no-repeat];
}
& .board-line-model::before {
@apply [mask:url('@assets/icons/cpu.svg')_center/contain_no-repeat];
}
& .board-line-firmware::before {
@apply [mask:url('@assets/icons/cube.svg')_center/contain_no-repeat];
}
& .board-line-firmware::before {
@apply [mask:url('@assets/icons/package.svg')_center/contain_no-repeat];
}
& .board-line-kernel::before {
@apply [mask:url('@assets/icons/cog.svg')_center/contain_no-repeat];
& .board-line-kernel::before {
@apply [mask:url('@assets/icons/tag.svg')_center/contain_no-repeat];
}
}
}
}
}
[data-nav-type="mega-menu"] &:has(.desktop-menu-container.active) {
@apply z-70;
}
.brand {
@apply hover:text-brand text-text inline-block shrink-0 text-xl font-semibold tracking-tight no-underline transition-[color,transform] duration-150 hover:-translate-y-0.5 max-md:flex-1 max-md:text-lg;
}
@ -105,16 +151,12 @@ header {
@apply relative;
.menu {
@apply text-text 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 transition-colors duration-150;
/* Unified pill for both dropdown modes the open/active category is a
filled brand pill, matching the submenu links and the sidebar. */
&.menu-active {
@apply text-text;
[data-nav-type="mega-menu"] & {
@apply underline decoration-2 underline-offset-4;
}
[data-nav-type="boxed-dropdown"] & {
@apply bg-brand-subtle text-brand;
}
@apply bg-brand-subtle text-brand;
}
}
@ -140,7 +182,13 @@ header {
@apply text-text block whitespace-nowrap no-underline;
[data-nav-type="boxed-dropdown"] & {
@apply hover:bg-hover-faint mx-2 rounded-xl px-4 py-2;
@apply hover:bg-hover-faint mx-2 rounded-xl px-4 py-2 transition-colors duration-150;
}
/* Same active pill as the mega submenu and the top-level
trigger one unified vocabulary across both dropdown modes. */
&.is-active-page {
@apply bg-brand-subtle text-brand font-medium;
}
}
}
@ -310,7 +358,7 @@ body[data-nav-type="sidebar"] {
@apply max-w-max-width mx-auto min-h-[calc(100vh-4rem)] w-23/24 px-4 max-md:w-full max-md:px-3;
#view {
@apply animate-in fade-in-0 slide-in-from-top-2 fill-mode-both mx-0 w-full bg-transparent p-0 shadow-none empty:hidden md:p-0;
@apply mx-0 w-full bg-transparent p-0 shadow-none empty:hidden md:p-0;
.cbi-title-section {
@apply mb-6 leading-relaxed max-md:mx-2 max-md:mb-3;

View File

@ -0,0 +1,25 @@
/**
* Reduced-transparency fallback.
*
* When the user asks for less transparency, drop the most expensive layer
* backdrop-filter blur everywhere, and turn the one surface that relies on
* "translucent + blur" to stay readable (the mega-menu backdrop) opaque, so
* removing the blur doesn't let the page content bleed through it.
*
* surface-overlay / surface are already opaque, so dropping their blur is
* harmless; scrim keeps its translucency on purpose (it's a dimming layer).
* --mega-menu-bg points at surface-overlay via var() so it tracks light/dark
* automatically (resolved at the point of use, not here).
*/
@media (prefers-reduced-transparency: reduce) {
*,
*::before,
*::after {
/* standard property only — lightningcss adds the -webkit- prefix per browserslist */
@apply [backdrop-filter:none]!;
}
:root {
--mega-menu-bg: var(--surface-overlay);
}
}

View File

@ -50,7 +50,7 @@
}
&.dropdown {
@apply border-hairline bg-surface-overlay absolute left-0 z-60 w-fit min-w-full overflow-y-auto rounded-lg border shadow-xl backdrop-blur-md backdrop-saturate-150;
@apply border-hairline bg-surface-overlay absolute left-0 z-50 w-fit min-w-full overflow-y-auto rounded-lg border shadow-xl backdrop-blur-md backdrop-saturate-150;
& > li {
@apply text-text-muted hover:bg-brand-subtle min-h-9 w-full cursor-pointer px-3 py-1.5 font-medium;

View File

@ -1,5 +1,5 @@
.alert-message {
@apply bg-surface-sunken text-text border-hairline sticky top-14 z-40 mb-4 rounded-4xl border p-6 max-md:mx-0 max-md:mb-3 max-md:rounded-3xl max-md:p-4;
@apply bg-surface-sunken text-text border-hairline sticky top-14 z-30 mb-4 rounded-4xl border p-6 max-md:mx-0 max-md:mb-3 max-md:rounded-3xl max-md:p-4;
&.modal {
@apply static top-auto;

View File

@ -70,7 +70,7 @@ body.mobile-navigation-open > header {
[data-nav-type="mega-menu"] {
& .desktop-menu-overlay {
@apply bg-scrim pointer-events-none fixed inset-0 z-50 opacity-0 transition-opacity duration-[250ms] max-md:hidden;
@apply bg-scrim pointer-events-none fixed inset-0 z-60 opacity-0 transition-opacity duration-[250ms] max-md:hidden;
&.active {
@apply pointer-events-auto opacity-100 backdrop-blur-lg backdrop-saturate-150;

View File

@ -42,3 +42,4 @@
@import "./_utilities.css";
@import "./_patches.css";
@import "./_reduced-motion.css";
@import "./_reduced-transparency.css";

View File

@ -255,11 +255,17 @@ return baseclass.extend({
if (!children.length) return E([]);
children.forEach((child) => {
ul.appendChild(
E("li", {}, [
E("a", { href: L.url(url, child.name) }, [_(child.title)]),
]),
);
// Mark the current page so it gets the active pill — same vocabulary
// as the top-level trigger (.menu-active). tree is the section node,
// so tree.name is its dispatch segment.
const isActive = this.isActivePath(tree.name, child.name);
const attributes = {
class: isActive ? "is-active-page" : "",
href: L.url(url, child.name),
};
if (isActive) attributes["aria-current"] = "page";
ul.appendChild(E("li", {}, [E("a", attributes, [_(child.title)])]));
});
}
@ -515,17 +521,32 @@ return baseclass.extend({
const children = [list];
if (document.body?.dataset?.navType === "mega-menu") {
// Constant canvas: links fill top-to-bottom; capping at 4 columns
// keeps the canvas height stable until a submenu exceeds 24 items.
// Constant canvas: links fill top-to-bottom. Base column count is 4;
// more items grow the row count, not the width, so the list stays
// inside the three-column canvas middle track.
list.style.setProperty(
"--menu-rows",
Math.max(6, Math.ceil(submenu.length / 4)),
);
// data-section keys the first-level icon off the node name (stable,
// language-independent) — see _layout.css. Unmapped names fall back
// to the default icon via var(--menu-icon, …).
children.unshift(
E("div", { class: "desktop-nav-anchor" }, [
E("span", { class: "desktop-nav-title" }, [_(child.title)]),
E(
"span",
{ class: "desktop-nav-title", "data-section": child.name },
[_(child.title)],
),
]),
);
// Right column: clone the server-rendered device board into each
// panel so the grid can lay it out as the third track. The original
// stays hidden as the template (see _layout.css).
const board = document.querySelector(
".desktop-menu-container > .desktop-menu-board",
);
if (board) children.push(board.cloneNode(true));
}
nav = E("div", { class: "desktop-nav" }, children);
@ -570,7 +591,7 @@ return baseclass.extend({
let maxPanel = 0;
header
.querySelectorAll(".desktop-nav")
.forEach((nav) => (maxPanel = Math.max(maxPanel, nav.scrollHeight)));
.forEach((nav) => (maxPanel = Math.max(maxPanel, nav.offsetHeight)));
canvasHeight = headerHeight + maxPanel;
}
container?.style.setProperty("--mega-menu-height", `${canvasHeight}px`);

View File

@ -385,6 +385,30 @@ test("renders an active group expanded with active page semantics", () => {
assert.equal(activeLink.getAttribute("aria-current"), "page");
});
test("renders the active desktop submenu link with current-page semantics", () => {
const menu = loadMenuModule({
dispatchpath: ["admin", "network", "wireless"],
});
const list = menu.renderMainMenu(
{
name: "network",
children: {
interfaces: { title: "Interfaces" },
wireless: { title: "Wireless" },
},
},
"admin/network",
1,
);
const [inactiveItem, activeItem] = list.children;
const inactiveLink = inactiveItem.children[0];
const activeLink = activeItem.children[0];
assert.equal(inactiveLink.hasAttribute("aria-current"), false);
assert.equal(activeLink.getAttribute("class"), "is-active-page");
assert.equal(activeLink.getAttribute("aria-current"), "page");
});
test("renders an inactive group collapsed and inert", () => {
const menu = loadMenuModule();
const item = menu.renderNavigationItem(
@ -506,6 +530,13 @@ test("skips mega-menu initialization when the top menu is missing", () => {
assert.ok(result instanceof FakeElement);
});
test("measures mega-menu canvas from the viewport-bounded panel height", () => {
const initMegaMenu = getMethodSource("initMegaMenu");
assert.match(initMegaMenu, /nav\.offsetHeight/);
assert.doesNotMatch(initMegaMenu, /nav\.scrollHeight/);
});
test("skips boxed-dropdown initialization when the top menu is missing", () => {
const menu = loadMenuModule({
document: createFakeDocument({ navType: "boxed-dropdown" }),

View File

@ -157,3 +157,22 @@ test("mobile drawer styles only provide mobile navigation density", () => {
);
assert.doesNotMatch(drawer, /bg-brand-subtle/);
});
test("mega-menu panels scroll within the viewport", () => {
const megaMenu = getBlock(layoutStyles, '[data-nav-type="mega-menu"] &');
const panel = getBlock(megaMenu, "& .desktop-nav");
assert.ok(panel.includes("max-h-[calc(100dvh-3.5rem)]"));
assertIncludesUtilities(panel, ["overflow-y-auto", "overscroll-contain"]);
});
test("mega-menu category masks use Tailwind arbitrary utilities", () => {
const title = getBlock(layoutStyles, "& .desktop-nav-title");
const icon = getBlock(title, "&::before");
assert.match(
icon,
/@apply[^;]*\[mask:var\(--menu-icon,url\(["']@assets\/icons\/category\.svg["']\)\)_center\/contain_no-repeat\]/,
);
assert.doesNotMatch(layoutStyles, /^\s*mask\s*:/m);
});

View File

@ -70,6 +70,47 @@ test("maincontent cards use a hairline border, not heavy shadow", () => {
assert.ok(!rule.includes("shadow-lg"), `still shadow-lg: ${rule}`);
});
test("main view does not create an animation stacking context", () => {
const layout = read("../src/media/_layout.css");
const rule = layout.match(/#view\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
for (const utility of [
"animate-in",
"fade-in-0",
"slide-in-from-top-2",
"fill-mode-backwards",
"fill-mode-both",
]) {
assert.ok(!rule.includes(utility), `view still uses ${utility}: ${rule}`);
}
});
test("content dropdowns stay above the closed header and below the open mega-menu", () => {
const layer = (value) => ["z", value].join("-");
const layout = read("../src/media/_layout.css");
const dropdown = read("../src/media/components/_dropdown.css");
const message = read("../src/media/components/_message.css");
const overlay = read("../src/media/components/_overlay.css");
const headerRule = layout.match(/^header\s*\{\s*@apply\s+([^;]+);/m)?.[1] ?? "";
const activeHeaderRule =
layout.match(
/\[data-nav-type="mega-menu"\] &:has\(\.desktop-menu-container\.active\)\s*\{\s*@apply\s+([^;]+);/,
)?.[1] ?? "";
const dropdownRule = dropdown.match(/&\.dropdown\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
const messageRule = message.match(/\.alert-message\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
const overlayRule =
overlay.match(/& \.desktop-menu-overlay\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
assert.ok(messageRule.includes(layer(30)), `message layer changed: ${messageRule}`);
assert.ok(headerRule.includes(layer(40)), `closed header layer changed: ${headerRule}`);
assert.ok(dropdownRule.includes(layer(50)), `dropdown layer changed: ${dropdownRule}`);
assert.ok(overlayRule.includes(layer(60)), `menu overlay layer changed: ${overlayRule}`);
assert.ok(
activeHeaderRule.includes(layer(70)),
`open mega-menu layer changed: ${activeHeaderRule}`,
);
});
test("tables get a hairline frame + sunken header, body stays unclipped", () => {
const t = read("../src/media/components/_table.css");
const root = t.match(/table\.table,\s*\.table\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";

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:=0.12.10
PKG_RELEASE:=27
PKG_VERSION:=0.12.11
PKG_RELEASE:=28
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -213,7 +213,7 @@
trim(`${boardinfo.release?.distribution ?? ''} ${boardinfo.release?.version ?? ''}`);
-%}
<div class="desktop-menu-container">
<div class="desktop-menu-board" aria-hidden="true">
<div class="desktop-menu-board" aria-hidden="true"><span class="board-label">{{ _('Device') }}</span>
<span class="board-line board-line-host" title="{{ entityencode(striptags(boardinfo.hostname ?? '?'), true) }}"><span class="board-text">{{ striptags(boardinfo.hostname ?? '?') }}</span></span>
{% if (board_model): %}
<span class="board-line board-line-model" title="{{ entityencode(striptags(board_model), true) }}"><span class="board-text">{{ striptags(board_model) }}</span></span>