🗽 Sync 2026-09-13 02:49:28

This commit is contained in:
github-actions[bot] committed 2026-09-13 02:49:28 +08:00
1 parent d95db9b2e1
commit 2f26109690
197 files changed
+9541 -8

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-ssr-plus
PKG_VERSION:=196
PKG_RELEASE:=69
PKG_RELEASE:=70
PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
@@ -609,6 +609,9 @@ prepare_clash_runtime_config() {
local client_policy_stats
local client_rules
local enable_fake_ip
local socks5_auth
local socks5_user
local socks5_pass
cache_file="$(get_clash_cache_file "$sid")"
workdir="$(get_clash_workdir "$sid")"
@@ -625,6 +628,16 @@ prepare_clash_runtime_config() {
client_policy_stats=""
client_rules=0
enable_fake_ip="$(uci_get_by_type server_subscribe enable_fake_ip "")"
socks5_auth="$(uci_get_by_type socks5_proxy socks5_auth noauth)"
socks5_user="$(uci_get_by_type socks5_proxy socks5_user "")"
socks5_pass="$(uci_get_by_type socks5_proxy socks5_pass "")"
if [ "$socks5_auth" = "password" ]; then
if [ -z "$socks5_user" ] || [ -z "$socks5_pass" ]; then
echolog "警告:SOCKS5 代理未完整配置用户名或密码,已自动降级为无认证模式 (noauth)。"
socks5_auth="noauth"
fi
fi
[ -s "$cache_file" ] || return 1
@@ -671,6 +684,12 @@ prepare_clash_runtime_config() {
if [ -n "$socks_port" ] && [ "$socks_port" != "0" ]; then
echo "socks-port: $socks_port" >>"$overlay_file"
if [ "$socks5_auth" = "password" ]; then
cat >>"$overlay_file" <<-EOF
authentication:
- "$socks5_user:$socks5_pass"
EOF
fi
fi
merge_stats="$(/usr/bin/lua "$CLASH_YAML_HELPER" merge "$raw_file" "$overlay_file" "$runtime_file" 2>/dev/null)" || return 1
@@ -56,7 +56,7 @@ local function load_yaml(path)
end
local function quote_nameserver_policy_keys(rendered)
rendered = rendered:gsub("[^\n]*", function(line)
rendered = rendered:gsub("[^\n]+", function(line)
local indent = line:match("^(%s*)geosite:geolocation%-!cn:%s*$")
if indent then
return indent .. '"geosite:geolocation-!cn":'
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=70
PKG_RELEASE:=71
endif
LUCI_TITLE:=Footstrap Theme
@@ -1418,14 +1418,30 @@ function observeContent() {
* normal with the engine ablated off. One pass and no `type` test, unlike the
* `records.find()` above: this observer registers `childList` only, and a record of any
* other type carries two empty node lists anyway. */
let gone = 0, came = 0;
for (const m of records)
let gone = 0, came = 0, took = 0, gave = 0;
for (const m of records) {
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
}
if (gone && came) {
forgetRest();
return;
}
if (trustEngine) lateDrift(settled, grew, floorShrink);
/* NOT ON A BATCH THAT ONLY TOOK NODES AWAY — task twohalves. `dom.content()` is empty-then-fill,
* and where the two halves reach this observer as two batches, arming on the first aims the
* correction at a page that is about to stop existing: CI measured `wrote--834` for a refill
* that grew the page by 120, the reader thrown by the transient and nothing left for the real
* half. Superseding the armed call with the later batch was measured instead and is WORSE —
* a real tick delivers around twenty records, each one cancelling the last, and the sweep went
* from clean to 12 findings of ordinary drift (docs/anchoring.md, "The later batch must NOT
* win"). This is the discriminator that survives both: a batch that removed nodes, added none
* and left the floored box no taller is a removal, and a removal is not a page to correct
* against. A synchronous `dom.content()` — every real poll tick — delivers its removals and
* its additions in ONE batch and is untouched. */
if (trustEngine) {
if (took && !gave && grew <= 0) why('emptying');
else lateDrift(settled, grew, floorShrink);
}
else scheduleAnchor(ref);
});
for (const host of hosts) {
+12
View File
@@ -0,0 +1,12 @@
# Optional — run `pnpm setup:router` once and it writes/updates every value below.
# OpenWrt router address — bare IP/hostname; the web proxy target and the
# .ut-sync ssh target both derive from it (ssh key selection etc. belongs
# in ~/.ssh/config)
# VITE_OPENWRT_HOST=192.168.1.1
# Dev server bind / port
VITE_DEV_PORT=5173
# 0.0.0.0 = LAN; 127.0.0.1 = local only
VITE_DEV_HOST=0.0.0.0
+4
View File
@@ -0,0 +1,4 @@
# Fail `pnpm install` early with a clear message on unsupported Node versions
# (the range in package.json `engines` mirrors Vite 8's requirement) instead
# of letting the install die somewhere deep inside a dependency.
engine-strict=true
+46
View File
@@ -0,0 +1,46 @@
# Build & dev server
**Dual-layer build**: source in `.dev/` → OpenWrt-compatible output committed to `htdocs/luci-static/`.
| source | output |
| ----------------------------------- | -------------------------------------------------------------------------------------- |
| `.dev/src/media/main.css` | `htdocs/luci-static/shadcn/main.css` |
| `.dev/src/media/login.css` | `htdocs/luci-static/shadcn/login.css` |
| `.dev/src/resource/*.js` | `htdocs/luci-static/resources/*.js` (`menu-shadcn`, `sidebar-shadcn`, `router-shadcn`) |
| `.dev/src/media/patches/*` | `htdocs/luci-static/shadcn/patches/*` (see [patches.md](patches.md)) |
| `.dev/public/shadcn/` | `htdocs/luci-static/shadcn/` — icons/images copied as-is |
| `ucode/template/themes/shadcn/*.ut` | not processed by Vite; pushed to a device via the SSH dev plugin |
`htdocs/` is generated output checked into git. Rebuild it with `pnpm build`, or trigger the manual `frontend-assets-build.yml` workflow, which builds and commits `htdocs/**`.
## Terser options that are load-bearing
`.dev/src/resource/*.js` goes through terser (compress + local-scope mangle, no bundling); each file stays a standalone LuCI `L.require()`-able module. Two options must not be dropped:
- `compress.directives: false` — keeps the leading `'require <dep>'` strings that LuCI's dependency scanner reads;
- `mangle.toplevel: false` — keeps top-level names (plus `parse.bare_returns` for the top-level `return`).
## Dev-server environment
All env vars are optional. `VITE_OPENWRT_HOST` is the bare router address (default `192.168.1.1`); the web proxy target and the `.ut`-sync SSH target (`root@<hostname>`) both derive from it — key selection etc. belongs in `~/.ssh/config`.
`ucode/template/themes/shadcn/*.ut` is pushed whole to `/usr/share/ucode/luci/template/themes/shadcn/` on dev-server startup and on every save (tar over ssh stdin), and `/cgi-bin` page loads wait for in-flight pushes.
`pnpm setup:router` writes all of that to `.env` and installs the SSH key on the device in one shot (`pnpm setup:router <ip>` to run non-interactively; safe to re-run after a reflash).
## `vite.config.ts` plugins
- `local-serve-plugin` — serves `main.css`/`login.css`/sidebar, menu & router JS at their `/luci-static/...` paths during `pnpm dev` and forces a full reload on change
- `ut-sync-plugin` — pushes the `.ut` template dir to the router over SSH (full push on startup + debounced push on save; `/cgi-bin` requests wait for pending pushes)
- `redirect-plugin` — redirects `/` to `/cgi-bin/luci` in dev
- `luci-js-compress` — runs `.dev/src/resource/*.js` through terser into `resources/`
- `mock-pages-plugin` — serves saved page snapshots at `/mocks/` against the live theme, and injects the mock bar into both those and proxied device pages (see [mock-pages.md](mock-pages.md))
## Tests & formatting
No linter CLI; the only tests are `.dev/tests/*.test.js` (plain `node:test`, no deps), run with `pnpm test`. Prettier (with `prettier-plugin-tailwindcss`) runs on format-on-save and sorts `@apply`/class lists — don't hand-reorder them.
## Releases
- `Makefile` (`PKG_VERSION` / `PKG_RELEASE`) is the OpenWrt package manifest, built via `feeds/luci/luci.mk`.
- `.github/workflows/build-theme.yml` builds `.ipk`/`.apk` via `eamonxg/build-luci-package` on version tags, pushes to `main`/`feat/**`, or when the commit message contains `[build]`.
+24
View File
@@ -0,0 +1,24 @@
# CSS conventions
Style with TailwindCSS v4 `@apply`, using CSS Nesting (`&:hover`, `&[disabled]`, `.parent &`, etc.) for scoped selectors — this is the dominant pattern across every component file. Fall back to raw CSS declarations only when `@apply` can't express the rule: custom properties, `@keyframes`/`animation`/`filter`, `clip-path`, `backdrop-filter`, and inline SVG data-URI backgrounds. The single deliberate exception is `patches/*.css`, plain CSS by design — see [patches.md](patches.md).
## Import order
`main.css` import order is meaningful (later imports win the cascade): `_tokens.css` → `_base.css` → `_layout.css` → `components/_*.css` → `_utilities.css` → `_shared.css`. New component styles get their own `components/_name.css`, imported before `_utilities.css`. Third-party app patches are **not** bundled into `main.css` — they load on demand per page.
Built CSS keeps Tailwind's native `@layer` structure. Theme partials (`_base.css`, `components/*`, `_utilities.css`, …) are plain unlayered CSS — organization comes from the file split, never wrap rules in `@layer`. Unlayered partials outrank Tailwind's layered base/utilities regardless of specificity; the OKLCH tokens already gate browsers to ones with `@layer` support.
## Tokens
- **Token source**: the engine and resolver (operators, `createResolver`, CSS emission helpers) come from `@eamonxg/luci-theme-tokens` (`/engine`, `/emit`); this repo only keeps `tokens/spec.js` (derivations, baked-alpha variants) + `tokens/defaults.js` (input colors). Edit those, then run `pnpm gen:tokens`. Do not edit generated `src/media/_tokens.css` directly.
- **`_tokens.css`**: generated flat OKLCH custom properties for light and dark modes plus the shared `@theme inline` mapping. It is imported by both `main.css` and `login.css`; runtime token-based `color-mix()` and relative `oklch(from …)` are prohibited.
- **`login.css`**: separate Vite build entry for the login page; it does **not** import `main.css`, but re-imports the generated `_tokens.css`.
## Dark mode
`@custom-variant dark` keyed on `[data-darkmode=true]`, set by an inline script in `header.ut` before paint (reads `localStorage['shadcn.theme']`) to avoid a flash of the wrong theme.
## Icons, two sources
- `.dev/src/assets/icons/` (Lucide SVGs) are referenced from CSS via the `@assets` alias as `mask-image`/`mask`, so they inherit `currentColor`.
- `.dev/public/shadcn/icons/` are SVGs referenced directly via `<img>`/JS (sidebar, menu, login, theme toggle) and copied verbatim to `htdocs/luci-static/shadcn/icons/`.
+33
View File
@@ -0,0 +1,33 @@
# Mock pages
Style a third-party app's page — write or adjust its `patches/*.css`, or check a `main.css`/component change against it — **without having the app (or a device) installed**. Snapshots live in `.dev/mocks/*.html` (git-ignored: large, device-specific, and they go stale). Served by `mock-pages-plugin` in `.dev/vite.config.ts`.
## The mock bar
Every HTML page the dev server hands out — proxied device pages and served snapshots alike — gets `scripts/mock-bar.client.js` (served at `/mocks/__bar.js`), a floating bottom-left bar in a Shadow DOM, so theme and patch CSS can neither restyle it nor be polluted by it.
On a device page it lists what `.dev/mocks/` holds, so the workflow is reachable without typing the `/mocks/` URL: `◆` appears when this page's `data-page` matches a snapshot and opens it in one click, `⊕` captures the open page, and an empty `.dev/mocks/` shrinks the bar to a lone `⊕`. Inside a snapshot it names the open one, steps through the rest, and `↩` goes back to the same page on the device. `✕` collapses it to a dot, remembered in `localStorage['shadcn.mockbar.collapsed']`.
The snapshot list is injected inline next to the script; both tags carry `data-shadcn-mock`, which is how a capture strips them back out — a snapshot must never bake in a list that is re-injected, current, on every serve.
## Capture
Open the page through the dev proxy and hit `⊕` on the mock bar — or press <kbd>Alt/Option+Shift+S</kbd>, or call `__shadcnMockCapture()`. It POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — doctype included, dev-only script tags stripped, custom-header gated so a foreign origin can't drive it. Hand-saved HTML works too; the filename is free, since a snapshot's identity is its `<body data-page>`.
**Capture from a device running _this_ theme — snapshots are not portable between themes.** A snapshot copies a rendered page verbatim, so it hard-codes the rendering theme in its stylesheet links (`/luci-static/shadcn/main.css` plus that page's patch), in the device's UCI token overrides inlined as `<style>`, and in the theme's own shell markup. A snapshot taken under `luci-theme-aurora` therefore renders **completely unstyled** here, since a dev server only serves its own `/luci-static/<theme>/` prefix; the tell is a terminal line naming the other theme's stylesheet (`[Mocks] miss /luci-static/aurora/main.css → 404 …`). Mirroring it under `mocks/static/`, which that generic hint suggests, is the wrong fix — re-capture on a shadcn device. Reusing a foreign snapshot regardless only makes sense for the app's own content region: repoint its stylesheet links at `shadcn`, and drop the inline `<style>`, whose captured tokens otherwise override this theme's.
## View
`pnpm dev`, then <http://localhost:5173/mocks/> — an index of every snapshot with its `data-page` and age. Each is served with the Vite HMR client injected, so editing `main.css`, a component, a `patches/*.css` or served JS full-reloads the open mock. Absolute `/luci-static/…` links resolve against this checkout and compile on the fly.
## Navigate
Inside a mock the bar also takes over clicks on the snapshot's own `/cgi-bin/luci/…` links, matching them to snapshots by `data-page` and jumping in place — an app's own tab bar works as it does on the device. Uncaptured targets are blocked with a hint naming the missing snapshot. The bar lists all snapshots and cycles with <kbd>[</kbd>/<kbd>]</kbd>; `↩` leaves for the real page, targeting the `requestpath` from LuCI's own inline bootstrap when that agrees with the snapshot's `data-page`, else falling back to splitting `data-page` (lossy when a segment contains a dash, `admin-status-disks-info`), then to the last device page visited in this tab.
## Third-party assets
Mirror an app's own css/js under `.dev/mocks/static/` following its URL (e.g. `.dev/mocks/static/luci-static/resources/foo/foo.css`); served as-is, no HMR. Misses requested by a mock page 404 instantly with a one-time terminal hint naming the mirror path, so mocks never hang on an unreachable router; anything else on `/luci-static` that falls through to the proxy is bounded to 5s → 504.
## No auth, no runtime
LuCI's runtime scripts (`luci.js`/`cbi.js`/`xhr.js`, `/cgi-bin/` endpoints) are stripped and `L`/`LuCI`/`XHR` stubbed, or LuCI would boot, poll, 403 and pop "Session expired". Framework-dependent theme JS (`menu-shadcn`, `sidebar-shadcn`) therefore no-ops — the captured DOM is already rendered, so the page still looks right; the theme's own inline scripts (dark mode, sidebar cache replay) still run.
+33
View File
@@ -0,0 +1,33 @@
# On-demand page patches
Third-party LuCI app/page compatibility fixes live one file per page in `src/media/patches/<page>.css`, where `<page>` is the `[data-page="..."]` value (request path segments joined by `-`). They are **not** bundled into `main.css`; `header.ut` links only the ones that match the page being rendered.
## Plain CSS, by design
**Patches are the one place that writes plain CSS instead of `@apply`.** Each is its own Rollup entry, so `@reference "../main.css";` + `@apply` made every file carry its own `@property` boilerplate (7 patches: 6,491 B → 1,083 B once rewritten).
Write narrow `[data-page]`/class-scoped overrides with native declarations, still reaching for theme values through the `:root` custom properties (`var(--panel-bg)`, `var(--foreground-a30)`, `var(--shadow-sm)`, …) rather than hardcoded colours or magic numbers. A value a patch needs but `:root` doesn't expose gets added to `STRUCTURE` in `scripts/gen-tokens.js` (that is why `--shadow-sm` exists), not inlined. CSS Nesting still works, since it needs no Tailwind processing.
The `@reference` + `@apply` route still _compiles_ for theme-repo patches and has real upsides — build-time validation (a typo'd utility fails the build; a typo'd `var()` fails silently at runtime) and the shared `dark:`/`md:`/`hover:` vocabulary — native is the default for the size numbers above, not a hard gate. App-shipped patches bypass the build entirely and have always been plain-CSS-only.
Globally-applicable chrome tweaks (e.g. icon opacity) belong in `_shared.css`, not here.
## Discovery and matching
`vite.config.ts` builds each patch as its own Rollup entry → `htdocs/luci-static/shadcn/patches/<page>.css`.
`header.ut` discovers installed patches at render time via `fs.lsdir()` (no build-time allow-list) and matches them against the cumulative path-segment prefixes of the current page: a patch applies to its page and all subpages, matching only on real segment boundaries so a prefix never leaks onto a lookalike sibling app. All matching patches load (sorted, so a shorter/general name precedes a longer/specific one, which then cascades on top) — this also lets dynamically generated pages (e.g. one page per contact/device) be covered by a patch named after their fixed prefix.
Because discovery is at render time, **any package — not just the theme — may drop a `<page-prefix>.css` into `luci-static/shadcn/patches/`** and it takes effect immediately, no theme rebuild required.
`PATCH_ALIASES` in `vite.config.ts` can duplicate one built payload (CSS and JS) under several page names when unrelated pages share it. It is currently empty — the log viewer ships only `patches/admin-status-logs.js`, whose prefix covers both log tabs on every supported release; its CSS is core-page styling and lives in `components/_syslog.css` inside `main.css`, where the `.syslog-view` markup contract is deliberately global so other packages can reuse the viewer. `_`-prefixed files in `patches/` are `@import`-only fragments, never entries.
## JS payloads
The same `lsdir()` sweep loads `patches/<page>.js` as `<script defer>` after the patch stylesheets (theme-owned sources in `src/resource/patches/`, Terser-compressed to `shadcn/patches/`; third-party packages may drop plain scripts the same way).
A JS patch must register `window.luciPatches[<stem>] = { mount, unmount }` and mount itself once at eval: the [client-side router](router.md) drives it across same-document navigations and a plain script would otherwise keep running on pages that are gone. `header.ut` also emits every installed patch file as `body[data-patches]` and marks its own `<link>`/`<script>` tags `data-luci-patch` so the router can enable/disable them per page.
## Adding / removing a theme patch
Create the file, run `pnpm build`, verify the built file is small. Removal is symmetric — delete the file, rebuild.
+647
View File
@@ -0,0 +1,647 @@
# The client-side router
> The router now ships from `@eamonxg/luci-theme-devkit` (`runtime/router.js`,
> page-scoped patches in `runtime/patches.js`), built into
> `resources/router-shadcn.js` by its Vite plugin; this theme keeps only the
> markers, the `luci-navigate` listener in `menu-shadcn.js` and the CSS. The
> design below is unchanged; the devkit copy is the maintained one.
How the theme turns a menu click into an in-document view swap instead of a
full page load, where it deliberately does not, and the invariants a router
inside LuCI has to keep. Source: `.dev/src/resource/router-shadcn.js`,
loaded from `footer.ut` after `menu-shadcn.js`. **No changes to luci-base
or to any view** — the router is additive theme JS plus small template hooks
(a patch manifest, `data-shadcn-*` markers on the stylesheets header.ut
itself renders, the asset version, a focusable `#maincontent`).
Ported from `luci-theme-aurora`'s `router-aurora.js` and kept in step with it
(currently its `58d6498`, 2026-08-17 — same-URL reloads handed to the server,
the Turbo-shaped progress bar, no focus ring on the landmark); the kernel,
resolver, teardown and gates are the same code, and the
theme-specific parts — hostname/title format, the sidebar sync in
`menu-shadcn.js`, `#maincontent` being the scroller — are the only
divergences, each called out below.
## At a glance
![One LuCI navigation: what the device does, what the browser does, and which of those steps the same-document router deletes](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.svg)
Two swimlanes over a shared millisecond axis — what the OpenWrt device does,
what the browser does — then a ledger of which steps the router deletes,
which stay on the device, and which the browser takes over, including the
teardown it now owes because the document no longer dies.
**The figures in it were measured with `luci-theme-aurora` deployed**, on the
shared code path (see "Why it pays" below). Two of them are aurora's alone
and do not transfer: the `header.ut` cost (1 ubus · 2 uci · 1 lsdir · 2
readfile — shadcn's header.ut makes 1 ubus and 1 `lsdir`) and the static
`main.css` row (191,899 B — shadcn's is 146,992 B). Everything else is
dispatcher and luci-base work, identical under either theme.
## Prior art
[luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)
(credited in the README) solves the same problem, and reading it informed
two pieces here: pausing `L.Poll` on a hidden tab, and folding a view's
read-only state along its dispatch path (both below). The rest is
independent, and one choice diverges deliberately. footstrap drives
navigation through the **History API** (`pushState`/`popstate`) with its own
scroll bookkeeping and a `prototype.render` guard to repair stale renders;
this router is built on the **Navigation API** instead (see "Kernel"), which
hands scroll, history and supersession to the browser and needs none of
that — at the cost of running only on newer browsers, where the theme falls
back to the plain MPA it already is. On top of that shared base this router
also adds a session-expiry gate, reproduces `template` pages from the
server's own shell rather than hand-porting them, and cross-fades the swap
with a view transition — each its own section below.
## Why it pays, measured
Numbers below were taken with the aurora theme's router on the same code
path (`bench-fullload.mjs` / `bench-dispatch.sh`, 2026-08-18); the shadcn
port shares the kernel, the resolver and the view render, so the shape holds
and the ratio is the point. Device: **Cudy TR3000** (mediatek/filogic,
ARMv8), OpenWrt SNAPSHOT r0-20d94d5, plain HTTP, warm cache, RUNS=10,
medians over the 8 pages below. Run-to-run spread is ±40 ms on a full load.
Where a full load's time goes:
| stage | ms | what it is |
| ----------------------------------------- | ------: | ------------------------------------------------------ |
| dispatch #1 — the page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut` → `header.ut` |
| dispatch #2 — `admin/translations/<lang>` | 124→209 | a _second_ CGI process, parser-blocking, uncacheable |
| DOMContentLoaded | 215 | the shell is back, byte-identical to the one discarded |
| view module + ubus data + render | 215→321 | static assets are already cache hits |
**209 of the 321 ms passes before anything page-specific has happened.** Both
dispatches re-derive a shell the browser already had on screen; the view's
own ubus calls do not start until 227 ms. A same-document swap deletes both
dispatches and keeps the last row — the same 8 pages land at a median of
**91 ms** warm, with the data calls starting at 2 ms instead of 227 ms.
The dispatch cost is the dispatcher's, not the theme's: measured on the
device over loopback, a `view` node's HTML is 75.4 ms, `admin/translations/en`
is 62.7 ms for a **13-byte** body, and a 191,899-byte static file is served
in 0.8 ms. The cost is the dispatch, not the payload — and a full page load
pays it twice.
End to end, click → view painted:
| page | full load | router (warm) | faster |
| ----------------------- | --------: | ------------: | -----: |
| status/routesj | 326 | 92 | 72 % |
| status/nftables | 316 | 90 | 72 % |
| status/logs | 281 | 100 | 64 % |
| status/processes | 457 | 228 | 50 % |
| status/channel_analysis | 401 | 54 | 87 % |
| status/realtime | 211 | 37 | 82 % |
| system/system | 496 | 132 | 73 % |
| system/admin | 231 | 40 | 83 % |
Median **73 % faster**, range 50–87 %. `bench-router.mjs timing`, an
independent harness, was run twice the same day and landed at 72 % and
74.5 % — all of that is inside the device's own spread, so treat the range,
not the digit, as the result. Document prefetch cannot reach it: it hides
the first dispatch at best, and the catalog is a subresource fetched after
the document arrives.
## Why it is possible
For a `view` node the dispatcher renders `view.ut`: the theme header, then
`<div id="view">` with an inline `L.require('ui').then(ui =>
ui.instantiateView('<path>'))`, then the theme footer. The server decides
_which_ view; the client renders it. The router repeats what `view.ut` does
without the reload: resolve the path against the menu tree the client
already holds (`ui.menu.load()` serves it from `sessionStorage`), swap the
content region, re-instantiate the view, and let the browser own the URL.
## Kernel: the Navigation API, and only that
`navigation.addEventListener('navigate', …)` + `event.intercept()`. One
event covers link clicks, `location.assign`, back/forward traversals to
same-document entries, and our own `navigation.navigate()`; the browser
writes the URL and history entry, exposes `event.signal` for supersession,
and (with `scroll: 'after-transition'`) restores window scroll on
traversal / scrolls to top on push, so the router carries no
`pushState`/`popstate` code and no fragment-vs-navigation heuristics. The
one piece of scroll bookkeeping it does keep is for `#maincontent`, the
theme's own scroller (see "The navigation procedure", step 7).
Why this API rather than the History API `luci-theme-footstrap` uses:
- **The browser owns URL, history and scroll.** `pushState` puts the
router in charge of all three and of keeping them consistent with what
it rendered; here it only ever renders.
- **Supersession is built in.** A newer navigation aborts the older one's
`event.signal`; the generation gate below is a check, not a state
machine, and no `render` guard is needed to repair a stale paint.
- **Every navigation kind arrives at one listener** — link click,
`location.assign`, back/forward to a same-document entry, our own
`navigation.navigate()` — so there is exactly one path to keep correct.
- **The fallback is free.** Where the API is missing, the theme is the
MPA it already was; nothing has to be polyfilled or feature-forked.
**Browsers without the API stay MPA.** `footer.ut` only requires the module
when `window.navigation` exists, and `__init__` re-checks the surface it
actually uses: `navigation.addEventListener`, `NavigateEvent`, and
`intercept` on its prototype. Chrome/Edge **105+**, Safari 26.2+, Firefox
147+ get the router — 105, not the 102 that first shipped the Navigation
API, because the method was called `transitionWhile()` until Chrome 108 and
`canIntercept` was `canTransition`; gating on `intercept` is what makes the
floor 105. The theme's declared floor (Chrome 111 / Safari 16.4 /
Firefox 128) keeps working as it does today. This is a deliberate trade:
one code path, correct by construction, over a second history-API path that
would double the surface of everything below.
## Compatibility
### Browsers — per platform feature
| Feature | Used for | Required? | Chrome / Edge | Safari | Firefox | Without it |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------- | ------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Navigation API (`navigation.addEventListener('navigate')`, `NavigateEvent.intercept()`, `event.destination/signal`, `navigation.navigate()/back()`) | the whole router | **yes — gate** | 105+ (2022) | 26.2+ (2026-01) | 147+ (2026-01) | `router-shadcn.js` is not even loaded (`footer.ut` checks `window.navigation`); the theme is the plain MPA it was before |
| `document.startViewTransition()` (same-document) | crossfade at the swap | no | 111+ | 18+ | 144+ | swap without animation; also off under `prefers-reduced-motion` |
| `fetch(url, { priority: 'low' })` | hover module prewarm | no | 101+ | 17.2+ | 132+ | the option is ignored, the fetch still runs at default priority |
| `MutationObserver`, `DOMParser`, `WeakSet`, `URL`, `Element.replaceWith`, `:scope`, `matchMedia`, optional chaining / `??=` | render completion, template shells, poison gate, staging | yes | ≥ 85 | ≥ 14 | ≥ 79 | all inside the theme's declared floor (Chrome 111 / Safari 16.4 / Firefox 128) |
So the router's effective floor is Chrome/Edge 105, Safari 26.2, Firefox 147;
everything older keeps the theme's existing floor and behaviour. The
aurora port was verified live in Chrome 151 (headless CDP); Safari/Firefox
by feature detection only — the gate is the same API surface, not a UA sniff.
### OpenWrt / LuCI
The theme already requires OpenWrt 23.05+ (ucode templates). Except for the
two version-scoped items called out below, the router touches only luci-base
surfaces that are identical in the `openwrt-23.05`, `openwrt-24.10`,
`openwrt-25.12` and `master` branches of `openwrt/luci` (checked against the
branch sources, 2026-08): `L.require` with instance caching and
`prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref`
registry, `L.env.{scriptname, base_url, resource_version, media,
requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`,
`L.Poll.{queue, start, stop, active, timer}` (and `start()`'s reset of
`tick`, which is what re-arms an incoming view's first poll), the
`poll-status` indicator id `setupDOM`'s `poll-start` handler registers,
`ui.menu.load()`'s session-cached tree with `satisfied` /
`firstchild_ineligible` / `wildcard` / `action.type` (`view`, `alias`,
`firstchild`, `template`), `ui.instantiateView`, `ui.hideIndicator`,
`ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`,
`network.js`'s uci-backed state, `Request.addInterceptor` /
`rpc.addInterceptor` and the `-32002` → `session.access` probe in
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding, `view.ut`'s `#view` +
inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
`node_weight` / alias re-dispatch semantics (ported line for line).
**Two surfaces are not the same across those branches**, and the resolver is
written against the newer one:
- **`node.css`** entered `build_pagetree`'s schema in master only
(7c6d8ff, 2026-08). 23.05, 24.10 and 25.12 carry no `css` on any node, so
`nodeCss()` returns `null` and the feature is simply inert there.
- **Wildcard descent.** `wildcardaction` exists in 25.12 and master, not in
23.05 or 24.10 — an absent key just falls back to `node.action`, which is
what those releases do anyway, so that part is safe. The _resolution rule_
around it is not: 25.12 and master descend into a matching `satisfied`
child before treating trailing segments as args, while 23.05 and 24.10
capture every remaining segment the moment a `wildcard` node is reached.
The router ports the 25.12/master rule. On 23.05 or 24.10 a tree that has
both `foo/*` and a real `foo/bar` child would therefore resolve differently
in the router than in the dispatcher — the exact "click opens one page, F5
opens another" failure this resolver exists to avoid. Both the rule and
`wildcardaction` came in as one commit (df90c60a7, 2026-01-17) whose stated
purpose is to let `path/*` carry an action distinct from the bare path, so
the shape had no defined behaviour before it and a tree written for
23.05/24.10 is unlikely to use it — but that is an argument, not a survey
of every installed `menu.d`, and the router has not been run on either
release. Treat 23.05/24.10 as inspected, not verified.
Live verification so far: the aurora build of this router on OpenWrt
SNAPSHOT r0-20d94d5 (2026-08, mediatek/filogic) and an earlier SNAPSHOT on
ipq60xx. 23.05 / 24.10 / 25.12 by branch source only, not on device.
That list is also executable: `contract()` in `router-shadcn.js` looks every
one of those surfaces up at boot (`L.view`, `L.require`, `L.dom.content`,
`L.env.{base_url,resource,media}`, `L.Request.addInterceptor`,
`L.uci.{load,unload,state}`, `rpc.addInterceptor`,
`poll.{queue,start,stop,active}`, `ui.menu.load`, `ui.hideModal`,
`ui.hideIndicator`, `E`) and, if any is missing, logs which and does not
activate — the theme is the MPA it was, not a broken router, on a luci-base
that moved.
## What is intercepted
A `navigate` event is intercepted only when **all** hold:
- `event.canIntercept` (same-origin, not cross-document-only), not
`hashChange`, no `downloadRequest`, no `formData`, `navigationType !== 'reload'`;
- the destination is **not the document's own URL** (fragment aside). A
same-URL navigation arrives as `navigationType: 'replace'`, not
`'reload'`, yet it is a reload by another name: luci-base's
`ui.changes.apply/revert` end in `window.location =
window.location.href.split('#')[0]` (and the expiry modal's button in the
same) precisely so the server re-renders the shell — a theme switched
under System → Language and Style, a new language, a new hostname, a
changed menu tree — and intercepting it left the swap showing the old
shell until F5. A click on the current page's own link is the same reload
it is in the MPA;
- the destination path (minus `L.env.scriptname`) resolves in the menu tree
to a **serviceable node** (below);
- the document is not **poisoned** (below) and its session is not known to
be **expired** (below);
- the router **activated** in this document: it does so only when the page
it booted on is itself serviceable. A `call`/`cbi`/`function` page carries
scripts (legacy `XHR.poll`, inline timers) that only a document death
retires; the first click away from one is always a full load.
Anything else falls through untouched: the browser performs the ordinary
full navigation, i.e. exactly what the theme did before. Modifier-clicks and
`target=_blank` never reach the event.
### Serviceable nodes
Resolved with a port of the dispatcher's own rules, not a paraphrase:
- `alias` → jump to `action.path` from the root and continue;
- `firstchild` → the same `resolve_firstchild()` / `node_weight()` the
dispatcher runs: candidates are `satisfied` children with a `title` and an
object `action`; weight `min(order ?? 9999, 9999)` +10000 for
`auth.login`; a `firstchild` candidate counts only if it resolves further;
`firstchild_ineligible` excluded; ties keep key order. The ACL check is
skipped because `/admin/menu` is already filtered for the session;
- `wildcard` nodes are descended into first — a segment that matches a
`satisfied` child wins over arg capture — and only the remainder becomes
request args; with args present the node's `wildcardaction` (the `path/*`
entry's own action) runs, and `action` for the bare path. This is the
25.12/master rule; 23.05 and 24.10 capture at the first `wildcard` node
instead — see "OpenWrt / LuCI" above;
- a hop counter (32) breaks cycles in a foreign `menu.d`;
- **any segment that does not match a `satisfied` child ends the attempt.**
The dispatcher would fall back to the deepest satisfied ancestor and
re-resolve from there; the router returns `null` and hands the navigation
to the server. Deliberate: the fallback costs one full load, guessing the
ancestor wrong costs the wrong page.
Two tracks are kept, as a full load keeps them: **requested** segments →
`L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved**
segments → `L.env.dispatchpath`, `L.env.nodespec`, the menu highlight, the
title. Pick a different child than the dispatcher would and a click opens
one page while F5 opens another — that is why the resolver is a port.
| node | served |
| --------------------------------------------------------- | ------------------------------------- |
| `view` | yes — `view.<path>` |
| `alias`, `firstchild` | yes — resolved to a leaf, recursively |
| `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below |
| Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load |
`rewrite` is deliberately not resolved: it is not in the tree and a splice
mistake opens the wrong page, which is worse than the reload it falls back to.
### Template nodes: the server's own shell, never a hand port
`admin/status/overview` is a `template` whose server side defines page
globals (`progressbar`, `renderBox`, `renderBadge`), emits an `<h2>` and a
`div.includes` (server-rendered Lua includes), and then instantiates
`view.status.index`. A first version re-implemented those helpers in the
router and drifted on the first real page (the network badges lost their
labels: upstream's `renderBadge` takes extra `L.itemlist` arguments the port
did not know about). So the router does not port anything: when a link to a
template node is hovered or focused, its page is **fetched once per
document** (the in-flight request is shared by every intent event that
arrives before it resolves), parsed with `DOMParser`, and the content region after `#tabmenu` (to the
end of `#maincontent` — this theme renders no `<footer>` inside it, and the
router stops at one if a template ever emits it) is kept as the page's
_shell_ — every node cloned, `#view`
replaced by an empty div, the inline `instantiateView('…')` script read for
the class name, the remaining inline scripts (the helpers) replayed into
global scope on staging. luci-base's own bootstrap (`luci.js` and
`L = new LuCI(env)`) also lives in that region and is filtered out. If the
document _is_ that template (the session started on Overview), the shell is
taken from the live region and no fetch happens. A template node is only
intercepted once its shell is known — so a Lua template page (no
`instantiateView` call) is remembered as unservable after one hover fetch
and never enters the router's error path, and a template clicked without a
prior hover is a plain full load that seeds the shell for the rest of the
document. Its status include modules are singletons carrying
`oneshot`/`hide` state that a full load would reset — verified against a
real full load, not against expectation.
## The navigation procedure
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,
handler in order:
1. **Generation.** `const gen = ++this.gen`; every later DOM write is gated
on it. `event.signal` aborts our own awaits, but it cannot cancel a LuCI
XHR (`L.Request` hands back a bare promise; the `XMLHttpRequest` only
surfaces on the _resolved_ `Response`, too late to abort) or a
`View.__init__` chain already running, so the generation is the
correctness mechanism and the signal is hygiene.
2. **Teardown of the departing document state**, i.e. what a document
death would have done for free:
- `Poll`: `queue.length = 0; stop(); start()` — three steps. The flush
drops the old view's pollers; `stop()` drops the tick; `start()` on an
empty queue re-arms `tick = 0` so the incoming view's `poll.add()`
auto-starts and fires immediately instead of waiting up to `interval`
seconds for the surviving tick to align. Upstream's `initDOM()` does
the same `Poll.start()` on an empty queue before the first view.
- `uci`: `unload()` every package present in `state.values` **or**
`uci.loaded` (documents start with an empty cache; four shipped apps
read `load()`'s return as an existence check and draw an error over
the page when the cache answers `[]`; and `uci.loaded` keeps a
package's request promise — a rejected one included — until
`unload()`, so a failed load left there would be handed to every later
view). Then, if `L.network` has been loaded,
`load(['network','luci'])` — plus `'wireless'` when
`L.hasSystemFeature('wifi')` — is re-issued and **awaited** —
and a rejection propagates to the hard-load fallback rather than
leaving `network.js` on an empty config: `network.js` fills its `_state` once and
from then on answers out of the uci cache (`getWifiDevices()` _is_
`uci.sections('wireless','wifi-device')`), so dropping those without
refilling hands every consumer an empty config for the rest of the
document. Unsaved local edits die with the page as they would on a
full load; saved changes live on the server and the Unsaved-changes
indicator is unaffected.
- bare `setInterval`s registered since the router booted are cleared
(`setInterval`/`clearInterval` are hooked in `__init__`, i.e. when
`L.require('router-shadcn')` instantiates the class; `poll.timer`, the
one interval `L.Poll` owns, is skipped). `setTimeout` and rAF are
**not** touched: the
core keeps tooltips, notification timeouts and a request timeout on
`setTimeout`, and there is no self-rescheduling timeout in any
shipped view.
- `window`/`document` listeners a view registered **while it rendered**
are removed. Several shipped views add them per render (statistics
graphs: an anonymous `resize` that later throws against detached DOM;
nlbwmon: `tooltip-open`/`touchstart`; the core's own dropdown widgets:
one `window` click/touchstart per instance), so they accumulate and
act on pages that are gone. The hook records registrations inside the
render window only. A **warm** render evaluates no module, so its
registrations are per-render by construction and go on the next
teardown. A **cold** render also runs the module's top level, whose
registrations must survive (removing them is one-way — an editor's
module-eval listeners never come back), so cold registrations are only
credited to the class and released when a later warm render of the
same class registers the same target/type, which proves them
per-render.
- `ui.hideIndicator('poll-status')` — luci-base leaves a
_Refreshing_ / _Paused_ indicator behind that a document death would
have taken with it.
- `ui.hideModal()`, the theme's own surfaces (mega
menu, mobile drawer, palette) close.
- page-scoped patch CSS is disabled and its JS patch unmounted (below).
The uci flush is the one part of this that is awaited rather than
fire-and-forget, so it is a separate step after `teardown()` returns.
3. **Environment.** `L.env.requestpath/dispatchpath/pathinfo/nodespec`,
`body[data-page]`, `document.title`. An alias is re-dispatched
server-side, so `requestpath` and `data-page` carry the alias target while
`pathinfo` keeps the URL as requested; a `firstchild` keeps the requested
path in both. header.ut titles pages `<hostname> | <title>`; the head
before the separator is read off the initial document, so it matches
whatever the template emitted. `nodespec` drives
`L.hasViewPermission()` and therefore the Save/Apply footer's readonly
state — and its `readonly` is **folded down the dispatch path** the way
`dispatcher.uc` does it: `ctx_append` collects every node's
`depends.acl` and one `check_acl_depends()` over the union is writable as
soon as _any_ group is writable, so a page is readonly only when every
acl-bearing node on its path is. The tree's per-node flag
(`apply_tree_acls`) covers that node's own acl alone; handing the leaf
node over as-is gave a read-only user a live Save & Apply on every page
under a read-only group. The tree object is not mutated (`nodespec` is a
copy). `data-page` keys `ui.tabs` session state and the theme's
page-scoped CSS.
4. **Chrome.** `menu-shadcn.js` exposes `syncRoute()`: it re-marks the
sidebar's `.active` item/sub-item/logout from `L.env.dispatchpath` with
the same longest-link-prefix rule header.ut's pre-paint replay uses on
the cached markup, opens the active accordion group and closes the rest
(unless the sidebar is collapsed to its icon rail), rebuilds the topbar
breadcrumb and re-renders `#tabmenu` for the new section. The sidebar is
**not** rebuilt — its DOM, delegated listeners and the sessionStorage
cache taken on `pagehide` all keep working — only its state changes.
`closeSurfaces()` (teardown) closes the palette, the mobile drawer and
the collapsed-rail popover.
5. **Staging.** A fresh `<div id="view" class="view-staging">` is inserted
right after `#tabmenu`, i.e. **first in tree order** —
`getElementById('view')` returns the first match, so everything LuCI's
view chain writes goes into the staged element while the outgoing page
stays on screen (dimmed, `.view-leaving`). The stage is invisible but
**laid out** (`visibility:hidden; height:0; overflow:hidden`, never
`display:none`): the realtime graphs size themselves from
`#view.offsetWidth` inside `render()`, and a `display:none` stage handed
them a 0-wide canvas. Nothing is removed yet.
6. **Patches.** `header.ut` emits the installed on-demand patch stems as
`body[data-patches]`; the router applies the same segment-prefix rule the
template applies at render time: matching `patches/<stem>.css` links are
ensured (`<link data-luci-patch>`, enabled for the page on screen,
`disabled` — not removed — for the rest, so a return costs nothing);
matching `patches/<stem>.js` files are loaded once and their
`window.luciPatches[stem]` `{ mount, unmount }` pair is driven per
visit (the list of stems to mount belongs to the navigation that computed
it, so a superseded one mounts nothing later); URLs the router adds carry
the same `?v=PKG_VERSION` luci.mk stamps on the template's own links,
read from `body[data-asset-version]`, so they hit the same cache entry — a JS patch that registers nothing is simply executed once,
MPA-style. A patch script mounts itself when it evaluates; if the user
has navigated on before it arrives, its `load` handler checks whether
the current page still wants that stem and unmounts it otherwise (a
same-stem page reached meanwhile keeps it mounted).
A menu.d node's own `css` (`header.ut` links `<resource>/<node.css>` for
the dispatched node, marked `data-luci-node-css`) is kept the same
way: one `<link>` per stylesheet, enabled for the page whose resolved leaf
declares it, `disabled` for every other page, never removed. Both
attributes are exempt from the poison gate.
7. **View.**
- **cold** (`view.<path>` never required in this document):
`window.L.require(className)` — the require _is_ the render (LuCI
instantiates on first require) and it must go through `window.L`, the
runtime instance, never the prototypal `L` a module factory receives
(`ui` hangs `itemlist`/`showModal` on `window.L`; a view required
through the wrong `L` dies three modules down on `L.itemlist is not a
function`, and because `require()` caches by name the binding is fixed
by the _first_ requirer);
- **warm**: `require()` hands back the cached instance whose `__init__`
already ran; LuCI's class system sets `prototype.constructor`, so
`new instance.constructor()` runs a fresh `__init__` → `load()` →
`render()` → `dom.content('#view')`, exactly what a full load starts
from.
- **completion** is observed, not assumed: a `MutationObserver` on the
staged element resolves when a non-spinner child lands (or the spinner
is removed for an empty render). Not completing within 15 s is a
**failure**, not a completion: committing the spinner and releasing the
serialization would let the still-running chain paint into a later
navigation's `#view`, so the timeout rejects and the catch path
hard-loads the destination. On completion —
and only if this navigation is still the latest — the outgoing region
(everything after `#tabmenu` except the staged element) is removed
and the staged view is unhidden inside `document.startViewTransition()`
when available and reduced motion is off; the navigation's `finished`
promise resolves after that swap. In this theme `#maincontent`, not the
window, is the scroller, so the browser's `scroll: 'after-transition'`
never sees the offset that matters: the departing entry's
`#maincontent.scrollTop` is remembered by `navigation.currentEntry.key`
when the navigate event is intercepted (it still names the departing
entry during dispatch) and put back on a `traverse` (by
`event.destination.key`), while a push lands at the top — as a full
load would.
- **Renders are serialized.** Neither an in-flight LuCI XHR nor a running
`View.__init__` chain can be cancelled (`L.Request` never exposes its
handle), and every chain paints into _whichever_ `#view` is first at
paint time. So a navigation first awaits the previous one's completion
(bounded by the same timeout) before it tears anything down or stages
anything — the previous chain finishes into its own staged element,
which is then discarded. Rapid A→B→C therefore never interleaves:
B is skipped when C arrives before B ran (`event.signal` /
generation), and C waits for whichever render is actually in flight.
The document's initial LuCI-rendered view is tracked the same way, so a
click during the first load cannot be painted over by it — and a first
render that never completes rejects that wait, so the first navigation
takes the hard-load fallback instead of staging next to a chain that
may still paint. That first render also runs inside a render window
(opened after the router's own listeners are registered), so the
listeners it adds are credited to its class like a cold render's; the
ones it registered before the router loaded are out of reach. The cost is
that a click during a slow load waits for that load; the alternative —
wrapping `prototype.render` per class and repairing stale cold renders
by re-navigating — leaves a real window open and needs three mechanisms
where one suffices.
8. **Focus and announcement.** `#maincontent` (`tabindex=-1`) with
`preventScroll`; the new `document.title` is written into
`#luci-nav-status` (`role=status`, `aria-live=polite`), since a
same-document swap fires no load a screen reader would announce.
9. **Progress.** A navigation that outlives 150 ms gets `#luci-nav-progress`
inserted — Turbo Drive's bar, in shape: a hairline at the top whose
`width` is driven inline and **trickles** in ever-smaller steps
(`+ (100 - w) / 30` every 300 ms) until commit, so a slow render keeps
visibly moving instead of looking stuck; on commit it fills to 100 %,
fades (`data-state="done"`) and is **removed from the DOM**. Shorter
navigations stay silent, overlapping ones share the bar. The browser's
own progress bar only shows for document loads, which is exactly what a
same-document swap is not — hence GitHub, YouTube, Turbo/HEY and every
nprogress user draw their own. Reduced motion drops the transitions, not
the bar.
10. Any exception → `console.error` (a silent fallback makes every router
regression look like "the page is just slow") → `location.href =
destination` — a hard full load, never a stuck page.
## The expiry gate
luci-base answers a dead session with `notifySessionExpiry()`: `Poll.stop()`
plus a modal whose only button is a hard reload. A same-document swap would
`hideModal()` and `Poll.start()` right through it and browse on, every page
erroring in turn (measured on the aurora port's expiry bench against a
router without the gate — `expiredFullLoad: false`). So the router listens
for the same two
signals luci-base acts on — a `403` with `X-LuCI-Login-Required: yes` on any
`L.Request`, and the `session.access` probe luci-base fires after a
`-32002` coming back denied or errored — and from then on intercepts nothing:
the next click is a full load, which the dispatcher turns into the login
page. A denied call on any other object is an ACL matter and is ignored.
Nothing is reset: the flag dies with the document, as the session did. The
same flag keeps the visibility gate (below) from restarting a poll the
expiry stopped.
## Hidden tabs
luci-base keeps polling in a background tab. The router stops `Poll` on
`visibilitychange` → hidden when it was active and starts it again on
return — unless the user had paused it, or the session died meanwhile. On
a weak router that is RPC work nobody is looking at.
## The poison gate
A `<style>`/`<link rel=stylesheet>` a view writes into `<head>` dies with
the document on a full load and **survives** a same-document swap, painting
every page after it (a shipped file manager hides Save/Reset on every config
page with one unlayered `!important` rule). Removing it is not an option: a
library that imports CSS at module eval never runs again, so deletion is
one-way (an editor page came back as a black rectangle two million pixels
tall). Hence a gate, not a sweep: before intercepting, any sheet outside
`#view` that is not one of the theme's own — header.ut marks everything it
renders (`data-luci-shell` on `main.css`, the font, custom and token
`<style>`s; `data-luci-patch` on patches; `data-luci-node-css` on the
menu.d node css) — marks the document **poisoned** and the navigation is a
full load — the fresh document carries no view CSS, so the router resumes
"Own" means _marked_, and the boot snapshot the gate
compares against is filtered by those markers, so a sheet the boot page's own
modules inserted before the router loaded still counts as foreign instead of
being grandfathered in for the rest of the document. Correctness over speed,
never the other way.
An owner-based refinement (stamp each sheet with the inserting module off
the call stack, enable it for pages whose dependency closure holds that
module, `disabled` for the rest) was built, verified on the device and
**removed again**: on this device one view page inserts its own CSS, the
saving is one reload when leaving it, and the price was three monkeypatches
plus an inline template script whose failure mode — a page silently missing
a shared library's CSS — is worse than the reload it avoids. Revisit only
with a real corpus of self-styling view pages.
## Module prewarm on hover
Entering (`pointerover`/`focusin`/`pointerdown`) a link to a serviceable
node `fetch()`es its view module with `priority: 'low'` — not `require()`,
which would render it. The URL is built byte-for-byte as `LuCI.require()`
builds it (`<base_url>/<name with . → />.js?v=<resource_version>`) or it
misses the HTTP cache. The walk is transitive: the fetched body is scanned
for its leading `'require x'` string literals with a regex that is **not**
line-anchored (shipped files are minified onto one line), and dotted names
are warmed the same way; dotless names are either luci-base's file-less
built-ins (`view`, `baseclass`, `dom`, `poll`, `request`, `session`) or flat
libraries the chrome has already loaded, so they are declined outright.
Deduplicated per class name; stops once a navigation to that link has
committed. Cold navigations are the only place this shows; warm ones are
already 0-byte cache hits.
## What is deliberately not done
- **No history-API path.** See "Kernel".
- **No document prefetch.** A hover prefetch of a document the router will
never load is pure router CPU; the theme ships no speculation rules.
- **No `unload`/`beforeunload`**, ever (bfcache).
- **No cancellation of in-flight XHR** — there is no handle to cancel with
(see step 1); the
generation gate makes it a waste, not a bug. Upstream-only.
- **No sweeping of a view's global listeners or timeouts** — one-way
deletions of module-eval registrations. If a per-render offender ever
appears, the answer is a targeted teardown, not a global hook.
- **`ui.changes.confirm/revert` and `awaitReconnect`** keep their hard
`window.location` writes — a rollback/reboot boundary _should_ be a fresh
document.
## Verification matrix
- Unit (`.dev/tests/router.test.js`): resolver against a fixture tree
(alias chain, nested firstchild, weights, ineligible, unsatisfied,
wildcard args, cycle); URL → segments; patch prefix matching; pragma scan
on a minified head; readonly folding; expiry signals; the same-URL reload
rule; node css of the resolved leaf; the contract check.
- Device (the aurora repo's `bench-router.mjs`, CDP — it drives the theme
through the browser and needs only its selectors adapted; not yet
re-run against this port):
1. full walk of every clickable node, each compared
against a real full load of the same URL — `data-page`,
`dispatchpath`, URL, title, tab count, footer presence, console clean;
2. click → view painted, median of N, router vs full load, warm and cold;
3. soak: 60 navigations over 12 pages, heap / DOM nodes / listeners /
poll queue length flat after the first pass;
4. back/forward chain through alias and firstchild URLs — no reload;
5. poison gate: a foreign `<style>` in `<head>` makes the next
navigation a full load, the one after is a same-document swap again;
5b. sheets: the same, on every walked view page that really inserts its
own sheets (found on the walk) instead of an injected one — reached
same-document and landed on directly (its modules insert before the
router boots), leaving is a full load either way;
5c. hygiene: no progress bar left in the DOM after a swap, live region
present and carrying the title, a hidden tab stops polling and a
visible one resumes it;
6. nodecss: a page whose menu.d node declares `css` — link enabled on
arrival, disabled after leaving, re-enabled without a duplicate on
return (skipped when no installed node declares one);
7. expiry (last, destroys the session): logout fetched from inside the
document, one failing RPC → luci-base's modal and `Poll.stop()`; the
next navigation is a full load landing on the login form.
The walk also compares `nodespec.readonly`, `L.hasViewPermission()`, the
set of enabled node-css links and the live-region text against the full
load, and reports which pages carry sheets that are not the theme's.
- Run the unit tests with `pnpm test` from `.dev/`.
+17
View File
@@ -0,0 +1,17 @@
# Sidebar & menu
- `header.ut`: near-minimal shell — empty `#sidebar` plus a parser-blocking inline script right after it that replays the sidebar cache (below) before first paint; sidebar chrome + nav are otherwise built client-side in `menu-shadcn.js`.
- `sidebar-shadcn.js`: state machine for theme (light/dark/device), sidebar collapse/expand, accordion, and mobile drawer — exposed as `window.ShadcnSidebar` after the `shadcn-sidebar-ready` event fires.
- `menu-shadcn.js`: resolves the `admin` branch of `ui.menu.load()`, then renders a two-level sidebar; `ICON_MAP` maps a LuCI menu node's `name` to `/shadcn/icons/*.svg`; deeper levels render as `#tabmenu`. `syncRoute()` re-marks the active sidebar item (same longest-link-prefix rule as the header.ut replay), breadcrumb and tabs from `L.env` after a [router](router.md) swap without rebuilding the sidebar; `closeSurfaces()` closes palette/drawer/popover.
## Sidebar cache (anti-flash)
`menu-shadcn.js` snapshots `#sidebar.innerHTML` + scroll position into `sessionStorage['shadcn.sidebar.cache']` (`{v, lang, html, scroll}`) after render and on `pagehide`. The `header.ut` inline script replays it pre-paint on the next navigation, recomputes the active highlight for the current URL (longest link-path prefix — keep in sync with menu-shadcn's dispatchpath matching), restores accordion/scroll state, and sets `data-shadcn-built` / `data-shadcn-restored` on `#sidebar`.
When restored, `renderSidebarChrome` only re-syncs the hostname, and `renderSidebarNav` preserves accordion/scroll across its authoritative rebuild.
Restored HTML loses inline JS handlers (`innerHTML` serialization), so anything that must work before the re-render needs a delegated listener — e.g. the logout click in `header.ut`, which clears the cache and sets `window.shadcnSuppressSidebarCache` so the `pagehide` re-cache stays suppressed.
**Bump `v` whenever the sidebar markup changes shape.**
Cross-document `@view-transition` rules live in `components/_view-transitions.css` (only `#sidebar` gets its own snapshot group — the topbar must not, or its top-layer snapshot escapes `.content-card`'s rounded-corner clipping during transitions); they assume the cache keeps the sidebar's first frame populated.
@@ -0,0 +1,10 @@
// Theme identity read by the Vite build (vite.config.ts) and the devkit dev
// layer / bins (@eamonxg/luci-theme-devkit). Everything theme-specific lives
// here; the machinery is generic.
export default {
name: "shadcn", // media dir (/luci-static/shadcn) + ucode theme dir
css: ["main", "login"], // src/media/<e>.css → /luci-static/shadcn/<e>.css
resources: ["menu-shadcn", "sidebar-shadcn"], // served at /luci-static/resources/<m>.js in dev
assets: { dir: "public/shadcn", only: /^icons\/[^/]+\.svg$/ }, // only icons served in dev
patchAliases: {},
};
+39
View File
@@ -0,0 +1,39 @@
{
"name": "luci-theme-shadcn",
"description": "Modern OpenWrt LuCI theme with sidebar layout built with Vite and TailwindCSS",
"type": "module",
"scripts": {
"dev": "vite",
"test": "node --test tests/*.test.js",
"setup:router": "luci-theme-setup",
"build": "pnpm clean && pnpm gen:tokens && pnpm check:contrast && vite build && pnpm check:colors",
"check:colors": "node scripts/check-built-css.js",
"check:contrast": "node scripts/check-token-contrast.js",
"gen:tokens": "node scripts/gen-tokens.js",
"clean": "luci-theme-clean",
"format": "prettier --config prettier.config.mjs --ignore-path ../.prettierignore --write .. --ignore-unknown",
"format:check": "prettier --config prettier.config.mjs --ignore-path ../.prettierignore --check .. --ignore-unknown",
"lint-staged": "lint-staged --cwd ..",
"prepare": "cd .. && husky"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"devDependencies": {
"@eamonxg/luci-theme-devkit": "^0.3.1",
"@eamonxg/luci-theme-tokens": "^2.0.0",
"@fontsource-variable/inter": "^5.2.8",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.9.1",
"colorjs.io": "^0.6.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4.3.0",
"terser": "^5.47.1",
"tw-animate-css": "^1.4.0",
"vite": "^8.0.13"
},
"packageManager": "pnpm@10.33.2"
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
import * as tailwindcss from "prettier-plugin-tailwindcss";
/** @type {import("prettier").Config} */
export default {
useTabs: false,
tabWidth: 2,
printWidth: 80,
singleQuote: false,
trailingComma: "all",
semi: true,
arrowParens: "always",
plugins: [tailwindcss],
};
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-activity"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>

After

Width:  |  Height:  |  Size: 425 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chart-bar"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M7 16h8" />
<path d="M7 11h12" />
<path d="M7 6h3" />
</svg>

After

Width:  |  Height:  |  Size: 397 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chevron-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chevron-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-container"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" />
<path d="M10 21.9V14L2.1 9.1" />
<path d="m10 14 11.9-6.9" />
<path d="M14 19.8v-8.1" />
<path d="M18 17.5V9.4" />
</svg>

After

Width:  |  Height:  |  Size: 586 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/></svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 275 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-gauge"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 14 4-4" />
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-hard-drive"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 16h.01" />
<path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
<path d="M21.946 12.013H2.054" />
<path d="M6 16h.01" />
</svg>

After

Width:  |  Height:  |  Size: 540 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-layers"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 587 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-layout-dashboard"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 503 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-lock"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>

After

Width:  |  Height:  |  Size: 384 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-log-out"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
</svg>

After

Width:  |  Height:  |  Size: 395 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-menu"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 5h16" />
<path d="M4 12h16" />
<path d="M4 19h16" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-monitor"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>

After

Width:  |  Height:  |  Size: 424 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-moon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-network"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="16" y="16" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="9" y="2" width="6" height="6" rx="1" />
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
<path d="M12 12V8" />
</svg>

After

Width:  |  Height:  |  Size: 523 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-panel-left-close"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-panel-left-open"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m14 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 399 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-phone"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384" />
</svg>

After

Width:  |  Height:  |  Size: 513 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-server"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 490 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-settings"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 665 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-shield"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 463 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 5H3" />
<path d="M12 19H3" />
<path d="M14 3v4" />
<path d="M16 17v4" />
<path d="M21 12h-9" />
<path d="M21 19h-5" />
<path d="M21 5h-7" />
<path d="M8 10v4" />
<path d="M8 12H3" />
</svg>

After

Width:  |  Height:  |  Size: 511 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-sun"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 559 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-user"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<rect width="1000" height="1000" rx="250" fill="#000"/>
<path d="M244 625 590 279M461 638 677 420M669 642l90-90" fill="none" stroke="#fff" stroke-width="40" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<rect width="1000" height="1000" rx="250" fill="#000"/>
<path d="M244 625 590 279M461 638 677 420M669 642l90-90" fill="none" stroke="#fff" stroke-width="40" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,21 @@
{
"name": "Shadcn",
"short_name": "Shadcn",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,40 @@
/**
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
* Licensed under the Apache License, Version 2.0.
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
const outputDir = resolve(
import.meta.dirname,
"../../htdocs/luci-static/shadcn",
);
const files = ["main.css", "login.css"];
const forbidden = [
{
label: "token-based color-mix()",
pattern: /color-mix\([^)]*var\(--/g,
},
{
label: "relative oklch()",
pattern: /oklch\(from/g,
},
];
const failures = [];
for (const file of files) {
const css = await readFile(resolve(outputDir, file), "utf8");
for (const { label, pattern } of forbidden) {
const count = css.match(pattern)?.length ?? 0;
if (count > 0) failures.push(`${file}: ${label} × ${count}`);
}
}
if (failures.length > 0) {
console.error(`Runtime token color functions found:\n${failures.join("\n")}`);
process.exitCode = 1;
} else {
console.log("Built CSS contains no runtime token color functions.");
}
@@ -0,0 +1,41 @@
/**
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
* Licensed under the Apache License, Version 2.0.
*/
import Color from "colorjs.io";
import { resolveMode } from "../tokens/resolve.js";
const MIN_TEXT_CONTRAST = 4.5;
const checks = [
["muted_foreground", "background"],
["sidebar_muted", "sidebar_bg"],
];
const failures = [];
for (const mode of ["light", "dark"]) {
const tokens = resolveMode(mode);
for (const [foreground, background] of checks) {
const ratio = Color.contrast(
new Color(tokens[background]),
new Color(tokens[foreground]),
"WCAG21",
);
if (ratio < MIN_TEXT_CONTRAST) {
failures.push(
`${mode}: ${foreground} on ${background} is ${ratio.toFixed(2)}:1`,
);
}
}
}
if (failures.length > 0) {
console.error(
`Text token contrast must be at least ${MIN_TEXT_CONTRAST}:1:\n${failures.join("\n")}`,
);
process.exitCode = 1;
} else {
console.log("Text token contrast meets WCAG AA.");
}
@@ -0,0 +1,98 @@
/**
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
* Licensed under the Apache License, Version 2.0.
*/
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { alpha, toOklch } from "@eamonxg/luci-theme-tokens/engine";
import { block, themeColors } from "@eamonxg/luci-theme-tokens/emit";
import { resolveMode } from "../tokens/resolve.js";
import { ALPHAS } from "../tokens/spec.js";
const snake = (s) => s.replace(/-/g, "_");
const withAlpha = (oklchStr, pct) => toOklch(alpha(oklchStr, pct / 100));
function alphaTokens(resolved) {
const out = {};
for (const [base, list] of Object.entries(ALPHAS)) {
const val = resolved[snake(base)];
if (val === undefined) throw new Error(`ALPHAS base not resolved: ${base}`);
for (const a of list) out[`${base}-a${a}`] = withAlpha(val, a);
}
return out;
}
const light = resolveMode("light");
const dark = resolveMode("dark");
const lightA = alphaTokens(light);
const darkA = alphaTokens(dark);
const STRUCTURE = `
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
--radius-base: 0.5rem;
/* Elevation. Emitted on :root (not just inlined into the shadow-* utilities)
so on-demand patches/*.css -- standalone entries that carry no Tailwind
helper output -- can reach it as var(--shadow-sm). */
--shadow-sm:
0 1px 3px 0 oklch(0% 0 0 / 0.1), 0 1px 2px -1px oklch(0% 0 0 / 0.1);
`;
const themeColorsCss = themeColors([
...Object.keys(light),
...Object.keys(lightA),
]);
// Add aliases for backward compatibility
const aliases = [
` --color-sidebar-fg: var(--sidebar-foreground);`,
` --color-terminal-fg: var(--terminal-foreground);`,
].join("\n");
const THEME = `@theme inline {
${themeColorsCss}
${aliases}
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--shadow-sm: var(--shadow-sm);
/* Radius ladder driven by a single knob; at 0.5rem it matches the
Tailwind default scale (rounded-lg = 0.5rem). Tune --radius-base
to scale all corners for custom-radius support. */
--radius-sm: calc(var(--radius-base) * 0.5);
--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);
}
`;
const HEADER = `/**
* luci-theme-shadcn: design tokens -- GENERATED, DO NOT EDIT.
* Run \`pnpm gen:tokens\`. Source: tokens/defaults.js + tokens/spec.js
* All color values are flat oklch() literals; no dynamic color functions.
* Dark mode overrides must stay after light mode block.
*/
`;
const css =
HEADER +
"\n" +
block(":root", light, lightA) +
STRUCTURE +
"}\n\n" +
block('[data-darkmode="true"]', dark, darkA) +
"}\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");
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-activity"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>

After

Width:  |  Height:  |  Size: 425 B

@@ -0,0 +1,6 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg class="lucide lucide-alert-circle" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" x2="12" y1="8" y2="12"/>
<line x1="12" x2="12.01" y1="16" y2="16"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1,4 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg class="lucide lucide-check" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>

After

Width:  |  Height:  |  Size: 293 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chevron-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chevron-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chevron-up"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m18 15-6-6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1,5 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg class="lucide lucide-circle-dot" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="1"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-hard-drive"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 16h.01" />
<path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
<path d="M21.946 12.013H2.054" />
<path d="M6 16h.01" />
</svg>

After

Width:  |  Height:  |  Size: 540 B

@@ -0,0 +1,6 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg class="lucide lucide-info" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4"/>
<path d="M12 8h.01"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-layers"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 587 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-layout-dashboard"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 503 B

@@ -0,0 +1,4 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg class="lucide lucide-loader-2" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-lock"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>

After

Width:  |  Height:  |  Size: 384 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-log-out"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
</svg>

After

Width:  |  Height:  |  Size: 395 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-menu"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 5h16" />
<path d="M4 12h16" />
<path d="M4 19h16" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-moon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-network"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="16" y="16" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="9" y="2" width="6" height="6" rx="1" />
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
<path d="M12 12V8" />
</svg>

After

Width:  |  Height:  |  Size: 523 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-panel-left-close"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-panel-left-open"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m14 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 399 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="6"/>
</svg>

After

Width:  |  Height:  |  Size: 122 B

@@ -0,0 +1,10 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg class="lucide lucide-refresh-cw-off" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8L18.74 5.74A9.75 9.75 0 0 0 12 3C11 3 10.03 3.16 9.13 3.47"/>
<path d="M8 16H3v5"/>
<path d="M3 12C3 9.51 4 7.26 5.64 5.64"/>
<path d="m3 16 2.26 2.26A9.75 9.75 0 0 0 12 21c2.49 0 4.74-1 6.36-2.64"/>
<path d="M21 12c0 1-.16 1.97-.47 2.87"/>
<path d="M21 3v5h-5"/>
<path d="M22 22 2 2"/>
</svg>

After

Width:  |  Height:  |  Size: 587 B

@@ -0,0 +1,7 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg class="lucide lucide-refresh-cw" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 21l-6 -6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-server"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 490 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-settings"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 665 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-shield"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 463 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 5H3" />
<path d="M12 19H3" />
<path d="M14 3v4" />
<path d="M16 17v4" />
<path d="M21 12h-9" />
<path d="M21 19h-5" />
<path d="M21 5h-7" />
<path d="M8 10v4" />
<path d="M8 12H3" />
</svg>

After

Width:  |  Height:  |  Size: 511 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-sun"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 559 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-user"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>

After

Width:  |  Height:  |  Size: 374 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-x"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 332 B

+135
View File
@@ -0,0 +1,135 @@
/* Only known scroll containers need themed scrollbars. Applying these rules to
every descendant makes selector matching scale with the whole LuCI page.
This list covers the theme's own scroll containers plus LuCI core markup —
a third-party app's own scroll container is not styled here. `scrollbar-color`
and `scrollbar-width` inherit from `html`, so such a container still picks up
the theme's colours wherever those properties are supported; only the 8px
`::-webkit-scrollbar` shape is missed. An app that needs the full treatment
adds its container to `patches/<page>.css` — see the file-manager patch. */
:where(
html,
.sidebar-nav,
#maincontent,
.cmdk-list,
.cbi-dropdown > ul.dropdown,
#modal_overlay,
#modal_overlay > .modal > ul,
#modal_overlay > .modal pre,
.cbi-tabmenu,
.tabs,
textarea
) {
scrollbar-color: var(--foreground-a30) transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-corner {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: var(--foreground-a30);
border-radius: 9999px;
}
}
html {
@apply bg-background text-foreground font-sans text-sm;
body {
/* flex-wrap: third parties append stray children to <body> (OpenClash
portals its dialogs there with oc.css's .oc { width: 100% }, browser
extensions inject helper divs). In a nowrap row they'd join the
sidebar/#main-wrapper line and shrink #main-wrapper (flex-basis 0) to
nothing; wrapping shunts them onto a second line below the viewport-
high first line (#sidebar/#main-wrapper are h-full), out of view —
the same place strays end up on a normally-flowing page (below the
content). */
/* h-dvh with no vh fallback: the OKLCH tokens already gate us to
browsers (Chrome 111+ / Safari 15.4+ / Firefox 113+) that all ship
dvh, so a fallback would only serve browsers the theme can't render
colors on anyway. */
@apply flex h-dvh flex-wrap;
/* Scroll lock while a LuCI modal is open — ui.js toggles this class on
<body>, the stock LuCI convention. Off-modal the shell needs no clamp:
the row exactly fills h-dvh and scrolling lives in #maincontent. */
&.modal-overlay-active {
@apply overflow-hidden;
}
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-foreground font-sans leading-tight font-semibold tracking-tight;
}
h1 {
@apply mb-4 text-2xl;
}
h2 {
@apply mb-3 text-xl;
}
h3 {
@apply mb-2 text-lg;
}
h4 {
@apply mb-2 text-base;
}
h5 {
@apply mb-1 text-sm;
}
h6 {
@apply mb-1 text-xs;
}
hr {
@apply border-border my-4 border-t;
}
strong {
@apply font-semibold;
}
abbr {
@apply cursor-help;
}
p {
@apply text-foreground text-sm leading-relaxed;
}
pre {
@apply border-border bg-muted text-muted-foreground block rounded-lg border px-3 py-2 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap;
}
code {
@apply bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-sm;
}
a {
@apply text-primary no-underline hover:underline;
}
var {
@apply text-primary font-mono;
}
small {
@apply text-muted-foreground text-xs font-normal;
}
em {
@apply text-muted-foreground font-medium italic;
}
@@ -0,0 +1,417 @@
/* ── Sidebar ──────────────────────────────────────────────────────── */
#sidebar {
/* Width only: `transition-all` would also ease background-color, making the
sidebar lag a beat behind the untransitioned content area on theme flips. */
@apply bg-sidebar-bg flex h-full w-[220px] shrink-0 flex-col overflow-hidden transition-[width] duration-200;
@apply max-md:fixed max-md:top-0 max-md:left-0 max-md:z-40 max-md:w-[240px] max-md:-translate-x-full max-md:shadow-xl max-md:transition-transform;
/* Drawer height tracks the dynamic viewport so its bottom edge meets the
Safari toolbar smoothly instead of jumping when the toolbar shows/hides. */
@apply max-md:h-dvh;
&[data-drawer-open="true"] {
@apply max-md:translate-x-0;
}
.icon-expand {
@apply hidden;
}
}
html[data-sidebar-collapsed="true"] #sidebar {
@apply md:w-12;
.sidebar-brand-text,
.sidebar-label,
.sidebar-chevron,
.icon-collapse {
@apply md:hidden;
}
.icon-expand {
@apply md:block;
}
.sidebar-accordion-sub {
@apply md:!hidden;
}
}
.sidebar-header {
@apply flex h-14 shrink-0 items-center justify-between px-3;
}
.sidebar-brand {
@apply text-sidebar-fg flex min-w-0 flex-1 items-center gap-2 truncate no-underline;
}
.sidebar-logo {
@apply shrink-0;
}
.sidebar-brand-text {
@apply truncate text-sm font-semibold;
}
.sidebar-toggle {
@apply text-sidebar-muted flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 transition-colors;
&:hover {
@apply bg-sidebar-accent text-sidebar-accent-fg;
}
svg,
img {
@apply h-4 w-4;
}
}
/* ── Sidebar nav ──────────────────────────────────────────────────── */
.sidebar-nav {
@apply flex-1 overflow-x-hidden overflow-y-auto px-2 py-2;
}
.sidebar-nav-item,
.sidebar-accordion-item {
@apply mb-0.5;
&.active > .sidebar-nav-parent {
@apply text-sidebar-active-fg;
}
}
.sidebar-nav-parent {
@apply text-sidebar-muted flex w-full cursor-pointer appearance-none items-center justify-start gap-2.5 rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium transition-colors duration-150;
&:hover {
@apply bg-sidebar-accent text-sidebar-accent-fg;
}
.sidebar-icon {
@apply flex shrink-0 items-center justify-center;
svg,
img {
@apply h-4 w-4;
}
}
.sidebar-label {
@apply flex-1 truncate;
}
.sidebar-chevron {
@apply ml-auto shrink-0 transition-transform duration-200;
svg,
img {
@apply h-3.5 w-3.5;
}
}
}
.sidebar-accordion-item {
&[data-open="true"] {
> .sidebar-nav-parent {
@apply bg-sidebar-active-bg text-sidebar-active-fg;
}
.sidebar-chevron {
@apply rotate-180;
}
}
}
/* Grid 0fr↔1fr accordion with an opacity fade: animates to the content's
real height with no JS measurement, and the active section renders open
so no transition fires on first paint. The clip sits on the grid
container, not the list — an auto-height grid re-resolves a mid-transition
Nfr against its own already-shrunk height, so a list clipped at its own
box loses rows faster than the space closes. The list keeps its natural
height (self-start) so its border-l spans every visible row, and carries
no vertical padding: padding would give the 0fr track a floor, and a
closing section that stops short while its exclusive sibling is still
opening bounces every row below them. visibility transitions too, so the
collapsed links leave the tab order only once the close has finished. */
.sidebar-accordion-sub {
@apply invisible grid grid-rows-[0fr] overflow-hidden opacity-0 transition-[grid-template-rows,opacity,visibility] duration-200 ease-out;
> * {
@apply min-h-0 self-start;
}
}
.sidebar-accordion-item[data-open="true"] > .sidebar-accordion-sub {
@apply visible grid-rows-[1fr] opacity-100;
}
.sidebar-sub-list {
@apply border-sidebar-border ml-4 list-none border-l pl-2;
}
.sidebar-sub-item {
@apply mb-0.5;
&.active .sidebar-sub-link {
@apply text-sidebar-active-fg font-medium;
}
}
.sidebar-sub-link {
@apply text-sidebar-muted block rounded-md px-2 py-1.5 text-sm no-underline transition-colors;
&:hover {
@apply bg-sidebar-accent text-sidebar-accent-fg;
}
}
/* ── Collapsed hover popover ──────────────────────────────────────── */
.sidebar-collapsed-popover {
@apply border-border bg-panel-bg fixed z-50 min-w-44 overflow-hidden rounded-lg border py-1 shadow-lg;
}
.sidebar-collapsed-popover-title {
@apply text-foreground px-3 pt-1.5 pb-1 text-xs font-semibold;
}
.sidebar-collapsed-popover-list {
@apply m-0 list-none p-0;
}
.sidebar-collapsed-popover-link {
@apply text-sidebar-muted mx-1 block rounded-sm px-2 py-1.5 text-sm no-underline transition-colors;
&:hover {
@apply bg-sidebar-accent text-sidebar-accent-fg;
}
&.active {
@apply text-sidebar-active-fg font-medium;
}
}
/* ── Sidebar footer ───────────────────────────────────────────────── */
.sidebar-footer {
@apply border-sidebar-border shrink-0 border-t p-2;
@apply pb-[max(0.5rem,env(safe-area-inset-bottom))];
}
.sidebar-logout {
@apply text-sidebar-muted flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm no-underline transition-colors;
&:hover {
@apply bg-sidebar-accent text-sidebar-accent-fg;
}
&.active {
@apply text-sidebar-active-fg font-medium;
}
.sidebar-icon {
@apply flex shrink-0 items-center justify-center;
svg,
img {
@apply h-4 w-4;
}
}
.sidebar-label {
@apply flex-1 truncate;
}
}
/* ── Main wrapper ─────────────────────────────────────────────────── */
#main-wrapper {
/* h-full (not stretch-derived): body wraps stray third-party children onto
a second flex line; the first line must always be exactly viewport-high
so those wrapped lines land offscreen. basis-full (not w-full) on mobile:
with the sidebar out of flow, a zero flex-basis would leave room for a
stray full-width sibling on the same line instead of forcing it to wrap. */
@apply bg-background flex h-full flex-1 flex-col overflow-hidden py-2 pr-2 pl-0 max-md:basis-full max-md:p-0;
}
/* ── Content card ────────────────────────────────────────────────── */
.content-card {
@apply border-panel-border bg-panel-bg flex flex-1 flex-col overflow-hidden rounded-xl border shadow-sm max-md:rounded-none max-md:border-none max-md:shadow-none;
}
/* ── Card header ─────────────────────────────────────────────────── */
.card-header {
@apply border-panel-border bg-panel-bg flex h-12 shrink-0 items-center justify-between border-b px-4;
}
.card-header-left {
@apply flex items-center gap-2;
}
.card-header-right {
@apply flex items-center gap-2;
}
.topbar-hamburger {
@apply text-muted-foreground hidden h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 transition-colors max-md:flex;
&:hover {
@apply bg-muted;
}
svg,
img {
@apply h-5 w-5;
}
}
.topbar-breadcrumb {
@apply flex items-center gap-1 text-sm;
.breadcrumb-item {
@apply text-muted-foreground;
&.active {
@apply text-foreground font-medium;
}
}
.breadcrumb-sep {
@apply text-muted-foreground;
svg,
img {
@apply h-3.5 w-3.5;
}
}
}
button.topbar-icon-btn {
@apply text-muted-foreground inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 transition-colors;
&:hover {
@apply bg-muted text-foreground;
}
img {
@apply h-4 w-4 shrink-0;
}
}
.topbar-indicators {
@apply flex items-center gap-2;
/* LuCI-owned spans: icon via ::before mask only (no JS / no img) */
span[data-indicator] {
> img {
@apply hidden;
}
@apply text-muted-foreground relative inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-[0px] transition-colors;
@apply before:relative before:block before:size-4 before:shrink-0 before:bg-current before:[mask-size:cover] before:[mask-position:center] before:[mask-repeat:no-repeat];
&:hover {
@apply bg-muted text-foreground;
}
&[data-indicator="poll-status"] {
&[data-style="active"] {
@apply before:[mask-image:url('@assets/icons/refresh-cw.svg')];
}
&[data-style="inactive"] {
@apply before:[mask-image:url('@assets/icons/refresh-cw-off.svg')];
}
}
&[data-indicator="media_error"] {
@apply before:[mask-image:url('@assets/icons/alert-circle.svg')];
}
&[data-indicator="uci-changes"] {
@apply before:[mask-image:url('@assets/icons/settings.svg')];
&[data-count]:not([data-count="0"]) {
@apply after:bg-primary after:text-primary-foreground after:absolute after:-top-0.5 after:-right-0.5 after:z-10 after:flex after:min-h-3 after:min-w-3 after:items-center after:justify-center after:rounded-full after:px-0.5 after:text-[8px] after:leading-none after:font-bold after:content-[attr(data-count)];
}
}
}
}
/* ── Maincontent ──────────────────────────────────────────────────── */
#maincontent {
@apply flex-1 overflow-y-auto p-6 max-md:p-4;
h1 {
@apply mb-4 border-0 pl-0 text-2xl font-semibold tracking-tight;
}
h2 {
@apply mb-3 border-0 pl-0 text-xl font-semibold;
}
h3 {
@apply mb-2 text-lg font-semibold;
}
.cbi-map-descr,
.cbi-section-descr {
@apply text-muted-foreground mb-4 text-sm leading-relaxed;
}
.cbi-page-actions {
@apply border-border mt-4 flex flex-wrap items-center justify-end gap-2 border-t pt-4;
}
/* Channel-analysis chart (#channel_graph) hardcodes a white bg + black grid
lines inline; recolor for dark mode. */
& div[style] {
> svg {
@apply dark:!bg-surface;
& line[style] {
@apply dark:!stroke-text;
}
}
}
.zone-forwards {
@apply flex flex-nowrap items-stretch gap-2 leading-relaxed;
> span {
@apply text-muted-foreground flex shrink-0 basis-8 items-center justify-center self-center;
}
.zone-src,
.zone-dest {
@apply flex flex-1 flex-col gap-1.5;
}
}
.controls {
@apply flex flex-wrap items-center gap-3;
label {
input[type="radio"],
input[type="checkbox"] {
@apply mr-1 align-middle;
}
}
}
.cbi-title-section {
@apply mb-4 leading-relaxed;
}
.control-group {
@apply mt-1;
}
.control-group,
.cbi-page-actions > div,
.cbi-section-actions > div {
@apply flex flex-row flex-nowrap items-center gap-2;
}
}
/* ── Mobile sidebar overlay ───────────────────────────────────────── */
#sidebar-overlay {
/* Absolute, not fixed: iOS Safari 26 tints its bars from edge-touching
fixed elements' background-color, opacity notwithstanding. */
@apply bg-overlay-base-a50 pointer-events-none absolute inset-0 z-30 opacity-0 transition-opacity duration-200 md:hidden;
&.active {
@apply pointer-events-auto opacity-100;
}
}
@@ -0,0 +1,56 @@
/**
* luci-theme-shadcn: rules shared by both build entries
* Copyright 2025 eamonxg <eamonxiong@gmail.com>
* Licensed to the public under the Apache License 2.0.
*
* main.css and login.css are separate build entries; anything they both need
* lives here so it is written once and imported by each.
*/
html {
@apply scheme-light;
}
html[data-darkmode="true"] {
@apply scheme-dark;
}
/* Lucide icons loaded via <img>: <img>-embedded SVGs render with black strokes
and ignore the parent's `color`, so flatten them to a single tone and invert
for dark mode (per-context opacity stays in each stylesheet). */
.card-header img,
.login-input-icon img,
.login-pw-toggle img,
img.shadcn-icon {
filter: brightness(0) saturate(100%);
}
html[data-darkmode="true"] .card-header img,
html[data-darkmode="true"] .login-input-icon img,
html[data-darkmode="true"] .login-pw-toggle img,
html[data-darkmode="true"] img.shadcn-icon {
filter: brightness(0) saturate(100%) invert(1);
}
/* Per-context opacity tuning for the flattened <img> icons above. These are
theme chrome (sidebar + card header), present on every admin page, so they
stay globally loaded here rather than in an on-demand per-page patch. */
.card-header img,
img.shadcn-icon {
opacity: 0.65;
}
#sidebar img.shadcn-icon {
opacity: 0.65;
}
html[data-darkmode="true"] #sidebar img.shadcn-icon {
opacity: 0.7;
}
.card-header button:hover img,
#sidebar .sidebar-nav-item.active img,
#sidebar .sidebar-accordion-item.active img,
#sidebar .sidebar-nav-parent:hover img {
opacity: 1;
}
@@ -0,0 +1,324 @@
/**
* luci-theme-shadcn: design tokens -- GENERATED, DO NOT EDIT.
* Run `pnpm gen:tokens`. Source: tokens/defaults.js + tokens/spec.js
* All color values are flat oklch() literals; no dynamic color functions.
* Dark mode overrides must stay after light mode block.
*/
:root {
--bg: oklch(0.985 0.004 275);
--surface: oklch(1 0 0);
--text: oklch(0.18 0.02 275);
--brand: oklch(0.567 0.158 275);
--on-brand: oklch(1 0 0);
--success: oklch(0.58 0.15 150);
--warning: oklch(0.70 0.13 75);
--danger: oklch(0.55 0.20 25);
--info: oklch(0.58 0.13 250);
--overlay-base: oklch(0 0 0);
--background: oklch(0.985 0.004 275);
--foreground: oklch(0.18 0.02 275);
--primary: oklch(0.567 0.158 275);
--primary-foreground: oklch(1 0 0);
--panel-bg: oklch(1 0 0);
--destructive: oklch(0.55 0.20 25);
--ring: oklch(0.567 0.158 275);
--border: oklch(89.65% 0.0058 275);
--input: oklch(89.65% 0.0058 275);
--sidebar-bg: oklch(0.985 0.004 275);
--sidebar-foreground: oklch(0.18 0.02 275);
--sidebar-accent-fg: oklch(0.18 0.02 275);
--sidebar-accent: oklch(94.5% 0.004 275);
--sidebar-hover-bg: oklch(94.5% 0.004 275);
--login-path-stroke: oklch(0.567 0.158 275);
--progress-bar-end: oklch(0.567 0.158 275);
--destructive-fg: oklch(100% 0 0);
--success-fg: oklch(100% 0 0);
--info-fg: oklch(100% 0 0);
--warning-fg: oklch(20% 0.05 75);
--terminal-foreground: oklch(85% 0.08 155);
--terminal-hover: oklch(85% 0.08 155 / 0.06);
--muted-foreground: oklch(52.62% 0.0131 275);
--secondary-foreground: oklch(30.08% 0.0176 275);
--sidebar-muted: oklch(52.62% 0.0131 275);
--focus-ring: oklch(56.7% 0.158 275 / 0.5);
--muted: oklch(95% 0.004 275);
--secondary: oklch(93.5% 0.004 275);
--label-surface: oklch(96.3% 0.004 275);
--panel-border: oklch(89.65% 0.0058 275);
--primary-hover: oklch(62.7% 0.158 275);
--sidebar-active-bg: oklch(56.7% 0.158 275 / 0.12);
--sidebar-active-fg: oklch(48.7% 0.158 275);
--sidebar-border: oklch(89.65% 0.0058 275);
--terminal-bg: oklch(14% 0.02 264);
--warning-soft-fg: oklch(42% 0.12 75);
--progress-bar-start: oklch(74.02% 0.0948 275);
--login-left-bg: oklch(93.5% 0.004 275);
--primary-a5: oklch(56.7% 0.158 275 / 0.05);
--primary-a10: oklch(56.7% 0.158 275 / 0.1);
--primary-a15: oklch(56.7% 0.158 275 / 0.15);
--primary-a20: oklch(56.7% 0.158 275 / 0.2);
--primary-a25: oklch(56.7% 0.158 275 / 0.25);
--primary-a60: oklch(56.7% 0.158 275 / 0.6);
--primary-a70: oklch(56.7% 0.158 275 / 0.7);
--primary-a90: oklch(56.7% 0.158 275 / 0.9);
--destructive-a8: oklch(55% 0.2 25 / 0.08);
--destructive-a10: oklch(55% 0.2 25 / 0.1);
--destructive-a20: oklch(55% 0.2 25 / 0.2);
--destructive-a30: oklch(55% 0.2 25 / 0.3);
--destructive-a40: oklch(55% 0.2 25 / 0.4);
--destructive-a50: oklch(55% 0.2 25 / 0.5);
--destructive-a60: oklch(55% 0.2 25 / 0.6);
--destructive-a70: oklch(55% 0.2 25 / 0.7);
--destructive-a80: oklch(55% 0.2 25 / 0.8);
--destructive-a90: oklch(55% 0.2 25 / 0.9);
--foreground-a5: oklch(18% 0.02 275 / 0.05);
--foreground-a30: oklch(18% 0.02 275 / 0.3);
--secondary-a80: oklch(93.5% 0.004 275 / 0.8);
--muted-a30: oklch(95% 0.004 275 / 0.3);
--muted-a80: oklch(95% 0.004 275 / 0.8);
--muted-foreground-a70: oklch(52.62% 0.0131 275 / 0.7);
--success-a10: oklch(58% 0.15 150 / 0.1);
--success-a15: oklch(58% 0.15 150 / 0.15);
--success-a30: oklch(58% 0.15 150 / 0.3);
--success-a40: oklch(58% 0.15 150 / 0.4);
--success-a80: oklch(58% 0.15 150 / 0.8);
--warning-a10: oklch(70% 0.13 75 / 0.1);
--warning-a15: oklch(70% 0.13 75 / 0.15);
--warning-a30: oklch(70% 0.13 75 / 0.3);
--warning-a40: oklch(70% 0.13 75 / 0.4);
--info-a10: oklch(58% 0.13 250 / 0.1);
--info-a15: oklch(58% 0.13 250 / 0.15);
--info-a30: oklch(58% 0.13 250 / 0.3);
--info-a40: oklch(58% 0.13 250 / 0.4);
--info-a80: oklch(58% 0.13 250 / 0.8);
--border-a60: oklch(89.65% 0.0058 275 / 0.6);
--border-a80: oklch(89.65% 0.0058 275 / 0.8);
--input-a30: oklch(89.65% 0.0058 275 / 0.3);
--label-surface-a50: oklch(96.3% 0.004 275 / 0.5);
--overlay-base-a50: oklch(0% 0 0 / 0.5);
--overlay-base-a60: oklch(0% 0 0 / 0.6);
--background-a60: oklch(98.5% 0.004 275 / 0.6);
--ring-a50: oklch(56.7% 0.158 275 / 0.5);
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
--radius-base: 0.5rem;
/* Elevation. Emitted on :root (not just inlined into the shadow-* utilities)
so on-demand patches/*.css -- standalone entries that carry no Tailwind
helper output -- can reach it as var(--shadow-sm). */
--shadow-sm:
0 1px 3px 0 oklch(0% 0 0 / 0.1), 0 1px 2px -1px oklch(0% 0 0 / 0.1);
}
[data-darkmode="true"] {
--bg: oklch(0.159 0.005 264);
--surface: oklch(0.205 0.004 264);
--text: oklch(0.90 0.006 264);
--brand: oklch(0.567 0.158 275);
--on-brand: oklch(1 0 0);
--success: oklch(0.637 0.175 147);
--warning: oklch(0.80 0.12 80);
--danger: oklch(0.63 0.16 25);
--info: oklch(0.70 0.10 250);
--overlay-base: oklch(0 0 0);
--background: oklch(0.159 0.005 264);
--foreground: oklch(0.90 0.006 264);
--primary: oklch(0.567 0.158 275);
--primary-foreground: oklch(1 0 0);
--panel-bg: oklch(0.205 0.004 264);
--destructive: oklch(0.63 0.16 25);
--ring: oklch(0.567 0.158 275);
--border: oklch(26.27% 0.0051 264);
--input: oklch(26.27% 0.0051 264);
--sidebar-bg: oklch(0.159 0.005 264);
--sidebar-foreground: oklch(0.90 0.006 264);
--sidebar-accent-fg: oklch(0.90 0.006 264);
--sidebar-accent: oklch(23% 0.004 264);
--sidebar-hover-bg: oklch(23% 0.004 264);
--login-path-stroke: oklch(0.567 0.158 275);
--progress-bar-end: oklch(0.567 0.158 275);
--destructive-fg: oklch(100% 0 0);
--success-fg: oklch(100% 0 0);
--info-fg: oklch(100% 0 0);
--warning-fg: oklch(20% 0.05 75);
--terminal-foreground: oklch(85% 0.08 155);
--terminal-hover: oklch(85% 0.08 155 / 0.06);
--muted-foreground: oklch(58.14% 0.0056 264);
--secondary-foreground: oklch(78.89% 0.0059 264);
--sidebar-muted: oklch(58.14% 0.0056 264);
--focus-ring: oklch(56.7% 0.158 275 / 0.5);
--muted: oklch(18.9% 0.005 264);
--secondary: oklch(22.5% 0.004 264);
--label-surface: oklch(19.5% 0.004 264);
--panel-border: oklch(90% 0.006 264 / 0.08);
--primary-hover: oklch(62.7% 0.158 275);
--sidebar-active-bg: oklch(56.7% 0.158 275 / 0.14);
--sidebar-active-fg: oklch(78.7% 0.158 275);
--sidebar-border: oklch(90% 0.006 264 / 0.08);
--terminal-bg: oklch(12% 0.02 264);
--warning-soft-fg: oklch(86% 0.1 85);
--progress-bar-start: oklch(42.22% 0.0964 274.8);
--login-left-bg: oklch(13.4% 0.005 264);
--primary-a5: oklch(56.7% 0.158 275 / 0.05);
--primary-a10: oklch(56.7% 0.158 275 / 0.1);
--primary-a15: oklch(56.7% 0.158 275 / 0.15);
--primary-a20: oklch(56.7% 0.158 275 / 0.2);
--primary-a25: oklch(56.7% 0.158 275 / 0.25);
--primary-a60: oklch(56.7% 0.158 275 / 0.6);
--primary-a70: oklch(56.7% 0.158 275 / 0.7);
--primary-a90: oklch(56.7% 0.158 275 / 0.9);
--destructive-a8: oklch(63% 0.16 25 / 0.08);
--destructive-a10: oklch(63% 0.16 25 / 0.1);
--destructive-a20: oklch(63% 0.16 25 / 0.2);
--destructive-a30: oklch(63% 0.16 25 / 0.3);
--destructive-a40: oklch(63% 0.16 25 / 0.4);
--destructive-a50: oklch(63% 0.16 25 / 0.5);
--destructive-a60: oklch(63% 0.16 25 / 0.6);
--destructive-a70: oklch(63% 0.16 25 / 0.7);
--destructive-a80: oklch(63% 0.16 25 / 0.8);
--destructive-a90: oklch(63% 0.16 25 / 0.9);
--foreground-a5: oklch(90% 0.006 264 / 0.05);
--foreground-a30: oklch(90% 0.006 264 / 0.3);
--secondary-a80: oklch(22.5% 0.004 264 / 0.8);
--muted-a30: oklch(18.9% 0.005 264 / 0.3);
--muted-a80: oklch(18.9% 0.005 264 / 0.8);
--muted-foreground-a70: oklch(58.14% 0.0056 264 / 0.7);
--success-a10: oklch(63.7% 0.175 147 / 0.1);
--success-a15: oklch(63.7% 0.175 147 / 0.15);
--success-a30: oklch(63.7% 0.175 147 / 0.3);
--success-a40: oklch(63.7% 0.175 147 / 0.4);
--success-a80: oklch(63.7% 0.175 147 / 0.8);
--warning-a10: oklch(80% 0.12 80 / 0.1);
--warning-a15: oklch(80% 0.12 80 / 0.15);
--warning-a30: oklch(80% 0.12 80 / 0.3);
--warning-a40: oklch(80% 0.12 80 / 0.4);
--info-a10: oklch(70% 0.1 250 / 0.1);
--info-a15: oklch(70% 0.1 250 / 0.15);
--info-a30: oklch(70% 0.1 250 / 0.3);
--info-a40: oklch(70% 0.1 250 / 0.4);
--info-a80: oklch(70% 0.1 250 / 0.8);
--border-a60: oklch(26.27% 0.0051 264 / 0.6);
--border-a80: oklch(26.27% 0.0051 264 / 0.8);
--input-a30: oklch(26.27% 0.0051 264 / 0.3);
--label-surface-a50: oklch(19.5% 0.004 264 / 0.5);
--overlay-base-a50: oklch(0% 0 0 / 0.5);
--overlay-base-a60: oklch(0% 0 0 / 0.6);
--background-a60: oklch(15.9% 0.005 264 / 0.6);
--ring-a50: oklch(56.7% 0.158 275 / 0.5);
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-text: var(--text);
--color-brand: var(--brand);
--color-on-brand: var(--on-brand);
--color-success: var(--success);
--color-warning: var(--warning);
--color-danger: var(--danger);
--color-info: var(--info);
--color-overlay-base: var(--overlay-base);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-panel-bg: var(--panel-bg);
--color-destructive: var(--destructive);
--color-ring: var(--ring);
--color-border: var(--border);
--color-input: var(--input);
--color-sidebar-bg: var(--sidebar-bg);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-accent-fg: var(--sidebar-accent-fg);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-hover-bg: var(--sidebar-hover-bg);
--color-login-path-stroke: var(--login-path-stroke);
--color-progress-bar-end: var(--progress-bar-end);
--color-destructive-fg: var(--destructive-fg);
--color-success-fg: var(--success-fg);
--color-info-fg: var(--info-fg);
--color-warning-fg: var(--warning-fg);
--color-terminal-foreground: var(--terminal-foreground);
--color-terminal-hover: var(--terminal-hover);
--color-muted-foreground: var(--muted-foreground);
--color-secondary-foreground: var(--secondary-foreground);
--color-sidebar-muted: var(--sidebar-muted);
--color-focus-ring: var(--focus-ring);
--color-muted: var(--muted);
--color-secondary: var(--secondary);
--color-label-surface: var(--label-surface);
--color-panel-border: var(--panel-border);
--color-primary-hover: var(--primary-hover);
--color-sidebar-active-bg: var(--sidebar-active-bg);
--color-sidebar-active-fg: var(--sidebar-active-fg);
--color-sidebar-border: var(--sidebar-border);
--color-terminal-bg: var(--terminal-bg);
--color-warning-soft-fg: var(--warning-soft-fg);
--color-progress-bar-start: var(--progress-bar-start);
--color-login-left-bg: var(--login-left-bg);
--color-primary-a5: var(--primary-a5);
--color-primary-a10: var(--primary-a10);
--color-primary-a15: var(--primary-a15);
--color-primary-a20: var(--primary-a20);
--color-primary-a25: var(--primary-a25);
--color-primary-a60: var(--primary-a60);
--color-primary-a70: var(--primary-a70);
--color-primary-a90: var(--primary-a90);
--color-destructive-a8: var(--destructive-a8);
--color-destructive-a10: var(--destructive-a10);
--color-destructive-a20: var(--destructive-a20);
--color-destructive-a30: var(--destructive-a30);
--color-destructive-a40: var(--destructive-a40);
--color-destructive-a50: var(--destructive-a50);
--color-destructive-a60: var(--destructive-a60);
--color-destructive-a70: var(--destructive-a70);
--color-destructive-a80: var(--destructive-a80);
--color-destructive-a90: var(--destructive-a90);
--color-foreground-a5: var(--foreground-a5);
--color-foreground-a30: var(--foreground-a30);
--color-secondary-a80: var(--secondary-a80);
--color-muted-a30: var(--muted-a30);
--color-muted-a80: var(--muted-a80);
--color-muted-foreground-a70: var(--muted-foreground-a70);
--color-success-a10: var(--success-a10);
--color-success-a15: var(--success-a15);
--color-success-a30: var(--success-a30);
--color-success-a40: var(--success-a40);
--color-success-a80: var(--success-a80);
--color-warning-a10: var(--warning-a10);
--color-warning-a15: var(--warning-a15);
--color-warning-a30: var(--warning-a30);
--color-warning-a40: var(--warning-a40);
--color-info-a10: var(--info-a10);
--color-info-a15: var(--info-a15);
--color-info-a30: var(--info-a30);
--color-info-a40: var(--info-a40);
--color-info-a80: var(--info-a80);
--color-border-a60: var(--border-a60);
--color-border-a80: var(--border-a80);
--color-input-a30: var(--input-a30);
--color-label-surface-a50: var(--label-surface-a50);
--color-overlay-base-a50: var(--overlay-base-a50);
--color-overlay-base-a60: var(--overlay-base-a60);
--color-background-a60: var(--background-a60);
--color-ring-a50: var(--ring-a50);
--color-sidebar-fg: var(--sidebar-foreground);
--color-terminal-fg: var(--terminal-foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--shadow-sm: var(--shadow-sm);
/* Radius ladder driven by a single knob; at 0.5rem it matches the
Tailwind default scale (rounded-lg = 0.5rem). Tune --radius-base
to scale all corners for custom-radius support. */
--radius-sm: calc(var(--radius-base) * 0.5);
--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);
}
@@ -0,0 +1,66 @@
.hidden {
@apply hidden!;
}
.left {
@apply text-left!;
&:is(td) {
@apply md:text-left!;
}
}
.right {
@apply text-right!;
&:is(td) {
@apply md:text-right!;
}
}
.center {
@apply text-center!;
&:is(td) {
@apply md:text-center!;
}
}
.top {
@apply align-top!;
&:is(td) {
@apply md:align-top!;
}
}
.bottom {
@apply align-bottom!;
&:is(td) {
@apply md:align-bottom!;
}
}
.middle {
@apply align-middle!;
&:is(td) {
@apply md:align-middle!;
}
}
.nowrap:not(span) {
@apply whitespace-nowrap;
}
.fade-in {
@apply animate-in fade-in-0 fill-mode-backwards duration-400;
}
.fade-out {
@apply pointer-events-none opacity-0 transition-opacity duration-400;
}
.spinning {
@apply relative pl-7!;
@apply before:absolute before:top-1/2 before:left-1.5 before:size-4 before:-translate-y-1/2 before:animate-spin before:bg-current before:mask-[url('@assets/icons/loader-2.svg')] before:mask-cover before:mask-center before:mask-no-repeat;
#view > &:first-child {
@apply mx-auto grid min-h-48 w-fit place-items-center;
}
}
@@ -0,0 +1,77 @@
.label {
@apply bg-muted text-muted-foreground inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
&.important {
@apply bg-info-a15 text-info;
}
&.warning {
@apply bg-warning-a15 text-warning-soft-fg;
}
&.success {
@apply bg-success-a15 text-success;
}
&.notice {
@apply bg-warning-a15 text-warning-soft-fg;
}
}
.zonebadge {
@apply border-secondary bg-secondary text-secondary-foreground inline-flex items-center gap-1.5 overflow-visible rounded-md border px-2 py-1 align-middle text-sm leading-normal font-semibold transition-colors duration-200;
&[style*="--zone-color-rgb"] {
@apply border-transparent bg-[rgb(var(--zone-color-rgb),.6)]!;
}
&.zonebadge-empty {
@apply border-border text-muted-foreground border-dashed bg-transparent font-normal italic;
}
/* Bare zone-colour swatch: the interface overview / tooltip renders a
label-less `.zonebadge` as a colour chip prefixing an `.ifacebadge`. The
tag redesign's horizontal padding is meant for text labels, so keep the
label-less swatch a compact pill instead of a wide block. */
.ifacebadge > & {
@apply rounded-full px-1 py-0.5;
}
.cbi-dropdown & {
@apply rounded-md px-1.5 py-0.5;
}
}
.ifacebadge {
@apply border-border bg-label-surface text-foreground inline-flex cursor-default flex-nowrap items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm font-normal transition-colors duration-200;
.zonebadge > & {
@apply rounded-md px-1.5 py-0;
img {
@apply mr-1 w-4;
}
}
.cbi-dropdown & {
@apply gap-1 rounded-md px-1.5 py-0.5;
img {
@apply mr-1 w-4;
}
}
> img,
> .cbi-tooltip-container > img {
@apply w-5 shrink-0 self-center;
}
> span {
.nowrap {
@apply mb-1.5 inline-block text-sm;
}
}
&.ifacebadge-active {
@apply border-primary font-semibold;
}
&.large {
@apply min-w-48 flex-1 px-4 py-3;
}
}
@@ -0,0 +1,53 @@
button,
input[type="button"],
input[type="submit"],
input[type="reset"],
.btn,
.cbi-button {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors duration-200;
&[disabled] {
@apply cursor-not-allowed opacity-40;
}
.td & {
@apply whitespace-nowrap;
}
}
.btn,
.cbi-button,
.cbi-button-default,
.cbi-button-download,
.cbi-button-find,
.cbi-button-link,
.cbi-button-up,
.cbi-button-down {
@apply border-secondary bg-secondary text-secondary-foreground hover:bg-secondary-a80;
}
.drag-handle {
@apply border-muted bg-muted text-muted-foreground hover:bg-muted-a80;
}
.btn.primary,
.cbi-button-action,
.cbi-button-apply,
.cbi-button-reload,
.cbi-button-add,
.cbi-button-edit {
@apply border-primary bg-primary text-primary-foreground hover:bg-primary-a90;
}
.cbi-button-positive,
.cbi-button-fieldadd,
.cbi-button-save {
@apply border-primary-a25 bg-primary-a15 text-primary hover:bg-primary-a25;
}
.cbi-button-negative,
.cbi-section-remove .cbi-button,
.cbi-button-reset,
.cbi-button-remove {
@apply border-destructive bg-destructive text-destructive-fg hover:bg-destructive-a90;
}
@@ -0,0 +1,148 @@
.cbi-section {
@apply border-panel-border bg-panel-bg mb-4 overflow-visible rounded-xl border p-4;
&[data-tab-title] {
@apply m-0 p-0;
}
&[data-tab-active="true"] {
@apply mb-4 p-4;
}
> .cbi-title {
@apply flex items-start justify-between;
> h3 {
@apply flex-1;
.label[data-clickable] {
@apply bg-label-surface text-foreground hover:bg-muted inline-flex size-6 cursor-pointer items-center justify-center rounded p-0 active:scale-95;
}
span[data-indicator="poll-status"] {
@apply relative size-4 cursor-pointer text-[0px];
@apply before:absolute before:size-4 before:bg-current before:[mask-size:cover] before:[mask-position:center] before:[mask-repeat:no-repeat];
&[data-style="active"] {
@apply before:[mask-image:url('@assets/icons/chevron-down.svg')];
}
&[data-style="inactive"] {
@apply before:[mask-image:url('@assets/icons/chevron-up.svg')];
}
}
}
}
legend {
@apply text-base font-semibold;
}
h3 {
@apply border-border text-foreground mb-3 border-b pb-3 text-base font-semibold;
}
.cbi-section-create {
@apply mt-4 flex flex-wrap items-center gap-2;
> div {
@apply flex-none;
}
}
.network-status-table {
@apply mb-3 flex flex-wrap justify-around gap-4 md:flex-nowrap;
}
div[style*="display:grid"]:has(> .ifacebox) {
@apply place-items-center;
}
}
.ifacebox {
@apply border-border bg-panel-bg relative inline-flex min-w-32 flex-col items-stretch overflow-visible rounded-lg border text-center text-sm leading-5;
#cbi-network-interface & {
@apply md:min-w-38;
}
.ifacebox-head {
@apply border-border bg-label-surface text-foreground w-full rounded-t-lg border-b px-3 py-2 text-center text-sm font-semibold transition-colors duration-200;
&[style*="--zone-color-rgb"] {
@apply bg-[rgb(var(--zone-color-rgb),.65)]!;
}
&.cbi-tooltip-container {
@apply static bg-transparent;
.cbi-tooltip {
@apply bottom-0 left-0;
}
}
&.active {
@apply border-primary bg-primary text-primary-foreground;
}
}
.ifacebox-body {
/* Flex column base: items-stretch lets label/value rows span full width
(e.g. luci-app-modemdata) while justify-center keeps rows tight together
instead of spread. Icon-only cards centre via text-center / mx-auto. The
interfaces overview layers its richer layout under .network-status-table. */
@apply text-foreground flex w-full flex-1 flex-col items-stretch justify-center gap-1 rounded-b-lg p-3 text-center;
> img {
@apply mx-auto w-5;
}
> span {
@apply space-y-1;
.cbi-tooltip {
@apply delay-200;
}
.nowrap {
@apply inline-block;
}
img {
@apply w-5 shrink-0;
}
}
> div {
@apply block w-full space-y-0;
}
.cbi-tooltip-container {
@apply text-foreground static inline-flex cursor-help items-center justify-center;
.cbi-tooltip {
@apply bottom-0;
.nowrap {
@apply whitespace-nowrap;
}
}
}
small {
@apply block text-xs leading-4;
}
}
}
/*
Network interfaces overview — the rich equal-height card layout is scoped here
(not on the base) so third-party pages keep the plain, tolerant defaults above.
*/
.network-status-table .ifacebox-body {
@apply flex h-full flex-1 flex-col items-center justify-around gap-2;
> div {
@apply flex flex-wrap gap-2;
}
/* flex-1 alone lets the badges shrink to min-content, so six SSIDs stay
crammed on one line instead of wrapping. The floor mirrors
luci-theme-bootstrap's min-width: 220px on the same badges. */
.ifacebadge {
@apply min-w-55 flex-1;
}
}
@@ -0,0 +1,134 @@
.cbi-dropdown {
@apply relative inline-flex h-fit max-w-full min-w-fit shrink-0 items-center p-0 whitespace-nowrap transition-colors duration-200;
&[disabled] {
@apply pointer-events-none opacity-40;
}
&:not(.btn):not(.cbi-button) {
@apply border-input bg-background max-w-full min-w-52.5 rounded-lg border md:max-w-100;
> ul > li[placeholder] {
@apply hidden;
}
}
> ul {
@apply m-0 flex flex-1 list-none flex-nowrap items-center gap-0.5 overflow-hidden;
> li {
@apply m-0 hidden list-none items-center gap-1 overflow-hidden px-2 py-1.5;
&[display] {
@apply !flex;
}
.hide-open {
@apply block;
}
.hide-close {
@apply hidden;
}
> form {
@apply pointer-events-none hidden;
}
> label {
@apply flex items-center gap-2;
}
img {
@apply h-5 w-5 flex-none align-middle;
}
span {
@apply truncate;
}
}
&.dropdown {
@apply border-border bg-panel-bg absolute left-0 z-50 w-fit min-w-full overflow-y-auto rounded-lg border shadow-lg backdrop-blur-sm;
> li {
@apply text-muted-foreground hover:bg-muted min-h-9 w-full cursor-pointer px-3 py-1.5 text-sm font-medium;
}
}
&.preview {
@apply hidden;
}
}
&[empty] {
> ul {
@apply max-h-px max-w-px;
> li {
@apply block;
}
}
}
&[optional][open] > ul.dropdown > li[placeholder] {
@apply block;
}
&[open] {
@apply border-primary ring-ring ring-2;
> ul.dropdown {
@apply block w-auto max-w-none;
> li {
@apply flex;
&[selected] {
@apply bg-primary-a15 text-primary;
}
&[unselectable] {
@apply opacity-40;
}
.hide-open {
@apply hidden;
}
.hide-close {
@apply block;
}
}
}
> ul.preview {
@apply flex;
}
}
&[multiple] {
&[more],
&[empty] {
> .more {
@apply flex flex-none shrink-0 px-2.5 py-1.5;
}
}
&[empty] > .more {
@apply flex-1;
}
&[open] > ul.dropdown > li > form {
@apply flex items-center;
}
> ul > li > label {
@apply flex;
}
}
> .open {
@apply border-input flex flex-none shrink-0 cursor-pointer items-center justify-center border-l px-2.5 py-0.5 text-sm text-current;
}
> .more {
@apply hidden;
}
}
@@ -0,0 +1,96 @@
.cbi-input-invalid,
.cbi-value-error {
@apply !border-destructive-a70 focus:!ring-destructive-a40;
}
.cbi-value {
@apply flex flex-row flex-wrap gap-4 py-1.5;
&.hidden {
@apply hidden;
}
& + & {
@apply mt-3;
}
> .cbi-value-title {
@apply text-foreground pt-1 text-right text-sm font-medium md:flex-[0_0_10rem];
}
> .cbi-value-field {
@apply max-w-full flex-1;
&:has(> .cbi-dropdown[disabled]) {
@apply cursor-not-allowed;
}
.cbi-value-description {
@apply text-muted-foreground relative mt-1 pl-5 text-sm leading-none break-words;
&:not(:empty) {
@apply before:bg-primary before:absolute before:left-0 before:inline-block before:size-4 before:[mask-image:url('@assets/icons/info.svg')] before:[mask-size:cover] before:[mask-position:center] before:[mask-repeat:no-repeat];
}
}
}
> .cbi-section,
> .cbi-tblsection {
@apply w-full;
}
}
.cbi-dynlist {
@apply inline-flex w-max max-w-96 flex-col items-start gap-3;
.item {
@apply border-border bg-panel-bg pointer-events-auto relative inline-flex cursor-move flex-col items-start gap-2 self-stretch overflow-hidden rounded-lg border py-3 pr-9 pl-4 break-all shadow-sm transition-all duration-200 select-text;
&::after {
content: "×";
@apply bg-muted text-muted-foreground absolute top-2 right-2 inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-sm font-medium;
}
&.dragging {
@apply scale-95 cursor-grabbing opacity-50 shadow-lg;
}
&.drag-over {
@apply border-primary-a60 bg-primary-a5 ring-ring scale-105 ring-2;
}
> span,
> code {
@apply pointer-events-none w-full rounded font-mono leading-relaxed;
}
}
.placeholder {
@apply break-all;
}
.add-item {
@apply border-border hover:border-border-a80 hover:bg-muted-a30 inline-flex items-center gap-1.5 self-stretch rounded-lg border border-dashed bg-transparent px-1 py-1 transition-colors duration-200;
> input {
@apply min-w-56 flex-1;
}
> .cbi-button {
@apply inline-flex min-h-6 min-w-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 py-0 text-sm font-medium shadow-none active:scale-95;
}
}
}
@media (max-width: 768px) {
.cbi-value {
@apply flex-col gap-1.5 py-1;
> .cbi-value-title {
@apply text-left;
}
}
.cbi-dynlist {
@apply max-w-full;
}
}
@@ -0,0 +1,54 @@
input {
@apply disabled:cursor-not-allowed disabled:opacity-40;
}
input[type="text"],
input[type="password"],
input[type="number"],
input[type="email"],
.cbi-input-text,
.cbi-input {
@apply border-input bg-background text-foreground placeholder:text-muted-foreground-a70 focus:border-primary focus:ring-ring relative w-52.5 max-w-full rounded-lg border px-3 py-1.5 text-sm shadow-none transition-colors duration-200 focus:ring-2 focus:outline-none;
.table.cbi-section-table & {
@apply w-full;
}
}
.cbi-input-text {
@apply min-w-20;
}
#localtime {
@apply min-w-64;
}
textarea {
@apply border-input bg-background text-foreground placeholder:text-muted-foreground-a70 focus:border-primary focus:ring-ring min-h-24 w-full resize-y rounded-lg border px-3 py-2 text-sm transition-colors duration-200 focus:ring-2 focus:outline-none;
&[disabled] {
@apply cursor-not-allowed opacity-40;
}
}
input[type="radio"],
input[type="checkbox"] {
@apply relative mr-3 inline-block h-4 w-4 cursor-pointer appearance-none align-middle disabled:cursor-not-allowed;
@apply before:border-input before:bg-background before:absolute before:inset-0 before:rounded before:border before:transition-colors before:duration-200;
@apply after:bg-primary-foreground after:absolute after:inset-0 after:[mask-size:cover] after:[mask-position:center] after:[mask-repeat:no-repeat] after:opacity-0 after:transition-opacity after:duration-200;
@apply checked:before:border-primary checked:before:bg-primary checked:after:opacity-100;
@apply focus:before:ring-ring focus:before:ring-2 focus:before:outline-none;
}
input[type="radio"] {
@apply before:rounded-full;
@apply after:[mask-image:url('@assets/icons/radio-dot.svg')];
}
input[type="checkbox"] {
@apply after:[mask-image:url('@assets/icons/check.svg')];
}
.cbi-checkbox {
@apply relative inline-flex items-center align-middle;
}
@@ -0,0 +1,40 @@
.alert-message {
/* Radius matches `.cbi-section`: the alert sits in the content layer, same
width and edge alignment as the sections it stacks above. */
@apply bg-muted text-foreground mb-4 rounded-xl border p-4 text-sm;
&.success {
@apply border-success-a30 bg-success-a10 text-success;
}
&.info {
@apply border-info-a30 bg-info-a10 text-info;
}
&.warning,
&.notice {
@apply border-warning-a30 bg-warning-a10 text-warning-soft-fg;
}
&.error,
&.danger {
@apply border-destructive-a30 bg-destructive-a10 text-destructive;
}
h4,
h5,
pre,
ul,
li,
p {
@apply border-0 bg-transparent text-inherit;
}
h4 {
@apply mb-2 text-base font-semibold;
}
p {
@apply text-sm leading-relaxed;
}
.right {
@apply mt-3;
}
}
@@ -0,0 +1,115 @@
#modal_overlay {
@apply pointer-events-none fixed top-0 bottom-0 hidden overflow-auto bg-transparent;
.modal-overlay-active & {
@apply bg-overlay-base-a60 pointer-events-auto inset-0 z-50 grid place-items-center backdrop-blur-sm;
}
> .modal {
@apply border-border bg-panel-bg flex w-full max-w-2xl flex-col gap-4 rounded-xl border p-6 break-words whitespace-normal shadow-2xl backdrop-blur-lg;
h4 {
@apply mb-0 text-center;
em {
@apply text-primary font-semibold;
}
}
h5 {
@apply text-foreground my-3;
}
p {
@apply text-foreground text-sm leading-relaxed;
}
> ul {
@apply border-border bg-background-a60 overflow-auto rounded-lg border p-3;
}
label.btn {
@apply border-border bg-background-a60 text-foreground hover:border-border border;
}
pre {
@apply overflow-auto;
&.errors {
@apply border-destructive-a60 bg-destructive-a20 text-destructive;
}
}
pre + pre {
@apply mt-3;
}
.button-row,
div.left,
div.right {
@apply border-border flex shrink-0 gap-3 border-t p-4;
}
div.left {
@apply justify-start;
}
.button-row,
div.right {
@apply justify-end;
}
&.uci-dialog {
ins,
del,
var {
@apply font-mono not-italic no-underline shadow-sm;
}
ins {
@apply border-success bg-success-a80 text-success-fg rounded border;
}
del {
@apply border-destructive bg-destructive-a80 text-destructive-fg rounded border;
}
var {
@apply border-info bg-info-a80 text-info-fg rounded border;
}
.uci-change-legend {
@apply border-border bg-background-a60 mt-4 grid grid-cols-2 gap-3 rounded-lg border p-4;
.uci-change-legend-label {
@apply text-foreground flex items-center gap-2 text-sm font-medium;
ins,
del,
var {
@apply flex h-6 min-w-6 items-center justify-center rounded px-2 text-xs font-semibold;
}
}
}
.uci-change-list {
@apply mt-4 space-y-3;
h5 {
@apply bg-muted text-foreground mt-4 mb-2 rounded-lg px-3 py-2 text-sm font-semibold first:mt-0;
}
ins,
del {
@apply block rounded-lg px-3 py-2 text-sm;
strong {
@apply text-sm;
}
}
var {
@apply inline-block rounded px-1.5 py-0.5 text-sm;
ins,
del {
@apply ml-1 inline-block px-1 py-px text-xs;
}
}
}
}
}
}
@@ -0,0 +1,174 @@
/**
* Command palette (⌘K), replaces the topbar route search: trigger button
* in the card header (markup in header.ut) + a lazily built overlay panel
* (menu-shadcn.js). Desktop: centered dialog over a dimmed, blurred page.
* <md: full-screen takeover in the system-search-page manner — input row
* with a right-edge Cancel, grouped content visible immediately.
*/
/* ── Trigger ─────────────────────────────────────────────────────── */
button.cmdk-trigger {
@apply border-input text-muted-foreground relative flex h-8 cursor-pointer items-center gap-2.5 rounded-lg border bg-transparent pr-1.5 pl-7.5 text-sm;
@apply max-md:w-8 max-md:justify-center max-md:border-0 max-md:p-0;
&::before {
@apply pointer-events-none absolute left-2.5 size-3.5 bg-current content-[''] [mask:url('@assets/icons/search.svg')_center/contain_no-repeat];
@apply max-md:static max-md:size-4;
}
&:hover {
@apply border-muted-foreground max-md:bg-muted;
}
}
.cmdk-trigger-label {
@apply truncate text-left max-md:hidden;
}
.cmdk-trigger-key {
@apply border-border bg-label-surface text-muted-foreground rounded-md border px-1.5 py-px font-mono text-xs max-md:hidden;
}
/* ── Overlay & panel ─────────────────────────────────────────────── */
#cmdk-overlay {
@apply bg-overlay-base-a60 fixed inset-0 z-50 flex items-start justify-center backdrop-blur-sm;
@apply max-md:bg-transparent max-md:backdrop-blur-none;
&[hidden] {
@apply hidden;
}
}
.cmdk-panel {
/* 560px: with the section label on the right edge, the width is what
separates title from label — 460 read cramped once rows became
two-anchored. */
@apply border-panel-border bg-panel-bg mt-[15vh] flex w-[min(560px,calc(100vw-32px))] flex-col overflow-hidden rounded-xl border shadow-2xl;
@apply transition-[opacity,translate] duration-150 ease-out starting:-translate-y-1.5 starting:opacity-0;
@apply max-md:mt-0 max-md:h-dvh max-md:w-full max-md:rounded-none max-md:border-0 max-md:pt-[env(safe-area-inset-top)] max-md:shadow-none max-md:transition-none;
}
/* ── Input row ───────────────────────────────────────────────────── */
.cmdk-inputrow {
@apply border-panel-border relative flex h-12 shrink-0 items-center gap-2 border-b pr-3 pl-10;
@apply max-md:h-14 max-md:pr-1.5;
&::before {
@apply text-muted-foreground pointer-events-none absolute left-3.5 size-3.5 bg-current content-[''] [mask:url('@assets/icons/search.svg')_center/contain_no-repeat];
}
/* Clear appears only once there is text to clear. */
&:has(input.cmdk-input:placeholder-shown) button.cmdk-clear {
@apply hidden;
}
}
/* input.-prefixed to tie with the global input[type="text"] baseline
(specificity 0,1,1) so the later import wins its border/ring reset. */
input.cmdk-input {
@apply text-text h-full min-w-0 flex-1 border-0 bg-transparent text-sm shadow-none outline-none;
/* 16px keeps iOS Safari from zooming the page when the input focuses. */
@apply max-md:text-base;
&:focus {
@apply border-0 ring-0 outline-none;
}
}
button.cmdk-clear {
@apply text-muted-foreground flex size-5.5 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0;
&::before {
@apply size-3.25 bg-current content-[''] [mask:url('@assets/icons/x.svg')_center/contain_no-repeat];
}
&:hover {
@apply bg-muted text-foreground;
}
}
/* Right-edge Cancel — the <md takeover's dismissal, system-search style. */
button.cmdk-cancel {
@apply text-primary hidden shrink-0 cursor-pointer border-0 bg-transparent text-sm;
@apply max-md:block max-md:py-1.5 max-md:pr-4 max-md:pl-1.5;
}
/* ── List ────────────────────────────────────────────────────────── */
.cmdk-list {
@apply max-h-[342px] overflow-y-auto overscroll-contain p-1;
@apply max-md:max-h-none max-md:flex-1 max-md:p-2 max-md:pb-[calc(0.75rem+env(safe-area-inset-bottom))];
}
.cmdk-group {
/* More air above than below so the label reads as owning its rows. */
@apply text-muted-foreground px-2 pt-2.5 pb-1 text-xs font-medium tracking-wide;
}
.cmdk-row {
/* justify-start is load-bearing: the global button rule in _button.css
centres every <button>, and command rows are buttons. Without it a row
only looks left-aligned while it happens to carry an ml-auto child. */
@apply text-text flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm no-underline;
@apply max-md:min-h-11 max-md:px-2.5;
/* The base stylesheet underlines every anchor on hover — a list row is
not prose, keep it flat. */
&:hover {
@apply no-underline;
}
img.shadcn-icon {
@apply size-3.75 shrink-0;
}
mark {
@apply text-primary bg-transparent font-semibold;
}
.cmdk-title {
@apply text-foreground min-w-0 truncate font-medium;
}
/* Demoted to the right edge in small type — the row reads title-first
and the section label stays available without a per-row breadcrumb. */
.cmdk-group-name {
@apply text-muted-foreground ml-auto shrink-0 text-xs;
}
/* The tick marks which theme mode is in effect, so it has to stay
legible on every row, focused or not. */
.cmdk-check {
@apply text-primary ml-auto block size-3.5 shrink-0 bg-current [mask:url('@assets/icons/check.svg')_center/contain_no-repeat];
}
&.is-selected {
@apply bg-muted;
}
}
.cmdk-empty {
@apply text-muted-foreground px-3 py-7 text-center text-sm;
}
/* ── Footer (desktop-only keyboard hints) ────────────────────────── */
.cmdk-footer {
@apply border-panel-border bg-background-a60 text-muted-foreground flex shrink-0 items-center gap-3.5 border-t px-3.5 py-2 text-xs;
@apply max-md:hidden;
kbd {
@apply border-border bg-label-surface rounded-md border px-1.5 py-px font-mono text-xs;
}
.cmdk-hint {
@apply flex items-center gap-1.5;
}
.cmdk-hint-close {
@apply ml-auto;
}
}
@@ -0,0 +1,8 @@
.cbi-progressbar {
@apply bg-secondary relative h-2.5 w-full cursor-help overflow-hidden rounded-full;
@apply before:text-foreground before:absolute before:inset-x-0 before:top-1/2 before:-translate-y-1/2 before:text-center before:text-[10px] before:leading-none before:whitespace-nowrap before:content-[attr(title)];
> div {
@apply from-progress-bar-start to-progress-bar-end h-full bg-gradient-to-r transition-all duration-300;
}
}
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more