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

This commit is contained in:
github-actions[bot] 2026-06-15 22:09:15 +08:00
parent d2ec4a48f6
commit 26e9df1a69
68 changed files with 2964 additions and 918 deletions

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 \

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"

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)

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

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

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)

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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": {

View File

@ -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 };
}

View File

@ -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;
};

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:= \

View File

@ -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",

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:= \

View File

@ -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

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

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",

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

View File

@ -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");

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;
}

View File

@ -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;
}

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;
}
}
}

View File

@ -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;
}
}

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);

View File

@ -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 {

View File

@ -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 & {

View File

@ -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;
}

View File

@ -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;

View File

@ -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 {

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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"] {

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -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];
}
}

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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 & {

View File

@ -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;
}

View File

@ -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;
}
}

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;

View File

@ -16,6 +16,7 @@
@import "./_tokens.css";
@import "./_base.css";
@import "./_elements.css";
@import "./_layout.css";
@import "./components/_nav.css";

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 = "";
}
},

View File

@ -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("));
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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/);
});

View File

@ -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/);
});

View File

@ -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,
);
});

View File

@ -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}`);
});

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)",
},
};

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" });

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] });

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)",
},
};

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: [
{

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.

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:=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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

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