🏅 Sync 2026-06-15 22:09:15

This commit is contained in:
github-actions[bot] committed 2026-06-15 22:09:15 +08:00
1 parent fae6bd4c8a
commit d30e14c6e7
68 files changed
+2964 -918

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=autocore
PKG_FLAGS:=nonshared
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_CONFIG_DEPENDS:= \
CONFIG_TARGET_bcm27xx \
-5
View File
@@ -9,11 +9,6 @@ start() {
sysctl -w net.core.rps_sock_flow_entries="$(( rfc * threads ))"
for fileRps in /sys/class/net/eth*/queues/rx-*/rps_cpus
do
echo "$threads" > "$fileRps"
done
for fileRfc in /sys/class/net/eth*/queues/rx-*/rps_flow_cnt
do
echo "$rfc" > "$fileRfc"
+3 -2
View File
@@ -6,11 +6,11 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=cloudreve
PKG_VERSION:=4.16.1
PKG_RELEASE:=8
PKG_RELEASE:=9
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/cloudreve/Cloudreve.git
PKG_SOURCE_VERSION:=26b6b1044b0253c5ead7d5a90eaa10ff67ac2582
PKG_SOURCE_VERSION:=ba2e870bbd17f1918dd2321de861e453f696d6a3
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-3.0-only
@@ -23,6 +23,7 @@ PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
GO_PKG:=github.com/cloudreve/Cloudreve/v4
GO_PKG_EXCLUDES:=pkg/request/ssrftest
GO_PKG_LDFLAGS_X:= \
$(GO_PKG)/application/constants.BackendVersion=$(PKG_VERSION) \
$(GO_PKG)/application/constants.LastCommit=$(PKG_VERSION)
+3 -3
View File
@@ -3,10 +3,10 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=dae
PKG_VERSION:=2026.06.08
PKG_RELEASE:=5
PKG_VERSION:=2026.06.14
PKG_RELEASE:=6
PKG_SOURCE:=dae-src-$(PKG_VERSION).tar.gz
PKG_SOURCE:=dae-src-2026.06.14-fa89ea7197a5.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip
+3 -3
View File
@@ -3,10 +3,10 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=daed
PKG_VERSION:=2026.06.08
PKG_RELEASE:=7
PKG_VERSION:=2026.06.14
PKG_RELEASE:=8
PKG_SOURCE:=daed-src-$(PKG_VERSION).tar.gz
PKG_SOURCE:=daed-src-2026.06.14-4e215048068a.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede
PKG_VERSION:=1.12.0
PKG_RELEASE:=8
PKG_RELEASE:=9
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
@@ -27,6 +27,7 @@ const CSS = [
'.dd-up-icon{font-size:14px;text-align:center;line-height:1}',
'.dd-up-ok{color:#3da66a}',
'.dd-up-warn{color:#d39e00}',
'.dd-up-new{color:#4a8cff}',
'.dd-up-err{color:#d96d6d}',
'.dd-up-name{font-weight:600;opacity:.85}',
'.dd-up-meta{opacity:.7;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
@@ -101,17 +102,10 @@ function probePkg(pkg) {
});
}
function tailLog(path, into) {
return L.resolveDefault(fs.read_direct(path, 'text'), '').then(function(content) {
if (content) {
into.textContent = content;
into.classList.add('show');
}
});
}
return view.extend({
load: function() {
// background apk update so new versions show on next poll
fs.exec('/usr/share/luci-app-daede/refresh-index.sh', []).catch(function() {});
return Promise.all([
backend.detectBackend(),
uci.load('daed').catch(function() {})
@@ -145,35 +139,48 @@ return view.extend({
]);
};
// === Data updates (GeoIP / GeoSite) ===
const updateGeo = function(kind, btn) {
const label = kind === 'geoip' ? 'GeoIP' : 'GeoSite';
// 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) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
return fs.exec('/usr/share/luci-app-daede/update-geo.sh', [kind]).then(function(res) {
if (res.code === 0) {
setTimeout(function() { tailLog('/tmp/luci-app-daede.' + kind + '.log', logPane); }, 2000);
}
let tries = 0;
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 (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();
}).catch(function() {}).finally(function() {
btn.disabled = false;
btn.textContent = _('Update');
btn.textContent = orig;
});
};
// === 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');
};
// === Package updates (dae|daed / luci-app-daede) ===
const upgradePkg = function(pkg, btn) {
if (!confirm(_('Run "apk upgrade %s" now? This may restart the active backend.').format(pkg)))
if (!confirm(_('Upgrade %s now? This may restart the active backend.').format(pkg)))
return;
btn.disabled = true;
btn.textContent = '...';
return fs.exec('/usr/share/luci-app-daede/update-pkg.sh', [pkg]).then(function(res) {
if (res.code === 0) {
setTimeout(function() { tailLog('/tmp/luci-app-daede.pkg-' + pkg + '.log', logPane); }, 3000);
}
}).catch(function() {}).finally(function() {
btn.disabled = false;
btn.textContent = _('Upgrade');
});
return runJob('update-pkg.sh', pkg, btn,
'/tmp/luci-app-daede.pkg-' + pkg + '.log', pkg);
};
const refresh = function() {
@@ -256,8 +263,8 @@ return view.extend({
btn.disabled = true;
}
pkgBody.appendChild(mkRow(
updatable ? '⚠' : (entry.r.installed ? '✓' : '✗'),
updatable ? 'dd-up-warn' : (entry.r.installed ? 'dd-up-ok' : 'dd-up-err'),
updatable ? '↑' : (entry.r.installed ? '✓' : '✗'),
updatable ? 'dd-up-new' : (entry.r.installed ? 'dd-up-ok' : 'dd-up-err'),
entry.name,
meta,
btn
@@ -0,0 +1,20 @@
#!/bin/sh
# Background apk/opkg update for the Updates view. Returns at once; throttled.
LOCK=/tmp/luci-app-daede.idx.lock
if [ -f "$LOCK" ]; then
mtime=$(date -r "$LOCK" +%s 2>/dev/null || echo 0)
[ "$(( $(date +%s) - mtime ))" -lt 90 ] && exit 0
fi
: > "$LOCK"
(
if command -v apk >/dev/null 2>&1; then
apk update
elif command -v opkg >/dev/null 2>&1; then
opkg update
fi
) >/dev/null 2>&1 </dev/null &
exit 0
@@ -44,6 +44,7 @@ fi
exec >"$LOG" 2>&1
trap 'rm -f "$LOCK"' EXIT INT TERM
rc=0
TMP="${DEST}.new"
mkdir -p "$(dirname "$DEST")"
echo "$(date '+%F %T') begin: $URL"
@@ -51,18 +52,19 @@ fi
if ! curl -fsSL --connect-timeout 15 --max-time 240 -o "$TMP" "$URL"; then
echo "$(date '+%F %T') download failed"
rm -f "$TMP"
exit 1
rc=1
else
size=$(wc -c < "$TMP" 2>/dev/null || echo 0)
if [ "$size" -lt 102400 ]; then
echo "$(date '+%F %T') file too small ($size bytes)"
rm -f "$TMP"
rc=2
else
mv "$TMP" "$DEST"
echo "$(date '+%F %T') updated $DEST ($size bytes)"
fi
fi
size=$(wc -c < "$TMP" 2>/dev/null || echo 0)
if [ "$size" -lt 102400 ]; then
echo "$(date '+%F %T') file too small ($size bytes)"
rm -f "$TMP"
exit 2
fi
mv "$TMP" "$DEST"
echo "$(date '+%F %T') updated $DEST ($size bytes)"
echo "$(date '+%F %T') done (rc=$rc)"
) </dev/null >/dev/null 2>&1 &
echo "started in background, see $LOG"
@@ -40,10 +40,23 @@ fi
if command -v apk >/dev/null 2>&1; then
echo "--- apk update ---"
apk update 2>&1
echo "--- apk upgrade $PKG ---"
# --no-self-upgrade so apk itself does not jump versions mid-op
apk upgrade --no-self-upgrade "$PKG" 2>&1
rc=$?
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.
if ! apk list --installed 2>/dev/null | grep -q "^${PKG}-"; then
echo "result: $PKG is not installed"
rc=1
elif apk list -u 2>/dev/null | grep -q "^${PKG}-"; then
echo "result: $PKG still has a pending upgrade"
rc=1
else
echo "result: $PKG is at the latest available version"
rc=0
fi
elif command -v opkg >/dev/null 2>&1; then
echo "--- opkg update ---"
opkg update 2>&1
@@ -27,6 +27,7 @@
"/usr/share/luci-app-daede/pkg-info.sh dae": [ "exec" ],
"/usr/share/luci-app-daede/pkg-info.sh daed": [ "exec" ],
"/usr/share/luci-app-daede/pkg-info.sh luci-app-daede": [ "exec" ],
"/usr/share/luci-app-daede/refresh-index.sh": [ "exec" ],
"/usr/share/luci-app-daede/proxy-check.sh": [ "exec" ]
},
"ubus": {
@@ -53,9 +53,9 @@ const methods = {
}
const type = req.args?.type;
let cmd = `generate ${type}`;
let result = {};
let cmd = `generate ${type}`;
if (type === 'age-x25519') {
cmd = 'age keygen';
} else if (type === 'age-mlkem768-x25519') {
@@ -63,7 +63,8 @@ const methods = {
} else if (type === 'age-convert') {
cmd = 'age convert';
}
const fd = popen(`/usr/bin/mihomo ${cmd}` + (req.args?.params ? ' ' + shellQuote(req.args.params) : ''));
const fd = popen(`/usr/bin/mihomo ${cmd} ${shellQuote(req.args.params ?? '')}`);
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (type === 'uuid')
@@ -140,7 +141,7 @@ const methods = {
infd.write(content);
infd.seek();
const out = executeCommand(infd, '/usr/bin/mihomo age', req.args?.action, req.args?.key, '-', '-');
const out = executeCommand(infd, '/usr/bin/mihomo age', req.args?.action, shellQuote(req.args?.key), '-', '-');
infd.close();
return out.exitcode === 0 ? { result: out.stdout } : { result: false, error: out.stderr };
@@ -162,7 +163,7 @@ const methods = {
if ((!req.args?.output) || match(req.args?.output, /\.\.\//))
return { result: false, error: 'illegal output' };
const out = executeCommand(null, '/usr/bin/mihomo age', req.args?.action, req.args?.key, req.args?.input, req.args?.output);
const out = executeCommand(null, '/usr/bin/mihomo age', req.args?.action, shellQuote(req.args?.key), shellQuote(req.args?.input), shellQuote(req.args?.output));
return out.exitcode === 0 ? { result: true } : { result: false, error: out.stderr };
}
@@ -228,7 +229,7 @@ const methods = {
if (!req.args?.url)
return { httpcode: null, error: 'illegal url' };
const httpcode = trim(popen(`wget --spider -t1 -ST3 '${req.args?.url}' 2>&1 | awk '/^\\s*HTTP\\//{print $2}'`).read('all')) || '-1';
const httpcode = trim(popen(`wget --spider -t1 -ST3 ${shellQuote(req.args?.url)} 2>&1 | awk '/^\\s*HTTP\\//{print $2}'`).read('all')) || '-1';
return { httpcode: httpcode };
}
@@ -239,8 +240,10 @@ const methods = {
call: function(req) {
if (req.args?.type == 'resources') {
system(`sed -i "/${replace(EXE_DIR, "/", "\\/")}\\/update_resources.sh/d" /etc/crontabs/root`);
if (req.args?.expr)
if ((req.args?.expr && match(req.args?.expr, /^[0-9 ,\*\/\?-]+$/)))
system(`echo -e "` + req.args?.expr + ` ${EXE_DIR}/update_resources.sh ALL" >> /etc/crontabs/root`);
else
return { result: false, error: 'illegal expr' };
} else
return { result: false, error: 'illegal type' };
@@ -396,7 +399,7 @@ const methods = {
if ((!req.args?.filename) || match(req.args?.filename, /\.\.\//))
return { result: false, error: 'illegal filename' };
system(`rm -f ${HM_DIR}/${req.args?.type}/${req.args?.filename}`);
system(`rm -f ${HM_DIR}/${req.args?.type}/${shellQuote(req.args?.filename)}`);
return { result: true };
}
@@ -85,7 +85,7 @@ export function yqRead(flags, command, content) {
};
export function yqReadFile(flags, command, filepath) {
const out = executeCommand(null, 'yq', flags, shellQuote(command), filepath);
const out = executeCommand(null, 'yq', flags, shellQuote(command), shellQuote(filepath));
return out.stdout;
};
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.6.3
PKG_RELEASE:=53
PKG_RELEASE:=54
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -1518,11 +1518,6 @@ function gen_config(var)
end
end)
table.insert(rules, {
outboundTag = "direct",
ip = { "geoip:private" }
})
if default_outboundTag then
local rule = {
ruleTag = "default",
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wechatpush
PKG_VERSION:=3.6.12
PKG_RELEASE:=4
PKG_RELEASE:=5
PKG_MAINTAINER:=tty228 <tty228@yeah.net>
PKG_CONFIG_DEPENDS:= \
@@ -2293,8 +2293,8 @@ monitor_logins() {
trap cleanup_child SIGINT SIGTERM
(
# 监听系统日志,-f 表示跟随实时日志,-p 表示日志级别为 notice
run_with_tag logread -f -p notice | while IFS= read -r line; do
# 监听系统日志,-f 表示跟随实时日志
run_with_tag logread -f | while IFS= read -r line; do
[ -n "$web_logged" ] && {
web_login_ip=$(echo "$line" | grep -i "accepted login" | awk '{print $NF}')
[ -n "$web_login_ip" ] && process_login "$web_login_ip" $(echo "$line" | awk '{print $4}') web_login_counts
@@ -2317,7 +2317,7 @@ monitor_logins() {
# 如果未能提取到 IP,从日志标识符提取失败用户的 ID,并再次提取 IP
if [ -z "$ssh_failed_ip" ]; then
ssh_failed_num=$(echo "$line" | sed -n 's/.*authpriv\.warn dropbear\[\([0-9]\+\)\]: Login attempt for nonexistent user/\1/p')
[ -n "$ssh_failed_num" ] && ssh_failed_ip=$(logread notice | grep "authpriv\.info dropbear\[${ssh_failed_num}\].*Child connection from" | awk '{print $NF}' | sed -nr 's#^(.*):[0-9]{1,5}#\1#gp' | sed -e 's/%.*//' | tail -n 1)
[ -n "$ssh_failed_num" ] && ssh_failed_ip=$(logread | grep "authpriv\.info dropbear\[${ssh_failed_num}\].*Child connection from" | awk '{print $NF}' | sed -nr 's#^(.*):[0-9]{1,5}#\1#gp' | sed -e 's/%.*//' | tail -n 1)
fi
# 如果成功提取到 IP 地址,调用 process_login 处理
@@ -2578,13 +2578,13 @@ login_send() {
# 登录方式
if [[ "$log_type" == "web"* ]]; then
# Web 登录、非法登录
local login_mode=$(logread notice | grep -E ".* $login_time.*$login_ip.*" | awk '{print $13}' | tail -n 1)
local login_mode=$(logread | grep -E ".* $login_time.*$login_ip.*" | awk '{print $13}' | tail -n 1)
[ "$login_mode" = "/" ] && login_mode="$(translate "/ (Homepage login)")"
elif [[ "$log_type" == "ssh_login"* ]]; then
# SSH 登录
local login_mode=$(logread notice | grep -E ".* $login_time.*$login_ip.*" | awk '{print $8}' | tail -n 1)
local login_mode=$(logread | grep -E ".* $login_time.*$login_ip.*" | awk '{print $8}' | tail -n 1)
else
local login_mode=$(logread notice | grep -E ".* $login_time.*$login_ip.*" | awk '{for(i=8;i<NF;i++) if($i=="from") break; else printf $i " "}' | tail -n 1)
local login_mode=$(logread | grep -E ".* $login_time.*$login_ip.*" | awk '{for(i=8;i<NF;i++) if($i=="from") break; else printf $i " "}' | tail -n 1)
fi
if [ -z "$login_disturb" ] || [ "$login_disturb" -ne "1" ]; then
+38 -8
View File
@@ -87,7 +87,7 @@ This will be compiled to standard CSS that works in all browsers.
The theme has two independent Tailwind CSS v4 entry points, both sourced from `.dev/src/media/`:
- **`main.css`** — the LuCI admin UI. It contains no rules of its own; it's a pure import manifest that pulls in (in order) `_tokens.css` (OKLCH theme tokens, mapped via `@theme inline`), `_base.css`, `_layout.css`, every file in `components/` (one partial per UI component — buttons, cards, modals, tables, etc.), `_utilities.css`, and `_patches.css`.
- **`main.css`** — the LuCI admin UI. It contains no rules of its own; it's a pure import manifest that pulls in (in order) `_tokens.css` (OKLCH theme tokens, mapped via `@theme inline`), `_base.css`, `_elements.css`, `_layout.css`, every file in `components/` (one partial per UI component — buttons, cards, modals, tables, etc.), `_utilities.css`, and `_patches.css`.
- **`login.css`** — the standalone login page (`sysauth.ut`). Self-contained: imports Tailwind and `_tokens.css` directly.
**Adding new styles:**
@@ -97,6 +97,21 @@ The theme has two independent Tailwind CSS v4 entry points, both sourced from `.
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties.
### Design Tokens
`src/media/_tokens.css` is **generated** — its header says "DO NOT EDIT". The source of truth is `.dev/tokens/`:
- **`defaults.js`** — the 10 editable input colors (`bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger`) for light and dark mode, as OKLCH strings.
- **`spec.js`** — `DERIVATIONS` (how every other token — `text_muted`, `surface_sunken`, `hairline`, `brand_hover`, `brand_subtle`, `focus_ring`, `progress_start`/`progress_end`, `*_surface`, `scrim`, `mega_menu_bg`, …) is computed from the inputs via `mix`/`shade`/`set`/`alpha`/`const` operators, and `FIXED` (mode-specific literals such as shadows that bypass derivation).
- **`engine.js`** — the OKLCH/OKLAB color math behind those operators, via [colorjs.io](https://colorjs.io/).
- **`resolve.js`** — `resolveMode(mode)` walks `DERIVATIONS` and returns a flat `{token: oklchString}` map with no `color-mix()`/`var()` left in it.
**Changing a color:**
1. Edit `tokens/defaults.js` (base input colors) and/or `tokens/spec.js` (derivation rules, fixed literals).
2. Run `pnpm gen:tokens` (also runs automatically as part of `pnpm build`) to rewrite `src/media/_tokens.css` — it emits `:root` (light) and `[data-darkmode="true"]` (dark) blocks plus the `@theme inline` mapping, in that order.
3. Run `pnpm test` to check the color-math operators and derived-token invariants (`tests/engine.test.js`, `tests/resolve.test.js`, `tests/surfaces.test.js`) — e.g. hue families, lightness ordering between `bg`/`surface_sunken`/`surface`, and translucency of menu backgrounds.
### LuCI JavaScript API
For LuCI-specific JavaScript development, refer to the official API documentation:
@@ -174,10 +189,11 @@ htdocs/luci-static/
**Build Process:**
1. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`)
2. Custom PostCSS plugin removes `@layer` at-rules for OpenWrt compatibility
3. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser
4. Static assets copied from `.dev/public/aurora/`
1. `pnpm gen:tokens` regenerates `src/media/_tokens.css` from `tokens/` (see [Design Tokens](#design-tokens))
2. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`)
3. Custom PostCSS plugin removes `@layer` at-rules for OpenWrt compatibility
4. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser
5. Static assets copied from `.dev/public/aurora/`
## Package Compilation
@@ -219,20 +235,33 @@ luci-theme-aurora/
│ │ ├── fonts/ # Web fonts (Lato)
│ │ └── images/ # Theme images + PWA icons
│ ├── scripts/ # Build scripts
│ │ └── clean.js # Build cleanup utility
│ │ ├── clean.js # Build cleanup utility
│ │ └── gen-tokens.js # Regenerates src/media/_tokens.css from tokens/
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
│ │ ├── media/ # CSS source (Tailwind CSS v4)
│ │ │ ├── main.css # Admin UI entry point (import manifest)
│ │ │ ├── login.css # Login page entry point
│ │ │ ├── _tokens.css # OKLCH theme tokens (@theme inline)
│ │ │ ├── _base.css # Base element styles
│ │ │ ├── _tokens.css # OKLCH theme tokens -- GENERATED, see tokens/
│ │ │ ├── _base.css # Document foundation (html/body viewport bg)
│ │ │ ├── _elements.css # Base element styles (headings, links, …)
│ │ │ ├── _layout.css # Page layout/structure
│ │ │ ├── _utilities.css # Custom utility classes
│ │ │ ├── _patches.css # Third-party LuCI app/page overrides
│ │ │ └── components/ # One partial per UI component
│ │ └── resource/ # JavaScript resources
│ │ └── menu-aurora.js # Menu logic
│ ├── tokens/ # Design token source (-> src/media/_tokens.css)
│ │ ├── defaults.js # 10 editable input colors (light/dark)
│ │ ├── spec.js # Derivation rules (DERIVATIONS) + fixed literals
│ │ ├── engine.js # OKLCH/OKLAB color math (mix/shade/set/alpha)
│ │ └── resolve.js # Resolves spec into a flat token map
│ ├── tests/ # All test suites (pnpm test)
│ │ ├── engine.test.js # Color-math operators
│ │ ├── resolve.test.js # Resolved token invariants
│ │ ├── surfaces.test.js # Surface/hue layering invariants
│ │ ├── overlay.test.js # Overlay/layout CSS assertions
│ │ └── navigation-*.test.js # Navigation model/rendering/styles
│ ├── .env.example # Environment variables template
│ ├── .prettierrc # Prettier configuration
│ ├── package.json # Node.js dependencies
@@ -270,6 +299,7 @@ luci-theme-aurora/
- **[Vite](https://vitejs.dev/)** - Build tool and development server
- **[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager
- **[lightningcss](https://lightningcss.dev/)** - CSS minifier
- **[colorjs.io](https://colorjs.io/)** - OKLCH/OKLAB color math for design token generation (`.dev/tokens/`)
- **[Terser](https://terser.org/)** - JavaScript minifier
- **[Prettier](https://prettier.io/)** - Code formatter
- **[prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)** - Tailwind class sorting
+7 -2
View File
@@ -4,12 +4,17 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run clean && vite build",
"clean": "node scripts/clean.js"
"build": "npm run clean && npm run gen:tokens && vite build",
"clean": "node scripts/clean.js",
"gen:tokens": "node scripts/gen-tokens.js",
"test": "node --test tests/*.test.js"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.0",
"browserslist": "^4.28.2",
"colorjs.io": "^0.6.1",
"lightningcss": "^1.32.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.0",
"tailwind-scrollbar": "^4.0.2",
+198 -6
View File
@@ -7,13 +7,23 @@ settings:
importers:
.:
dependencies:
colorjs.io:
specifier: ^0.6.1
version: 0.6.1
devDependencies:
'@tailwindcss/vite':
specifier: ^4.1.11
version: 4.1.18(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))
version: 4.1.18(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0))
'@types/node':
specifier: ^24.0.0
version: 24.10.9
browserslist:
specifier: ^4.28.2
version: 4.28.2
lightningcss:
specifier: ^1.32.0
version: 1.32.0
prettier:
specifier: ^3.6.2
version: 3.8.1
@@ -34,7 +44,7 @@ importers:
version: 1.4.0
vite:
specifier: ^7.1.1
version: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)
version: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)
packages:
@@ -459,13 +469,29 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
baseline-browser-mapping@2.10.37:
resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==}
engines: {node: '>=6.0.0'}
hasBin: true
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
colorjs.io@0.6.1:
resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -473,6 +499,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
electron-to-chromium@1.5.372:
resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
enhanced-resolve@5.18.4:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
@@ -482,6 +511,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -509,30 +542,60 @@ packages:
cpu: [arm64]
os: [android]
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.30.2:
resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.2:
resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.2:
resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.2:
resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.2:
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
engines: {node: '>= 12.0.0'}
@@ -540,6 +603,13 @@ packages:
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
@@ -547,6 +617,13 @@ packages:
os: [linux]
libc: [musl]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
@@ -554,6 +631,13 @@ packages:
os: [linux]
libc: [glibc]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
@@ -561,22 +645,45 @@ packages:
os: [linux]
libc: [musl]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.2:
resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.2:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -585,6 +692,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
node-releases@2.0.47:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -709,6 +820,12 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -989,12 +1106,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))':
'@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0))':
dependencies:
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18
vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)
vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)
'@types/estree@1.0.8': {}
@@ -1006,14 +1123,30 @@ snapshots:
acorn@8.15.0: {}
baseline-browser-mapping@2.10.37: {}
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.37
caniuse-lite: 1.0.30001799
electron-to-chromium: 1.5.372
node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-from@1.1.2: {}
caniuse-lite@1.0.30001799: {}
clsx@2.1.1: {}
colorjs.io@0.6.1: {}
commander@2.20.3: {}
detect-libc@2.1.2: {}
electron-to-chromium@1.5.372: {}
enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
@@ -1048,6 +1181,8 @@ snapshots:
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
escalade@3.2.0: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -1062,36 +1197,69 @@ snapshots:
lightningcss-android-arm64@1.30.2:
optional: true
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.30.2:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-x64@1.30.2:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.30.2:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.2:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.30.2:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.30.2:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.30.2:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.30.2:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.30.2:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.30.2:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss@1.30.2:
dependencies:
detect-libc: 2.1.2
@@ -1108,12 +1276,30 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.11: {}
node-releases@2.0.47: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -1205,7 +1391,13 @@ snapshots:
undici-types@7.16.0: {}
vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0):
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.1
vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@@ -1217,5 +1409,5 @@ snapshots:
'@types/node': 24.10.9
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
lightningcss: 1.32.0
terser: 5.46.0
@@ -0,0 +1,80 @@
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { resolveMode } from "../tokens/resolve.js";
import { FIXED } from "../tokens/spec.js";
const kebab = (s) => s.replace(/_/g, "-");
function block(selector, colors, fixed) {
const lines = [];
for (const [k, v] of Object.entries(colors)) {
lines.push(` --${kebab(k)}: ${v};`);
}
for (const [k, v] of Object.entries(fixed)) {
lines.push(` --${kebab(k)}: ${v};`);
}
return `${selector} {\n${lines.join("\n")}\n`;
}
const light = resolveMode("light");
const dark = resolveMode("dark");
const STRUCTURE = `
--font-sans: "Lato", ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
--spacing: 0.25rem;
--container-max-width: 80rem;
--radius-base: 0.5rem;
`;
const THEME = `@theme inline {
${Object.keys(light)
.map((k) => ` --color-${kebab(k)}: var(--${kebab(k)});`)
.join("\n")}
--shadow-sm: var(--app-shadow-sm);
--shadow-md: var(--app-shadow-md);
--shadow-lg: var(--app-shadow-md);
--shadow-xl: var(--app-shadow-lg);
--shadow-2xl: var(--app-shadow-lg);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--spacing: var(--spacing);
--container-max-width: var(--container-max-width);
--radius-sm: calc(var(--radius-base) * 0.25);
--radius: calc(var(--radius-base) * 0.5);
--radius-md: calc(var(--radius-base) * 0.75);
--radius-lg: var(--radius-base);
--radius-xl: calc(var(--radius-base) * 1.5);
--radius-2xl: calc(var(--radius-base) * 2);
--radius-3xl: calc(var(--radius-base) * 3);
--radius-4xl: calc(var(--radius-base) * 4);
}
`;
const HEADER = `/**
* luci-theme-aurora: design tokens -- GENERATED, DO NOT EDIT.
* Run \`pnpm gen:tokens\`. Source: tokens/spec.js + tokens/defaults.js
* All color values are flat; lightningcss adds legacy fallbacks.
* ORDER MATTERS: [data-darkmode="true"] must stay after :root.
*/
`;
const css =
HEADER +
"\n" +
block(":root", light, FIXED.light) +
STRUCTURE +
"}\n\n" +
block('[data-darkmode="true"]', dark, FIXED.dark) +
"}\n\n" +
THEME;
await writeFile(
resolve(import.meta.dirname, "../src/media/_tokens.css"),
css,
"utf-8",
);
console.log("gen-tokens: wrote src/media/_tokens.css");
+7 -83
View File
@@ -1,88 +1,12 @@
*:not(html) {
@apply scrollbar-thumb-rounded-full scrollbar-thin scrollbar-track-transparent scrollbar-thumb-content-muted;
}
/**
* Document foundation — shared by main.css and login.css.
* Solid bg on html/body — one paint path with header (no body::before;
* fixed pseudo made the same var(--bg) look mismatched).
*/
html {
@apply scrollbar-thumb-rounded-full scrollbar-thin scrollbar-track-surface scrollbar-thumb-content-muted;
@apply bg-canvas relative h-full font-sans;
@apply bg-bg min-h-dvh font-sans;
body {
@apply text-content relative flex flex-col bg-transparent text-sm leading-relaxed font-normal;
&.modal-overlay-active {
@apply h-screen overflow-hidden;
}
@apply bg-bg min-h-dvh;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-content font-sans leading-tight font-semibold tracking-tight;
}
h1 {
@apply mb-4 pl-6 text-3xl max-md:pl-4 max-md:text-2xl;
}
h2 {
@apply mb-4 pl-5 text-2xl max-md:mb-2 max-md:pl-4 max-md:text-xl;
}
h3 {
@apply mb-4 text-xl font-semibold tracking-tight max-md:mb-2 max-md:text-lg;
}
h4 {
@apply mb-2 text-lg max-md:text-base;
}
h5 {
@apply mb-2 text-base max-md:text-sm;
}
h6 {
@apply mb-2 text-sm max-md:text-xs;
}
hr {
@apply border-0;
}
strong {
@apply font-semibold tracking-tight;
}
abbr {
@apply cursor-help;
}
p {
@apply text-content text-sm leading-relaxed;
}
pre {
@apply text-content-muted border-border-subtle bg-surface-muted block rounded-xl border px-3 py-2 font-mono text-sm leading-relaxed wrap-break-word whitespace-pre-wrap;
}
code {
@apply text-content-muted bg-surface-muted rounded px-2 py-1 font-mono text-sm;
}
a {
@apply text-link no-underline hover:underline;
}
var {
@apply text-link font-mono;
}
small {
@apply text-content text-xs font-normal;
}
em {
@apply text-content-muted font-medium italic;
}
@@ -0,0 +1,88 @@
*:not(html) {
@apply scrollbar-thumb-rounded-full scrollbar-thin scrollbar-track-transparent scrollbar-thumb-text-muted;
}
html {
@apply scrollbar-thumb-rounded-full scrollbar-thin scrollbar-track-surface scrollbar-thumb-text-muted;
body {
@apply text-text relative flex min-h-dvh flex-col text-sm leading-relaxed font-normal;
&.modal-overlay-active {
@apply h-screen overflow-hidden;
}
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-text font-sans leading-tight font-semibold tracking-tight;
}
h1 {
@apply mb-4 pl-6 text-3xl max-md:pl-4 max-md:text-2xl;
}
h2 {
@apply mb-4 pl-5 text-2xl max-md:mb-2 max-md:pl-4 max-md:text-xl;
}
h3 {
@apply mb-4 text-xl font-semibold tracking-tight max-md:mb-2 max-md:text-lg;
}
h4 {
@apply mb-2 text-lg max-md:text-base;
}
h5 {
@apply mb-2 text-base max-md:text-sm;
}
h6 {
@apply mb-2 text-sm max-md:text-xs;
}
hr {
@apply border-0;
}
strong {
@apply font-semibold tracking-tight;
}
abbr {
@apply cursor-help;
}
p {
@apply text-text text-sm leading-relaxed;
}
pre {
@apply text-text-muted border-hairline bg-surface-sunken block rounded-xl border px-3 py-2 font-mono text-sm leading-relaxed wrap-break-word whitespace-pre-wrap;
}
code {
@apply text-text-muted bg-surface-sunken rounded px-2 py-1 font-mono text-sm;
}
a {
@apply text-link no-underline hover:underline;
}
var {
@apply text-link font-mono;
}
small {
@apply text-text text-xs font-normal;
}
em {
@apply text-text-muted font-medium italic;
}
+38 -59
View File
@@ -1,5 +1,5 @@
header {
@apply bg-header-bg sticky top-0 z-60 mb-2;
@apply bg-bg sticky top-0 z-60 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;
@@ -7,7 +7,7 @@ header {
[data-nav-type="mega-menu"] & {
& .desktop-menu-container {
@apply bg-header-glass-bg pointer-events-none absolute inset-x-0 top-0 z-30 h-(--mega-menu-height,0) w-full overflow-hidden opacity-0 backdrop-blur-xl transition-[clip-path,opacity] duration-200 ease-out [will-change:clip-path,opacity] [clip-path:inset(0_0_100%_0)] max-md:hidden;
@apply border-hairline bg-mega-menu-bg pointer-events-none absolute inset-x-0 top-0 z-30 h-(--mega-menu-height,0) w-full overflow-hidden border-b opacity-0 shadow-xl backdrop-blur-lg backdrop-saturate-150 transition-[clip-path,opacity] duration-[250ms] ease-out [will-change:clip-path,opacity] [clip-path:inset(0_0_100%_0)] max-md:hidden;
&.active {
@apply pointer-events-auto opacity-100 [clip-path:inset(0)];
@@ -22,7 +22,7 @@ header {
fixed slot left of them. The entering panel floats up 6px —
transform + opacity only. */
& .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-200 ease-out;
@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;
&.active {
@apply pointer-events-auto translate-y-0 opacity-100;
@@ -32,7 +32,7 @@ header {
@apply absolute top-7 left-[calc(50%-30rem)] w-56 max-lg:hidden;
& .desktop-nav-title {
@apply text-content text-2xl font-semibold tracking-tight;
@apply text-text text-2xl font-semibold tracking-tight;
}
}
@@ -48,7 +48,7 @@ header {
@apply m-0 list-none;
& > a {
@apply text-content block w-fit py-2.5 text-sm whitespace-nowrap no-underline hover:underline hover:decoration-2 hover:underline-offset-4;
@apply text-text block w-fit py-2.5 text-sm whitespace-nowrap no-underline hover:underline hover:decoration-2 hover:underline-offset-4;
}
}
}
@@ -64,7 +64,7 @@ header {
@apply absolute top-34 left-[calc(50%-30rem)] flex w-56 flex-col gap-2 max-lg:hidden;
& .board-line {
@apply text-content-muted flex max-w-full items-center gap-2.5 text-xs;
@apply text-text-muted flex max-w-full items-center gap-2.5 text-xs;
&::before {
@apply size-4 shrink-0 bg-current content-[''];
@@ -95,7 +95,7 @@ header {
}
.brand {
@apply hover:text-brand text-content inline-block shrink-0 text-xl font-semibold tracking-tight no-underline transition-[color,transform] duration-200 hover:-translate-y-0.5 max-md:flex-1 max-md:text-lg;
@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;
}
.nav {
@@ -105,15 +105,15 @@ header {
@apply relative;
.menu {
@apply text-content block rounded-xl px-3.5 py-1.5 font-medium no-underline transition-colors duration-150;
@apply text-text block rounded-xl px-3.5 py-1.5 font-medium no-underline transition-colors duration-150;
&.menu-active {
@apply text-content;
@apply text-text;
[data-nav-type="mega-menu"] & {
@apply underline decoration-2 underline-offset-4;
}
[data-nav-type="boxed-dropdown"] & {
@apply bg-header-interactive-bg;
@apply bg-brand-subtle text-brand;
}
}
}
@@ -131,16 +131,16 @@ header {
& .desktop-nav-list {
[data-nav-type="boxed-dropdown"] & {
@apply border-border-subtle bg-header-glass-bg flex flex-col gap-y-1 rounded-3xl border py-2 shadow-lg backdrop-blur-md backdrop-saturate-150;
@apply border-hairline bg-surface-overlay flex flex-col gap-y-1 rounded-3xl border py-2 shadow-lg backdrop-blur-md backdrop-saturate-150;
}
& > li {
@apply m-0 list-none;
& > a {
@apply text-content block whitespace-nowrap no-underline;
@apply text-text block whitespace-nowrap no-underline;
[data-nav-type="boxed-dropdown"] & {
@apply hover:bg-header-interactive-bg mx-2 rounded-xl px-4 py-2;
@apply hover:bg-hover-faint mx-2 rounded-xl px-4 py-2;
}
}
}
@@ -157,15 +157,15 @@ header {
}
& .navigation-toggle {
@apply cursor-pointer rounded-none border-0 bg-transparent p-0 shadow-none transition-transform duration-200 hover:scale-105 active:scale-95;
@apply cursor-pointer rounded-none border-0 bg-transparent p-0 shadow-none transition-transform duration-150 hover:scale-105 active:scale-95;
& svg {
@apply text-content size-5;
@apply text-text size-5;
}
& .navigation-toggle-line {
transform-box: fill-box;
@apply origin-center transition-[transform,opacity] duration-300 ease-in-out;
@apply origin-center transition-[transform,opacity] duration-150 ease-in-out;
}
&.is-expanded {
@@ -189,7 +189,7 @@ header {
@apply flex shrink-0 flex-row-reverse gap-5 md:gap-8;
& span[data-indicator] {
@apply before:text-content size-5 cursor-pointer text-[0px] before:absolute before:size-5 before:bg-current;
@apply before:text-text size-5 cursor-pointer text-[0px] before:absolute before:size-5 before:bg-current;
&[data-indicator="media_error"] {
@apply before:[mask:url('@assets/icons/error.svg')_center/cover_no-repeat];
@@ -223,14 +223,14 @@ body[data-nav-type="sidebar"] {
so collapsing the sidebar never stretches it — lets the sidebar column
animate to zero, and lets #maincontent fluidly reclaim the freed space.
overflow-x-clip keeps the sliding sidebar out of horizontal overflow. */
@apply md:grid md:grid-cols-[17rem_minmax(0,1fr)] md:grid-rows-[auto_minmax(0,1fr)] md:overflow-x-clip md:transition-[grid-template-columns] md:duration-300 md:ease-out;
@apply md:grid md:grid-cols-[17rem_minmax(0,1fr)] md:grid-rows-[auto_minmax(0,1fr)] md:overflow-x-clip md:transition-[grid-template-columns] md:duration-[250ms] md:ease-out;
&.sidebar-collapsed {
@apply md:grid-cols-[0_minmax(0,1fr)];
}
& > header {
@apply md:border-border-subtle md:col-span-full md:row-start-1 md:mb-0 md:border-b;
@apply md:border-hairline md:col-span-full md:row-start-1 md:mb-0 md:border-b;
}
& > .sidebar-panel {
@@ -242,7 +242,7 @@ body[data-nav-type="sidebar"] {
}
& .header-crumb {
@apply text-content-muted my-0 mr-auto ml-6 hidden min-w-0 list-none items-center gap-2 overflow-hidden p-0 text-sm md:flex;
@apply text-text-muted my-0 mr-auto ml-6 hidden min-w-0 list-none items-center gap-2 overflow-hidden p-0 text-sm md:flex;
& li {
@apply m-0 list-none whitespace-nowrap;
@@ -253,12 +253,12 @@ body[data-nav-type="sidebar"] {
}
& .current {
@apply text-content min-w-0 truncate font-medium;
@apply text-text min-w-0 truncate font-medium;
}
}
& .sidebar-panel {
@apply border-border-subtle sticky top-14 hidden h-[calc(100vh-3.5rem)] w-full overflow-hidden rounded-none border-r transition-[visibility] duration-300 md:block;
@apply border-hairline sticky top-14 hidden h-[calc(100vh-3.5rem)] w-full overflow-hidden rounded-none border-r transition-[visibility] duration-[250ms] md:block;
}
/* Visibility flips at the end of the slide so collapsed controls leave the
@@ -268,7 +268,7 @@ body[data-nav-type="sidebar"] {
}
& .sidebar-panel-inner {
@apply bg-header-bg flex h-full w-68 flex-col overflow-hidden transition-[translate,opacity] duration-300;
@apply bg-bg flex h-full w-68 flex-col overflow-hidden transition-[translate,opacity] duration-[250ms];
}
&.sidebar-collapsed .sidebar-panel-inner {
@@ -283,46 +283,25 @@ body[data-nav-type="sidebar"] {
}
}
& .sidebar-list .nav-link {
& .sidebar-list .navigation-direct {
@apply truncate text-lg;
}
/* Selected-pill background for top-level leaf links. The shared
.nav-link-active carries only brand text, so the sidebar owns its pill
here (submenu links get theirs below). */
& .sidebar-list > li > .nav-link-active {
@apply bg-brand-faint hover:bg-brand-faint;
}
& .sidebar-section {
@apply grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-200 ease-out;
}
& .sidebar-group-open > .sidebar-section {
@apply grid-rows-[1fr] opacity-100;
}
/* Child links sit indented under their category — hierarchy comes from
spacing alone, no guide line, for a calmer look. */
& .sidebar-submenu {
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-3;
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-4;
}
& .sidebar-submenu .nav-link {
@apply text-content-muted hover:text-content rounded-lg px-3 py-1.5 text-sm;
}
& .sidebar-submenu .nav-link-active {
@apply bg-brand-faint text-brand hover:text-brand;
& .sidebar-submenu .navigation-sublink {
@apply px-3 py-1.5 text-sm;
}
& .sidebar-footer {
@apply border-border-subtle shrink-0 border-t p-3;
@apply border-hairline shrink-0 border-t p-3;
/* Logout: muted at rest; on hover the base .nav-link pill background
plus error-colored text is the intended compound state (matches v1). */
& .nav-link {
@apply text-content-muted hover:text-danger-content;
@apply text-text-muted hover:text-danger;
}
}
}
@@ -331,7 +310,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 md:border-border-faint md:bg-surface mx-0 w-full p-0 shadow-none empty:hidden md:rounded-4xl md:border md:p-6 md:shadow-xl;
@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;
.cbi-title-section {
@apply mb-6 leading-relaxed max-md:mx-2 max-md:mb-3;
@@ -365,7 +344,7 @@ body[data-nav-type="sidebar"] {
@apply dark:!bg-surface;
& line[style] {
@apply dark:!stroke-content;
@apply dark:!stroke-text;
}
}
}
@@ -373,18 +352,18 @@ body[data-nav-type="sidebar"] {
.cbi-map-descr,
.cbi-section-descr {
@apply text-content-muted mb-6 text-sm leading-relaxed max-md:mx-2 max-md:mb-3 max-md:text-sm;
@apply text-text-muted mb-6 text-sm leading-relaxed max-md:mx-2 max-md:mb-3 max-md:text-sm;
}
.cbi-page-actions {
@apply border-border-subtle mt-6 flex flex-wrap items-center justify-end gap-3 border-t pt-4 max-md:mx-2 max-md:mt-4 max-md:gap-2 max-md:pt-3;
@apply border-hairline mt-6 flex flex-wrap items-center justify-end gap-3 border-t pt-4 max-md:mx-2 max-md:mt-4 max-md:gap-2 max-md:pt-3;
}
.zone-forwards {
@apply flex items-start gap-3 leading-relaxed;
& > span {
@apply text-content-muted mt-1.5;
@apply text-text-muted mt-1.5;
}
.zone-dest {
@@ -398,12 +377,12 @@ body[data-nav-type="sidebar"] {
}
#syslog {
@apply bg-terminal-bg text-terminal-content rounded-3xl border p-6 shadow-lg max-md:p-3;
@apply bg-surface-sunken text-text rounded-3xl border p-6 shadow-lg max-md:p-3;
}
}
footer {
@apply text-content-muted mt-2 flex min-h-16 flex-wrap items-center justify-between text-xs;
@apply text-text-muted mt-2 flex min-h-16 flex-wrap items-center justify-between text-xs;
& a {
@apply text-brand;
@@ -414,13 +393,13 @@ footer {
}
& .breadcrumb {
@apply bg-glass-surface hover:bg-surface flex items-center gap-1 rounded-full px-3 py-1.5 text-xs shadow-sm backdrop-blur-sm transition-colors duration-200;
@apply bg-surface-overlay hover:bg-surface flex items-center gap-1 rounded-full px-3 py-1.5 text-xs shadow-sm backdrop-blur-md backdrop-saturate-150 transition-colors duration-150;
& li {
@apply list-none;
&.active a {
@apply text-content;
@apply text-text;
}
}
}
@@ -52,17 +52,17 @@
&.tr {
@apply rounded-none;
}
@apply bg-glass-raised not-[.tr]:border-border-faint not-[.tr]:hover:border-border-subtle max-md:bg-glass-raised not-[.tr]:max-md:border-border-faint not-[.tr]:border not-[.tr]:shadow-lg not-[.tr]:hover:shadow-xl not-[.tr]:max-md:border;
@apply bg-surface-overlay not-[.tr]:border-hairline not-[.tr]:hover:border-hairline max-md:bg-surface-overlay not-[.tr]:max-md:border-hairline not-[.tr]:border not-[.tr]:shadow-lg not-[.tr]:hover:shadow-xl not-[.tr]:max-md:border;
/* Dark mode: override custom.css hardcoded light-mode colors on all text nodes */
& span {
@apply dark:text-content!;
@apply dark:text-text!;
}
& td {
@apply dark:text-content!;
@apply dark:text-text!;
}
& th {
@apply dark:text-content!;
@apply dark:text-text!;
}
img[src*=".svg"] {
@@ -94,7 +94,7 @@
.title {
h3 {
@apply text-content border-0 pb-4 max-md:mx-0 max-md:pb-2;
@apply text-text border-0 pb-4 max-md:mx-0 max-md:pb-2;
}
}
+83 -204
View File
@@ -1,122 +1,44 @@
/**
* luci-theme-aurora: design tokens
* Copyright 2025 eamonxg <eamonxiong@gmail.com>
* Licensed to the public under the Apache License 2.0.
*
* Shared between main.css and login.css.
*
* Layer 1 contains broad theme inputs. Layer 2 names semantic and component
* responsibilities. Components consume named roles and never apply numeric
* color opacity modifiers.
*
* ORDER MATTERS: [data-darkmode="true"] ties with :root on specificity, so
* the dark block must remain after :root.
* luci-theme-aurora: design tokens -- GENERATED, DO NOT EDIT.
* Run `pnpm gen:tokens`. Source: tokens/spec.js + tokens/defaults.js
* All color values are flat; lightningcss adds legacy fallbacks.
* ORDER MATTERS: [data-darkmode="true"] must stay after :root.
*/
:root {
/* Layer 1: theme inputs */
--canvas: oklch(0.968 0.007 247.896);
--bg: oklch(0.967 0.003 264);
--surface: oklch(1 0 0);
--surface-raised: oklch(1 0 0);
--content: oklch(0.208 0.042 265.755);
--text: oklch(0.21 0.02 264);
--brand: oklch(0.68 0.11 233);
--on-brand: oklch(1 0 0);
--link: oklch(0.74 0.238 322.16);
--info: oklch(0.35 0.08 240);
--info: oklch(0.43 0.2 255);
--warning: oklch(0.35 0.08 60);
--success: oklch(0.32 0.09 165);
--danger: oklch(0.35 0.12 25);
--text-muted: oklch(49.77% 0.0135 264);
--text-subtle: oklch(60.36% 0.0112 264);
--surface-sunken: oklch(95.7% 0.003 264);
--surface-overlay: oklch(98.3% 0.003 264);
--hairline: oklch(21% 0.02 264 / 0.08);
--hover-faint: oklch(92.7% 0.003 264);
--brand-hover: oklch(62% 0.11 233);
--brand-subtle: oklch(93.26% 0.0155 238);
--brand-subtle-hover: oklch(89.26% 0.0155 238);
--focus-ring: oklch(68% 0.11 233 / 0.6);
--progress-start: oklch(77.7% 0.0724 233.4);
--progress-end: oklch(0.68 0.11 233);
--info-surface: oklch(94% 0.05 255);
--warning-surface: oklch(95% 0.05 60);
--success-surface: oklch(94% 0.05 165);
--danger-surface: oklch(94% 0.05 25);
--danger-surface-hover: oklch(90% 0.05 25);
--scrim: oklch(0% 0 0 / 0.6);
--mega-menu-bg: oklch(98.3% 0.003 264 / 0.66);
--app-shadow-sm: 0 1px 3px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04);
--app-shadow-md: 0 4px 16px oklch(0 0 0 / 0.08), 0 1px 3px oklch(0 0 0 / 0.04);
--app-shadow-lg: 0 12px 32px oklch(0 0 0 / 0.12);
/* Layer 2: semantic surface and content roles */
--surface-subtle: oklch(from var(--surface-raised) calc(l - 0.02) c h);
--surface-muted: oklch(from var(--surface-raised) calc(l - 0.05) c h);
--content-muted: oklch(0.446 0.043 257.281);
--content-subtle: color-mix(in oklab, var(--content) 52%, var(--surface-raised));
/* Layer 2: semantic brand roles */
--brand-faint: color-mix(in oklab, var(--brand) 8%, var(--surface-raised));
--brand-soft: color-mix(in oklab, var(--brand) 15%, var(--surface-raised));
--brand-emphasis: color-mix(in oklab, var(--brand) 25%, var(--surface-raised));
--brand-emphasis-hover: color-mix(
in oklab,
var(--brand) 30%,
var(--surface-raised)
);
--brand-hover: color-mix(in oklab, var(--brand) 90%, var(--surface-raised));
--focus-ring: var(--brand-emphasis);
/* Layer 2: semantic border and material roles */
--border-faint: color-mix(in oklab, var(--content) 8%, transparent);
--border-subtle: color-mix(in oklab, var(--content) 12%, transparent);
--border-strong: color-mix(in oklab, var(--content) 28%, transparent);
--glass-surface: color-mix(in oklab, var(--surface) 92%, transparent);
--glass-raised: color-mix(in oklab, var(--surface-raised) 96%, transparent);
--scrim: oklch(0 0 0 / 0.5);
/* Layer 2: semantic status families */
--info-surface: oklch(from var(--info) 0.94 0.05 h);
--info-content: var(--info);
--info-border: color-mix(in oklab, var(--info) 28%, var(--surface-raised));
--warning-surface: oklch(from var(--warning) 0.95 0.05 h);
--warning-content: var(--warning);
--warning-border: color-mix(in oklab, var(--warning) 28%, var(--surface-raised));
--success-surface: oklch(from var(--success) 0.94 0.05 h);
--success-content: var(--success);
--success-border: color-mix(in oklab, var(--success) 28%, var(--surface-raised));
--danger-surface: oklch(from var(--danger) 0.94 0.05 h);
--danger-content: var(--danger);
--danger-border: color-mix(in oklab, var(--danger) 28%, var(--surface-raised));
/* Layer 2: shell and navigation */
--header-bg: var(--canvas);
--header-interactive-bg: oklch(from var(--header-bg) calc(l - 0.12) c h);
--header-glass-bg: color-mix(in oklab, var(--header-bg) 40%, transparent);
/* Layer 2: focused component responsibilities */
--tooltip-bg: oklch(from var(--surface) calc(l - 0.015) c h);
--terminal-bg: oklch(0.24 0.02 260);
--terminal-content: oklch(1 0 0);
--progress-track-bg: oklch(0.929 0.013 255.508);
--progress-start: var(--brand);
--progress-end: oklch(from var(--brand) calc(l + 0.07) c calc(h - 35));
--input-bg: var(--surface);
--input-checked-content: oklch(1 0 0);
/* Layer 2: buttons and neutral status */
--button-secondary-bg: oklch(0.929 0.013 255.508);
--button-secondary-hover-bg: color-mix(
in oklab,
var(--button-secondary-bg) 90%,
var(--surface-raised)
);
--button-secondary-content: oklch(0.372 0.044 257.287);
--button-secondary-border: var(--button-secondary-bg);
--button-muted-bg: var(--surface-muted);
--button-muted-hover-bg: color-mix(
in oklab,
var(--button-muted-bg) 90%,
var(--surface-raised)
);
--button-muted-content: var(--content-muted);
--button-muted-border: var(--button-muted-bg);
--neutral-status-surface: oklch(0.97 0 0);
--neutral-status-content: oklch(0.205 0 0);
--neutral-status-border: var(--border-faint);
/* Layer 2: responsibilities formerly shared by label-surface */
--card-action-bg: var(--surface-subtle);
--interface-badge-bg: var(--surface-subtle);
--segmented-control-bg: var(--surface-subtle);
--ifacebox-header-bg: var(--surface-subtle);
--table-header-bg: var(--surface-subtle);
--table-row-hover-bg: var(--surface-subtle);
--table-row-alternate-bg: color-mix(
in oklab,
var(--table-row-hover-bg) 50%,
var(--surface-raised)
);
/* Structure */
--font-sans: "Lato", ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
--spacing: 0.25rem;
@@ -125,52 +47,44 @@
}
[data-darkmode="true"] {
/* Layer 1: theme inputs */
--canvas: oklch(0.32 0.059 188.42);
--surface: oklch(0.21 0.034 264.665);
--surface-raised: oklch(0.279 0.041 260.031);
--content: oklch(0.968 0.007 247.896);
--bg: oklch(0.13 0.018 264);
--surface: oklch(0.21 0.02 264);
--text: oklch(0.985 0.002 264);
--brand: oklch(0.6 0.13 188.745);
--on-brand: oklch(1 0 0);
--link: oklch(0.702 0.183 293.541);
--info: oklch(0.88 0.06 230);
--warning: oklch(0.924 0.12 95.746);
--success: oklch(0.92 0.09 160);
--danger: oklch(0.88 0.14 25);
/* Mode-sensitive semantic roles */
--surface-subtle: oklch(from var(--surface-raised) calc(l + 0.04) c h);
--surface-muted: oklch(from var(--surface-raised) calc(l + 0.09) c h);
--content-muted: oklch(0.704 0.04 256.788);
--info-surface: oklch(from var(--info) 0.34 0.07 h);
--warning-surface: oklch(from var(--warning) 0.35 0.08 h);
--success-surface: oklch(from var(--success) 0.33 0.06 h);
--danger-surface: oklch(from var(--danger) 0.34 0.09 h);
/* Mode-sensitive component roles */
--header-bg: var(--surface);
--header-interactive-bg: oklch(from var(--header-bg) calc(l + 0.24) c h);
--header-glass-bg: color-mix(in oklab, var(--header-bg) 90%, transparent);
--tooltip-bg: oklch(from var(--surface) calc(l + 0.06) c h);
--terminal-bg: oklch(0.18 0.03 260);
--terminal-content: oklch(1 0 0);
--progress-track-bg: oklch(0.372 0.044 257.287);
--progress-start: oklch(0.4318 0.0865 166.91);
--progress-end: oklch(0.621 0.145 189.632);
--input-checked-content: oklch(1 0 0);
--button-secondary-bg: oklch(0.372 0.044 257.287);
--button-secondary-content: oklch(0.928 0.006 264.531);
--button-muted-content: oklch(0.704 0.04 256.788);
--neutral-status-surface: oklch(0.274 0.006 286.033);
--neutral-status-content: oklch(0.985 0.01 285.805);
--link: oklch(0.77 0.14 168);
--info: oklch(0.8 0.11 255);
--warning: oklch(0.82 0.13 80);
--success: oklch(0.72 0.13 158);
--danger: oklch(0.7 0.16 22);
--text-muted: oklch(66.01% 0.0081 264);
--text-subtle: oklch(48.91% 0.0113 264);
--surface-sunken: oklch(16.5% 0.02 264);
--surface-overlay: oklch(23% 0.02 264);
--hairline: oklch(98.5% 0.002 264 / 0.1);
--hover-faint: oklch(98.5% 0.002 264 / 0.05);
--brand-hover: oklch(55% 0.13 188.7);
--brand-subtle: oklch(20.52% 0.0287 219.4);
--brand-subtle-hover: oklch(24.52% 0.0287 219.4);
--focus-ring: oklch(60% 0.13 188.7 / 0.6);
--progress-start: oklch(43.18% 0.0865 166.9);
--progress-end: oklch(62.1% 0.145 189.6);
--info-surface: oklch(32% 0.05 255);
--warning-surface: oklch(33% 0.06 80);
--success-surface: oklch(30% 0.05 158);
--danger-surface: oklch(32% 0.08 22);
--danger-surface-hover: oklch(36% 0.08 22);
--scrim: oklch(0% 0 0 / 0.6);
--mega-menu-bg: oklch(23% 0.02 264 / 0.62);
--app-shadow-sm: 0 4px 12px oklch(0 0 0 / 0.3);
--app-shadow-md: 0 10px 28px oklch(0 0 0 / 0.42);
--app-shadow-lg: 0 20px 48px oklch(0 0 0 / 0.55);
}
@theme inline {
/* Layer 1 */
--color-canvas: var(--canvas);
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-raised: var(--surface-raised);
--color-content: var(--content);
--color-text: var(--text);
--color-brand: var(--brand);
--color-on-brand: var(--on-brand);
--color-link: var(--link);
@@ -178,72 +92,37 @@
--color-warning: var(--warning);
--color-success: var(--success);
--color-danger: var(--danger);
/* Layer 2: semantic */
--color-surface-subtle: var(--surface-subtle);
--color-surface-muted: var(--surface-muted);
--color-content-muted: var(--content-muted);
--color-content-subtle: var(--content-subtle);
--color-brand-faint: var(--brand-faint);
--color-brand-soft: var(--brand-soft);
--color-brand-emphasis: var(--brand-emphasis);
--color-brand-emphasis-hover: var(--brand-emphasis-hover);
--color-text-muted: var(--text-muted);
--color-text-subtle: var(--text-subtle);
--color-surface-sunken: var(--surface-sunken);
--color-surface-overlay: var(--surface-overlay);
--color-hairline: var(--hairline);
--color-hover-faint: var(--hover-faint);
--color-brand-hover: var(--brand-hover);
--color-brand-subtle: var(--brand-subtle);
--color-brand-subtle-hover: var(--brand-subtle-hover);
--color-focus-ring: var(--focus-ring);
--color-border-faint: var(--border-faint);
--color-border-subtle: var(--border-subtle);
--color-border-strong: var(--border-strong);
--color-glass-surface: var(--glass-surface);
--color-glass-raised: var(--glass-raised);
--color-scrim: var(--scrim);
--color-info-surface: var(--info-surface);
--color-info-content: var(--info-content);
--color-info-border: var(--info-border);
--color-warning-surface: var(--warning-surface);
--color-warning-content: var(--warning-content);
--color-warning-border: var(--warning-border);
--color-success-surface: var(--success-surface);
--color-success-content: var(--success-content);
--color-success-border: var(--success-border);
--color-danger-surface: var(--danger-surface);
--color-danger-content: var(--danger-content);
--color-danger-border: var(--danger-border);
/* Layer 2: component */
--color-header-bg: var(--header-bg);
--color-header-interactive-bg: var(--header-interactive-bg);
--color-header-glass-bg: var(--header-glass-bg);
--color-tooltip-bg: var(--tooltip-bg);
--color-terminal-bg: var(--terminal-bg);
--color-terminal-content: var(--terminal-content);
--color-progress-track-bg: var(--progress-track-bg);
--color-progress-start: var(--progress-start);
--color-progress-end: var(--progress-end);
--color-input-bg: var(--input-bg);
--color-input-checked-content: var(--input-checked-content);
--color-button-secondary-bg: var(--button-secondary-bg);
--color-button-secondary-hover-bg: var(--button-secondary-hover-bg);
--color-button-secondary-content: var(--button-secondary-content);
--color-button-secondary-border: var(--button-secondary-border);
--color-button-muted-bg: var(--button-muted-bg);
--color-button-muted-hover-bg: var(--button-muted-hover-bg);
--color-button-muted-content: var(--button-muted-content);
--color-button-muted-border: var(--button-muted-border);
--color-neutral-status-surface: var(--neutral-status-surface);
--color-neutral-status-content: var(--neutral-status-content);
--color-neutral-status-border: var(--neutral-status-border);
--color-card-action-bg: var(--card-action-bg);
--color-interface-badge-bg: var(--interface-badge-bg);
--color-segmented-control-bg: var(--segmented-control-bg);
--color-ifacebox-header-bg: var(--ifacebox-header-bg);
--color-table-header-bg: var(--table-header-bg);
--color-table-row-hover-bg: var(--table-row-hover-bg);
--color-table-row-alternate-bg: var(--table-row-alternate-bg);
--color-info-surface: var(--info-surface);
--color-warning-surface: var(--warning-surface);
--color-success-surface: var(--success-surface);
--color-danger-surface: var(--danger-surface);
--color-danger-surface-hover: var(--danger-surface-hover);
--color-scrim: var(--scrim);
--color-mega-menu-bg: var(--mega-menu-bg);
--shadow-sm: var(--app-shadow-sm);
--shadow-md: var(--app-shadow-md);
--shadow-lg: var(--app-shadow-md);
--shadow-xl: var(--app-shadow-lg);
--shadow-2xl: var(--app-shadow-lg);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--spacing: var(--spacing);
--container-max-width: var(--container-max-width);
--radius-sm: calc(var(--radius-base) * 0.25);
--radius: calc(var(--radius-base) * 0.5);
--radius-md: calc(var(--radius-base) * 0.75);
@@ -52,11 +52,11 @@
}
.fade-in {
@apply animate-in fade-in-0 fill-mode-backwards duration-400;
@apply animate-in fade-in-0 fill-mode-backwards duration-[250ms];
}
.fade-out {
@apply pointer-events-none opacity-0 transition-opacity duration-400;
@apply pointer-events-none opacity-0 transition-opacity duration-[250ms];
}
.spinning {
@@ -1,31 +1,31 @@
.label {
@apply bg-neutral-status-surface text-neutral-status-content rounded-full px-3 py-1 text-xs font-bold uppercase;
@apply bg-surface-sunken text-text rounded-full px-3 py-1 text-xs font-bold uppercase;
&.important {
@apply bg-info-surface text-info-content;
@apply bg-info-surface text-info;
}
&.warning {
@apply bg-danger-surface text-danger-content;
@apply bg-danger-surface text-danger;
}
&.success {
@apply bg-success-surface text-success-content;
@apply bg-success-surface text-success;
}
&.notice {
@apply bg-warning-surface text-warning-content;
@apply bg-warning-surface text-warning;
}
}
.zonebadge {
@apply border-border-subtle bg-interface-badge-bg text-content inline-flex items-center gap-1.5 overflow-visible rounded-full border px-1 py-1.5 text-sm font-medium shadow-sm transition-[box-shadow] duration-200 hover:shadow-md max-md:gap-1 max-md:text-xs;
@apply border-hairline bg-surface-sunken text-text inline-flex items-center gap-1.5 overflow-visible rounded-full border px-1 py-1.5 text-sm font-medium shadow-sm transition-[box-shadow] duration-150 hover:shadow-md max-md:gap-1 max-md:text-xs;
&[style*="--zone-color-rgb"] {
@apply bg-[rgb(var(--zone-color-rgb),.7)]!;
& em {
@apply text-content;
@apply text-text;
}
}
@@ -35,7 +35,7 @@
}
.ifacebadge {
@apply border-border-subtle bg-interface-badge-bg text-content hover:border-border-strong hover:bg-interface-badge-bg inline-flex cursor-default flex-wrap items-center gap-2 rounded-2xl border px-3 py-2 text-sm font-normal shadow-sm transition-[box-shadow,border-color,background-color] duration-200 hover:shadow-md max-md:gap-1.5 max-md:px-2.5 max-md:py-1.5 max-md:text-xs;
@apply border-hairline bg-surface-sunken text-text hover:border-hairline hover:bg-surface-sunken inline-flex cursor-default flex-wrap items-center gap-2 rounded-2xl border px-3 py-2 text-sm font-normal shadow-sm transition-[box-shadow,border-color,background-color] duration-150 hover:shadow-md max-md:gap-1.5 max-md:px-2.5 max-md:py-1.5 max-md:text-xs;
.cbi-dropdown &,
.cbi-tooltip & {
@@ -6,7 +6,7 @@ input[type="reset"],
.cbi-button {
/* transform stays in this list for the .cbi-button active:scale-95
variant in _form.css, which relies on this base transition. */
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-2xl border px-3 py-1.5 text-sm font-medium antialiased shadow-sm transition-[background-color,border-color,transform] duration-200 max-md:text-base max-md:font-medium;
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-xl border px-3 py-1.5 text-sm font-medium antialiased transition-[background-color,border-color,transform] duration-150 max-md:text-base max-md:font-medium;
&[disabled] {
@apply cursor-not-allowed opacity-40 dark:opacity-30;
}
@@ -23,11 +23,11 @@ input[type="reset"],
.cbi-button-link,
.cbi-button-up,
.cbi-button-down {
@apply border-button-secondary-border bg-button-secondary-bg text-button-secondary-content hover:bg-button-secondary-hover-bg;
@apply border-hairline bg-surface-sunken text-text hover:bg-hover-faint;
}
.drag-handle {
@apply border-button-muted-border bg-button-muted-bg text-button-muted-content hover:bg-button-muted-hover-bg;
@apply border-hairline bg-surface-sunken text-text-muted hover:bg-hover-faint;
}
.btn.primary,
@@ -42,12 +42,12 @@ input[type="reset"],
.cbi-button-positive,
.cbi-button-fieldadd,
.cbi-button-save {
@apply border-brand-emphasis bg-brand-emphasis text-brand hover:bg-brand-emphasis-hover;
@apply border-transparent bg-brand-subtle text-brand hover:bg-brand-subtle-hover;
}
.cbi-button-negative,
.cbi-section-remove .cbi-button,
.cbi-button-reset,
.cbi-button-remove {
@apply border-danger-border bg-danger-surface text-danger-content hover:bg-danger-border;
@apply border-transparent bg-danger-surface text-danger hover:bg-danger-surface-hover;
}
@@ -9,7 +9,7 @@
}
#maincontent & {
@apply border-border-faint bg-surface-raised hover:border-border-subtle border shadow-lg hover:shadow-xl;
@apply bg-surface border-hairline border shadow-sm;
}
& > .cbi-title {
@@ -19,12 +19,12 @@
@apply flex-1;
& .label[data-clickable] {
@apply text-content bg-card-action-bg;
@apply inline-flex size-7 cursor-pointer items-center justify-center p-0 before:size-4 before:bg-current before:transition-transform before:duration-300 hover:shadow-md active:scale-95 max-md:size-6;
@apply text-text bg-surface-sunken;
@apply inline-flex size-7 cursor-pointer items-center justify-center p-0 before:size-4 before:bg-current before:transition-transform before:duration-150 hover:shadow-md active:scale-95 max-md:size-6;
}
& span[data-indicator="poll-status"] {
@apply relative size-5 cursor-pointer text-[0px] before:absolute before:size-5 before:bg-current before:transition-transform before:duration-300 before:ease-out hover:before:scale-110 active:before:scale-95;
@apply relative size-5 cursor-pointer text-[0px] before:absolute before:size-5 before:bg-current before:transition-transform before:duration-150 before:ease-out hover:before:scale-110 active:before:scale-95;
&[data-style="active"] {
@apply before:rotate-0 before:[mask:url('@assets/icons/arrow-right.svg')_center/cover_no-repeat];
@@ -42,7 +42,7 @@
}
& h3 {
@apply text-content border-border-subtle border-b pb-4 max-md:mx-0 max-md:pb-2;
@apply text-text border-hairline border-b pb-4 max-md:mx-0 max-md:pb-2;
}
& div[style*="display:grid"] {
@@ -63,14 +63,14 @@
}
.ifacebox {
@apply border-border-subtle bg-glass-raised hover:border-border-strong max-md:border-border-subtle relative inline-flex min-w-40 flex-col items-stretch overflow-visible rounded-2xl border text-center text-base leading-5 shadow-md hover:shadow-lg max-md:min-w-30 max-md:flex-1 max-md:rounded-3xl max-md:shadow-sm;
@apply border-hairline bg-surface-overlay hover:border-hairline max-md:border-hairline relative inline-flex min-w-40 flex-col items-stretch overflow-visible rounded-2xl border text-center text-base leading-5 shadow-md hover:shadow-xl max-md:min-w-30 max-md:flex-1 max-md:rounded-3xl max-md:shadow-sm;
td & {
@apply max-md:flex-row;
}
& .ifacebox-head {
@apply border-border-subtle bg-ifacebox-header-bg text-content w-full rounded-t-2xl border-b px-4 py-3 text-center font-semibold tracking-tight transition-[background-color,border-color,color] duration-200 max-md:rounded-t-3xl max-md:px-3 max-md:py-2.5 max-md:text-sm max-md:leading-5 max-md:font-medium;
@apply border-hairline bg-surface-sunken text-text w-full rounded-t-2xl border-b px-4 py-3 text-center font-semibold tracking-tight transition-[background-color,border-color,color] duration-150 max-md:rounded-t-3xl max-md:px-3 max-md:py-2.5 max-md:text-sm max-md:leading-5 max-md:font-medium;
&[style*="--zone-color-rgb"] {
@apply bg-[rgb(var(--zone-color-rgb),.75)]!;
@@ -93,14 +93,14 @@
}
& .ifacebox-body {
@apply text-content flex w-full flex-1 flex-col items-center justify-around gap-2 rounded-b-2xl p-4 text-center max-md:rounded-b-3xl;
@apply text-text flex w-full flex-1 flex-col items-center justify-around gap-2 rounded-b-2xl p-4 text-center max-md:rounded-b-3xl;
td & {
@apply max-md:flex-row max-md:rounded-r-3xl max-md:rounded-bl-none max-md:py-2 max-md:pr-2 max-md:pl-4;
}
& > span {
@apply text-content space-y-1.5 max-md:space-y-1 max-md:text-sm max-md:leading-5;
@apply text-text space-y-1.5 max-md:space-y-1 max-md:text-sm max-md:leading-5;
.network-status-table & {
@apply w-full;
@@ -138,7 +138,7 @@
}
& .cbi-tooltip-container {
@apply text-content static inline-flex cursor-help items-center justify-center;
@apply text-text static inline-flex cursor-help items-center justify-center;
& .cbi-tooltip {
@apply bottom-0 md:left-0;
@@ -6,7 +6,7 @@
}
&:not(.btn):not(.cbi-button) {
@apply border-border-subtle bg-surface rounded-2xl border;
@apply border-hairline bg-surface rounded-2xl border;
& > ul > li {
&[placeholder] {
@apply hidden;
@@ -50,10 +50,10 @@
}
&.dropdown {
@apply border-border-subtle bg-glass-surface absolute left-0 z-60 w-fit min-w-full overflow-y-auto rounded-lg border shadow-xl backdrop-blur-sm;
@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;
& > li {
@apply text-content-muted hover:bg-brand-soft min-h-9 w-full cursor-pointer px-3 py-1.5 font-medium;
@apply text-text-muted hover:bg-brand-subtle min-h-9 w-full cursor-pointer px-3 py-1.5 font-medium;
}
}
@@ -86,7 +86,7 @@
@apply flex;
&[selected] {
@apply bg-brand-emphasis text-brand;
@apply bg-brand-subtle text-brand;
}
&[unselectable] {
@@ -127,7 +127,7 @@
}
& > .open {
@apply border-border-subtle flex flex-none shrink-0 cursor-pointer items-center justify-center border-l px-2.5 py-0.5 text-sm text-current;
@apply border-hairline flex flex-none shrink-0 cursor-pointer items-center justify-center border-l px-2.5 py-0.5 text-sm text-current;
}
& > .more {
@@ -2,22 +2,22 @@
@apply fixed right-4 bottom-4 z-40 flex flex-col items-center max-md:right-3 max-md:bottom-3;
.toolbar-list {
@apply visible mb-2 grid origin-bottom grid-rows-[1fr] opacity-100 transition-[grid-template-rows,transform,opacity,visibility] duration-300 ease-in-out max-md:mb-1.5;
@apply visible mb-2 grid origin-bottom grid-rows-[1fr] opacity-100 transition-[grid-template-rows,transform,opacity,visibility] duration-[250ms] ease-in-out max-md:mb-1.5;
.toolbar-list-inner {
@apply border-border-subtle bg-glass-surface flex min-h-0 flex-col items-center gap-2 overflow-hidden rounded-full border p-1 shadow-sm max-md:gap-1.5 max-md:p-0.5;
@apply border-hairline bg-surface-overlay flex min-h-0 flex-col items-center gap-2 overflow-hidden rounded-full border p-1 shadow-sm max-md:gap-1.5 max-md:p-0.5;
}
}
.toolbar-btn {
@apply flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full p-2 transition-[background-color,transform,opacity] duration-200 ease-in-out active:scale-95 max-md:h-9 max-md:w-9 max-md:p-1.5;
@apply flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full p-2 transition-[background-color,transform,opacity] duration-150 ease-in-out active:scale-95 max-md:h-9 max-md:w-9 max-md:p-1.5;
.icon {
@apply h-5 w-5 shrink-0 transition-transform duration-300 ease-in-out max-md:h-4 max-md:w-4;
@apply h-5 w-5 shrink-0 transition-transform duration-150 ease-in-out max-md:h-4 max-md:w-4;
}
&:not(.toggle) {
@apply hover:bg-border-faint no-underline hover:scale-110;
@apply hover:bg-hover-faint no-underline hover:scale-110;
.icon {
@apply object-contain dark:invert;
}
@@ -26,7 +26,7 @@
&.toggle {
@apply bg-surface border-0 shadow-sm;
.icon {
@apply transition-transform duration-300 ease-out;
@apply transition-transform duration-150 ease-out;
}
}
}
@@ -1,6 +1,6 @@
.cbi-input-invalid,
.cbi-value-error {
@apply !border-danger-border focus:!ring-danger-border;
@apply !border-danger focus:!ring-danger;
}
.cbi-value {
@@ -25,10 +25,10 @@
@apply cursor-not-allowed;
}
& .cbi-value-description {
@apply text-content-muted relative mt-1 pl-4 leading-none wrap-break-word;
@apply text-text-muted relative mt-1 pl-4 leading-none wrap-break-word;
&:not(:empty) {
@apply before:bg-info-content before:absolute before:-left-0.5 before:inline-block before:size-4 before:[mask:url('@assets/icons/info.svg')_center/cover_no-repeat];
@apply before:bg-info before:absolute before:-left-0.5 before:inline-block before:size-4 before:[mask:url('@assets/icons/info.svg')_center/cover_no-repeat];
}
}
}
@@ -43,15 +43,15 @@
@apply inline-flex w-max max-w-120 flex-col items-start gap-3 max-md:max-w-full!;
& .item {
@apply border-border-faint bg-surface-raised pointer-events-auto relative inline-flex cursor-move flex-col items-start gap-2 self-stretch overflow-hidden rounded-2xl border py-3 pr-10 pl-4 break-all shadow-sm transition-[transform,box-shadow,opacity,border-color,background-color] duration-200 select-text max-md:w-full max-md:py-2.5 max-md:pr-8 max-md:pl-3;
@apply after:bg-button-muted-bg after:text-button-muted-content after:absolute after:top-2.5 after:right-2 after:inline-flex after:h-6 after:w-6 after:shrink-0 after:cursor-pointer after:items-center after:justify-center after:rounded-xl after:text-sm after:font-medium after:content-['\00D7'];
@apply border-hairline bg-surface pointer-events-auto relative inline-flex cursor-move flex-col items-start gap-2 self-stretch overflow-hidden rounded-2xl border py-3 pr-10 pl-4 break-all shadow-sm transition-[transform,box-shadow,opacity,border-color,background-color] duration-150 select-text max-md:w-full max-md:py-2.5 max-md:pr-8 max-md:pl-3;
@apply after:bg-surface-sunken after:text-text-muted after:absolute after:top-2.5 after:right-2 after:inline-flex after:h-6 after:w-6 after:shrink-0 after:cursor-pointer after:items-center after:justify-center after:rounded-xl after:text-sm after:font-medium after:content-['\00D7'];
&.dragging {
@apply scale-95 cursor-grabbing opacity-50 shadow-2xl;
}
&.drag-over {
@apply border-brand bg-brand-faint ring-brand-emphasis scale-105 shadow-lg ring-2;
@apply border-brand bg-brand-subtle ring-brand-subtle scale-105 shadow-lg ring-2;
}
& > span,
@@ -65,7 +65,7 @@
}
& .add-item {
@apply border-border-subtle hover:border-border-strong hover:bg-surface-subtle inline-flex items-center gap-1.5 self-stretch rounded-2xl border border-dashed bg-transparent px-1 py-1 transition-[border-color,background-color] duration-200;
@apply border-hairline hover:border-hairline hover:bg-surface-sunken inline-flex items-center gap-1.5 self-stretch rounded-2xl border border-dashed bg-transparent px-1 py-1 transition-[border-color,background-color] duration-150;
& > input {
@apply min-w-61 flex-1;
@@ -6,7 +6,7 @@ input[type="text"],
input[type="password"],
.cbi-input-text,
.cbi-input {
@apply text-content border-border-subtle bg-surface placeholder-content-muted focus:border-brand focus:ring-brand-emphasis relative rounded-2xl border px-3 py-1.5 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-200 focus:ring-2 focus:outline-none;
@apply text-text border-hairline bg-surface-sunken placeholder-text-muted focus:border-brand focus:ring-focus-ring relative rounded-2xl border px-3 py-1.5 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-150 focus:ring-2 focus:outline-none;
.table.cbi-section-table & {
@apply w-full;
@@ -23,7 +23,7 @@ input[type="password"],
input[type="radio"],
input[type="checkbox"] {
@apply focus:before:border-brand focus:before:ring-brand-emphasis checked:before:border-brand checked:before:bg-brand before:border-border-subtle before:bg-surface after:bg-input-checked-content hover:before:border-border-strong relative mr-3 inline-block h-4 w-4 cursor-pointer appearance-none before:absolute before:top-0 before:left-0 before:h-4 before:w-4 before:border before:transition-[background-color,border-color,box-shadow] before:duration-200 after:absolute after:top-0.5 after:left-0.5 after:h-3 after:w-3 after:opacity-0 after:transition-opacity after:duration-200 checked:after:opacity-100 focus:before:ring-2 focus:before:outline-none disabled:cursor-not-allowed;
@apply focus:before:border-brand focus:before:ring-focus-ring checked:before:border-brand checked:before:bg-brand before:border-hairline before:bg-surface-sunken after:bg-on-brand hover:before:border-hairline relative mr-3 inline-block h-4 w-4 cursor-pointer appearance-none before:absolute before:top-0 before:left-0 before:h-4 before:w-4 before:border before:transition-[background-color,border-color,box-shadow] before:duration-150 after:absolute after:top-0.5 after:left-0.5 after:h-3 after:w-3 after:opacity-0 after:transition-opacity after:duration-150 checked:after:opacity-100 focus:before:ring-2 focus:before:outline-none disabled:cursor-not-allowed;
}
input[type="radio"] {
@@ -1,5 +1,5 @@
.alert-message {
@apply bg-neutral-status-surface text-neutral-status-content border-neutral-status-border 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-40 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;
@@ -14,21 +14,21 @@
}
&.success {
@apply border-success-border bg-success-surface text-success-content;
@apply border-hairline bg-success-surface text-success;
}
&.info {
@apply border-info-border bg-info-surface text-info-content;
@apply border-hairline bg-info-surface text-info;
}
&.warning,
&.notice {
@apply border-warning-border bg-warning-surface text-warning-content;
@apply border-hairline bg-warning-surface text-warning;
}
&.error,
&.danger {
@apply border-danger-border bg-danger-surface text-danger-content;
@apply border-hairline bg-danger-surface text-danger;
}
h4,
@@ -2,11 +2,11 @@
@apply pointer-events-none fixed top-0 bottom-0 hidden overflow-auto bg-transparent;
.modal-overlay-active & {
@apply bg-scrim pointer-events-auto inset-0 right-0 left-0 z-100 grid grid-cols-1 place-items-center backdrop-blur-sm;
@apply bg-scrim pointer-events-auto inset-0 right-0 left-0 z-100 grid grid-cols-1 place-items-center backdrop-blur-md backdrop-saturate-150;
}
& > .modal {
@apply border-border-subtle bg-surface relative flex w-5xl flex-col gap-4 rounded-3xl border p-6 wrap-break-word whitespace-normal shadow-2xl max-md:w-full max-md:gap-3 max-md:p-4;
@apply border-hairline bg-surface-overlay relative flex w-5xl flex-col gap-4 rounded-3xl border p-6 wrap-break-word whitespace-normal shadow-2xl max-md:w-full max-md:gap-3 max-md:p-4;
& h4 {
@apply mb-0 text-center;
@@ -16,26 +16,26 @@
}
& h5 {
@apply text-content my-3;
@apply text-text my-3;
}
& p {
@apply text-content text-sm leading-relaxed;
@apply text-text text-sm leading-relaxed;
}
& > ul {
@apply border-border-subtle bg-surface-subtle overflow-auto rounded-2xl border p-3;
@apply border-hairline overflow-auto rounded-2xl border p-3;
}
& label.btn {
@apply border-border-subtle bg-surface-subtle text-content hover:border-border-strong border;
@apply border-hairline bg-surface-sunken text-text hover:border-hairline border;
}
& pre {
@apply overflow-auto;
&.errors {
@apply border-danger-border bg-danger-surface text-danger-content;
@apply border-hairline bg-danger-surface text-danger;
}
}
@@ -46,7 +46,7 @@
& .button-row,
& div.left,
& div.right {
@apply border-border-subtle flex shrink-0 gap-3 border-t p-4 max-md:gap-1.5 max-md:p-2.5;
@apply border-hairline flex shrink-0 gap-3 border-t p-4 max-md:gap-1.5 max-md:p-2.5;
}
& div.left {
@@ -66,22 +66,22 @@
}
& ins {
@apply border-success-border bg-success-surface text-success-content border;
@apply border-hairline bg-success-surface text-success border;
}
& del {
@apply border-danger-border bg-danger-surface text-danger-content border;
@apply border-hairline bg-danger-surface text-danger border;
}
& var {
@apply border-info-border bg-info-surface text-info-content border;
@apply border-hairline bg-info-surface text-info border;
}
& .uci-change-legend {
@apply border-border-subtle bg-surface-subtle mt-4 grid grid-cols-2 gap-3 rounded-2xl border p-4 max-md:grid-cols-1 max-md:gap-2 max-md:p-3;
@apply border-hairline bg-surface-sunken mt-4 grid grid-cols-2 gap-3 rounded-2xl border p-4 max-md:grid-cols-1 max-md:gap-2 max-md:p-3;
& .uci-change-legend-label {
@apply text-content flex items-center gap-2 text-sm font-medium;
@apply text-text flex items-center gap-2 text-sm font-medium;
& ins,
& del,
@@ -102,7 +102,7 @@
@apply mt-4 space-y-3;
& h5 {
@apply bg-neutral-status-surface text-neutral-status-content mt-6 mb-2 rounded-xl px-3 py-2 text-sm font-semibold first:mt-0;
@apply bg-surface-sunken text-text mt-6 mb-2 rounded-xl px-3 py-2 text-sm font-semibold first:mt-0;
}
& ins,
@@ -8,15 +8,17 @@
*/
.nav-link {
@apply text-content hover:bg-header-interactive-bg block rounded-xl px-3 py-1.5 no-underline transition-all duration-150;
@apply text-text hover:bg-hover-faint block rounded-xl px-3 py-1.5 no-underline transition-all duration-150;
}
/* Mode-agnostic active state: brand text + weight only. The selected-pill
background is a sidebar affordance (set in _layout.css); the mobile drawer
deliberately omits it and uses an accent indicator bar instead (_overlay.css).
Repeats text-brand on hover so the base link's hover color never bleeds through. */
.nav-link-active {
@apply text-brand hover:text-brand font-medium;
.navigation-direct {
@apply text-text hover:text-text;
}
/* Direct destinations use the same selected state in both navigation modes:
a filled brand pill, matching the active sublink. */
.navigation-direct.is-active-page {
@apply text-brand hover:text-brand bg-brand-subtle font-medium;
}
/* Category rows are group labels, not buttons: no hover surface, just a
@@ -24,28 +26,53 @@
appearance-none + border-0 + shadow-none strip the theme's global
<button> chrome (_button.css), which would otherwise box every row. */
.nav-category {
@apply text-content-muted hover:text-content focus-visible:ring-focus-ring flex w-full cursor-pointer appearance-none items-center gap-2 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-lg font-semibold tracking-wide shadow-none transition-colors duration-150 select-none focus-visible:ring-2 focus-visible:outline-none;
@apply text-text-muted hover:text-text focus-visible:ring-focus-ring flex w-full cursor-pointer appearance-none items-center gap-2 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-lg font-semibold tracking-wide shadow-none transition-colors duration-150 select-none focus-visible:ring-2 focus-visible:outline-none;
}
/* Trailing chevron shares arrow-right.svg with the collapsible cards:
points right when closed, rotates to point down when open. */
@apply after:size-3.5 after:shrink-0 after:bg-current after:opacity-55 after:transition-[transform,opacity] after:duration-200 after:content-[''] after:[mask:url('@assets/icons/arrow-right.svg')_center/cover_no-repeat] hover:after:opacity-100;
.navigation-group-toggle {
@apply after:size-3.5 after:shrink-0 after:bg-current after:opacity-55 after:transition-[transform,opacity] after:duration-[250ms] after:content-[''] after:[mask:url('@assets/icons/arrow-right.svg')_center/cover_no-repeat] hover:after:opacity-100;
}
.nav-category-label {
@apply min-w-0 flex-1 truncate;
}
/* Open group: label darkens to foreground; the active child's pill is the
sole location anchor, so no primary tint on the label itself. */
.sidebar-group-open > .nav-category {
@apply text-content after:rotate-90;
/* An expanded group's label turns brand and its arrow rotates open. */
.navigation-group.is-expanded > .navigation-group-toggle {
@apply text-brand after:rotate-90;
}
/* The active group keeps its brand label even when the user manually
collapses it — otherwise the active-page pill is hidden inside the inert
region and no "you are here" marker survives. No arrow rotation here: a
collapsed group's arrow must still point right. */
.navigation-group.is-active-group > .navigation-group-toggle {
@apply text-brand;
}
.navigation-group-region {
@apply grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-[250ms] ease-out;
}
.navigation-group.is-expanded > .navigation-group-region {
@apply grid-rows-[1fr] opacity-100;
}
.navigation-submenu-list {
@apply min-h-0 list-none overflow-hidden;
}
.navigation-sublink {
@apply text-text-muted hover:bg-hover-faint hover:text-text relative flex rounded-lg font-medium no-underline transition-colors duration-150;
}
/* Active page: a filled brand pill, shared by the sidebar and the drawer. */
.navigation-sublink.is-active-page {
@apply text-brand hover:text-brand bg-brand-subtle font-semibold;
}
/* The desktop sidebar no longer uses previews; the mobile drawer is the
sole consumer and sets its own px/text-size (_overlay.css), so the base
only carries the mode-agnostic bits. */
.nav-category-preview {
@apply text-content-subtle truncate pb-1;
@apply text-text-subtle truncate pb-1;
& .nav-preview-current {
@apply text-brand;
@@ -1,5 +1,5 @@
.mobile-menu-overlay {
@apply max-md:invisible max-md:fixed max-md:inset-0 max-md:z-60 max-md:bg-(--header-bg)/45 max-md:opacity-0 max-md:backdrop-blur-2xl max-md:backdrop-saturate-200 max-md:transition-[visibility,opacity] max-md:duration-280 md:hidden;
@apply max-md:bg-mega-menu-bg max-md:invisible max-md:fixed max-md:inset-0 max-md:z-60 max-md:opacity-0 max-md:backdrop-blur-lg max-md:backdrop-saturate-150 max-md:transition-[visibility,opacity] max-md:duration-[250ms] md:hidden;
&.mobile-menu-open {
@apply max-md:visible max-md:opacity-100;
@@ -12,70 +12,43 @@
@apply max-md:flex max-md:min-h-0 max-md:flex-1 max-md:flex-col max-md:px-5 max-md:pt-6;
& .mobile-nav-label {
@apply max-md:text-content-muted max-md:mb-3 max-md:text-sm max-md:font-medium;
@apply max-md:text-text-muted max-md:mb-3 max-md:text-sm max-md:font-medium;
}
}
& .mobile-nav-list {
@apply max-md:m-0 max-md:flex max-md:min-h-0 max-md:flex-1 max-md:-translate-y-2 max-md:list-none max-md:flex-col max-md:overflow-y-auto max-md:overscroll-contain max-md:p-0 max-md:pb-5 max-md:opacity-0 max-md:transition-[translate,opacity] max-md:duration-280 max-md:ease-out;
@apply max-md:m-0 max-md:flex max-md:min-h-0 max-md:flex-1 max-md:-translate-y-2 max-md:list-none max-md:flex-col max-md:overflow-y-auto max-md:overscroll-contain max-md:p-0 max-md:pb-5 max-md:opacity-0 max-md:transition-[translate,opacity] max-md:duration-[250ms] max-md:ease-out;
& .mobile-nav-item {
@apply max-md:shrink-0;
& .mobile-nav-link {
@apply max-md:text-content max-md:hover:text-brand max-md:flex max-md:min-h-13 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-4 max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:px-0 max-md:py-2.5 max-md:text-left max-md:text-2xl max-md:leading-tight max-md:font-medium max-md:tracking-tight max-md:no-underline max-md:shadow-none max-md:transition-colors max-md:duration-200;
&.nav-link-active {
@apply max-md:text-brand max-md:hover:text-brand;
}
}
&.has-submenu > .mobile-nav-link {
@apply max-md:after:size-4 max-md:after:shrink-0 max-md:after:bg-current max-md:after:opacity-55 max-md:after:transition-transform max-md:after:duration-200 max-md:after:content-[''] max-md:after:[mask:url('@assets/icons/arrow-right.svg')_center/cover_no-repeat];
}
&.submenu-expanded > .mobile-nav-link {
@apply max-md:after:rotate-90;
@apply max-md:flex max-md:min-h-13 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-4 max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:px-0 max-md:py-2.5 max-md:text-left max-md:text-2xl max-md:leading-tight max-md:font-medium max-md:tracking-tight max-md:no-underline max-md:shadow-none;
}
& .mobile-nav-submenu {
@apply max-md:grid max-md:grid-rows-[0fr] max-md:opacity-0 max-md:transition-[grid-template-rows,opacity] max-md:duration-300 max-md:ease-in-out;
& .mobile-nav-submenu-list {
@apply max-md:before:bg-border-subtle max-md:relative max-md:mx-0 max-md:mt-0 max-md:mb-2 max-md:min-h-0 max-md:list-none max-md:overflow-hidden max-md:py-1 max-md:pr-0 max-md:pl-4 max-md:before:absolute max-md:before:top-1 max-md:before:bottom-1 max-md:before:left-0 max-md:before:w-px max-md:before:origin-top max-md:before:scale-y-0 max-md:before:transition-transform max-md:before:duration-300 max-md:before:ease-out max-md:before:content-[''];
@apply max-md:mx-0 max-md:mt-0 max-md:mb-2 max-md:py-1 max-md:pr-0 max-md:pl-4;
}
& .mobile-nav-subitem {
& .mobile-nav-sublink {
@apply max-md:text-content max-md:hover:text-brand max-md:relative max-md:flex max-md:min-h-10 max-md:w-full max-md:items-center max-md:py-2 max-md:text-[0.9375rem] max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-200;
&.nav-link-active {
@apply max-md:text-brand max-md:hover:text-brand max-md:font-semibold;
@apply max-md:before:bg-brand max-md:before:absolute max-md:before:top-1.5 max-md:before:bottom-1.5 max-md:before:-left-4 max-md:before:w-0.5 max-md:before:rounded-full max-md:before:content-[''];
}
@apply max-md:min-h-10 max-md:w-full max-md:items-center max-md:px-3 max-md:py-2 max-md:text-base;
}
}
}
&.submenu-expanded .mobile-nav-submenu {
@apply max-md:grid-rows-[1fr] max-md:opacity-100;
& .mobile-nav-submenu-list {
@apply max-md:before:scale-y-100;
}
}
}
}
& .mobile-nav-footer {
@apply max-md:border-border-faint max-md:flex max-md:shrink-0 max-md:items-center max-md:justify-between max-md:border-t max-md:px-5 max-md:pt-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))];
@apply max-md:border-hairline max-md:flex max-md:shrink-0 max-md:items-center max-md:justify-between max-md:border-t max-md:px-5 max-md:pt-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))];
& .mobile-nav-footer-action {
@apply max-md:flex max-md:items-center;
}
& .mobile-nav-logout {
@apply max-md:text-content max-md:hover:text-brand max-md:text-sm max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-200;
@apply max-md:text-text max-md:hover:text-brand max-md:text-sm max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-150;
}
& .theme-switcher {
@@ -97,10 +70,10 @@ 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-300 max-md:hidden;
@apply bg-scrim pointer-events-none fixed inset-0 z-50 opacity-0 transition-opacity duration-[250ms] max-md:hidden;
&.active {
@apply pointer-events-auto opacity-100 backdrop-blur-sm;
@apply pointer-events-auto opacity-100 backdrop-blur-lg backdrop-saturate-150;
}
}
}
@@ -1,10 +1,10 @@
.cbi-progressbar {
@apply before:text-content bg-progress-track-bg relative h-3.5 w-full cursor-help overflow-hidden rounded-full before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-2xl before:text-xs before:whitespace-nowrap before:content-[attr(title)] max-md:h-4 max-md:rounded-2xl max-md:before:text-xs max-md:before:leading-normal;
@apply before:text-text bg-surface-sunken relative h-3.5 w-full cursor-help overflow-hidden rounded-full before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-2xl before:text-xs before:whitespace-nowrap before:content-[attr(title)] max-md:h-4 max-md:rounded-2xl max-md:before:text-xs max-md:before:leading-normal;
[data-page="admin-system-package-manager"] & {
@apply max-sm:before:text-[10px];
}
& > div {
@apply from-progress-start to-progress-end h-full bg-linear-to-r transition-[width] duration-300;
@apply from-progress-start to-progress-end h-full bg-linear-to-r transition-[width] duration-[250ms];
}
}
@@ -1,5 +1,5 @@
.cbi-tabmenu {
@apply border-border-subtle bg-segmented-control-bg mb-1 flex items-center gap-1 overflow-x-auto rounded-4xl border p-1;
@apply border-hairline bg-surface-sunken mb-1 flex items-center gap-1 overflow-x-auto rounded-4xl border p-1;
& li {
@apply relative m-0 min-w-fit shrink-0 list-none;
@@ -7,15 +7,15 @@
@apply after:bg-danger after:text-on-brand after:absolute after:-top-0.5 after:-right-1.5 after:z-20 after:inline-flex after:min-h-4 after:min-w-4 after:items-center after:justify-center after:rounded-full after:px-1.5 after:py-0.5 after:text-xs after:font-semibold after:content-[attr(data-errors)];
& > a {
@apply border-danger-border border;
@apply border-hairline border;
}
}
& > a {
@apply text-content-muted hover:text-content block rounded-4xl px-4 py-2 text-center text-sm font-medium whitespace-nowrap no-underline;
@apply text-text-muted hover:text-text block rounded-4xl px-4 py-2 text-center text-sm font-medium whitespace-nowrap no-underline;
}
&.cbi-tab a {
@apply bg-content text-surface font-semibold shadow-sm;
@apply bg-text text-surface font-semibold shadow-sm;
}
}
}
@@ -1,5 +1,5 @@
select {
@apply text-content border-border-subtle bg-surface focus:border-brand focus:ring-brand-emphasis appearance-none rounded-2xl border px-3 py-1.5 pr-10 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-200 focus:ring-2 focus:outline-none;
@apply text-text border-hairline bg-surface-sunken focus:border-brand focus:ring-focus-ring appearance-none rounded-2xl border px-3 py-1.5 pr-10 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-150 focus:ring-2 focus:outline-none;
@apply bg-[url('@assets/icons/arrow-down.svg')] bg-size-[16px] bg-position-[right_0.75rem_center] bg-no-repeat dark:bg-[url('@assets/icons/arrow-down-dark.svg')];
&[disabled] {
@apply cursor-not-allowed opacity-40 dark:opacity-30;
@@ -1,34 +1,34 @@
.theme-switcher {
@apply bg-surface relative inline-flex items-center gap-0 rounded-full shadow-sm backdrop-blur-sm transition-transform duration-300 max-md:py-0.5;
@apply border-border-subtle border;
@apply before:bg-border-subtle before:ring-border-subtle before:absolute before:top-1 before:left-1 before:z-0 before:h-[calc(100%-0.5rem)] before:w-[calc(33.333%-0.5rem)] before:rounded-full before:shadow-md before:ring-1 before:transition-transform before:duration-300;
@apply bg-surface relative inline-flex items-center gap-0 rounded-full shadow-sm backdrop-blur-md backdrop-saturate-150 transition-transform duration-150 max-md:py-0.5;
@apply border-hairline border;
@apply before:bg-hairline before:ring-hairline before:absolute before:top-1 before:left-1 before:z-0 before:h-[calc(100%-0.5rem)] before:w-[calc(33.333%-0.5rem)] before:rounded-full before:shadow-md before:ring-1 before:transition-transform before:duration-[250ms];
footer & {
@apply max-md:hidden;
}
& .theme-option {
@apply relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-full px-3 py-1.5 transition-transform duration-300 hover:scale-105 active:scale-95;
@apply relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-full px-3 py-1.5 transition-transform duration-150 hover:scale-105 active:scale-95;
& input[type="radio"] {
@apply sr-only;
}
& .theme-icon {
@apply text-content-muted flex items-center justify-center transition-colors duration-300;
@apply text-text-muted flex items-center justify-center transition-colors duration-150;
& svg {
@apply size-4 transition-transform duration-300;
@apply size-4 transition-transform duration-150;
}
}
& .theme-label {
@apply text-content-muted hidden text-xs font-medium transition-colors duration-300;
@apply text-text-muted hidden text-xs font-medium transition-colors duration-150;
}
&.active {
& .theme-icon {
@apply text-content;
@apply text-text;
& svg {
@apply scale-110;
@@ -36,17 +36,17 @@
}
& .theme-label {
@apply text-content;
@apply text-text;
}
}
&:hover:not(.active) {
& .theme-icon {
@apply text-content;
@apply text-text;
}
& .theme-label {
@apply text-content;
@apply text-text;
}
}
}
@@ -3,16 +3,16 @@
}
.tabs {
@apply border-border-subtle relative flex items-center gap-0 overflow-x-auto border-b;
@apply border-hairline relative flex items-center gap-0 overflow-x-auto border-b;
& a {
@apply text-content-muted hover:text-content block px-8 py-4 text-center text-sm font-medium whitespace-nowrap transition-colors duration-200 hover:no-underline;
@apply text-text-muted hover:text-text block px-8 py-4 text-center text-sm font-medium whitespace-nowrap transition-colors duration-150 hover:no-underline;
}
& > li.active {
@apply border-content border-b-2;
@apply border-text border-b-2;
& > a {
@apply text-content font-semibold;
@apply text-text font-semibold;
}
}
}
@@ -1,6 +1,6 @@
table.table,
.table {
@apply border-border-subtle max-md:bg-glass-raised mb-3 w-full border-separate border-spacing-0 overflow-visible rounded-2xl border max-md:mx-0 max-md:block max-md:w-full max-md:overflow-visible max-md:border-0 max-md:shadow-none;
@apply border-hairline max-md:bg-surface-overlay mb-3 w-full border border-separate border-spacing-0 overflow-visible rounded-2xl max-md:mx-0 max-md:block max-md:w-full max-md:overflow-visible max-md:border-0 max-md:shadow-none;
&[width="100%"] {
@apply w-full;
@@ -17,7 +17,7 @@ table.table,
& .cbi-section-table-titles.named,
& .cbi-section-table-descr.named,
& .cbi-section-table-row[data-title] {
@apply before:border-border-subtle before:table-cell before:px-3 before:py-3 before:align-middle before:text-base before:font-semibold before:content-[attr(data-title)] before:max-md:flex before:max-md:flex-[0_0_100%] before:max-md:px-3 before:max-md:py-2.5 before:max-md:text-sm before:md:border-b;
@apply before:border-hairline before:table-cell before:px-3 before:py-3 before:align-middle before:text-base before:font-semibold before:content-[attr(data-title)] before:max-md:flex before:max-md:flex-[0_0_100%] before:max-md:px-3 before:max-md:py-2.5 before:max-md:text-sm before:md:border-b;
&:not([data-title]),
&:has(td.cbi-section-table-titles) {
@apply before:content-none;
@@ -25,7 +25,7 @@ table.table,
}
& .cbi-section-table-titles.named {
@apply before:bg-table-header-bg before:max-md:bg-table-header-bg before:md:rounded-tl-2xl;
@apply before:text-text-subtle before:text-xs before:font-semibold before:tracking-wider before:uppercase;
}
& tbody,
@@ -35,30 +35,16 @@ table.table,
& tr,
& .tr {
@apply hover:bg-table-row-hover-bg transition-colors duration-200 max-md:border-border-subtle max-md:flex max-md:flex-wrap max-md:border-b max-md:px-2 max-md:py-2;
@apply max-md:border-hairline max-md:flex max-md:flex-wrap max-md:border-b max-md:px-2 max-md:py-2;
@apply has-[.cbi-progressbar]:max-md:flex has-[.cbi-progressbar]:max-md:items-center has-[.cbi-progressbar]:max-md:justify-center;
&:first-child {
&:not([data-title]) {
& .th,
& .td {
@apply first:md:rounded-tl-2xl;
}
}
& .th,
& .td {
@apply last:md:rounded-tr-2xl;
}
}
&:last-child {
@apply max-md:rounded-b-2xl;
&[data-title] {
@apply before:rounded-bl-2xl before:border-b-0;
@apply before:border-b-0;
}
.td {
@apply border-b-0 first:md:rounded-bl-2xl last:md:rounded-br-2xl;
@apply border-b-0;
}
}
@@ -79,12 +65,9 @@ table.table,
@apply max-md:rounded-t-2xl;
}
&.cbi-rowstyle-1 {
@apply bg-surface-raised;
}
&.cbi-rowstyle-1,
&.cbi-rowstyle-2 {
@apply bg-table-row-alternate-bg;
@apply bg-transparent;
}
& > .col-1 {
@@ -130,7 +113,7 @@ table.table,
& th,
& .th {
@apply border-border-subtle bg-table-header-bg border-b px-3 py-3 text-left text-base font-medium max-md:flex-[1_1_50%] max-md:border-0 max-md:text-sm;
@apply border-hairline bg-surface-sunken text-text-subtle border-b px-3 py-3 text-left text-xs font-semibold tracking-wider uppercase first:rounded-tl-2xl last:rounded-tr-2xl max-md:flex-[1_1_50%] max-md:border-0 max-md:bg-transparent;
#cbi-samba4 & {
@apply px-0.5 py-1.5;
@@ -145,7 +128,7 @@ table.table,
}
&[data-sortable-row="true"] {
@apply hover:bg-table-row-hover-bg cursor-pointer select-none after:ml-0.5 after:text-xs;
@apply hover:bg-hover-faint cursor-pointer select-none after:ml-0.5 after:text-xs;
&[data-sort-direction="asc"] {
@apply after:content-['\25B2'];
}
@@ -163,7 +146,7 @@ table.table,
& td,
& .td {
@apply border-border-subtle table-cell border-b px-3 py-3 align-middle text-base wrap-break-word whitespace-normal max-md:flex-[1_1_50%] max-md:border-0 max-md:px-1 max-md:py-1 max-md:text-sm;
@apply border-hairline table-cell border-b px-3 py-3 align-middle text-base wrap-break-word whitespace-normal max-md:flex-[1_1_50%] max-md:border-0 max-md:px-1 max-md:py-1 max-md:text-sm;
@apply before:hidden before:content-[attr(data-title)] before:max-md:mb-1 before:max-md:block before:max-md:text-xs before:max-md:font-semibold before:max-md:tracking-wider;
#cbi-samba4 & {
@@ -1,5 +1,5 @@
textarea {
@apply text-content border-border-subtle bg-surface placeholder-content-muted focus:border-brand focus:ring-brand-emphasis min-h-24 w-full resize-y rounded-2xl border px-3 py-2 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-200 focus:ring-2 focus:outline-none;
@apply text-text border-hairline bg-surface-sunken placeholder-text-muted focus:border-brand focus:ring-focus-ring min-h-24 w-full resize-y rounded-2xl border px-3 py-2 text-sm font-normal shadow-sm transition-[border-color,box-shadow] duration-150 focus:ring-2 focus:outline-none;
&[disabled] {
@apply cursor-not-allowed opacity-40 dark:opacity-30;
}
@@ -1,16 +1,16 @@
.cbi-tooltip {
@apply bg-tooltip-bg text-content border-border-subtle absolute z-110 max-w-xs scale-95 rounded-xl border px-3 py-2 text-xs break-normal whitespace-normal opacity-0 shadow-lg transition-[visibility,opacity,scale] duration-150 ease-out;
@apply bg-surface-overlay text-text border-hairline absolute z-110 max-w-xs scale-95 rounded-xl border px-3 py-2 text-xs break-normal whitespace-normal opacity-0 shadow-xl transition-[visibility,opacity,scale] duration-150 ease-out;
&.error {
@apply border-danger-border bg-danger-surface text-danger-content;
@apply border-hairline bg-danger-surface text-danger;
}
&.success {
@apply border-success-border bg-success-surface text-success-content;
@apply border-hairline bg-success-surface text-success;
}
&.info {
@apply border-info-border bg-info-surface text-info-content;
@apply border-hairline bg-info-surface text-info;
}
&.notice {
@apply border-warning-border bg-warning-surface text-warning-content;
@apply border-hairline bg-warning-surface text-warning;
}
}
+14 -25
View File
@@ -7,19 +7,10 @@
@import "tailwindcss";
@import "./_tokens.css";
@import "./_reduced-motion.css";
@import "./_base.css";
@custom-variant dark (&:where([data-darkmode=true], [data-darkmode=true] *));
@layer base {
html {
@apply bg-canvas h-full font-sans;
}
body {
@apply text-content bg-canvas min-h-full;
}
}
@keyframes divider-in {
from {
opacity: 0;
@@ -38,16 +29,14 @@
/* Fixed sibling layer (not form ancestor) on its own GPU surface so input
paints don't re-sample the bg image. DOM order keeps the form on top. */
/* LQIP shown immediately; falls back to full image when no LQIP stored */
.login-bg {
@apply pointer-events-none fixed inset-0 transform-[translateZ(0)] bg-cover bg-center bg-no-repeat;
/* LQIP shown immediately; falls back to full image when no LQIP stored */
background-image: var(--login-bg-lqip, var(--login-bg));
@apply pointer-events-none fixed inset-0 transform-[translateZ(0)] bg-cover bg-center bg-no-repeat [background-image:var(--login-bg-lqip,var(--login-bg))];
}
/* Full image fades in on top once loaded (compositor-only opacity change) */
.login-bg::after {
@apply pointer-events-none absolute inset-0 content-[''] bg-cover bg-center bg-no-repeat opacity-0 [will-change:opacity] [transition:opacity_0.5s_ease];
background-image: var(--login-bg);
@apply pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 [will-change:opacity] content-[''] [transition:opacity_0.5s_ease] [background-image:var(--login-bg)];
}
.login-bg.full-loaded::after {
@@ -55,7 +44,7 @@
}
.login-card {
@apply bg-surface-raised border-border-subtle flex flex-col gap-7 rounded-4xl border p-14 max-md:rounded-3xl max-md:p-9;
@apply bg-surface flex flex-col gap-7 rounded-4xl p-14 shadow-lg max-md:rounded-3xl max-md:p-9;
}
.login-brand {
@@ -63,11 +52,11 @@
}
.login-divider {
@apply via-border-subtle h-px w-full border-0 bg-linear-to-r from-transparent to-transparent animate-[divider-in_0.7s_cubic-bezier(0.16,1,0.3,1)_0.1s_both];
@apply via-hairline h-px w-full animate-[divider-in_0.7s_cubic-bezier(0.16,1,0.3,1)_0.1s_both] border-0 bg-linear-to-r from-transparent to-transparent;
}
.login-logo {
@apply bg-brand-soft ring-brand-emphasis flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ring-1;
@apply bg-brand-subtle ring-brand-subtle flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ring-1;
& img {
@apply h-6 w-6;
@@ -79,11 +68,11 @@
}
.login-hostname {
@apply text-content text-xl font-semibold tracking-tight;
@apply text-text text-xl font-semibold tracking-tight;
}
.login-subtitle {
@apply text-content text-sm;
@apply text-text text-sm;
}
.login-form {
@@ -99,11 +88,11 @@
}
.login-label {
@apply text-content text-[0.6875rem] font-semibold tracking-widest uppercase;
@apply text-text text-xs font-semibold tracking-widest uppercase;
}
.input-wrap {
@apply border-border-subtle bg-surface-raised relative rounded-2xl border [contain:layout_style];
@apply border-hairline bg-surface-sunken relative rounded-2xl border [contain:layout_style];
}
/* Focus ring: opacity-only transition runs on compositor thread, not main thread —
@@ -117,11 +106,11 @@
}
.login-input {
@apply text-content placeholder:text-content-subtle w-full appearance-none rounded-2xl border-0 bg-transparent px-3 py-2.5 text-base outline-none;
@apply text-text placeholder:text-text-subtle w-full appearance-none rounded-2xl border-0 bg-transparent px-3 py-2.5 text-base outline-none;
}
.login-error {
@apply bg-danger-surface text-danger-content border-danger-border rounded-2xl border px-3 py-2 text-center text-sm;
@apply bg-danger-surface text-danger border-hairline rounded-2xl border px-3 py-2 text-center text-sm;
}
.login-actions {
@@ -133,7 +122,7 @@
}
.login-footer {
@apply text-content-muted absolute right-0 bottom-5 left-0 px-5 text-center text-xs;
@apply text-text-muted absolute right-0 bottom-5 left-0 px-5 text-center text-xs;
& p {
@apply whitespace-normal;
@@ -16,6 +16,7 @@
@import "./_tokens.css";
@import "./_base.css";
@import "./_elements.css";
@import "./_layout.css";
@import "./components/_nav.css";
+224 -256
View File
@@ -27,24 +27,13 @@ return baseclass.extend({
if (!navigationToggle || !overlay) return;
const mobileList = overlay.querySelector("#mobile-nav-list");
const desktop = window.matchMedia("(min-width: 768px)");
const SIDEBAR_COLLAPSED_KEY = "aurora.sidebarCollapsed";
const isDesktopSidebar = () =>
desktop.matches && document.body.dataset.navType === "sidebar";
const resetMobileSubmenus = () => {
overlay
.querySelectorAll(".mobile-nav-item.submenu-expanded")
.forEach((item) => this.setMobileSubmenuExpanded(item, false));
};
const expandActiveMobileGroup = () => {
const activeGroup = overlay.querySelector(".mobile-nav-item.has-active");
if (activeGroup) this.setMobileSubmenuExpanded(activeGroup, true);
};
const updateToggleState = (expanded) => {
const desktopSidebar = isDesktopSidebar();
@@ -74,7 +63,7 @@ return baseclass.extend({
overlay.classList.remove("mobile-menu-open");
document.body.classList.remove("mobile-navigation-open");
document.body.style.overflow = "";
resetMobileSubmenus();
this.resetNavigationGroups(mobileList);
};
const getNavigationExpanded = () => {
@@ -106,8 +95,8 @@ return baseclass.extend({
document.body.classList.toggle("mobile-navigation-open", expanded);
document.body.style.overflow = expanded ? "hidden" : "";
if (expanded) expandActiveMobileGroup();
else resetMobileSubmenus();
if (expanded) this.expandActiveNavigationGroup(mobileList);
else this.resetNavigationGroups(mobileList);
updateToggleState(expanded);
};
@@ -151,31 +140,8 @@ return baseclass.extend({
syncNavigationState();
document.addEventListener("click", (e) => {
const toggle = e.target.closest(".mobile-nav-toggle");
if (toggle && overlay.contains(toggle)) {
e.preventDefault();
e.stopPropagation();
const item = toggle.closest(".mobile-nav-item");
if (!item) return;
const isExpanded = item.classList.contains("submenu-expanded");
overlay
.querySelectorAll(".mobile-nav-item.submenu-expanded")
.forEach((expandedItem) => {
if (expandedItem !== item) {
this.setMobileSubmenuExpanded(expandedItem, false);
}
});
this.setMobileSubmenuExpanded(item, !isExpanded);
return;
}
const destination = e.target.closest(
".mobile-nav-link:not(.mobile-nav-toggle), .mobile-nav-sublink, .mobile-nav-logout",
const destination = e.target?.closest?.(
".navigation-direct, .navigation-sublink, .mobile-nav-logout",
);
if (destination && overlay.contains(destination)) {
@@ -184,105 +150,31 @@ return baseclass.extend({
});
},
renderMobileMenu(tree, url) {
renderMobileMenu(items) {
const list = document.querySelector("#mobile-nav-list");
const footerAction = document.querySelector("#mobile-nav-footer-action");
const children = ui.menu.getChildren(tree);
if (list) list.innerHTML = "";
if (footerAction) footerAction.innerHTML = "";
if (!list) return;
list.innerHTML = "";
if (footerAction) footerAction.innerHTML = "";
if (!children.length) return;
children.forEach((child) => {
const submenu = ui.menu.getChildren(child);
const hasSubmenu = submenu.length > 0;
const isActive = this.isActivePath(child.name);
if (child.name === "logout") {
items.forEach((item) => {
if (item.isLogout) {
if (footerAction) {
footerAction.appendChild(
E(
"a",
{
class: "mobile-nav-logout",
href: L.url(url, child.name),
},
[_(child.title)],
),
E("a", { class: "mobile-nav-logout", href: item.href }, [
item.title,
]),
);
}
return;
}
const itemClasses = [
"mobile-nav-item",
hasSubmenu ? "has-submenu" : "",
isActive && hasSubmenu ? "has-active" : "",
].filter(Boolean);
const submenuId = `mobile-submenu-${String(child.name).replace(
/[^A-Za-z0-9_-]/g,
"-",
)}`;
const control = hasSubmenu
? E(
"button",
{
class: `mobile-nav-link mobile-nav-toggle${
isActive ? " nav-link-active" : ""
}`,
type: "button",
"aria-expanded": "false",
"aria-controls": submenuId,
},
[_(child.title)],
)
: E(
"a",
{
class: `mobile-nav-link${isActive ? " nav-link-active" : ""}`,
href: L.url(url, child.name),
},
[_(child.title)],
);
const li = E("li", { class: itemClasses.join(" ") }, [control]);
if (hasSubmenu) {
const wrap = E("div", {
class: "mobile-nav-submenu",
id: submenuId,
"aria-hidden": "true",
inert: "",
});
const ul = E("ul", { class: "mobile-nav-submenu-list" });
submenu.forEach((item) => {
const subActive = this.isActivePath(child.name, item.name);
ul.appendChild(
E("li", { class: "mobile-nav-subitem" }, [
E(
"a",
{
class: `mobile-nav-sublink${
subActive ? " nav-link-active" : ""
}`,
href: L.url(url, child.name, item.name),
},
[_(item.title)],
),
]),
);
});
wrap.appendChild(ul);
li.appendChild(wrap);
}
list.appendChild(li);
list.appendChild(this.renderNavigationItem(item, "mobile"));
});
this.bindNavigationAccordion(list);
},
render(tree) {
@@ -336,26 +228,32 @@ return baseclass.extend({
return ul;
},
renderMainMenu(tree, url, level = 0) {
renderMainMenu(tree, url, level = 0, navigationItems = null) {
const ul = level
? E("ul", { class: "desktop-nav-list" })
: document.querySelector("#topmenu");
const children = ui.menu.getChildren(tree);
if (!children.length || level > 1) return E([]);
if (level > 1) return E([]);
if (level === 0) {
const navType = document.body?.dataset?.navType || "mega-menu";
if (navType === "sidebar") {
this.renderSidebar(children, url);
return ul;
} else if (navType === "mega-menu") {
this.renderSidebar(navigationItems || []);
return ul || E([]);
}
if (!ul || !children.length) return E([]);
if (navType === "mega-menu") {
this.initMegaMenu(children, url, ul);
} else {
this.initBoxedDropdown(children, url, ul);
}
} else {
if (!children.length) return E([]);
children.forEach((child) => {
ul.appendChild(
E("li", {}, [
@@ -377,144 +275,208 @@ return baseclass.extend({
return page == null || L.env.dispatchpath[2] === page;
},
setMobileSubmenuExpanded(item, expanded) {
const toggle = item.querySelector(".mobile-nav-toggle");
const submenu = item.querySelector(".mobile-nav-submenu");
buildNavigationModel(children, url) {
return children
.filter((child) => child?.name)
.map((child) => {
const pages = ui.menu
.getChildren(child)
.filter((page) => page?.name)
.map((page) => ({
name: page.name,
title: _(page.title),
href: L.url(url, child.name, page.name),
isActivePage: this.isActivePath(child.name, page.name),
}));
const hasChildren = pages.length > 0;
const isCurrentTopLevel = this.isActivePath(child.name);
item.classList.toggle("submenu-expanded", expanded);
toggle?.setAttribute("aria-expanded", expanded ? "true" : "false");
submenu?.setAttribute("aria-hidden", expanded ? "false" : "true");
if (expanded) submenu?.removeAttribute("inert");
else submenu?.setAttribute("inert", "");
return {
name: child.name,
title: _(child.title),
href: L.url(url, child.name),
hasChildren,
isLogout: child.name === "logout",
isActiveGroup: hasChildren && isCurrentTopLevel,
isActivePage: !hasChildren && isCurrentTopLevel,
activePage: pages.find((page) => page.isActivePage) || null,
pages,
};
});
},
setSidebarSectionExpanded(item, expanded) {
const category = item.querySelector(".nav-category");
const section = item.querySelector(".sidebar-section");
navigationSubmenuId(mode, name) {
return `${mode}-submenu-${encodeURIComponent(String(name))}`;
},
item.classList.toggle("sidebar-group-open", expanded);
category?.setAttribute("aria-expanded", expanded ? "true" : "false");
section?.setAttribute("aria-hidden", expanded ? "false" : "true");
renderNavigationItem(item, mode) {
const mobile = mode === "mobile";
const itemClass = mobile ? "mobile-nav-item" : "";
const directClass = mobile
? "navigation-direct mobile-nav-link"
: "navigation-direct nav-link";
if (!item.hasChildren) {
const attributes = {
class: `${directClass}${item.isActivePage ? " is-active-page" : ""}`,
href: item.href,
};
if (item.isActivePage) attributes["aria-current"] = "page";
return E("li", { class: itemClass }, [E("a", attributes, [item.title])]);
}
const submenuId = this.navigationSubmenuId(mode, item.name);
const groupClasses = [
"navigation-group",
mobile ? "mobile-nav-item" : "sidebar-group",
item.isActiveGroup ? "is-active-group" : "",
item.isActiveGroup ? "is-expanded" : "",
].filter(Boolean);
const toggleAttributes = {
class: mobile
? "navigation-group-toggle mobile-nav-link mobile-nav-toggle"
: "navigation-group-toggle nav-category",
type: "button",
"aria-expanded": item.isActiveGroup ? "true" : "false",
"aria-controls": submenuId,
};
if (item.isActiveGroup) toggleAttributes["aria-current"] = "location";
const list = E("ul", {
class: mobile
? "navigation-submenu-list mobile-nav-submenu-list"
: "navigation-submenu-list sidebar-submenu",
});
item.pages.forEach((page) => {
const linkAttributes = {
class: [
"navigation-sublink",
mobile ? "mobile-nav-sublink" : "",
page.isActivePage ? "is-active-page" : "",
]
.filter(Boolean)
.join(" "),
href: page.href,
};
if (page.isActivePage) linkAttributes["aria-current"] = "page";
list.appendChild(
E("li", { class: mobile ? "mobile-nav-subitem" : "" }, [
E("a", linkAttributes, [page.title]),
]),
);
});
const regionAttributes = {
class: mobile
? "navigation-group-region mobile-nav-submenu"
: "navigation-group-region sidebar-section",
id: submenuId,
"aria-hidden": item.isActiveGroup ? "false" : "true",
};
if (!item.isActiveGroup) regionAttributes.inert = "";
return E("li", { class: groupClasses.join(" ") }, [
E("button", toggleAttributes, [
E("span", { class: "nav-category-label" }, [item.title]),
]),
E("div", regionAttributes, [list]),
]);
},
setNavigationGroupExpanded(item, expanded) {
const toggle = item.querySelector(".navigation-group-toggle");
const region = item.querySelector(".navigation-group-region");
item.classList.toggle("is-expanded", expanded);
toggle?.setAttribute("aria-expanded", expanded ? "true" : "false");
region?.setAttribute("aria-hidden", expanded ? "false" : "true");
if (expanded) region?.removeAttribute("inert");
else region?.setAttribute("inert", "");
},
setExclusiveNavigationGroupExpanded(surface, item, expanded) {
if (!surface || !item) return;
if (expanded) {
section?.removeAttribute("inert");
} else {
section?.setAttribute("inert", "");
surface
.querySelectorAll(".navigation-group.is-expanded")
.forEach((expandedItem) => {
if (expandedItem !== item) {
this.setNavigationGroupExpanded(expandedItem, false);
}
});
}
this.setNavigationGroupExpanded(item, expanded);
},
resetNavigationGroups(surface) {
surface
?.querySelectorAll(".navigation-group.is-expanded")
.forEach((item) => this.setNavigationGroupExpanded(item, false));
},
expandActiveNavigationGroup(surface) {
const activeGroup = surface?.querySelector(
".navigation-group.is-active-group",
);
if (activeGroup) {
this.setExclusiveNavigationGroupExpanded(surface, activeGroup, true);
}
},
renderSidebar(children, url) {
bindNavigationAccordion(surface) {
if (!surface || surface.dataset.accordionBound === "true") return;
surface.dataset.accordionBound = "true";
surface.addEventListener("click", (event) => {
const toggle = event.target?.closest?.(".navigation-group-toggle");
const item = toggle?.closest?.(".navigation-group");
if (!item || !surface.contains(item)) return;
event.preventDefault();
event.stopPropagation();
this.setExclusiveNavigationGroupExpanded(
surface,
item,
!item.classList.contains("is-expanded"),
);
});
},
renderSidebar(items) {
const list = document.querySelector("#sidebar-list");
const footer = document.querySelector("#sidebar-footer");
const crumbEl = document.querySelector("#header-crumb");
if (list) list.innerHTML = "";
if (footer) footer.innerHTML = "";
if (crumbEl) crumbEl.innerHTML = "";
if (!list) return;
const crumb = [];
const link = (href, title, active) =>
E("a", { class: `nav-link${active ? " nav-link-active" : ""}`, href }, [
_(title),
]);
children.forEach((child) => {
const submenu = ui.menu.getChildren(child);
const inGroup = this.isActivePath(child.name);
if (inGroup) {
crumb.push(_(child.title));
const page = submenu.find((item) =>
this.isActivePath(child.name, item.name),
);
if (page) crumb.push(_(page.title));
items.forEach((item) => {
if (item.isActiveGroup || item.isActivePage) {
crumb.push(item.title);
if (item.activePage) crumb.push(item.activePage.title);
}
if (child.name === "logout") {
if (item.isLogout) {
(footer || list).appendChild(
link(L.url(url, child.name), child.title, false),
E("a", { class: "nav-link", href: item.href }, [item.title]),
);
return;
}
if (!submenu.length) {
list.appendChild(
E("li", {}, [link(L.url(url, child.name), child.title, inGroup)]),
);
return;
}
const sectionId = `sidebar-section-${String(child.name).replace(
/[^A-Za-z0-9_-]/g,
"-",
)}`;
const ul = E("ul", { class: "sidebar-submenu" });
submenu.forEach((item) => {
ul.appendChild(
E("li", {}, [
link(
L.url(url, child.name, item.name),
item.title,
this.isActivePath(child.name, item.name),
),
]),
);
});
const sectionAttrs = {
class: "sidebar-section",
id: sectionId,
"aria-hidden": inGroup ? "false" : "true",
};
if (!inGroup) sectionAttrs.inert = "";
const groupClasses = [
"sidebar-group",
inGroup ? "sidebar-group-open" : "",
].filter(Boolean);
list.appendChild(
E(
"li",
{
class: groupClasses.join(" "),
"data-section": child.name,
},
[
E(
"button",
{
class: "nav-category",
type: "button",
"aria-expanded": inGroup ? "true" : "false",
"aria-controls": sectionId,
},
[E("span", { class: "nav-category-label" }, [_(child.title)])],
),
E("div", sectionAttrs, [ul]),
],
),
);
list.appendChild(this.renderNavigationItem(item, "sidebar"));
});
list.addEventListener("click", (e) => {
const category = e.target.closest(".nav-category");
const item = category?.closest(".sidebar-group");
if (!item || !list.contains(item)) return;
const shouldOpen = !item.classList.contains("sidebar-group-open");
list.querySelectorAll(".sidebar-group-open").forEach((group) => {
if (group !== item) this.setSidebarSectionExpanded(group, false);
});
this.setSidebarSectionExpanded(item, shouldOpen);
});
const crumbEl = document.querySelector("#header-crumb");
this.bindNavigationAccordion(list);
crumb.forEach((title, i) => {
if (i) crumbEl?.appendChild(E("li", { class: "crumb-sep" }, ["/"]));
@@ -789,25 +751,31 @@ return baseclass.extend({
? child.name === L.env.requestpath[0]
: index === 0;
ul.appendChild(
E(
"li",
{
class: isActive ? "active" : "",
},
[E("a", { href: L.url(child.name) }, [_(child.title)])],
),
);
if (ul) {
ul.appendChild(
E(
"li",
{
class: isActive ? "active" : "",
},
[E("a", { href: L.url(child.name) }, [_(child.title)])],
),
);
}
if (isActive) activeChild = child;
});
if (activeChild) {
this.renderMainMenu(activeChild, activeChild.name);
this.renderMobileMenu(activeChild, activeChild.name);
const navigationItems = this.buildNavigationModel(
ui.menu.getChildren(activeChild),
activeChild.name,
);
this.renderMainMenu(activeChild, activeChild.name, 0, navigationItems);
this.renderMobileMenu(navigationItems);
}
if (ul.children.length > 1) {
if (ul?.children.length > 1) {
ul.style.display = "";
}
},
@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import Color from "colorjs.io";
import { mix, shade, set, alpha, konst, toOklch } from "../tokens/engine.js";
const l = (v) => new Color(toOklch(v)).to("oklch").coords[0];
const aOf = (v) => {
const c = new Color(toOklch(v));
return c.alpha;
};
test("mix: 50% white + black ≈ mid gray", () => {
const L = l(mix("white", "black", 0.5));
assert.ok(L > 0.4 && L < 0.6, `L=${L}`);
});
test("shade: lowers lightness by delta", () => {
const L = l(shade("oklch(0.5 0.1 200)", -0.1));
assert.ok(Math.abs(L - 0.4) < 0.02, `L=${L}`);
});
test("set: fixes L and C, keeps hue", () => {
const c = new Color(
toOklch(set("oklch(0.5 0.1 200)", 0.9, 0.05)),
).to("oklch");
assert.ok(Math.abs(c.coords[0] - 0.9) < 0.001);
assert.ok(Math.abs(c.coords[1] - 0.05) < 0.001);
assert.ok(Math.abs(c.coords[2] - 200) < 0.5);
});
test("alpha: sets opacity", () => {
assert.ok(
Math.abs(aOf(alpha("oklch(0.6 0.1 200)", 0.5)) - 0.5) < 0.001,
);
});
test("toOklch: serializes without color-mix/var", () => {
const s = toOklch(mix("oklch(0.68 0.11 233)", "white", 0.15));
assert.ok(s.startsWith("oklch("));
assert.ok(!s.includes("color-mix") && !s.includes("var("));
});
@@ -0,0 +1,569 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
const source = await readFile(
new URL("../src/resource/menu-aurora.js", import.meta.url),
"utf8",
);
const menuOrder = (item) => (Number.isFinite(item.order) ? item.order : 1000);
const compareMenuItems = (a, b) => {
const orderDifference = menuOrder(a) - menuOrder(b);
if (orderDifference) return orderDifference;
if (a.name === b.name) return 0;
return a.name < b.name ? -1 : 1;
};
const getMenuChildren = (children = {}) =>
Object.entries(children)
.filter(([, child]) => child?.satisfied !== false && child?.title)
.map(([name, child]) => ({ ...child, name }))
.sort(compareMenuItems);
const loadMenuModule = (
dispatchpath,
getChildren = (item) => getMenuChildren(item?.children),
) => {
const baseclass = {
extend(module) {
return module;
},
};
const ui = { menu: { getChildren } };
const E = () => ({});
const L = {
env: { dispatchpath },
url: (...segments) => `/${segments.join("/")}`,
};
const translate = (title) => `translated:${title}`;
const document = {};
const window = {};
const localStorage = {};
return new Function(
"baseclass",
"ui",
"E",
"L",
"_",
"document",
"window",
"localStorage",
source,
)(baseclass, ui, E, L, translate, document, window, localStorage);
};
test("normalizes, orders, and filters a mixed navigation model", () => {
const menu = loadMenuModule(["admin", "network", "wireless"]);
const tree = {
children: {
logout: { title: "Logout", order: 30 },
titleless: { order: 5 },
status: { title: "Status", order: 20 },
unavailable: { title: "Unavailable", order: 4, satisfied: false },
network: {
title: "Network",
order: 10,
children: {
wireless: { title: "Wireless", order: 10 },
hidden: { title: "Hidden", order: 5, satisfied: false },
titleless: { order: 6 },
interfaces: { title: "Interfaces", order: 10 },
},
},
},
};
assert.deepEqual(
menu.buildNavigationModel(getMenuChildren(tree.children), "admin"),
[
{
name: "network",
title: "translated:Network",
href: "/admin/network",
hasChildren: true,
isLogout: false,
isActiveGroup: true,
isActivePage: false,
activePage: {
name: "wireless",
title: "translated:Wireless",
href: "/admin/network/wireless",
isActivePage: true,
},
pages: [
{
name: "interfaces",
title: "translated:Interfaces",
href: "/admin/network/interfaces",
isActivePage: false,
},
{
name: "wireless",
title: "translated:Wireless",
href: "/admin/network/wireless",
isActivePage: true,
},
],
},
{
name: "status",
title: "translated:Status",
href: "/admin/status",
hasChildren: false,
isLogout: false,
isActiveGroup: false,
isActivePage: false,
activePage: null,
pages: [],
},
{
name: "logout",
title: "translated:Logout",
href: "/admin/logout",
hasChildren: false,
isLogout: true,
isActiveGroup: false,
isActivePage: false,
activePage: null,
pages: [],
},
],
);
});
test("marks a direct current destination as an active page, not a group", () => {
const menu = loadMenuModule(["admin", "status"]);
const tree = {
children: {
status: { title: "Status" },
},
};
assert.deepEqual(
menu.buildNavigationModel(getMenuChildren(tree.children), "admin"),
[
{
name: "status",
title: "translated:Status",
href: "/admin/status",
hasChildren: false,
isLogout: false,
isActiveGroup: false,
isActivePage: true,
activePage: null,
pages: [],
},
],
);
});
test("omits invalid top-level items without a name", () => {
const menu = loadMenuModule(["admin", "status"]);
assert.deepEqual(
menu.buildNavigationModel(
[
{ title: "Invalid", children: {} },
...getMenuChildren({
status: { title: "Status" },
}),
],
"admin",
),
[
{
name: "status",
title: "translated:Status",
href: "/admin/status",
hasChildren: false,
isLogout: false,
isActiveGroup: false,
isActivePage: true,
activePage: null,
pages: [],
},
],
);
});
test("omits pages without a name returned by the menu API", () => {
const getChildren = () => [
{ title: "Invalid" },
{ name: "wireless", title: "Wireless" },
];
const menu = loadMenuModule(["admin", "network", "wireless"], getChildren);
assert.deepEqual(
menu.buildNavigationModel(
[{ name: "network", title: "Network" }],
"admin",
)[0].pages,
[
{
name: "wireless",
title: "translated:Wireless",
href: "/admin/network/wireless",
isActivePage: true,
},
],
);
});
test("returns an empty navigation model for empty input", () => {
const menu = loadMenuModule(["admin"]);
assert.deepEqual(menu.buildNavigationModel([], "admin"), []);
});
const createClassList = (...classes) => {
const values = new Set(classes);
return {
contains(className) {
return values.has(className);
},
toggle(className, force) {
const enabled = force ?? !values.has(className);
if (enabled) values.add(className);
else values.delete(className);
return enabled;
},
};
};
const createFakeElement = (...classes) => {
const attributes = new Map();
const element = {
classList: createClassList(...classes),
parentElement: null,
closest(selector) {
if (!selector.startsWith(".")) return null;
const className = selector.slice(1);
let current = element;
while (current) {
if (current.classList?.contains(className)) return current;
current = current.parentElement;
}
return null;
},
getAttribute(name) {
return attributes.get(name) ?? null;
},
hasAttribute(name) {
return attributes.has(name);
},
querySelector() {
return null;
},
removeAttribute(name) {
attributes.delete(name);
},
setAttribute(name, value) {
attributes.set(name, String(value));
},
};
return element;
};
const createNavigationGroup = (...classes) => {
const item = createFakeElement("navigation-group", ...classes);
const toggle = createFakeElement("navigation-group-toggle");
const region = createFakeElement("navigation-group-region");
toggle.parentElement = item;
region.parentElement = item;
item.querySelector = (selector) => {
if (selector === ".navigation-group-toggle") return toggle;
if (selector === ".navigation-group-region") return region;
return null;
};
return { item, region, toggle };
};
const createNavigationSurface = (groups) => {
const items = groups.map((group) => group.item);
const listeners = new Map();
return {
dataset: {},
addEventListener(type, listener) {
const typeListeners = listeners.get(type) ?? [];
typeListeners.push(listener);
listeners.set(type, typeListeners);
},
contains(item) {
return items.includes(item);
},
dispatch(type, event) {
for (const listener of listeners.get(type) ?? []) listener(event);
},
listenerCount(type) {
return listeners.get(type)?.length ?? 0;
},
querySelector(selector) {
if (selector === ".navigation-group.is-active-group") {
return (
items.find((item) => item.classList.contains("is-active-group")) ??
null
);
}
return null;
},
querySelectorAll(selector) {
if (selector === ".navigation-group.is-expanded") {
return items.filter((item) => item.classList.contains("is-expanded"));
}
return [];
},
};
};
test("sets one navigation group expanded while preserving active state", () => {
const menu = loadMenuModule(["admin"]);
const { item, region, toggle } = createNavigationGroup(
"is-active-group",
"custom-state",
);
region.setAttribute("aria-hidden", "true");
region.setAttribute("inert", "");
menu.setNavigationGroupExpanded(item, true);
assert.equal(item.classList.contains("is-expanded"), true);
assert.equal(item.classList.contains("is-active-group"), true);
assert.equal(item.classList.contains("custom-state"), true);
assert.equal(toggle.getAttribute("aria-expanded"), "true");
assert.equal(region.getAttribute("aria-hidden"), "false");
assert.equal(region.hasAttribute("inert"), false);
menu.setNavigationGroupExpanded(item, false);
assert.equal(item.classList.contains("is-expanded"), false);
assert.equal(item.classList.contains("is-active-group"), true);
assert.equal(item.classList.contains("custom-state"), true);
assert.equal(toggle.getAttribute("aria-expanded"), "false");
assert.equal(region.getAttribute("aria-hidden"), "true");
assert.equal(region.getAttribute("inert"), "");
});
test("sets an expanded navigation group when controls or regions are missing", () => {
const menu = loadMenuModule(["admin"]);
const item = createFakeElement("navigation-group");
assert.doesNotThrow(() => menu.setNavigationGroupExpanded(item, true));
assert.equal(item.classList.contains("is-expanded"), true);
});
test("exclusively expands one navigation group", () => {
const menu = loadMenuModule(["admin"]);
const first = createNavigationGroup();
const second = createNavigationGroup();
const surface = createNavigationSurface([first, second]);
menu.setNavigationGroupExpanded(first.item, true);
menu.setExclusiveNavigationGroupExpanded(surface, second.item, true);
assert.equal(first.item.classList.contains("is-expanded"), false);
assert.equal(first.toggle.getAttribute("aria-expanded"), "false");
assert.equal(first.region.getAttribute("aria-hidden"), "true");
assert.equal(first.region.hasAttribute("inert"), true);
assert.equal(second.item.classList.contains("is-expanded"), true);
assert.equal(second.toggle.getAttribute("aria-expanded"), "true");
assert.equal(second.region.getAttribute("aria-hidden"), "false");
assert.equal(second.region.hasAttribute("inert"), false);
});
test("collapses only the requested navigation group", () => {
const menu = loadMenuModule(["admin"]);
const first = createNavigationGroup();
const second = createNavigationGroup();
const surface = createNavigationSurface([first, second]);
menu.setNavigationGroupExpanded(first.item, true);
menu.setNavigationGroupExpanded(second.item, true);
menu.setExclusiveNavigationGroupExpanded(surface, second.item, false);
assert.equal(first.item.classList.contains("is-expanded"), true);
assert.equal(first.toggle.getAttribute("aria-expanded"), "true");
assert.equal(second.item.classList.contains("is-expanded"), false);
assert.equal(second.toggle.getAttribute("aria-expanded"), "false");
});
test("ignores missing exclusive navigation group arguments", () => {
const menu = loadMenuModule(["admin"]);
const group = createNavigationGroup();
const surface = createNavigationSurface([group]);
assert.doesNotThrow(() =>
menu.setExclusiveNavigationGroupExpanded(null, group.item, true),
);
assert.doesNotThrow(() =>
menu.setExclusiveNavigationGroupExpanded(surface, null, true),
);
assert.equal(group.item.classList.contains("is-expanded"), false);
});
test("resets every expanded navigation group", () => {
const menu = loadMenuModule(["admin"]);
const first = createNavigationGroup();
const second = createNavigationGroup();
const collapsed = createNavigationGroup();
const surface = createNavigationSurface([first, second, collapsed]);
menu.setNavigationGroupExpanded(first.item, true);
menu.setNavigationGroupExpanded(second.item, true);
menu.resetNavigationGroups(surface);
assert.equal(first.item.classList.contains("is-expanded"), false);
assert.equal(first.toggle.getAttribute("aria-expanded"), "false");
assert.equal(first.region.hasAttribute("inert"), true);
assert.equal(second.item.classList.contains("is-expanded"), false);
assert.equal(second.toggle.getAttribute("aria-expanded"), "false");
assert.equal(second.region.hasAttribute("inert"), true);
assert.equal(collapsed.item.classList.contains("is-expanded"), false);
assert.equal(collapsed.toggle.getAttribute("aria-expanded"), null);
assert.doesNotThrow(() => menu.resetNavigationGroups(null));
});
test("expands the active navigation group exclusively", () => {
const menu = loadMenuModule(["admin"]);
const first = createNavigationGroup();
const active = createNavigationGroup("is-active-group");
const surface = createNavigationSurface([first, active]);
menu.setNavigationGroupExpanded(first.item, true);
menu.expandActiveNavigationGroup(surface);
assert.equal(first.item.classList.contains("is-expanded"), false);
assert.equal(active.item.classList.contains("is-expanded"), true);
assert.equal(active.item.classList.contains("is-active-group"), true);
assert.equal(active.toggle.getAttribute("aria-expanded"), "true");
assert.equal(active.region.hasAttribute("inert"), false);
});
test("ignores missing active navigation groups", () => {
const menu = loadMenuModule(["admin"]);
const surface = createNavigationSurface([createNavigationGroup()]);
assert.doesNotThrow(() => menu.expandActiveNavigationGroup(surface));
assert.doesNotThrow(() => menu.expandActiveNavigationGroup(null));
});
test("binds one delegated navigation accordion listener", () => {
const menu = loadMenuModule(["admin"]);
const first = createNavigationGroup();
const second = createNavigationGroup();
const surface = createNavigationSurface([first, second]);
let prevented = 0;
let stopped = 0;
menu.setNavigationGroupExpanded(first.item, true);
menu.bindNavigationAccordion(surface);
menu.bindNavigationAccordion(surface);
assert.equal(surface.dataset.accordionBound, "true");
assert.equal(surface.listenerCount("click"), 1);
surface.dispatch("click", {
target: second.toggle,
preventDefault() {
prevented += 1;
},
stopPropagation() {
stopped += 1;
},
});
assert.equal(prevented, 1);
assert.equal(stopped, 1);
assert.equal(first.item.classList.contains("is-expanded"), false);
assert.equal(second.item.classList.contains("is-expanded"), true);
surface.dispatch("click", {
target: createFakeElement("outside"),
preventDefault() {
prevented += 1;
},
stopPropagation() {
stopped += 1;
},
});
assert.equal(prevented, 1);
assert.equal(stopped, 1);
assert.equal(second.item.classList.contains("is-expanded"), true);
assert.doesNotThrow(() => menu.bindNavigationAccordion(null));
});
test("ignores delegated clicks from targets without closest", () => {
const menu = loadMenuModule(["admin"]);
const group = createNavigationGroup();
const surface = createNavigationSurface([group]);
let prevented = 0;
let stopped = 0;
menu.bindNavigationAccordion(surface);
assert.doesNotThrow(() =>
surface.dispatch("click", {
target: {},
preventDefault() {
prevented += 1;
},
stopPropagation() {
stopped += 1;
},
}),
);
assert.equal(prevented, 0);
assert.equal(stopped, 0);
assert.equal(group.item.classList.contains("is-expanded"), false);
});
test("handles delegated clicks from inside a navigation group toggle", () => {
const menu = loadMenuModule(["admin"]);
const first = createNavigationGroup();
const second = createNavigationGroup();
const surface = createNavigationSurface([first, second]);
const label = createFakeElement("navigation-group-label");
const span = createFakeElement("navigation-group-label-text");
let prevented = 0;
let stopped = 0;
label.parentElement = second.toggle;
span.parentElement = label;
menu.setNavigationGroupExpanded(first.item, true);
menu.bindNavigationAccordion(surface);
surface.dispatch("click", {
target: span,
preventDefault() {
prevented += 1;
},
stopPropagation() {
stopped += 1;
},
});
assert.equal(prevented, 1);
assert.equal(stopped, 1);
assert.equal(first.item.classList.contains("is-expanded"), false);
assert.equal(second.item.classList.contains("is-expanded"), true);
});
@@ -0,0 +1,723 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
const source = await readFile(
new URL("../src/resource/menu-aurora.js", import.meta.url),
"utf8",
);
const getMethodSource = (name) => {
const start = source.indexOf(` ${name}(`);
if (start === -1) return "";
const rest = source.slice(start + 2);
const nextMethod = rest.slice(name.length).search(/^ [A-Za-z_$][\w$]*\(/m);
return nextMethod === -1
? source.slice(start)
: source.slice(start, start + 2 + name.length + nextMethod);
};
class FakeClassList {
constructor(element) {
this.element = element;
this.values = new Set();
}
add(...classNames) {
classNames.forEach((className) => this.values.add(className));
this.sync();
}
contains(className) {
return this.values.has(className);
}
remove(...classNames) {
classNames.forEach((className) => this.values.delete(className));
this.sync();
}
replace(classNames) {
this.values = new Set(classNames.split(/\s+/).filter(Boolean));
}
sync() {
this.element.attributes.set("class", [...this.values].join(" "));
}
toggle(className, force) {
const enabled = force ?? !this.values.has(className);
if (enabled) this.values.add(className);
else this.values.delete(className);
this.sync();
return enabled;
}
}
class FakeElement {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.classList = new FakeClassList(this);
this.dataset = {};
this.listeners = new Map();
this.parentElement = null;
this.style = {
display: "",
properties: new Map(),
removeProperty: (name) => this.style.properties.delete(name),
setProperty: (name, value) => this.style.properties.set(name, value),
};
Object.entries(attributes).forEach(([name, value]) => {
this.setAttribute(name, value);
});
children.forEach((child) => this.appendChild(child));
}
appendChild(child) {
this.children.push(child);
if (child instanceof FakeElement) child.parentElement = this;
return child;
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
closest(selector) {
let current = this;
while (current) {
if (current.matches(selector)) return current;
current = current.parentElement;
}
return null;
}
contains(element) {
if (element === this) return true;
return this.children.some(
(child) => child instanceof FakeElement && child.contains(element),
);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
hasAttribute(name) {
return this.attributes.has(name);
}
listenerCount(type) {
return this.listeners.get(type)?.length ?? 0;
}
matches(selector) {
if (selector.startsWith("#")) {
return this.getAttribute("id") === selector.slice(1);
}
if (selector.startsWith(".")) {
return selector
.slice(1)
.split(".")
.every((className) => this.classList.contains(className));
}
return this.tagName === selector;
}
querySelector(selector) {
for (const child of this.children) {
if (!(child instanceof FakeElement)) continue;
if (child.matches(selector)) return child;
const descendant = child.querySelector(selector);
if (descendant) return descendant;
}
return null;
}
querySelectorAll(selector) {
const matches = [];
for (const child of this.children) {
if (!(child instanceof FakeElement)) continue;
if (child.matches(selector)) matches.push(child);
matches.push(...child.querySelectorAll(selector));
}
return matches;
}
removeAttribute(name) {
this.attributes.delete(name);
if (name === "class") this.classList.replace("");
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === "class") this.classList.replace(String(value));
if (name.startsWith("data-")) {
const dataName = name
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
this.dataset[dataName] = String(value);
}
}
set innerHTML(value) {
assert.equal(value, "");
this.children = [];
}
}
const E = (tagName, attributes, children) =>
new FakeElement(tagName, attributes, children);
const getMenuChildren = (item) =>
Object.entries(item?.children ?? {}).map(([name, child]) => ({
...child,
name,
}));
const createFakeDocument = ({ elements = {}, navType = "mega-menu" } = {}) => ({
body: {
dataset: { navType },
},
querySelector(selector) {
return elements[selector] ?? null;
},
querySelectorAll() {
return [];
},
});
const loadMenuModule = ({
dispatchpath = [],
document = createFakeDocument(),
getChildren = getMenuChildren,
requestpath = [],
translate = (value) => value,
} = {}) => {
const baseclass = {
extend(module) {
return module;
},
};
const ui = { menu: { getChildren } };
const L = {
env: { dispatchpath, requestpath },
url: (...segments) => `/${segments.join("/")}`,
};
return new Function(
"baseclass",
"ui",
"E",
"L",
"_",
"document",
"window",
"localStorage",
source,
)(baseclass, ui, E, L, translate, document, {}, {});
};
const textContent = (element) =>
element.children
.map((child) =>
child instanceof FakeElement ? textContent(child) : String(child),
)
.join("");
const directItem = (overrides = {}) => ({
name: "status",
title: "Status",
href: "/admin/status",
hasChildren: false,
isLogout: false,
isActiveGroup: false,
isActivePage: false,
activePage: null,
pages: [],
...overrides,
});
const groupItem = (overrides = {}) => ({
name: "network",
title: "Network",
href: "/admin/network",
hasChildren: true,
isLogout: false,
isActiveGroup: false,
isActivePage: false,
activePage: null,
pages: [],
...overrides,
});
test("defines the shared navigation renderer and emits its common contract", () => {
const renderer = getMethodSource("renderNavigationItem");
assert.match(renderer, /renderNavigationItem\(item, mode\)/);
[
"navigation-group",
"navigation-group-toggle",
"navigation-group-region",
"navigation-submenu-list",
"navigation-sublink",
"navigation-direct",
"is-active-group",
"is-expanded",
"is-active-page",
].forEach((className) => assert.match(renderer, new RegExp(className)));
assert.match(renderer, /aria-current/);
});
test("renders both navigation surfaces through the shared renderer and accordion", () => {
const mobile = getMethodSource("renderMobileMenu");
const sidebar = getMethodSource("renderSidebar");
assert.match(mobile, /renderNavigationItem\(item,\s*"mobile"\)/);
assert.match(mobile, /bindNavigationAccordion\(list\)/);
assert.match(sidebar, /renderNavigationItem\(item,\s*"sidebar"\)/);
assert.match(sidebar, /bindNavigationAccordion\(list\)/);
});
test("removes legacy navigation state helpers and class tokens", () => {
[
"setMobileSubmenuExpanded",
"setSidebarSectionExpanded",
"submenu-expanded",
"sidebar-group-open",
"has-active",
].forEach((token) => assert.doesNotMatch(source, new RegExp(token)));
});
test("builds the navigation model once and passes the same items to both surfaces", () => {
const renderModeMenu = getMethodSource("renderModeMenu");
assert.equal(source.match(/\bbuildNavigationModel\(/g)?.length, 2);
assert.match(
renderModeMenu,
/const navigationItems = this\.buildNavigationModel\(\s*ui\.menu\.getChildren\(activeChild\),\s*activeChild\.name,\s*\)/,
);
assert.match(
renderModeMenu,
/this\.renderMainMenu\(activeChild,\s*activeChild\.name,\s*0,\s*navigationItems\)/,
);
assert.match(renderModeMenu, /this\.renderMobileMenu\(navigationItems\)/);
});
test("renders an active direct item with shared and mode-specific state", () => {
const menu = loadMenuModule();
const item = menu.renderNavigationItem(
{
name: "status",
title: "Status",
href: "/admin/status",
hasChildren: false,
isActivePage: true,
},
"mobile",
);
const anchor = item.children[0];
assert.equal(item.getAttribute("class"), "mobile-nav-item");
assert.equal(
anchor.getAttribute("class"),
"navigation-direct mobile-nav-link is-active-page",
);
assert.equal(anchor.getAttribute("href"), "/admin/status");
assert.equal(anchor.getAttribute("aria-current"), "page");
});
test("renders an active group expanded with active page semantics", () => {
const menu = loadMenuModule();
const item = menu.renderNavigationItem(
{
name: "network",
title: "Network",
hasChildren: true,
isActiveGroup: true,
pages: [
{
title: "Wireless",
href: "/admin/network/wireless",
isActivePage: true,
},
],
},
"sidebar",
);
const [toggle, region] = item.children;
const list = region.children[0];
const activeLink = list.children[0].children[0];
assert.equal(
item.getAttribute("class"),
"navigation-group sidebar-group is-active-group is-expanded",
);
assert.equal(toggle.getAttribute("aria-controls"), "sidebar-submenu-network");
assert.equal(toggle.getAttribute("aria-current"), "location");
assert.equal(toggle.getAttribute("aria-expanded"), "true");
assert.equal(region.getAttribute("aria-hidden"), "false");
assert.equal(region.hasAttribute("inert"), false);
assert.equal(
activeLink.getAttribute("class"),
"navigation-sublink 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(
{
name: "system/tools",
title: "System",
hasChildren: true,
isActiveGroup: false,
pages: [],
},
"mobile",
);
const [toggle, region] = item.children;
assert.equal(item.getAttribute("class"), "navigation-group mobile-nav-item");
assert.equal(
toggle.getAttribute("aria-controls"),
"mobile-submenu-system%2Ftools",
);
assert.equal(toggle.getAttribute("aria-expanded"), "false");
assert.equal(toggle.hasAttribute("aria-current"), false);
assert.equal(region.getAttribute("aria-hidden"), "true");
assert.equal(region.getAttribute("inert"), "");
});
test("renders mode and mobile navigation without desktop containers", () => {
const mobileList = new FakeElement("ul");
const mobileFooter = new FakeElement("div");
const document = createFakeDocument({
elements: {
"#mobile-nav-footer-action": mobileFooter,
"#mobile-nav-list": mobileList,
},
});
const menu = loadMenuModule({
dispatchpath: ["admin", "status"],
document,
requestpath: ["admin"],
translate: (value) => `translated:${value}`,
});
const tree = {
children: {
admin: {
title: "Administration",
children: {
status: { title: "Status" },
},
},
services: {
title: "Services",
children: {
dns: { title: "DNS" },
},
},
},
};
const originalBuildNavigationModel = menu.buildNavigationModel.bind(menu);
const originalRenderMainMenu = menu.renderMainMenu.bind(menu);
const originalRenderMobileMenu = menu.renderMobileMenu.bind(menu);
const desktopCalls = [];
const mobileCalls = [];
let buildCount = 0;
menu.buildNavigationModel = (...args) => {
buildCount += 1;
return originalBuildNavigationModel(...args);
};
menu.renderMainMenu = (...args) => {
desktopCalls.push(args);
return originalRenderMainMenu(...args);
};
menu.renderMobileMenu = (...args) => {
mobileCalls.push(args);
return originalRenderMobileMenu(...args);
};
assert.doesNotThrow(() => menu.renderModeMenu(tree));
assert.equal(buildCount, 1);
assert.equal(desktopCalls.length, 1);
assert.equal(desktopCalls[0][0].name, "admin");
assert.equal(mobileCalls.length, 1);
assert.strictEqual(desktopCalls[0][3], mobileCalls[0][0]);
assert.deepEqual(mobileCalls[0][0], [
{
name: "status",
title: "translated:Status",
href: "/admin/status",
hasChildren: false,
isLogout: false,
isActiveGroup: false,
isActivePage: true,
activePage: null,
pages: [],
},
]);
assert.equal(mobileList.children.length, 1);
});
test("skips mega-menu initialization when the top menu is missing", () => {
const menu = loadMenuModule({
document: createFakeDocument({ navType: "mega-menu" }),
});
const tree = {
children: {
status: { title: "Status" },
},
};
let calls = 0;
let result;
menu.initMegaMenu = () => {
calls += 1;
};
assert.doesNotThrow(() => {
result = menu.renderMainMenu(tree, "admin");
});
assert.equal(calls, 0);
assert.ok(result instanceof FakeElement);
});
test("skips boxed-dropdown initialization when the top menu is missing", () => {
const menu = loadMenuModule({
document: createFakeDocument({ navType: "boxed-dropdown" }),
});
const tree = {
children: {
status: { title: "Status" },
},
};
let calls = 0;
let result;
menu.initBoxedDropdown = () => {
calls += 1;
};
assert.doesNotThrow(() => {
result = menu.renderMainMenu(tree, "admin");
});
assert.equal(calls, 0);
assert.ok(result instanceof FakeElement);
});
test("renders sidebar items when the top menu is missing", () => {
const menu = loadMenuModule({
document: createFakeDocument({ navType: "sidebar" }),
});
const tree = {
children: {
status: { title: "Status" },
},
};
const items = [directItem()];
let renderedItems = null;
let result;
menu.renderSidebar = (navigationItems) => {
renderedItems = navigationItems;
};
assert.doesNotThrow(() => {
result = menu.renderMainMenu(tree, "admin", 0, items);
});
assert.strictEqual(renderedItems, items);
assert.ok(result instanceof FakeElement);
});
test("generates stable collision-safe submenu IDs", () => {
const menu = loadMenuModule();
assert.equal(
menu.navigationSubmenuId("mobile", "system/tools"),
"mobile-submenu-system%2Ftools",
);
assert.equal(
menu.navigationSubmenuId("mobile", "system-tools"),
"mobile-submenu-system-tools",
);
assert.equal(
menu.navigationSubmenuId("mobile", "system.tools"),
"mobile-submenu-system.tools",
);
});
test("renders unique submenu controls for punctuation-distinct group names", () => {
const menu = loadMenuModule();
const names = ["system/tools", "system-tools", "system.tools"];
const rendered = names.map((name) =>
menu.renderNavigationItem(groupItem({ name }), "mobile"),
);
const controlIds = rendered.map((item) =>
item.children[0].getAttribute("aria-controls"),
);
const regionIds = rendered.map((item) => item.children[1].getAttribute("id"));
assert.deepEqual(controlIds, [
"mobile-submenu-system%2Ftools",
"mobile-submenu-system-tools",
"mobile-submenu-system.tools",
]);
assert.deepEqual(regionIds, controlIds);
assert.equal(new Set(controlIds).size, names.length);
});
test("renders mobile items and logout repeatedly without duplicate listeners", () => {
const list = new FakeElement("ul", {}, [new FakeElement("li")]);
const footer = new FakeElement("div", {}, [new FakeElement("a")]);
const document = createFakeDocument({
elements: {
"#mobile-nav-footer-action": footer,
"#mobile-nav-list": list,
},
});
const menu = loadMenuModule({ document });
const items = [
directItem(),
groupItem(),
directItem({
name: "logout",
title: "Logout",
href: "/admin/logout",
isLogout: true,
}),
];
menu.renderMobileMenu(items);
menu.renderMobileMenu(items);
assert.equal(list.children.length, 2);
assert.equal(
list.children[0].children[0].getAttribute("class"),
"navigation-direct mobile-nav-link",
);
assert.equal(
list.children[1].getAttribute("class"),
"navigation-group mobile-nav-item",
);
assert.equal(footer.children.length, 1);
assert.equal(footer.children[0].getAttribute("class"), "mobile-nav-logout");
assert.equal(footer.children[0].getAttribute("href"), "/admin/logout");
assert.equal(textContent(footer.children[0]), "Logout");
assert.equal(list.dataset.accordionBound, "true");
assert.equal(list.listenerCount("click"), 1);
});
test("renders sidebar items, logout, and translated crumbs without duplication", () => {
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",
});
const menu = loadMenuModule({
dispatchpath: ["admin", "network", "wireless"],
document,
translate: (value) => `translated:${value}`,
});
const tree = {
children: {
network: {
title: "Network",
children: {
wireless: { title: "Wireless" },
},
},
status: { title: "Status" },
logout: { title: "Logout" },
},
};
const items = menu.buildNavigationModel(getMenuChildren(tree), "admin");
menu.renderSidebar(items);
menu.renderSidebar(items);
assert.equal(list.children.length, 2);
assert.equal(
list.children[0].getAttribute("class"),
"navigation-group sidebar-group is-active-group is-expanded",
);
assert.equal(
list.children[1].children[0].getAttribute("class"),
"navigation-direct nav-link",
);
assert.equal(footer.children.length, 1);
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.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);
});
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]);
const document = createFakeDocument({
elements: {
"#mobile-menu-overlay": overlay,
"#mobile-nav-list": list,
},
});
const menu = loadMenuModule({ document });
menu.renderMobileMenu([
groupItem({
isActiveGroup: true,
pages: [
{
title: "Wireless",
href: "/admin/network/wireless",
isActivePage: true,
},
],
}),
]);
const item = list.children[0];
const [toggle, region] = item.children;
assert.equal(overlay.classList.contains("mobile-menu-open"), true);
assert.equal(item.classList.contains("is-expanded"), true);
assert.equal(toggle.getAttribute("aria-expanded"), "true");
assert.equal(region.getAttribute("aria-hidden"), "false");
assert.equal(region.hasAttribute("inert"), false);
});
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
const readStylesheet = (path) =>
readFile(new URL(`../src/media/${path}`, import.meta.url), "utf8");
const [navigationStyles, layoutStyles, overlayStyles] = await Promise.all([
readStylesheet("components/_nav.css"),
readStylesheet("_layout.css"),
readStylesheet("components/_overlay.css"),
]);
const getBlock = (source, selector) => {
const selectorIndex = source.indexOf(selector);
assert.notEqual(selectorIndex, -1, `Missing selector: ${selector}`);
const blockStart = source.indexOf("{", selectorIndex);
assert.notEqual(blockStart, -1, `Missing block for selector: ${selector}`);
let depth = 1;
for (let index = blockStart + 1; index < source.length; index += 1) {
if (source[index] === "{") depth += 1;
if (source[index] === "}") depth -= 1;
if (depth === 0) return source.slice(blockStart + 1, index);
}
assert.fail(`Unclosed block for selector: ${selector}`);
};
const assertIncludesUtilities = (block, utilities) => {
for (const utility of utilities) {
assert.match(
block,
new RegExp(
`(^|\\s)${utility.replaceAll("[", "\\[").replaceAll("]", "\\]")}($|\\s|;)`,
),
);
}
};
test("shared navigation styles define active and expanded states", () => {
const direct = getBlock(navigationStyles, ".navigation-direct");
const directActive = getBlock(
navigationStyles,
".navigation-direct.is-active-page",
);
const expandedToggle = getBlock(
navigationStyles,
".navigation-group.is-expanded > .navigation-group-toggle",
);
const activeGroupToggle = getBlock(
navigationStyles,
".navigation-group.is-active-group > .navigation-group-toggle",
);
const activeSublink = getBlock(
navigationStyles,
".navigation-sublink.is-active-page",
);
const sublink = getBlock(navigationStyles, ".navigation-sublink");
assertIncludesUtilities(direct, ["text-text", "hover:text-text"]);
// Active page is a filled brand pill on both direct links and sublinks.
assertIncludesUtilities(directActive, [
"text-brand",
"hover:text-brand",
"font-medium",
"bg-brand-subtle",
]);
// An expanded group's label turns brand and rotates its arrow open.
assertIncludesUtilities(expandedToggle, ["after:rotate-90", "text-brand"]);
// The active group keeps a brand label even when manually collapsed, so the
// current section stays marked while its pill is hidden — but it must not
// rotate the arrow open in that collapsed state.
assertIncludesUtilities(activeGroupToggle, ["text-brand"]);
assert.doesNotMatch(activeGroupToggle, /after:rotate-90/);
// The pill shape lives with the pill fill in the shared recipe, so the
// hover/active background is rounded the same way on desktop and mobile.
assertIncludesUtilities(sublink, [
"font-medium",
"hover:bg-hover-faint",
"rounded-lg",
]);
assertIncludesUtilities(activeSublink, [
"text-brand",
"hover:text-brand",
"font-semibold",
"bg-brand-subtle",
]);
// The left accent bar is gone — no before:* rail on the active sublink.
assert.doesNotMatch(activeSublink, /before:/);
});
test("shared navigation styles own accordion animation without a guide rail", () => {
const toggle = getBlock(navigationStyles, ".navigation-group-toggle");
const region = getBlock(navigationStyles, ".navigation-group-region");
const expandedRegion = getBlock(
navigationStyles,
".navigation-group.is-expanded > .navigation-group-region",
);
const submenu = getBlock(navigationStyles, ".navigation-submenu-list");
assertIncludesUtilities(toggle, [
"after:transition-[transform,opacity]",
"after:duration-[250ms]",
]);
assert.match(toggle, /arrow-right\.svg/);
assertIncludesUtilities(region, [
"grid",
"grid-rows-[0fr]",
"opacity-0",
"transition-[grid-template-rows,opacity]",
"duration-[250ms]",
]);
assertIncludesUtilities(expandedRegion, ["grid-rows-[1fr]", "opacity-100"]);
// The vertical guide rail is removed: the submenu list carries no before:*
// hairline anymore.
assert.doesNotMatch(submenu, /before:bg-hairline/);
});
test("desktop sidebar styles only provide desktop navigation density", () => {
const sidebar = getBlock(layoutStyles, 'body[data-nav-type="sidebar"]');
const direct = getBlock(sidebar, "& .sidebar-list .navigation-direct");
const submenu = getBlock(sidebar, "& .sidebar-submenu");
const sublink = getBlock(sidebar, "& .sidebar-submenu .navigation-sublink");
assertIncludesUtilities(direct, ["truncate", "text-lg"]);
assertIncludesUtilities(submenu, ["pl-4"]);
assertIncludesUtilities(sublink, ["px-3", "py-1.5", "text-sm"]);
assert.doesNotMatch(
sidebar,
/sidebar-section|sidebar-group-open|nav-link-active|has-active/,
);
assert.doesNotMatch(sidebar, /bg-brand-subtle/);
});
test("mobile drawer styles only provide mobile navigation density", () => {
const drawer = getBlock(overlayStyles, ".mobile-menu-overlay");
const submenu = getBlock(drawer, "& .mobile-nav-submenu-list");
const sublink = getBlock(drawer, "& .mobile-nav-sublink");
assertIncludesUtilities(submenu, ["max-md:pl-4"]);
assertIncludesUtilities(sublink, [
"max-md:min-h-10",
"max-md:px-3",
"max-md:py-2",
"max-md:text-base",
]);
assert.doesNotMatch(sublink, /max-md:font-(?:normal|medium|semibold|bold)/);
assert.doesNotMatch(
drawer,
/has-submenu|submenu-expanded|nav-link-active|has-active/,
);
assert.doesNotMatch(drawer, /bg-brand-subtle/);
});
@@ -0,0 +1,27 @@
import { readFile } from "node:fs/promises";
import { test } from "node:test";
import assert from "node:assert/strict";
const overlayCss = await readFile(
new URL("../src/media/components/_overlay.css", import.meta.url),
"utf8",
);
const layoutCss = await readFile(
new URL("../src/media/_layout.css", import.meta.url),
"utf8",
);
test("mobile and mega navigation share the translucent menu background", () => {
const overlayRule = overlayCss.match(
/\.mobile-menu-overlay\s*\{([\s\S]*?)\n\s*\.mobile-nav\s*\{/,
)?.[1];
const navRule = overlayCss.match(
/\.mobile-nav\s*\{\s*@apply\s+([^;]+);/,
)?.[1];
assert.ok(overlayRule?.includes("max-md:bg-mega-menu-bg"));
assert.ok(!overlayRule?.includes("max-md:bg-scrim"));
assert.ok(!overlayRule?.includes("max-md:p-1"));
assert.ok(!navRule?.includes("max-md:bg-"));
assert.match(layoutCss, /\bbg-mega-menu-bg\b/);
});
@@ -0,0 +1,69 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveMode } from "../tokens/resolve.js";
test("resolveMode(light) returns flat oklch for all tokens", () => {
const out = resolveMode("light");
// 10 inputs + 19 derived = 29 color tokens.
for (const k of [
"bg",
"surface",
"text",
"brand",
"text_muted",
"surface_sunken",
"hairline",
"brand_subtle",
"focus_ring",
"progress_start",
"progress_end",
"info_surface",
"scrim",
"mega_menu_bg",
]) {
assert.ok(out[k], `missing ${k}`);
assert.ok(
!out[k].includes("color-mix") && !out[k].includes("var("),
`${k} not flat: ${out[k]}`,
);
}
});
test("progress_end aliases brand", () => {
const out = resolveMode("light");
assert.equal(out.progress_end, out.brand);
});
test("scrim uses one 60% alpha value in both modes", () => {
assert.equal(resolveMode("light").scrim, "oklch(0% 0 0 / 0.6)");
assert.equal(resolveMode("dark").scrim, "oklch(0% 0 0 / 0.6)");
});
test("menu backgrounds are translucent Gray glass (softer than 0.8)", () => {
const l = resolveMode("light");
const d = resolveMode("dark");
assert.ok(l.mega_menu_bg.includes("/ 0.66"), `light: ${l.mega_menu_bg}`);
assert.ok(d.mega_menu_bg.includes("/ 0.62"), `dark: ${d.mega_menu_bg}`);
});
test("light bg is the Gray canvas; surface stays pure white", () => {
const light = resolveMode("light");
assert.equal(light.bg, "oklch(0.967 0.003 264)");
assert.equal(light.surface, "oklch(1 0 0)");
});
test("dark brand and progress colors match the mobile navigation design", () => {
const dark = resolveMode("dark");
assert.equal(dark.brand, "oklch(0.6 0.13 188.745)");
assert.equal(dark.on_brand, "oklch(1 0 0)");
assert.equal(dark.progress_start, "oklch(43.18% 0.0865 166.9)");
assert.equal(dark.progress_end, "oklch(62.1% 0.145 189.6)");
});
test("dark differs from light", () => {
assert.notEqual(
resolveMode("light").surface_sunken,
resolveMode("dark").surface_sunken,
);
});
@@ -0,0 +1,84 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import Color from "colorjs.io";
import { resolveMode } from "../tokens/resolve.js";
// [L, C, H] in oklch; alpha separate.
const lch = (s) => {
const c = new Color(s).to("oklch");
return { L: c.coords[0], C: c.coords[1], H: c.coords[2] || 0, a: c.alpha };
};
const nearHue = (s, h, tol = 8) => {
const H = lch(s).H;
const d = Math.abs(((H - h + 540) % 360) - 180);
return d <= tol;
};
const read = (p) => readFileSync(new URL(p, import.meta.url), "utf8");
test("light low-emphasis surfaces carry the Gray tint (chroma > 0)", () => {
const l = resolveMode("light");
assert.ok(lch(l.surface_sunken).C > 0.001, `sunken flat: ${l.surface_sunken}`);
assert.ok(lch(l.surface_overlay).C > 0.001, `overlay flat: ${l.surface_overlay}`);
});
test("light bg, sunken and overlay share one hue family (~264)", () => {
const l = resolveMode("light");
for (const k of ["bg", "surface_sunken", "surface_overlay"]) {
assert.ok(nearHue(l[k], 264), `${k} hue off: ${l[k]}`);
}
});
test("light sunken is darker than bg, overlay is lighter (toward white)", () => {
const l = resolveMode("light");
assert.ok(lch(l.surface_sunken).L < lch(l.bg).L, "sunken not below bg");
assert.ok(lch(l.surface_overlay).L > lch(l.bg).L, "overlay not above bg");
});
test("dark surfaces are the Gray family (~264), not zinc (286)", () => {
const d = resolveMode("dark");
for (const k of ["bg", "surface", "surface_sunken", "text_muted"]) {
assert.ok(nearHue(d[k], 264, 10), `${k} hue off: ${d[k]}`);
}
});
test("dark layering: page < card, sunken between page and card", () => {
const d = resolveMode("dark");
const bg = lch(d.bg).L, surf = lch(d.surface).L, sunk = lch(d.surface_sunken).L;
assert.ok(bg < surf, "page not darker than card");
assert.ok(sunk >= bg && sunk < surf, `sunken not between page and card: ${d.surface_sunken}`);
});
test("mega-menu panel and overlays use stronger blur", () => {
const layout = read("../src/media/_layout.css");
const overlay = read("../src/media/components/_overlay.css");
// The mega-menu panel line carries the menu background + blur.
const panel = layout.split("\n").find((l) => l.includes("bg-mega-menu-bg"));
assert.ok(panel?.includes("backdrop-blur-lg"), `panel blur: ${panel}`);
assert.ok(overlay.includes("max-md:backdrop-blur-lg"), "mobile overlay blur");
assert.ok(
/&\.active\s*\{\s*@apply[^;]*backdrop-blur-lg/.test(overlay),
"desktop overlay blur",
);
});
test("maincontent cards use a hairline border, not heavy shadow", () => {
const card = read("../src/media/components/_card.css");
const rule = card.match(/#maincontent &\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
assert.ok(rule.includes("border-hairline"), `no hairline: ${rule}`);
assert.ok(rule.includes("border"), `no border: ${rule}`);
assert.ok(!rule.includes("shadow-lg"), `still shadow-lg: ${rule}`);
});
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] ?? "";
assert.ok(root.includes("border-hairline") && root.includes("border"), `no frame: ${root}`);
assert.ok(root.includes("rounded-2xl"), `no radius: ${root}`);
// Dropdowns inside cells must not be clipped: no global overflow-hidden on the table.
assert.ok(!root.includes("overflow-hidden"), `body clipped: ${root}`);
assert.ok(root.includes("overflow-visible"), `not overflow-visible: ${root}`);
// Header carries the sunken surface.
const th = t.match(/&\s*th,\s*&\s*\.th\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
assert.ok(th.includes("bg-surface-sunken"), `header not sunken: ${th}`);
});
+27
View File
@@ -0,0 +1,27 @@
// 10 editable input defaults for light/dark, sourced from v2 (commit 3c4c67a).
export const DEFAULTS = {
light: {
bg: "oklch(0.967 0.003 264)",
surface: "oklch(1 0 0)",
text: "oklch(0.21 0.02 264)",
brand: "oklch(0.68 0.11 233)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.74 0.238 322.16)",
info: "oklch(0.43 0.2 255)",
warning: "oklch(0.35 0.08 60)",
success: "oklch(0.32 0.09 165)",
danger: "oklch(0.35 0.12 25)",
},
dark: {
bg: "oklch(0.13 0.018 264)",
surface: "oklch(0.21 0.02 264)",
text: "oklch(0.985 0.002 264)",
brand: "oklch(0.6 0.13 188.745)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.77 0.14 168)",
info: "oklch(0.8 0.11 255)",
warning: "oklch(0.82 0.13 80)",
success: "oklch(0.72 0.13 158)",
danger: "oklch(0.7 0.16 22)",
},
};
+32
View File
@@ -0,0 +1,32 @@
import Color from "colorjs.io";
const C = (v) => (v instanceof Color ? v : new Color(v));
// color-mix(in oklab, a p%, b) => position toward b is (1 - p)
export const mix = (a, b, p) =>
Color.mix(C(a), C(b), 1 - p, { space: "oklab", outputSpace: "oklch" });
export const shade = (a, dl) => {
const c = C(a).to("oklch");
c.coords[0] += dl;
return c;
};
export const set = (a, L, Ch) => {
const c = C(a).to("oklch");
c.coords[0] = L;
c.coords[1] = Ch;
return c;
};
export const alpha = (a, p) => {
const c = C(a).to("oklch");
c.alpha = p;
return c;
};
export const konst = (s) => C(s).to("oklch");
// Serialize a Color (or string) to an oklch() literal -- no color-mix / var().
export const toOklch = (v) =>
C(v).to("oklch").toString({ precision: 4, format: "oklch" });
+51
View File
@@ -0,0 +1,51 @@
import { DERIVATIONS } from "./spec.js";
import { DEFAULTS } from "./defaults.js";
import { mix, shade, set, alpha, konst, toOklch } from "./engine.js";
// inputs: {name: oklchString}. Returns flat {name: oklchString} values.
export function resolveTokens(mode, inputs) {
const derivs = DERIVATIONS[mode];
const resolved = { ...inputs };
const ref = (name) => {
if (resolved[name] === undefined) compute(name);
return resolved[name];
};
function compute(name) {
const rule = derivs[name];
if (!rule) throw new Error(`unknown derived token: ${name}`);
const [op, ...args] = rule;
let color;
switch (op) {
case "mix":
color = mix(ref(args[0]), ref(args[1]), args[2]);
break;
case "shade":
color = shade(ref(args[0]), args[1]);
break;
case "set":
color = set(ref(args[0]), args[1], args[2]);
break;
case "alpha":
color = alpha(ref(args[0]), args[1]);
break;
case "const":
if (args[0].startsWith("var:")) {
resolved[name] = ref(args[0].slice(4));
return;
}
color = konst(args[0]);
break;
default:
throw new Error(`unknown op: ${op}`);
}
resolved[name] = toOklch(color);
}
for (const name of Object.keys(derivs)) compute(name);
return resolved;
}
export const resolveMode = (mode) =>
resolveTokens(mode, { ...DEFAULTS[mode] });
+62
View File
@@ -0,0 +1,62 @@
// Operators: ['mix',a,b,p] ['shade',a,dl] ['set',a,L,C]
// ['alpha',a,p] ['const',str]
export const DERIVATIONS = {
light: {
text_muted: ["mix", "text", "bg", 0.62],
text_subtle: ["mix", "text", "bg", 0.48],
surface_sunken: ["shade", "bg", -0.010],
surface_overlay: ["shade", "bg", 0.016],
hairline: ["alpha", "text", 0.08],
hover_faint: ["shade", "bg", -0.04],
brand_hover: ["shade", "brand", -0.06],
brand_subtle: ["mix", "brand", "bg", 0.12],
brand_subtle_hover: ["shade", "brand_subtle", -0.04],
focus_ring: ["alpha", "brand", 0.6],
progress_start: ["mix", "brand", "surface_sunken", 0.65],
progress_end: ["const", "var:brand"],
info_surface: ["set", "info", 0.94, 0.05],
warning_surface: ["set", "warning", 0.95, 0.05],
success_surface: ["set", "success", 0.94, 0.05],
danger_surface: ["set", "danger", 0.94, 0.05],
danger_surface_hover: ["shade", "danger_surface", -0.04],
scrim: ["const", "oklch(0 0 0 / 0.6)"],
mega_menu_bg: ["alpha", "surface_overlay", 0.66],
},
dark: {
text_muted: ["mix", "text", "bg", 0.62],
text_subtle: ["mix", "text", "bg", 0.42],
surface_sunken: ["shade", "surface", -0.045],
surface_overlay: ["shade", "surface", 0.02],
hairline: ["alpha", "text", 0.1],
hover_faint: ["alpha", "text", 0.05],
brand_hover: ["shade", "brand", -0.05],
brand_subtle: ["mix", "brand", "bg", 0.16],
brand_subtle_hover: ["shade", "brand_subtle", 0.04],
focus_ring: ["alpha", "brand", 0.6],
progress_start: ["const", "oklch(0.4318 0.0865 166.91)"],
progress_end: ["const", "oklch(0.621 0.145 189.632)"],
info_surface: ["set", "info", 0.32, 0.05],
warning_surface: ["set", "warning", 0.33, 0.06],
success_surface: ["set", "success", 0.3, 0.05],
danger_surface: ["set", "danger", 0.32, 0.08],
danger_surface_hover: ["shade", "danger_surface", 0.04],
scrim: ["const", "oklch(0 0 0 / 0.6)"],
mega_menu_bg: ["alpha", "surface_overlay", 0.62],
},
};
// Fixed mode-specific literals, emitted verbatim rather than derived.
export const FIXED = {
light: {
app_shadow_sm:
"0 1px 3px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04)",
app_shadow_md:
"0 4px 16px oklch(0 0 0 / 0.08), 0 1px 3px oklch(0 0 0 / 0.04)",
app_shadow_lg: "0 12px 32px oklch(0 0 0 / 0.12)",
},
dark: {
app_shadow_sm: "0 4px 12px oklch(0 0 0 / 0.3)",
app_shadow_md: "0 10px 28px oklch(0 0 0 / 0.42)",
app_shadow_lg: "0 20px 48px oklch(0 0 0 / 0.55)",
},
};
+7
View File
@@ -4,9 +4,11 @@
*/
import tailwindcss from "@tailwindcss/vite";
import browserslist from "browserslist";
import { exec } from "child_process";
import { watch as fsWatch } from "fs";
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
import { browserslistToTargets } from "lightningcss";
import { basename, dirname, join, relative, resolve } from "path";
import { minify as terserMinify } from "terser";
import { promisify } from "util";
@@ -382,6 +384,11 @@ export default defineConfig(({ mode }) => {
],
css: {
lightningcss: {
targets: browserslistToTargets(
browserslist("last 4 versions, Firefox ESR, not dead"),
),
},
postcss: {
plugins: [
{
+9 -5
View File
@@ -8,12 +8,14 @@ All dev commands run from `.dev/`:
```bash
cd .dev/
pnpm dev # Start Vite dev server (proxies to OpenWrt device)
pnpm build # Clean + build production assets to htdocs/luci-static/
pnpm clean # Remove build output only
pnpm dev # Start Vite dev server (proxies to OpenWrt device)
pnpm build # Clean + regenerate tokens + build production assets to htdocs/luci-static/
pnpm gen:tokens # Regenerate src/media/_tokens.css from tokens/ (also runs as part of build)
pnpm test # Run tests/*.test.js (node:test)
pnpm clean # Remove build output only
```
No test suite or linter CLI. Formatting uses Prettier with format-on-save (`.vscode/settings.json`).
No linter CLI. Formatting uses Prettier with format-on-save (`.vscode/settings.json`).
## Architecture
@@ -24,7 +26,9 @@ No test suite or linter CLI. Formatting uses Prettier with format-on-save (`.vsc
- `.dev/public/aurora/` → `htdocs/luci-static/aurora/` (copied as-is)
- `ucode/template/themes/aurora/*.ut` — server-side templates, not processed by Vite
**CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/` — `main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_layout.css`, `components/*.css`, `_utilities.css`, `_patches.css`) and `login.css` (standalone login page). All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. `@layer` at-rules stripped by PostCSS plugin for OpenWrt compatibility. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout.
**CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/` — `main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_elements.css`, `_layout.css`, `components/*.css`, `_utilities.css`, `_patches.css`) and `login.css` (standalone login page; imports `_base.css`). All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. `@layer` at-rules stripped by PostCSS plugin for OpenWrt compatibility. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout.
**Design tokens**: `_tokens.css` is GENERATED — do not hand-edit (see its header comment). Source of truth is `.dev/tokens/`: `defaults.js` holds the 10 editable input colors per mode (`bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger`); `spec.js` declares how every other token derives from those via `mix`/`shade`/`set`/`alpha`/`const` operators plus `FIXED` literals (shadows); `engine.js`/`resolve.js` do the OKLCH math and flatten everything to plain `oklch(...)` values. After changing inputs or derivations, run `pnpm gen:tokens` to rewrite `_tokens.css`, and `pnpm test` to check the color-math and token invariants.
**JavaScript**: LuCI `E()` DOM API (not React/Vue). Minified but not bundled.
+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:=0.12.8
PKG_RELEASE:=26
PKG_VERSION:=0.12.10
PKG_RELEASE:=27
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=
@@ -1,2 +1,2 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@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-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-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--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-content:"";--tw-border-style:solid;--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-font-weight:initial;--tw-tracking:initial}}:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-md:28rem;--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-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--radius-2xl:calc(var(--radius-base)*2);--animate-spin:spin 1s linear infinite;--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);--container-max-width:var(--container-max-width)}*,: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 (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkitLine truncated
@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-border-style:solid;--tw-font-weight:initial;--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-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--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-duration:initial;--tw-content:"";--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-tracking:initial}}:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-md:28rem;--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);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--radius-sm:calc(var(--radius-base)*.25);--radius-md:calc(var(--radius-base)*.75);--radius-lg:var(--radius-base);--radius-xl:calc(var(--radius-base)*1.5);--radius-2xl:calc(var(--radius-base)*2);--radius-3xl:calc(var(--radius-base)*3);--radius-4xl:calc(var(--radius-base)*4);--shadow-sm:var(--app-shadow-sm);--shadow-md:var(--app-shadow-md);--shadow-lg:var(--app-shadow-md);--shadow-xl:var(--app-shadow-lg);--shadow-2xl:var(--app-shadow-lg);--animate-spin:spin 1s linear infinite;--blur-lg:16px;--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);--radius:calc(var(--radius-base)*.5);--container-max-width:var(--container-max-width)}*,: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}:wheLine truncated
@@ -1,2 +1,2 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@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-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-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--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-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0;--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-content:"";--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}}: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;--radius-2xl:calc(var(--radius-base)*2);--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-md:12px;--blur-xl:24px;--blur-2xl:40px;--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);--container-max-width:var(--container-max-width)}*,: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{Line truncated
@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-border-style:solid;--tw-font-weight:initial;--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-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--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-duration:initial;--tw-content:"";--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0;--tw-leading:initial;--tw-tracking:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--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}}: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;--radius-sm:calc(var(--radius-base)*.25);--radius-md:calc(var(--radius-base)*.75);--radius-lg:var(--radius-base);--radius-xl:calc(var(--radius-base)*1.5);--radius-2xl:calc(var(--radius-base)*2);--radius-3xl:calc(var(--radius-base)*3);--radius-4xl:calc(var(--radius-base)*4);--shadow-sm:var(--app-shadow-sm);--shadow-md:var(--app-shadow-md);--shadow-lg:var(--app-shadow-md);--shadow-xl:var(--app-shadow-lg);--shadow-2xl:var(--app-shadow-lg);--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;--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);--radius:calc(var(--radius-base)*.5);--container-max-width:var(--container-max-width)}*,: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-feLine truncated
@@ -1 +1 @@
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(tree=>this.render(tree));this.initNavigationControls();this.initUciIndicator()},initUciIndicator(){const original=ui.changes?.setIndicator;if(!original)return;ui.changes.setIndicator=function(n){original.call(this,n);document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",n||0)}},initNavigationControls(){const overlay=document.querySelector("#mobile-menu-overlay");const navigationToggle=document.querySelector("#navigation-toggle");if(!navigationToggle||!overlay)return;const desktop=window.matchMedia("(min-width: 768px)");const SIDEBAR_COLLAPSED_KEY="aurora.sidebarCollapsed";const isDesktopSidebar=()=>desktop.matches&&document.body.dataset.navType==="sidebar";const resetMobileSubmenus=()=>{overlay.querySelectorAll(".mobile-nav-item.submenu-expanded").forEach(item=>this.setMobileSubmenuExpanded(item,false))};const expandActiveMobileGroup=()=>{const activeGroup=overlay.querySelector(".mobile-nav-item.has-active");if(activeGroup)this.setMobileSubmenuExpanded(activeGroup,true)};const updateToggleState=expanded=>{const desktopSidebar=isDesktopSidebar();navigationToggle.classList.toggle("is-expanded",expanded&&!desktop.matches);navigationToggle.setAttribute("aria-expanded",expanded?"true":"false");navigationToggle.setAttribute("aria-controls",desktopSidebar?"sidebar-panel":"mobile-menu-overlay");const label=expanded&&!desktopSidebar?_("Close"):_("Navigation");navigationToggle.setAttribute("title",label);navigationToggle.setAttribute("aria-label",label)};const closeMobileNavigation=()=>{overlay.classList.remove("mobile-menu-open");document.body.classList.remove("mobile-navigation-open");document.body.style.overflow="";resetMobileSubmenus()};const getNavigationExpanded=()=>{if(isDesktopSidebar()){return!document.body.classList.contains("sidebar-collapsed")}return!desktop.matches&&overlay.classList.contains("mobile-menu-open")};const setNavigationExpanded=expanded=>{if(isDesktopSidebar()){closeMobileNavigation();const collapsed=!expanded;document.body.classList.toggle("sidebar-collapsed",collapsed);localStorage.setItem(SIDEBAR_COLLAPSED_KEY,collapsed);updateToggleState(expanded);return}if(desktop.matches){closeMobileNavigation();updateToggleState(false);return}overlay.classList.toggle("mobile-menu-open",expanded);document.body.classList.toggle("mobile-navigation-open",expanded);document.body.style.overflow=expanded?"hidden":"";if(expanded)expandActiveMobileGroup();else resetMobileSubmenus();updateToggleState(expanded)};const toggleNavigation=()=>setNavigationExpanded(!getNavigationExpanded());const syncNavigationState=()=>{closeMobileNavigation();if(isDesktopSidebar()){const collapsed=localStorage.getItem(SIDEBAR_COLLAPSED_KEY)==="true";document.body.classList.toggle("sidebar-collapsed",collapsed);updateToggleState(!collapsed);return}updateToggleState(false)};navigationToggle.addEventListener("click",e=>{e.stopPropagation();toggleNavigation()});overlay.addEventListener("click",e=>{if(e.target===overlay)setNavigationExpanded(false)});document.addEventListener("keydown",e=>{if(e.key==="Escape"&&overlay.classList.contains("mobile-menu-open")){setNavigationExpanded(false)}});desktop.addEventListener("change",syncNavigationState);syncNavigationState();document.addEventListener("click",e=>{const toggle=e.target.closest(".mobile-nav-toggle");if(toggle&&overlay.contains(toggle)){e.preventDefault();e.stopPropagation();const item=toggle.closest(".mobile-nav-item");if(!item)return;const isExpanded=item.classList.contains("submenu-expanded");overlay.querySelectorAll(".mobile-nav-item.submenu-expanded").forEach(expandedItem=>{if(expandedItem!==item){this.setMobileSubmenuExpanded(expandedItem,false)}});this.setMobileSubmenuExpanded(item,!isExpanded);return}const destination=e.target.closest(".mobile-nav-link:not(.mobile-nav-toggle), .mobile-nav-sublink, .mobile-nav-logout");if(destination&&overlay.contains(destination)){setNavigationExpanded(false)}})},renderMobileMenu(tree,url){const list=document.querySelector("#mobile-nav-list");const footerAction=document.querySelector("#mobile-nav-footer-action");const children=ui.menu.getChildren(tree);if(!list)return;list.innerHTML="";if(footerAction)footerAction.innerHTML="";if(!children.length)return;children.forEach(child=>{const submenu=ui.menu.getChildren(child);const hasSubmenu=submenu.length>0;const isActive=this.isActivePath(child.name);if(child.name==="logout"){if(footerAction){footerAction.appendChild(E("a",{class:"mobile-nav-logout",href:L.url(url,child.name)},[_(child.title)]))}return}const itemClasses=["mobile-nav-item",hasSubmenu?"has-submenu":"",isActive&&hasSubmenu?"has-active":""].filter(Boolean);const submenuId=`mobile-submenu-${String(child.name).replace(/[^A-Za-z0-9_-]/g,"-")}`;const control=hasSubmenu?E("button",{class:`mobile-nav-link mobile-nav-toggle${isActive?" nav-link-active":""}`,type:"button","aria-expanded":"false","aria-controls":sLine truncated
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(tree=>this.render(tree));this.initNavigationControls();this.initUciIndicator()},initUciIndicator(){const original=ui.changes?.setIndicator;if(!original)return;ui.changes.setIndicator=function(n){original.call(this,n);document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",n||0)}},initNavigationControls(){const overlay=document.querySelector("#mobile-menu-overlay");const navigationToggle=document.querySelector("#navigation-toggle");if(!navigationToggle||!overlay)return;const mobileList=overlay.querySelector("#mobile-nav-list");const desktop=window.matchMedia("(min-width: 768px)");const SIDEBAR_COLLAPSED_KEY="aurora.sidebarCollapsed";const isDesktopSidebar=()=>desktop.matches&&document.body.dataset.navType==="sidebar";const updateToggleState=expanded=>{const desktopSidebar=isDesktopSidebar();navigationToggle.classList.toggle("is-expanded",expanded&&!desktop.matches);navigationToggle.setAttribute("aria-expanded",expanded?"true":"false");navigationToggle.setAttribute("aria-controls",desktopSidebar?"sidebar-panel":"mobile-menu-overlay");const label=expanded&&!desktopSidebar?_("Close"):_("Navigation");navigationToggle.setAttribute("title",label);navigationToggle.setAttribute("aria-label",label)};const closeMobileNavigation=()=>{overlay.classList.remove("mobile-menu-open");document.body.classList.remove("mobile-navigation-open");document.body.style.overflow="";this.resetNavigationGroups(mobileList)};const getNavigationExpanded=()=>{if(isDesktopSidebar()){return!document.body.classList.contains("sidebar-collapsed")}return!desktop.matches&&overlay.classList.contains("mobile-menu-open")};const setNavigationExpanded=expanded=>{if(isDesktopSidebar()){closeMobileNavigation();const collapsed=!expanded;document.body.classList.toggle("sidebar-collapsed",collapsed);localStorage.setItem(SIDEBAR_COLLAPSED_KEY,collapsed);updateToggleState(expanded);return}if(desktop.matches){closeMobileNavigation();updateToggleState(false);return}overlay.classList.toggle("mobile-menu-open",expanded);document.body.classList.toggle("mobile-navigation-open",expanded);document.body.style.overflow=expanded?"hidden":"";if(expanded)this.expandActiveNavigationGroup(mobileList);else this.resetNavigationGroups(mobileList);updateToggleState(expanded)};const toggleNavigation=()=>setNavigationExpanded(!getNavigationExpanded());const syncNavigationState=()=>{closeMobileNavigation();if(isDesktopSidebar()){const collapsed=localStorage.getItem(SIDEBAR_COLLAPSED_KEY)==="true";document.body.classList.toggle("sidebar-collapsed",collapsed);updateToggleState(!collapsed);return}updateToggleState(false)};navigationToggle.addEventListener("click",e=>{e.stopPropagation();toggleNavigation()});overlay.addEventListener("click",e=>{if(e.target===overlay)setNavigationExpanded(false)});document.addEventListener("keydown",e=>{if(e.key==="Escape"&&overlay.classList.contains("mobile-menu-open")){setNavigationExpanded(false)}});desktop.addEventListener("change",syncNavigationState);syncNavigationState();document.addEventListener("click",e=>{const destination=e.target?.closest?.(".navigation-direct, .navigation-sublink, .mobile-nav-logout");if(destination&&overlay.contains(destination)){setNavigationExpanded(false)}})},renderMobileMenu(items){const list=document.querySelector("#mobile-nav-list");const footerAction=document.querySelector("#mobile-nav-footer-action");if(list)list.innerHTML="";if(footerAction)footerAction.innerHTML="";if(!list)return;items.forEach(item=>{if(item.isLogout){if(footerAction){footerAction.appendChild(E("a",{class:"mobile-nav-logout",href:item.href},[item.title]))}return}list.appendChild(this.renderNavigationItem(item,"mobile"))});this.bindNavigationAccordion(list)},render(tree){this.renderModeMenu(tree);if(L.env.dispatchpath.length>=3){let node=tree;let url="";for(let i=0;i<3&&node;i++){const segment=L.env.dispatchpath[i];node=node.children?.[segment];url+=(url?"/":"")+segment}if(node)this.renderTabMenu(node,url)}},renderTabMenu(tree,url,level=0){const container=document.querySelector("#tabmenu");const ul=E("ul",{class:"tabs"});const children=ui.menu.getChildren(tree);let activeNode=null;children.forEach(child=>{const isActive=L.env.dispatchpath[3+level]===child.name;ul.appendChild(E("li",{class:`tabmenu-item-${child.name}${isActive?" active":""}`},[E("a",{href:L.url(url,child.name)},[_(child.title)])]));if(isActive)activeNode=child});if(!ul.children.length)return E([]);container.appendChild(ul);container.style.display="";if(activeNode){this.renderTabMenu(activeNode,`${url}/${activeNode.name}`,level+1)}return ul},renderMainMenu(tree,url,level=0,navigationItems=null){const ul=level?E("ul",{class:"desktop-nav-list"}):document.querySelector("#topmenu");const children=ui.menu.getChildren(tree);if(level>1)return E([]);if(level===0){const navType=document.body?.dataset?.navType||"mega-menu";if(navType==="sidebar"){this.renderSidebLine truncated
+17 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=opera-proxy
PKG_VERSION:=1.24.0
PKG_RELEASE:=3
PKG_RELEASE:=4
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
PKG_LICENSE:=MIT
@@ -44,9 +44,24 @@ endif
PKG_SOURCE_URL:=https://github.com/Alexey71/opera-proxy/releases/download/v$(PKG_VERSION)
PKG_SOURCE:=opera-proxy.linux-$(GOARCH_SUFFIX)
PKG_HASH:=skip
PKG_BUILD_DEPENDS:=upx/host
include $(INCLUDE_DIR)/package.mk
UPX:=$(STAGING_DIR_HOST)/bin/upx
UPX_AVAILABLE:=$(shell [ -x "$(UPX)" ] && echo yes || echo no)
ifeq ($(UPX_AVAILABLE),yes)
define upxcompress
@echo "Compressing $(1) with UPX..."
$(UPX) --lzma --best $(1)
endef
else
define upxcompress
@echo "UPX not found at $(UPX), skipping compression"
endef
endif
define Package/opera-proxy
SECTION:=net
CATEGORY:=Network
@@ -69,6 +84,7 @@ define Build/Prepare
mkdir -p $(PKG_BUILD_DIR)
$(CP) $(DL_DIR)/$(PKG_SOURCE) $(PKG_BUILD_DIR)/opera-proxy
chmod +x $(PKG_BUILD_DIR)/opera-proxy
$(call upxcompress,$(PKG_BUILD_DIR)/opera-proxy)
endef
define Build/Compile
+22 -2
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=tun2socks
PKG_VERSION:=2.6.0
PKG_RELEASE:=3
PKG_RELEASE:=4
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/xjasonlyu/tun2socks.git
@@ -12,7 +12,7 @@ PKG_MAINTAINER:=Konstantine Shevlakov <shevlako@132lan.ru>
PKG_LICENSE:=GPL-3.0
PKG_LICENSE_FILES:=LICENSE
PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_DEPENDS:=golang/host upx/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
@@ -21,6 +21,25 @@ GO_PKG=github.com/xjasonlyu/tun2socks
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
# --- UPX support ---
UPX:=$(STAGING_DIR_HOST)/bin/upx
UPX_AVAILABLE:=$(shell [ -x "$(UPX)" ] && echo yes || echo no)
ifeq ($(UPX_AVAILABLE),yes)
define upxcompress
@echo "Compressing $(1) with UPX..."
$(UPX) --lzma --best $(1)
endef
else
define upxcompress
@echo "UPX not found at $(UPX), skipping compression"
endef
endif
GO_PKG_LDFLAGS:=-s -w
GO_PKG_LDFLAGS_X:= \
github.com/xjasonlyu/tun2socks/internal/version.Version=v$(PKG_VERSION) \
github.com/xjasonlyu/tun2socks/internal/version.GitCommit=$(shell echo $(PKG_SOURCE_VERSION) | cut -c1-7)
GO_MOD_ARGS:=
GO_PKG_BUILD_VARS+= GO111MODULE=on
@@ -37,6 +56,7 @@ define Package/$(PKG_NAME)/install
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/$(PKG_NAME) $(1)/usr/sbin/
$(call upxcompress,$(1)/usr/sbin/$(PKG_NAME))
$(CP) ./root/* $(1)/
endef