🍓 Sync 2026-07-27 08:34:56

This commit is contained in:
github-actions[bot] 2026-07-27 08:34:56 +08:00
parent bf03fd23d8
commit 076b725e5a
28 changed files with 924 additions and 141 deletions

View File

@ -113,7 +113,7 @@ Third-party compatibility patches are **not** bundled into `main.css` — they a
- New UI component → create `components/_<name>.css` and add an `@import` line to `main.css`. Each file is its own organizational unit — don't add `@layer` wrappers: theme partials stay unlayered, so they outrank Tailwind's layered base/utilities regardless of specificity.
- Compatibility fix for a third-party LuCI app/page → add a new file under `media/patches/` (see below).
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties.
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties. The one deliberate exception is `media/patches/*.css`, which is plain native CSS by design (see below).
### On-Demand Third-Party Patches
@ -123,9 +123,23 @@ Some third-party LuCI apps ship markup that doesn't adapt to the theme and needs
1. **One file per page, named by `data-page`.** Each patch lives at `media/patches/<page>.css`, where `<page>` is the value of `<body data-page="…">` for the target page — i.e. the request path segments joined by `-` (e.g. `admin-services-openclash-config`). `header.ut` computes the same string at render time from `ctx.request_path`, falling back to `ctx.path` when `request_path` is empty (`join('-', length(ctx.request_path) ? ctx.request_path : ctx.path)`) so default landings reached without an explicit path still resolve their patch.
2. **Build splits, not bundles.** `vite.config.ts` adds every `media/patches/*.css` as its own Rollup entry, so each compiles to `htdocs/luci-static/aurora/patches/<page>.css`. They are no longer part of `main.css`.
3. **`@reference`, not `@import`.** Every patch file starts with `@reference "../main.css";`. This loads the theme context (tokens, custom utilities like `bg-surface`, the `dark:` variant) so `@apply` resolves — **without** re-emitting `main.css` into the patch output. Using `@import` here would inline all of `main.css` into every patch (hundreds of KB); `@reference` keeps each patch to just its own rules.
3. **Plain CSS, not `@apply`.** Patches are the one place that writes native declarations instead of Tailwind utilities. Each patch is its own build entry, and the old `@reference "../main.css";` + `@apply` setup made every file carry its own `@property`/helper boilerplate. Rewriting the *same rules* natively — measured on the v1.1.7 → v1.1.8 conversion, built output, identical selectors and values:
| patch | `@reference` + `@apply` | native CSS | saved |
| --- | ---: | ---: | ---: |
| admin-dashboard | 6,940 B | 2,639 B | 62% |
| admin-modem-modemdata-modempreview | 162 B | 96 B | 41% |
| admin-modem-qmodem | 9,483 B | 4,497 B | 53% |
| admin-network-network | 160 B | 86 B | 46% |
| admin-services-openclash-config | 187 B | 87 B | 53% |
| admin-services-openclash-settings | 638 B | 509 B | 20% |
| admin-statistics-graphs | 1,609 B | 110 B | 93% |
| admin-system-filemanager | 576 B | 184 B | 68% |
| **total** | **19,755 B** | **8,208 B** | **58%** |
The overhead is per-entry and fixed-ish (`@property` registrations, `--tw-*` helper chains), so the smaller the patch, the worse the ratio — the two-rule statistics patch paid 15× its own weight. uhttpd serves identity bytes (no gzip), so raw size is wire size. Note the runtime `:root` exposes the raw color tokens plus `--radius-base` and `--app-shadow-*` — the `--radius-3xl`-style names only exist inside the Tailwind build, so a patch writes `calc(var(--radius-base) * 3)` for radii, never `var(--radius-3xl)`. The old Tailwind mode is still *supported* for theme-repo patches — a file starting with `@reference "../main.css";` and using `@apply` continues to compile — and it has real upsides worth weighing against the table above: **build-time validation** (a typo'd utility or color name fails the build, while a typo'd `var()` fails silently at runtime — exactly how the `--radius-3xl` regression slipped into the native rewrite), the **same vocabulary** as the component partials with the `dark:`/`md:`/`hover:` variant shorthands, and **automatic tracking** when the theme remaps a utility (a radius-chain or shadow-token change recompiles into Tailwind patches for free; native patches need a hand edit). Native is the default convention for the size numbers, not a hard gate. App-shipped patches (installed straight into the device's `patches/` directory) have never had a choice: they bypass the build entirely, so `@apply` would reach the browser as dead text — plain CSS only, as always. It also means third-party patch authors need no Tailwind knowledge. Reach theme values through the `:root` custom properties (`var(--surface-sunken)`, `var(--hairline)`, `var(--radius-3xl)`, `var(--shadow-lg)`, …) rather than hardcoding colors or radii; the dark variant is a plain selector (`[data-darkmode="true"] & { … }`), breakpoints are plain media queries (`@media (width < 48rem)`), and CSS Nesting still works the build (lightningcss) minifies and lowers it for the supported browsers.
4. **`header.ut` discovers patches at render time.** On each (non-login) page render, `header.ut` lists `/www/luci-static/aurora/patches/` with ucode's `fs.lsdir()` (a readdir of a dozen entries — microseconds, dwarfed by the template's existing `ubus` call) and matches the installed `*.css` filenames against the **cumulative path-segment prefixes** of the request: a patch matches its exact page and any subpage, but only on real segment boundaries — `admin-services-wol.css` covers `admin/services/wol/plus`, yet never a sibling app whose own segment merely starts the same way (`admin/services/wol-plus`). Every matching patch is linked right after `main.css`, in lexical order — so a general patch loads before a more specific one and the specific one cascades on top. Pages with no match get nothing — no extra request, no 404. If the directory is missing or unreadable, the list is empty and the page renders unpatched.
5. **The patches directory is a drop-in extension point.** Because discovery is at render time, patches don't have to ship with the theme: **any package may install a `<page-prefix>.css` into `/www/luci-static/aurora/patches/`** and the theme will load it on matching pages. Install/uninstall lifecycle is automatic — the file appears and disappears with the package, no registration or allow-list rebuild. (Patches shipped this way are plain CSS served as-is; the theme's own patches additionally go through the Tailwind build below.)
5. **The patches directory is a drop-in extension point.** Because discovery is at render time, patches don't have to ship with the theme: **any package may install a `<page-prefix>.css` into `/www/luci-static/aurora/patches/`** and the theme will load it on matching pages. Install/uninstall lifecycle is automatic — the file appears and disappears with the package, no registration or allow-list rebuild. (Patches shipped this way are plain CSS served as-is; the theme's own patches are written the same way and only pass through the build for minification.)
6. **Dynamically generated pages are covered by their fixed prefix.** Some apps mint a page per entity — e.g. QModem's SMS conversations render as `admin-modem-qmodem-sms-conversation-<contact>`. Name the patch after the fixed prefix (`admin-modem-qmodem-sms-conversation.css`) and the prefix match loads it for every conversation page, regardless of the contact name. No wildcard syntax is needed (and `*` in a filename is not supported).
**Adding a patch:**
@ -134,14 +148,14 @@ Some third-party LuCI apps ship markup that doesn't adapt to the theme and needs
2. Create `media/patches/<that-string>.css`:
```css
/* PATCH: <page> (luci-app-foo) */
@reference "../main.css";
[data-page="<page>"] {
/* narrow, selector-scoped overrides using @apply + CSS Nesting */
/* narrow, selector-scoped overrides — native CSS + CSS Nesting,
theme values via var(--surface), var(--hairline), … */
}
```
3. Run `pnpm build`. There is no allow-list to regenerate — the loader discovers whatever `.css` files are installed under `patches/` at render time.
4. Verify `htdocs/luci-static/aurora/patches/<page>.css` is small (just your rules, not a copy of `main.css`).
4. Verify `htdocs/luci-static/aurora/patches/<page>.css` is small (just your rules).
> Removing a patch is symmetric: delete the file and rebuild — the loader stops linking it because it no longer exists.
@ -171,7 +185,11 @@ Two rules of thumb that follow from prefix matching:
- **A patch applies to its page and all subpages by default.** `admin-services-foo.css` loads on every page under `admin/services/foo/…`. When you need finer targeting, narrow it in either of two ways: scope individual rules inside the file (`[data-page="admin-services-foo-general"] { … }` only affects that one page), or ship an additional, longer-named file (`admin-services-foo-rules.css`) for page-specific rules — on a page matching both, **both load**, shorter name first, so the more specific file wins the cascade.
- **Matching respects path-segment boundaries**, so a prefix never leaks onto a lookalike sibling: `admin-services-wol.css` covers `admin/services/wol/plus` but not a different app at `admin/services/wol-plus`. The one unavoidable collision is two paths joining to the same `data-page` string (`wol/plus` vs `wol-plus`) — such a patch loads on both pages. If that matters, key rules to your app's own class names/ids so an accidental load matches nothing.
> Unlike the `_`-prefixed partials (which are `@import`-only fragments), patch filenames have no `_` prefix — each is a real build entry that ships to `htdocs/`.
> Unlike the `_`-prefixed partials (which are `@import`-only fragments), patch filenames have no `_` prefix — each is a real build entry that ships to `htdocs/`. This also holds inside `media/patches/` itself: a `_`-prefixed file there is a shared fragment for other patches to `@import`, skipped by the entry scan and never shipped.
**JS payloads.** The mechanism is not CSS-specific: the same `lsdir()` sweep also collects `patches/<page>.js` files, emitted as `<script defer src>` right after the patch stylesheet links — same per-page prefix matching, same drop-in lifecycle for third-party packages (ship a plain script; it must not assume LuCI modules are loadable via `L.require` at parse time — run after DOM ready or poll for your target element). Theme-owned JS patches live at `src/resource/patches/<page>.js` and build through the same Terser pass as other resource JS, but land under `aurora/patches/` so one directory listing serves both payload types. First user: the log-viewer enhancement on `admin-status-logs` (parses the read-only `#syslog` textarea into a colored, column-aligned view, with a parse-success gate that falls back to the stock textarea on unknown log formats; the one prefix covers the System Log tab, the Kernel Log tab and the bare `/logs` alias on every supported release). Its CSS is deliberately **not** a patch: the log pages are stock LuCI, so their styling lives in `components/_syslog.css` inside `main.css`, where the full Tailwind token pipeline (reactive radii, shadows, config-app overrides) applies — patches/ stays reserved for third-party compatibility.
**Aliases — one payload, several pages.** When two unrelated page names need the same payload, naming the file after their shared prefix would over-match (an `admin-status` patch would load on *every* status subpage, including the busiest overview page). For that case `PATCH_ALIASES` in `vite.config.ts` duplicates the built output (CSS and JS) under each alias name, and the dev server resolves alias requests to the shared source. Duplicate CSS *entries* would not work as an alternative: Rollup deduplicates identical-content assets into a single file, so one of the two names would silently never ship. (The map is currently empty — the log viewer turned out to need only `admin-status-logs`, since every supported release mounts both log pages under `admin/status/logs/*`.)
### Mock Pages

View File

@ -545,7 +545,7 @@ body[data-nav-type="sidebar"] {
/* Centred shells (top-bar navigation) clamp here and gutter the remainder.
The sidebar shell overrides this it caps the shell instead, see
body[data-nav-type="sidebar"] above. */
@apply max-w-(--content-width-centered) mx-auto min-h-[calc(100vh-4rem)] w-23/24 px-4 max-md:w-full max-md:px-3;
@apply mx-auto min-h-[calc(100vh-4rem)] w-23/24 max-w-(--content-width-centered) px-4 max-md:w-full max-md:px-3;
#view {
@apply mx-0 w-full bg-transparent p-0 shadow-none empty:hidden md:p-0;
@ -616,7 +616,9 @@ body[data-nav-type="sidebar"] {
}
#syslog {
@apply bg-surface-sunken text-text rounded-3xl border p-6 shadow-lg max-md:p-3;
/* Explicit border color: bare `border` falls back to currentColor
(near-black text) in Tailwind v4. */
@apply bg-surface-sunken border-hairline text-text rounded-3xl border p-6 shadow-lg max-md:p-3;
}
}

View File

@ -0,0 +1,110 @@
/*
Log viewer for the core System/Kernel Log pages (admin/status/logs/*).
This is theme styling for a stock LuCI page, so it lives in the main.css
component pipeline NOT in patches/ and consumes the Tailwind token
system: radii resolve through the --radius-base chain, shadows through
--app-shadow-*, colors through the theme map, so config-app overrides of
the base tokens reach this page like any other. (patches/ stays reserved
for third-party compatibility, written in plain CSS.)
The class names are deliberately global, not page-scoped: any package may
reuse the same markup contract for its own log UI .syslog-view wrapping
.log-row rows built from .log-time / .log-sev / .log-tag / .log-msg spans,
with an lv-{info|notice|warn|err|crit|debug} severity class on the row
and inherit this component wholesale.
The companion JS ships on-demand as patches/admin-status-logs.js. It
parses the read-only #syslog textarea into a .syslog-view sibling and
marks #content_syslog with .log-enhanced. Without JS (or when its parse
gate trips) none of the viewer markup exists and the stock textarea
styling from _layout.css still applies.
Chosen design (user-picked variant "彩色级别词", Vercel/Axiom style):
fixed-width time · level · source columns so messages start flush; the
level is a short uppercase word colored per severity (facility lives in
its tooltip), source neutral, err-and-above rows tinted, whole lines with
sideways scrolling (the stock textarea's wrap="off" model). Fields are
inline-blocks inside inline flow (not grid/flex) so the real spaces
between them survive into copied text.
*/
#content_syslog.log-enhanced > #syslog {
@apply hidden;
}
.syslog-view {
/* Direct child of #content_syslog: undo the filter-row flex from
_layout.css's `#content_syslog > div` rule. */
@apply block w-full;
/* Same shell as the stock textarea, with the border color made explicit:
a bare `border` falls back to currentColor (near-black) in Tailwind v4. */
@apply bg-surface-sunken border-hairline text-text rounded-3xl border p-6 shadow-lg max-md:p-3;
@apply font-mono text-xs leading-relaxed;
/* Whole lines, sideways scroll (the stock textarea's wrap="off" model). */
@apply overflow-x-auto;
& .log-row {
/* max-content width so hover/tint backgrounds span the scrolled width. */
@apply w-max min-w-full rounded-md px-2 whitespace-pre;
&:hover {
@apply bg-hover-faint;
}
}
/* The browser-default selection highlight turns the dimmed fields to mud;
use the theme's info surface pair and restore full text contrast. */
& ::selection {
@apply bg-info-surface text-text;
}
& .log-time {
@apply text-text-subtle;
@apply md:inline-block md:w-[15ch] md:overflow-hidden md:align-bottom md:whitespace-nowrap;
}
& .log-sev {
@apply font-semibold tracking-wide uppercase;
@apply md:inline-block md:w-[7ch] md:overflow-hidden md:align-bottom md:whitespace-nowrap;
}
& .log-tag {
@apply text-text-muted;
@apply md:inline-block md:max-w-[18ch] md:overflow-hidden md:align-bottom md:text-ellipsis md:whitespace-nowrap;
}
& .lv-info .log-sev {
@apply text-info;
}
& .lv-notice .log-sev {
@apply text-success;
}
& .lv-warn .log-sev {
@apply text-warning;
}
& .lv-debug {
@apply text-text-subtle;
& .log-sev,
& .log-tag {
@apply text-text-subtle;
}
}
/* err and above are rare and load-bearing — full-row emphasis. */
& .lv-err,
& .lv-crit {
@apply bg-danger-surface;
&:hover {
@apply bg-danger-surface-hover;
}
& .log-sev {
@apply text-danger;
}
}
}

View File

@ -33,6 +33,7 @@
@import "./components/_modal.css";
@import "./components/_overlay.css";
@import "./components/_table.css";
@import "./components/_syslog.css";
@import "./components/_form.css";
@import "./components/_segmented.css";
@import "./components/_tab.css";

View File

@ -1,71 +1,117 @@
/*
PATCH: admin-dashboard (luci-mod-dashboard)
Loaded on-demand by header.ut only when data-page matches this filename.
@reference makes @apply / theme utilities resolvable without re-emitting main.css.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-page="admin-dashboard"] {
.Dashboard {
& > .section-content {
& .dashboard-bg {
background: var(--surface-overlay);
&.tr {
@apply rounded-none;
border-radius: 0;
}
&:not(.tr) {
border: 1px solid var(--hairline);
box-shadow: var(--app-shadow-lg);
&:hover {
border-color: var(--hairline);
/* The theme maps the xl utility onto --app-shadow-md. */
box-shadow: var(--app-shadow-md);
}
}
@apply bg-surface-overlay not-[.tr]:border-hairline not-[.tr]:hover:border-hairline max-md:bg-surface-overlay not-[.tr]:max-md:border-hairline not-[.tr]:border not-[.tr]:shadow-lg not-[.tr]:hover:shadow-xl not-[.tr]:max-md:border;
/* Dark mode: override custom.css hardcoded light-mode colors on all text nodes */
& span {
@apply dark:text-text!;
}
& td {
@apply dark:text-text!;
}
& span,
& td,
& th {
@apply dark:text-text!;
[data-darkmode="true"] & {
color: var(--text) !important;
}
}
img[src*=".svg"] {
@apply dark:invert;
[data-darkmode="true"] & {
filter: invert(100%);
}
}
/* Compact spacing for assoclist cells (carry .td/.th classes) */
& .td {
@apply py-2 max-md:py-1;
padding-block: 0.5rem;
@media (width < 48rem) {
padding-block: 0.25rem;
}
}
& .th {
@apply py-2;
padding-block: 0.5rem;
}
/* Inline info tables (e.g. DNS): strip card decoration, restore table layout */
& .table:not(.assoclist) {
@apply mb-0 rounded-none border-0 bg-transparent shadow-none;
margin-bottom: 0;
border-radius: 0;
border: 0;
background: transparent;
box-shadow: none;
& tr {
@apply hover:bg-transparent max-md:table-row max-md:border-0 max-md:px-0 max-md:py-0;
&:hover {
background: transparent;
}
@media (width < 48rem) {
display: table-row;
border: 0;
padding: 0;
}
}
& td {
@apply border-0 px-0 py-0.5 max-md:table-cell;
border: 0;
padding: 0.125rem 0;
@media (width < 48rem) {
display: table-cell;
}
&:first-child {
@apply pr-2 whitespace-nowrap opacity-70;
padding-right: 0.5rem;
white-space: nowrap;
opacity: 0.7;
}
}
}
}
.title {
h3 {
@apply text-text border-0 pb-4 max-md:mx-0 max-md:pb-2;
.title h3 {
color: var(--text);
border: 0;
padding-bottom: 1rem;
@media (width < 48rem) {
margin-inline: 0;
padding-bottom: 0.5rem;
}
}
.router-status-wifi {
.wifi-info {
.devices-info {
.tr {
.td {
@apply nth-3:relative nth-3:top-0 nth-3:w-full nth-3:px-0 max-md:flex-[1_1_50%];
}
}
.wifi-info .devices-info .tr .td {
&:nth-child(3) {
position: relative;
top: 0;
width: 100%;
padding-inline: 0;
}
@media (width < 48rem) {
flex: 1 1 50%;
}
}
}

View File

@ -1,14 +1,12 @@
/*
PATCH: admin-modem-modemdata-modempreview (luci-app-modemdata)
Loaded on-demand by header.ut only when data-page matches this filename.
@reference makes @apply / theme utilities resolvable without re-emitting main.css.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-page="admin-modem-modemdata-modempreview"] {
& .ifacebox-body {
& .ifacebadge {
@apply w-auto!;
}
& .ifacebox-body .ifacebadge {
width: auto !important;
}
}

View File

@ -2,48 +2,64 @@
PATCH: admin-modem-qmodem (luci-app-qmodem)
Unified per-application patch loaded on all admin/modem/qmodem/* pages.
Prefix-matched by header.ut: admin-modem-qmodem(-overview|-sms|).
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-page^="admin-modem-qmodem"] {
.copyright-section {
@apply text-text-muted! text-center! bg-surface-sunken border-hairline border-1;
color: var(--text-muted) !important;
text-align: center !important;
background: var(--surface-sunken);
border: 1px solid var(--hairline);
}
fieldset.cbi-section > legend {
@apply border-hairline text-text float-left mb-4 w-full border-b px-0 pb-3 leading-tight;
border-bottom: 1px solid var(--hairline);
color: var(--text);
float: left;
margin-bottom: 1rem;
width: 100%;
padding: 0 0 0.75rem;
line-height: 1.25;
}
fieldset.cbi-section {
@apply flow-root;
display: flow-root;
}
fieldset.cbi-section > :not(legend) {
@apply clear-both;
clear: both;
}
.cbi-section-table-titles.named {
@apply before:bg-surface-sunken before:rounded-tl-2xl before:border-b! before:border-hairline!;
.cbi-section-table-titles.named::before {
background: var(--surface-sunken);
/* :root exposes only --radius-base; the theme's radius scale is
calc multiples of it (kept reactive to config overrides). */
border-top-left-radius: calc(var(--radius-base) * 2);
border-bottom: 1px solid var(--hairline) !important;
}
}
[data-page^="admin-modem-qmodem-sms-conversation"] {
#sms-messages-area {
@apply border-hairline! border-1 bg-surface-sunken! text-text!;
border: 1px solid var(--hairline) !important;
background: var(--surface-sunken) !important;
color: var(--text) !important;
}
.sms-message-bubble {
@apply border-hairline! border-1 bg-surface-overlay! text-text!;
border: 1px solid var(--hairline) !important;
background: var(--surface-overlay) !important;
color: var(--text) !important;
}
.sms-message-content {
@apply text-text!;
color: var(--text) !important;
}
.sms-message-meta {
@apply text-text-muted!;
color: var(--text-muted) !important;
}
}
@ -51,97 +67,140 @@
/* NOTE: targets <li> directly (not li > a) because qmodem's tab markup
has no <a> inside tabs so this won't auto-update with the shared
_segmented.css conventions. */
.cbi-tabmenu {
& li {
@apply text-text-muted hover:text-text hover:bg-hover-faint cursor-pointer rounded-4xl px-4 py-2 text-center text-sm font-medium whitespace-nowrap transition-colors duration-150;
.cbi-tabmenu li {
color: var(--text-muted);
cursor: pointer;
border-radius: calc(var(--radius-base) * 4);
padding: 0.5rem 1rem;
text-align: center;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 500;
white-space: nowrap;
transition:
color 0.15s,
background-color 0.15s;
&.cbi-tab {
@apply bg-text text-surface font-semibold shadow-sm;
}
&:hover {
color: var(--text);
background: var(--hover-faint);
}
&.cbi-tab {
background: var(--text);
color: var(--surface);
font-weight: 600;
box-shadow: var(--app-shadow-sm);
}
}
}
[data-page="admin-modem-qmodem-sms"] {
.td > div {
@apply text-text-muted! mt-1.5! text-xs! leading-relaxed!;
color: var(--text-muted) !important;
margin-top: 0.375rem !important;
font-size: 0.75rem !important;
line-height: 1.625 !important;
}
.td > span[style*="margin-left"] {
@apply text-text ml-2!;
color: var(--text);
margin-left: 0.5rem !important;
}
.sms-conversations {
@apply mt-5 mb-0;
margin-top: 1.25rem;
margin-bottom: 0;
& > .cbi-section {
@apply m-0! border-0! bg-transparent! p-0! shadow-none!;
margin: 0 !important;
border: 0 !important;
background: transparent !important;
padding: 0 !important;
box-shadow: none !important;
}
& fieldset.cbi-section {
@apply mb-0;
margin-bottom: 0;
}
& .table.cbi-section-table {
@apply mb-0 overflow-hidden;
margin-bottom: 0;
overflow: hidden;
}
}
.cbi-section-table-row.tr {
@apply text-text transition-colors duration-150;
color: var(--text);
transition:
color 0.15s,
background-color 0.15s;
& > .td,
& > td {
@apply text-text;
color: var(--text);
}
& > .td:nth-child(2),
& > td:nth-child(2) {
@apply text-text-muted;
color: var(--text-muted);
}
&:hover {
@apply bg-hover-faint! text-text!;
background: var(--hover-faint) !important;
color: var(--text) !important;
& > .td,
& > td {
@apply text-text! bg-transparent!;
color: var(--text) !important;
background: transparent !important;
}
}
}
.sms-unread-conversation {
@apply bg-brand-subtle! font-semibold;
background: var(--brand-subtle) !important;
font-weight: 600;
}
.sms-unread-badge {
@apply bg-danger! text-on-brand! ml-2! rounded-full! px-2! py-0.5! text-xs! font-semibold!;
background: var(--danger) !important;
color: var(--on-brand) !important;
margin-left: 0.5rem !important;
border-radius: 9999px !important;
padding: 0.125rem 0.5rem !important;
font-size: 0.75rem !important;
font-weight: 600 !important;
}
}
[data-page="admin-modem-qmodem-sms_sim"] {
.cbi-section-table-row.tr {
@apply text-text transition-colors duration-150;
color: var(--text);
transition:
color 0.15s,
background-color 0.15s;
& > .td,
& > td {
@apply text-text;
color: var(--text);
}
&:hover {
@apply bg-hover-faint!;
background: var(--hover-faint) !important;
& > .td,
& > td {
@apply text-text! bg-transparent!;
color: var(--text) !important;
background: transparent !important;
}
}
}
}
[data-page="admin-modem-qmodem-settings"] {
.cbi-section-table-descr.named {
@apply before:bg-surface-sunken before:content-['']!;
.cbi-section-table-descr.named::before {
background: var(--surface-sunken);
content: "" !important;
}
}

View File

@ -1,10 +1,12 @@
/*
PATCH: admin-network-network (luci-app-network bridge VLAN)
Loaded on-demand by header.ut only when data-page matches this filename.
@reference makes @apply / theme utilities resolvable without re-emitting main.css.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-name="bridge-vlan"] > div {
@apply max-md:overflow-visible!;
@media (width < 48rem) {
[data-name="bridge-vlan"] > div {
overflow: visible !important;
}
}

View File

@ -1,14 +1,13 @@
/*
PATCH: admin-services-openclash-config (luci-app-openclash)
Loaded on-demand by header.ut only when data-page matches this filename.
@reference makes @apply / theme utilities resolvable without re-emitting main.css.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-page="admin-services-openclash-config"] {
.sub_div {
img {
@apply h-5 w-5;
}
.sub_div img {
height: 1.25rem;
width: 1.25rem;
}
}

View File

@ -1,20 +1,34 @@
/*
PATCH: admin-services-openclash-settings (luci-app-openclash)
Loaded on-demand by header.ut only when data-page matches this filename.
@reference makes @apply / theme utilities resolvable without re-emitting main.css.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-page="admin-services-openclash-settings"] {
.cbi-value-field {
& .cbi-input-select {
@apply max-md:w-full!;
@media (width < 48rem) {
.cbi-value-field .cbi-input-select {
width: 100% !important;
}
}
.diag-style {
@apply max-md:grid max-md:grid-cols-1 max-md:gap-4;
& > div {
@apply flex flex-col items-center gap-2 max-md:w-full! max-md:gap-1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
@media (width < 48rem) {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 1rem;
& > div {
width: 100% !important;
gap: 0.25rem;
}
}
}
}

View File

@ -1,10 +1,10 @@
/*
PATCH: admin-statistics-graphs (luci-app-statistics)
Loaded on-demand by header.ut only when data-page matches this filename.
@reference makes @apply / theme utilities resolvable without re-emitting main.css.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-page="admin-statistics-graphs"] [data-plugin] img {
@apply dark:hue-rotate-150 dark:invert;
[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
filter: hue-rotate(150deg) invert(100%);
}

View File

@ -1,15 +1,17 @@
/*
PATCH: admin-system-filemanager (luci-app-filemanager)
Loaded on-demand by header.ut only when data-page matches this filename.
@reference makes @apply / theme utilities resolvable without re-emitting main.css.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
@reference "../main.css";
[data-page="admin-system-filemanager"] {
#file-manager-container {
@apply overflow-auto;
overflow: auto;
#status-bar {
@apply bg-surface border-0;
background: var(--surface);
border: 0;
}
}
}

View File

@ -0,0 +1,256 @@
"use strict";
/* Aurora log viewer enhances the read-only <textarea id="syslog"> on the
System Log and Kernel Log pages (both under admin/status/logs/* on every
supported release, so this one patch name covers them) into a parsed,
column-aligned view. The textarea stays in the DOM as the data source:
LuCI's own poll keeps rewriting its .value, which never fires events or
mutations, so a cheap dirty-check interval re-reads it instead. When too
few lines parse (PARSE_GATE), the enhancement tears itself down and the
stock textarea shows again the worst case is the styled status quo,
never a garbled log. Node tests require() the parsing exports below. */
(function () {
// RFC 5424 short names exactly as LuCI emits them. tools/views.js reads the
// untranslated slot of its tables and ubox logread prints C-locale names, so
// UI language never changes these. A line whose facility/severity falls
// outside the whitelists is not a log format we know — shown raw, uncolored.
const FACILITIES = new Set([
"kern",
"user",
"mail",
"daemon",
"auth",
"syslog",
"lpr",
"news",
"uucp",
"cron",
"authpriv",
"ftp",
"ntp",
"security",
"console",
"local0",
"local1",
"local2",
"local3",
"local4",
"local5",
"local6",
"local7",
"unknown",
]);
const SEVERITIES = new Set([
"emerg",
"alert",
"crit",
"err",
"warn",
"notice",
"info",
"debug",
]);
const SEV_CLASS = {
emerg: "crit",
alert: "crit",
crit: "crit",
err: "err",
warn: "warn",
notice: "notice",
info: "info",
debug: "debug",
};
// Below this parse-success ratio the value is not a log we understand
// (syslog-ng replacement, future format change) — fall back to the textarea.
const PARSE_GATE = 0.8;
function splitTag(rest) {
// `tag[pid]: msg` / `tag: msg`; tags with spaces (kernel's
// "mt7921e 0000:01:00.0:") deliberately fail into plain msg.
const m = rest.match(/^([A-Za-z0-9_.\/-]+(?:\[\d+\])?):\s+([\s\S]*)$/);
return m ? { tag: m[1], msg: m[2] } : { tag: "", msg: rest };
}
// Compress "Jul 24, 2026, 10:59:50 PM UTC"-style stamps (master's RPC path
// formats with the *browser* locale). Only en-style patterns are recognized;
// anything else is shown untouched rather than guessed at.
function shortenTime(datestr) {
const m = datestr.match(
/^(\w{3}) (\d{1,2}), \d{4}, (\d{1,2}):(\d{2}):(\d{2})\s*(AM|PM)?/,
);
if (!m) return datestr;
let h = parseInt(m[3], 10);
if (m[6] === "PM" && h < 12) h += 12;
if (m[6] === "AM" && h === 12) h = 0;
const hh = (h < 10 ? "0" : "") + h;
return `${m[1]} ${m[2].padStart(2, "0")} ${hh}:${m[4]}:${m[5]}`;
}
/* System log line, two shapes:
A (master RPC): [<locale datestr>] fac.sev: tag[pid]: msg
B (classic logread, Www Mmm dd hh:mm:ss yyyy fac.sev tag[pid]: msg
23.05 + 24.10 syslog-wrapper + master's RPC-failure fallback) */
function parseSyslogLine(line) {
let m = line.match(/^\[(.+?)\]\s+(\w+)\.(\w+):\s+([\s\S]*)$/);
if (m && FACILITIES.has(m[2]) && SEVERITIES.has(m[3]))
return {
time: shortenTime(m[1]),
fullTime: m[1],
fac: m[2],
sev: m[3],
...splitTag(m[4]),
};
m = line.match(
/^\w{3} (\w{3}) +(\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4}) (\w+)\.(\w+) ([\s\S]*)$/,
);
if (m && FACILITIES.has(m[5]) && SEVERITIES.has(m[6]))
return {
time: `${m[1]} ${m[2].padStart(2, "0")} ${m[3]}`,
fullTime: line.slice(0, line.indexOf(` ${m[5]}.${m[6]} `)),
fac: m[5],
sev: m[6],
...splitTag(m[7]),
};
return null;
}
/* Kernel log line: `[ 73412.882110] msg`. No severity survives into the
text on any LuCI version (both eras strip/drop the <N> prefix), so dmesg
gets time/tag/typography only no level colors, no keyword guessing. */
function parseDmesgLine(line) {
const m = line.match(/^\[\s*(\d+\.\d+)\]\s?([\s\S]*)$/);
if (!m) return null;
return {
time: `[${m[1]}]`,
fullTime: "",
fac: "",
sev: "",
...splitTag(m[2]),
};
}
function evaluate(value, parseLine) {
const lines = value ? value.split(/\n/) : [];
const rows = lines.map((line) => ({ line, parsed: parseLine(line) }));
const parsed = rows.reduce((n, r) => n + (r.parsed ? 1 : 0), 0);
return { rows, ratio: rows.length ? parsed / rows.length : 1 };
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
parseSyslogLine,
parseDmesgLine,
splitTag,
shortenTime,
evaluate,
PARSE_GATE,
};
}
if (typeof document === "undefined") return;
// Kernel-log page under either menu layout: admin-status-dmesg on
// 23.05/24.10, admin-status-logs-dmesg on master's combined Logs menu.
const page = document.body?.dataset?.page || "";
const parseLine = /(^|-)dmesg(-|$)/.test(page)
? parseDmesgLine
: parseSyslogLine;
let viewer = null;
let lastValue = null;
function span(cls, text, title) {
const el = document.createElement("span");
el.className = cls;
el.textContent = text;
if (title) el.title = title;
return el;
}
function buildRow(entry) {
const row = document.createElement("div");
if (!entry.parsed) {
row.className = "log-row";
row.textContent = entry.line;
return row;
}
const p = entry.parsed;
row.className = "log-row" + (p.sev ? ` lv-${SEV_CLASS[p.sev]}` : "");
// Real space text nodes between plain inline spans: the row wraps like
// terminal output and selection copies clean text. The level is the short
// severity word (CSS uppercases and colors it; facility lives in its
// tooltip); the source keeps its full name in a tooltip for when the
// aligned column truncates it.
row.appendChild(span("log-time", p.time, p.fullTime));
if (p.sev) {
row.appendChild(document.createTextNode(" "));
row.appendChild(span("log-sev", p.sev, `${p.fac}.${p.sev}`));
}
if (p.tag) {
row.appendChild(document.createTextNode(" "));
row.appendChild(span("log-tag", p.tag, p.tag));
}
row.appendChild(document.createTextNode(" "));
row.appendChild(span("log-msg", p.msg));
return row;
}
function selectionInside(el) {
const sel = window.getSelection && window.getSelection();
if (!sel || sel.isCollapsed) return false;
return el.contains(sel.anchorNode) || el.contains(sel.focusNode);
}
function teardown(container) {
if (viewer) viewer.remove();
viewer = null;
container.classList.remove("log-enhanced");
}
function render(textarea, container, rows) {
if (!viewer) {
viewer = document.createElement("div");
viewer.className = "syslog-view";
textarea.insertAdjacentElement("afterend", viewer);
container.classList.add("log-enhanced");
}
const frag = document.createDocumentFragment();
for (const entry of rows) frag.appendChild(buildRow(entry));
viewer.replaceChildren(frag);
}
function tick() {
const textarea = document.getElementById("syslog");
const container = document.getElementById("content_syslog");
if (!textarea || !container) return;
const value = textarea.value;
if (value === lastValue) return;
// Re-rendering would destroy an in-progress selection; retry next tick.
if (viewer && selectionInside(viewer)) return;
lastValue = value;
const { rows, ratio } = evaluate(value, parseLine);
if (ratio >= PARSE_GATE) render(textarea, container, rows);
else teardown(container);
}
// Content updates: LuCI's poll rewrites .value, which fires nothing — a
// cheap dirty-check covers it.
setInterval(tick, 1000);
// First paint: the view (and its textarea) is inserted asynchronously by
// LuCI after this script runs. Element insertion IS an observable childList
// mutation (unlike .value writes), and the callback runs as a microtask
// before the browser paints the inserted textarea — so the viewer takes
// over without a flash of the raw log.
if (document.getElementById("syslog")) {
tick();
} else {
const arrival = new MutationObserver(function () {
if (!document.getElementById("syslog")) return;
arrival.disconnect();
tick();
});
arrival.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
})();

View File

@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { test } from "node:test";
/* The patch ships as a plain deferred script, not a module. Evaluate it with
an injected `module` to reach its parsing exports; `document` stays
undefined so the DOM bootstrap is skipped. */
const source = await readFile(
new URL("../src/resource/patches/admin-status-logs.js", import.meta.url),
"utf8",
);
const mod = { exports: {} };
new Function("module", "exports", "document", "window", source)(
mod,
mod.exports,
undefined,
undefined,
);
const { parseSyslogLine, parseDmesgLine, shortenTime, evaluate, PARSE_GATE } =
mod.exports;
test("classic logread format (23.05 syslog, 24.10 syslog-wrapper) parses", () => {
const r = parseSyslogLine(
"Sat Jul 26 03:14:15 2026 daemon.info dnsmasq-dhcp[1]: DHCPACK(br-lan) 192.168.1.140",
);
assert.deepEqual(
{ time: r.time, fac: r.fac, sev: r.sev, tag: r.tag },
{
time: "Jul 26 03:14:15",
fac: "daemon",
sev: "info",
tag: "dnsmasq-dhcp[1]",
},
);
assert.equal(r.msg, "DHCPACK(br-lan) 192.168.1.140");
// ctime() pads single-digit days with a space.
const padded = parseSyslogLine(
"Sun Jul 6 09:05:07 2026 daemon.warn odhcpd[2029]: No default route present",
);
assert.equal(padded.time, "Jul 06 09:05:07");
// Kernel lines routed through logread; hostapd logs without a pid.
assert.equal(
parseSyslogLine(
"Sat Jul 26 03:14:16 2026 kern.info kernel: [73412.882110] br-lan: port 3(lan3) entered forwarding state",
).tag,
"kernel",
);
assert.equal(
parseSyslogLine(
"Sat Jul 26 03:14:17 2026 daemon.notice hostapd: wlan1: AP-STA-CONNECTED 3c:22:fb:8a:12:9d",
).tag,
"hostapd",
);
});
test("master RPC bracket format parses across browser locales", () => {
const en = parseSyslogLine(
"[Jul 24, 2026, 10:59:50 PM UTC] daemon.info: dnsmasq-dhcp[1]: DHCPREQUEST(br-lan) 192.168.1.140",
);
assert.equal(en.time, "Jul 24 22:59:50");
assert.equal(en.fullTime, "Jul 24, 2026, 10:59:50 PM UTC");
// timeStyle "full" spells the zone out; still en-shaped, still shortened.
assert.equal(
parseSyslogLine(
"[Jul 24, 2026, 10:59:50 PM Coordinated Universal Time] daemon.warn: odhcpd[2029]: No default route present",
).time,
"Jul 24 22:59:50",
);
// Non-en datestrs parse fine and display untouched — never guessed at.
const zh = parseSyslogLine(
"[2026年7月24日 GMT 22:59:50] daemon.err: odhcpd[2029]: Failed to send RA",
);
assert.equal(zh.sev, "err");
assert.equal(zh.time, "2026年7月24日 GMT 22:59:50");
});
test("severity comes from the field, never from message content", () => {
const r = parseSyslogLine(
"[Jul 24, 2026, 11:00:00 PM UTC] daemon.info: logger: user said daemon.err: fake",
);
assert.equal(r.sev, "info");
assert.equal(r.msg, "user said daemon.err: fake");
});
test("unknown facility/severity and foreign formats are rejected", () => {
assert.equal(
parseSyslogLine("[Jul 24, 2026, 11:00:00 PM UTC] bogus.info: x: y"),
null,
);
assert.equal(
parseSyslogLine("[Jul 24, 2026, 11:00:00 PM UTC] daemon.bogus: x: y"),
null,
);
// syslog-ng's classic template has no facility.severity field at all.
assert.equal(
parseSyslogLine("Jul 26 10:00:00 OpenWrt dnsmasq[123]: query answered"),
null,
);
assert.equal(parseSyslogLine(""), null);
});
test("dmesg lines: uptime stamp and safe tag split, no severity", () => {
const r = parseDmesgLine(
"[ 12.345678] br-lan: port 3(lan3) entered forwarding state",
);
assert.equal(r.time, "[12.345678]");
assert.equal(r.tag, "br-lan");
assert.equal(r.sev, "");
// A device path with spaces must not be mistaken for a tag.
const pci = parseDmesgLine(
"[75821.004518] mt7921e 0000:01:00.0: Message 00000010 timeout",
);
assert.equal(pci.tag, "");
assert.equal(pci.msg, "mt7921e 0000:01:00.0: Message 00000010 timeout");
assert.equal(parseDmesgLine("no bracket prefix here"), null);
});
test("shortenTime handles 12-hour edges and leaves unknown shapes alone", () => {
assert.equal(shortenTime("Jul 24, 2026, 12:05:01 AM UTC"), "Jul 24 00:05:01");
assert.equal(shortenTime("Jul 24, 2026, 12:15:09 PM UTC"), "Jul 24 12:15:09");
assert.equal(
shortenTime("2026年7月24日 GMT 22:59:50"),
"2026年7月24日 GMT 22:59:50",
);
});
test("parse gate: mixed content falls back, empty value stays calm", () => {
const good = "Sat Jul 26 03:14:15 2026 daemon.info dnsmasq[1]: ok";
const bad = "Jul 26 10:00:00 OpenWrt dnsmasq[123]: syslog-ng style";
const healthy = evaluate(
[good, good, good, good, bad].join("\n"),
parseSyslogLine,
);
assert.ok(healthy.ratio >= PARSE_GATE, "4/5 parsed must stay enhanced");
const foreign = evaluate([bad, bad, bad].join("\n"), parseSyslogLine);
assert.ok(foreign.ratio < PARSE_GATE, "foreign log must fall back");
const empty = evaluate("", parseSyslogLine);
assert.equal(empty.rows.length, 0);
assert.equal(empty.ratio, 1, "empty log must not trip the gate");
});
test("built log payloads land in their homes", () => {
const out = resolve(import.meta.dirname, "../../htdocs/luci-static/aurora");
// The JS ships on-demand under one name — every supported release
// (23.05/24.10/master) mounts both log pages under admin/status/logs/*.
const js = resolve(out, "patches/admin-status-logs.js");
assert.ok(existsSync(js), `${js} missing — run pnpm build first`);
assert.ok(statSync(js).size <= 6_000, `${js} exceeds 6 KB`);
// The CSS is core-page styling and lives in the main bundle, where the
// Tailwind token pipeline (radius-base chain, app shadows) applies — a
// stray patches/ copy would mean the component migration regressed.
const main = readFileSync(resolve(out, "main.css"), "utf8");
assert.ok(main.includes(".syslog-view"), "main.css lost the log viewer");
assert.ok(
main.includes("calc(var(--radius-base)"),
"radius utilities no longer resolve through the reactive base chain",
);
assert.ok(!existsSync(resolve(out, "patches/admin-status-logs.css")));
assert.ok(!existsSync(resolve(out, "patches/_log-shared.css")));
});

View File

@ -9,7 +9,10 @@ import { exec } from "child_process";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
import type { IncomingMessage, ServerResponse } from "http";
import { browserslistToTargets, transform as lightningcssTransform } from "lightningcss";
import {
browserslistToTargets,
transform as lightningcssTransform,
} from "lightningcss";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { minify as terserMinify } from "terser";
import { promisify } from "util";
@ -42,8 +45,8 @@ function createLuciJsCompressPlugin(): Plugin {
},
async generateBundle() {
const srcDir = resolve(CURRENT_DIR, "src/resource");
const jsFiles = (await readdir(srcDir, { recursive: true })).filter(
(f) => f.endsWith(".js"),
const jsFiles = (await readdir(srcDir, { recursive: true })).filter((f) =>
f.endsWith(".js"),
);
await Promise.all(
jsFiles.map(async (relPath) => {
@ -58,9 +61,24 @@ function createLuciJsCompressPlugin(): Plugin {
mangle: true,
format: { comments: false, beautify: false },
});
const outputPath = join(outDir, "resources", normalized);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, compressed.code || sourceCode, "utf-8");
// patches/* are payloads of the on-demand patches mechanism and
// ship next to the CSS patches (media dir), not under resources/.
const stem = normalized.startsWith("patches/")
? normalized.slice("patches/".length, -".js".length)
: null;
const outputPaths = stem
? [stem, ...(PATCH_ALIASES[stem] ?? [])].map((p) =>
join(outDir, "aurora", "patches", `${p}.js`),
)
: [join(outDir, "resources", normalized)];
for (const outputPath of outputPaths) {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(
outputPath,
compressed.code || sourceCode,
"utf-8",
);
}
} catch (error: any) {
console.error(
`${tag("JS Compress")} src/resource/${normalized}: ${error?.message}`,
@ -72,6 +90,30 @@ function createLuciJsCompressPlugin(): Plugin {
};
}
/* Duplicate built CSS patches under their PATCH_ALIASES names (JS aliases are
handled inside the compress plugin). Runs post-bundle because the sources
are Rollup entries. */
function createPatchAliasPlugin(): Plugin {
return {
name: "patch-alias",
apply: "build",
enforce: "post",
async closeBundle() {
for (const [stem, aliases] of Object.entries(PATCH_ALIASES)) {
const source = resolve(BUILD_OUTPUT, `aurora/patches/${stem}.css`);
if (!existsSync(source)) continue;
const css = await readFile(source, "utf-8");
for (const alias of aliases)
await writeFile(
resolve(BUILD_OUTPUT, `aurora/patches/${alias}.css`),
css,
"utf-8",
);
}
},
};
}
async function removeMacOsMetadata(dir: string): Promise<void> {
if (!existsSync(dir)) return;
for (const entry of await readdir(dir, { withFileTypes: true })) {
@ -105,7 +147,9 @@ function createLoginCssPrunePlugin(): Plugin {
for (const [, name, value] of css.matchAll(
new RegExp(`(?<=[{;])(--[\\w-]+|[a-zA-Z-]+):(${VALUE})`, "g"),
)) {
const refs = [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
const refs = [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(
(m) => m[1],
);
if (!name.startsWith("--")) refs.forEach((r) => roots.add(r));
else {
const deps = edges.get(name) ?? new Set<string>();
@ -168,6 +212,27 @@ function createPackageHygienePlugin(): Plugin {
const PATCH_PUBLIC_PREFIX = "/luci-static/aurora/patches/";
const PATCH_SRC_DIR = resolve(CURRENT_DIR, "src/media/patches");
// JS payloads of the same on-demand mechanism: src/resource/patches/<page>.js
// builds to aurora/patches/<page>.js so header.ut's single lsdir() discovers
// CSS and JS patches together.
const JS_PATCH_SRC_DIR = resolve(CURRENT_DIR, "src/resource/patches");
// A built payload (CSS or JS) can serve several pages via aliases, keyed by
// source stem. Prefix matching is per-page, and naming one file after a
// shorter shared prefix (e.g. `admin-status`) would load it on every status
// subpage — including the busiest overview page — so the built output is
// duplicated under each alias instead. Two identical CSS *entries* would not
// work either: Rollup deduplicates same-content assets into a single file.
// Currently empty: the log viewer needs only admin-status-logs — every
// supported release (23.05/24.10/master) mounts both log pages under
// admin/status/logs/*, so the prefix covers System Log, Kernel Log and the
// bare /logs alias with one name.
const PATCH_ALIASES: Record<string, string[]> = {};
const patchAliasSource = (stem: string): string | undefined =>
Object.entries(PATCH_ALIASES).find(([, aliases]) =>
aliases.includes(stem),
)?.[0];
// Theme assets (public/aurora/**: images, fonts): serve the copy in this
// checkout at /luci-static/aurora/<path>. Without this, header.ut's logo,
// favicon, webmanifest and font references fall through to the OpenWrt proxy
@ -230,13 +295,44 @@ function createLocalServePlugin(): Plugin {
pathname.startsWith(PATCH_PUBLIC_PREFIX) &&
pathname.endsWith(".css")
) {
const file = basename(pathname);
if (existsSync(join(PATCH_SRC_DIR, file))) {
// Aliases resolve to their shared source, mirroring the build-time
// duplication.
const stem = basename(pathname, ".css");
const source = existsSync(join(PATCH_SRC_DIR, `${stem}.css`))
? stem
: patchAliasSource(stem);
if (source && existsSync(join(PATCH_SRC_DIR, `${source}.css`))) {
req.url =
`/src/media/patches/${file}` + (search ? `?${search}` : "");
`/src/media/patches/${source}.css` + (search ? `?${search}` : "");
return next();
}
}
if (
pathname.startsWith(PATCH_PUBLIC_PREFIX) &&
pathname.endsWith(".js")
) {
const stem = basename(pathname, ".js");
const source = existsSync(join(JS_PATCH_SRC_DIR, `${stem}.js`))
? stem
: patchAliasSource(stem);
if (source && existsSync(join(JS_PATCH_SRC_DIR, `${source}.js`))) {
try {
const code = await readFile(
join(JS_PATCH_SRC_DIR, `${source}.js`),
"utf-8",
);
res.setHeader("Content-Type", "text/javascript");
res.setHeader("Cache-Control", "no-store");
res.statusCode = 200;
res.end(code);
return;
} catch (err: any) {
console.error(
`${tag("Serve")} ${pathname} → cannot read patches/${source}.js: ${err?.message ?? err}`,
);
}
}
}
if (pathname.startsWith(ASSET_PUBLIC_PREFIX)) {
const asset = localAsset(pathname.slice(ASSET_PUBLIC_PREFIX.length));
if (asset) {
@ -266,7 +362,10 @@ function createLocalServePlugin(): Plugin {
const nf = file.replace(/\\/g, "/");
const isThemeCss =
nf.startsWith(MEDIA_SRC_DIR + "/") && nf.endsWith(".css");
if (isThemeCss || reloadJsFiles.has(nf)) {
const isJsPatch =
nf.startsWith(JS_PATCH_SRC_DIR.replace(/\\/g, "/") + "/") &&
nf.endsWith(".js");
if (isThemeCss || isJsPatch || reloadJsFiles.has(nf)) {
console.log(
`${tag("Serve")} ${relative(CURRENT_DIR, file)} → full reload`,
);
@ -377,7 +476,10 @@ function neutralizeLuciRuntime(html: string): string {
"",
);
return stripped.includes("</head>")
? stripped.replace(/<head\b[^>]*>/i, (m) => `${m}\n ${MOCK_RUNTIME_GUARD}`)
? stripped.replace(
/<head\b[^>]*>/i,
(m) => `${m}\n ${MOCK_RUNTIME_GUARD}`,
)
: MOCK_RUNTIME_GUARD + stripped;
}
@ -884,6 +986,7 @@ export default defineConfig(({ mode }) => {
createUtSyncPlugin(OPENWRT_SSH_HOST),
createLuciJsCompressPlugin(),
createLoginCssPrunePlugin(),
createPatchAliasPlugin(),
createPackageHygienePlugin(),
],
css: {
@ -902,9 +1005,11 @@ export default defineConfig(({ mode }) => {
// On-demand third-party patches: one entry per page, output to
// aurora/patches/<page>.css (the `patches/` key prefix lands them there
// via assetFileNames below). header.ut links the matching one per page.
// `_`-prefixed files are shared partials @imported by entries, not
// entries themselves (they'd otherwise ship as never-matching patches).
...Object.fromEntries(
(existsSync(PATCH_SRC_DIR) ? readdirSync(PATCH_SRC_DIR) : [])
.filter((f) => f.endsWith(".css"))
.filter((f) => f.endsWith(".css") && !f.startsWith("_"))
.map((f) => [
`patches/${f.slice(0, -4)}`,
join(PATCH_SRC_DIR, f),

View File

@ -27,7 +27,7 @@ No linter CLI. Formatting uses Prettier with format-on-save (`.vscode/settings.j
- `.dev/public/aurora/``htdocs/luci-static/aurora/` (copied as-is)
- `ucode/template/themes/aurora/*.ut` — server-side templates, not processed by Vite
**CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/``main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_elements.css`, `_layout.css`, `components/*.css`, `_utilities.css`) and `login.css` (standalone login page; imports `_base.css`). Third-party compatibility patches are not bundled into `main.css`; each lives in `media/patches/<data-page>.css`, builds to its own `htdocs/luci-static/aurora/patches/<data-page>.css`, and is linked on demand by `header.ut`, which discovers installed patches at render time via `fs.lsdir()` and matches them on path-segment-boundary prefixes (dynamic subpages inherit their prefix's patch; third-party packages may drop their own patch into the directory) — see "On-Demand Third-Party Patches" in `.dev/docs/DEVELOPMENT.md`. All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. Built CSS keeps Tailwind's native `@layer` structure (theme partials are unlayered, so they outrank layered utilities); the OKLCH requirement already gates browsers to ones with `@layer` support. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout.
**CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/``main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_elements.css`, `_layout.css`, `components/*.css`, `_utilities.css`) and `login.css` (standalone login page; imports `_base.css`). Third-party compatibility patches are not bundled into `main.css`; each lives in `media/patches/<data-page>.css`, builds to its own `htdocs/luci-static/aurora/patches/<data-page>.css`, and is linked on demand by `header.ut`, which discovers installed patches at render time via `fs.lsdir()` and matches them on path-segment-boundary prefixes (dynamic subpages inherit their prefix's patch; third-party packages may drop their own patch into the directory). The same sweep also loads `patches/<data-page>.js` payloads as deferred scripts (theme-owned JS patches live in `.dev/src/resource/patches/`; `PATCH_ALIASES` in `vite.config.ts` duplicates one built payload under several page names) — see "On-Demand Third-Party Patches" in `.dev/docs/DEVELOPMENT.md`. All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). The one deliberate exception is `media/patches/*.css`, which is plain native CSS by design (smaller standalone output, and third-party patch authors need no Tailwind) — reach theme values through the `:root` custom properties (`var(--surface)`, `var(--radius-base)`, …). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. Built CSS keeps Tailwind's native `@layer` structure (theme partials are unlayered, so they outrank layered utilities); the OKLCH requirement already gates browsers to ones with `@layer` support. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout.
**Design tokens**: `_tokens.css` is GENERATED — do not hand-edit (see its header comment). Source of truth is `.dev/tokens/`: `defaults.js` holds the 10 editable input colors per mode (`bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger`); `spec.js` declares how every other token derives from those via `mix`/`shade`/`set`/`alpha`/`const` operators plus `FIXED` literals (shadows); `engine.js`/`resolve.js` do the OKLCH math and flatten everything to plain `oklch(...)` values. After changing inputs or derivations, run `pnpm gen:tokens` to rewrite `_tokens.css`, and `pnpm test` to check the color-math and token invariants.

View File

@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.1.6
PKG_RELEASE:=59
PKG_VERSION:=1.1.8
PKG_RELEASE:=60
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
[data-page=admin-modem-modemdata-modempreview] .ifacebox-body .ifacebadge{width:auto!important}

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
@media not all and (min-width:48rem){[data-name=bridge-vlan]>div{overflow:visible!important}}
@media not (min-width:48rem){[data-name=bridge-vlan]>div{overflow:visible!important}}

View File

@ -1,2 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
[data-page=admin-services-openclash-config] .sub_div img{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}
[data-page=admin-services-openclash-config] .sub_div img{width:1.25rem;height:1.25rem}

View File

@ -1,2 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
@media not all and (min-width:48rem){[data-page=admin-services-openclash-settings] .cbi-value-field .cbi-input-select{width:100%!important}[data-page=admin-services-openclash-settings] .diag-style{gap:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}}[data-page=admin-services-openclash-settings] .diag-style>div{align-items:center;gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}@media not all and (min-width:48rem){[data-page=admin-services-openclash-settings] .diag-style>div{gap:var(--spacing);width:100%!important}}
@media not (min-width:48rem){[data-page=admin-services-openclash-settings] .cbi-value-field .cbi-input-select{width:100%!important}}[data-page=admin-services-openclash-settings] .diag-style>div{flex-direction:column;align-items:center;gap:.5rem;display:flex}@media not (min-width:48rem){[data-page=admin-services-openclash-settings] .diag-style{grid-template-columns:repeat(1,minmax(0,1fr));gap:1rem;display:grid}[data-page=admin-services-openclash-settings] .diag-style>div{gap:.25rem;width:100%!important}}

View File

@ -1,2 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}[data-page=admin-statistics-graphs] [data-plugin] img:where([data-darkmode=true],[data-darkmode=true] *){--tw-hue-rotate:hue-rotate(150deg);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);--tw-invert:invert(100%)}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
[data-darkmode=true] [data-page=admin-statistics-graphs] [data-plugin] img{filter:hue-rotate(150deg)invert()}

View File

@ -0,0 +1 @@
"use strict";!function(){const e=new Set(["kern","user","mail","daemon","auth","syslog","lpr","news","uucp","cron","authpriv","ftp","ntp","security","console","local0","local1","local2","local3","local4","local5","local6","local7","unknown"]),t=new Set(["emerg","alert","crit","err","warn","notice","info","debug"]),n={emerg:"crit",alert:"crit",crit:"crit",err:"err",warn:"warn",notice:"notice",info:"info",debug:"debug"};function o(e){const t=e.match(/^([A-Za-z0-9_.\/-]+(?:\[\d+\])?):\s+([\s\S]*)$/);return t?{tag:t[1],msg:t[2]}:{tag:"",msg:e}}function s(e){const t=e.match(/^(\w{3}) (\d{1,2}), \d{4}, (\d{1,2}):(\d{2}):(\d{2})\s*(AM|PM)?/);if(!t)return e;let n=parseInt(t[3],10);"PM"===t[6]&&n<12&&(n+=12),"AM"===t[6]&&12===n&&(n=0);const o=(n<10?"0":"")+n;return`${t[1]} ${t[2].padStart(2,"0")} ${o}:${t[4]}:${t[5]}`}function c(n){let c=n.match(/^\[(.+?)\]\s+(\w+)\.(\w+):\s+([\s\S]*)$/);return c&&e.has(c[2])&&t.has(c[3])?{time:s(c[1]),fullTime:c[1],fac:c[2],sev:c[3],...o(c[4])}:(c=n.match(/^\w{3} (\w{3}) +(\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4}) (\w+)\.(\w+) ([\s\S]*)$/),c&&e.has(c[5])&&t.has(c[6])?{time:`${c[1]} ${c[2].padStart(2,"0")} ${c[3]}`,fullTime:n.slice(0,n.indexOf(` ${c[5]}.${c[6]} `)),fac:c[5],sev:c[6],...o(c[7])}:null)}function l(e){const t=e.match(/^\[\s*(\d+\.\d+)\]\s?([\s\S]*)$/);return t?{time:`[${t[1]}]`,fullTime:"",fac:"",sev:"",...o(t[2])}:null}function a(e,t){const n=(e?e.split(/\n/):[]).map(e=>({line:e,parsed:t(e)})),o=n.reduce((e,t)=>e+(t.parsed?1:0),0);return{rows:n,ratio:n.length?o/n.length:1}}if("undefined"!=typeof module&&module.exports&&(module.exports={parseSyslogLine:c,parseDmesgLine:l,splitTag:o,shortenTime:s,evaluate:a,PARSE_GATE:.8}),"undefined"==typeof document)return;const r=document.body?.dataset?.page||"",d=/(^|-)dmesg(-|$)/.test(r)?l:c;let i=null,u=null;function m(e,t,n){const o=document.createElement("span");return o.className=e,o.textContent=t,n&&(o.title=n),o}function g(e){const t=document.createElement("div");if(!e.parsed)return t.className="log-row",t.textContent=e.line,t;const o=e.parsed;return t.className="log-row"+(o.sev?` lv-${n[o.sev]}`:""),t.appendChild(m("log-time",o.time,o.fullTime)),o.sev&&(t.appendChild(document.createTextNode(" ")),t.appendChild(m("log-sev",o.sev,`${o.fac}.${o.sev}`))),o.tag&&(t.appendChild(document.createTextNode(" ")),t.appendChild(m("log-tag",o.tag,o.tag))),t.appendChild(document.createTextNode(" ")),t.appendChild(m("log-msg",o.msg)),t}function f(){const e=document.getElementById("syslog"),t=document.getElementById("content_syslog");if(!e||!t)return;const n=e.value;if(n===u)return;if(i&&function(e){const t=window.getSelection&&window.getSelection();return!(!t||t.isCollapsed)&&(e.contains(t.anchorNode)||e.contains(t.focusNode))}(i))return;u=n;const{rows:o,ratio:s}=a(n,d);s>=.8?function(e,t,n){i||(i=document.createElement("div"),i.className="syslog-view",e.insertAdjacentElement("afterend",i),t.classList.add("log-enhanced"));const o=document.createDocumentFragment();for(const e of n)o.appendChild(g(e));i.replaceChildren(o)}(e,t,o):function(e){i&&i.remove(),i=null,e.classList.remove("log-enhanced")}(t)}if(setInterval(f,1e3),document.getElementById("syslog"))f();else{const e=new MutationObserver(function(){document.getElementById("syslog")&&(e.disconnect(),f())});e.observe(document.documentElement,{childList:!0,subtree:!0})}}();

View File

@ -1,2 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid}}}[data-page=admin-system-filemanager] #file-manager-container{overflow:auto}[data-page=admin-system-filemanager] #file-manager-container #status-bar{border-style:var(--tw-border-style);background-color:var(--surface);border-width:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}
[data-page=admin-system-filemanager] #file-manager-container{overflow:auto}[data-page=admin-system-filemanager] #file-manager-container #status-bar{background:var(--surface);border:0}

View File

@ -22,15 +22,19 @@
const page_segs = length(ctx?.request_path) ? ctx.request_path : (ctx?.path || []);
const page = join('-', page_segs);
// On-demand patches — see "On-Demand Third-Party Patches" in DEVELOPMENT.md.
let patch_files = [];
let patch_files = [], patch_scripts = [];
if (!blank_page) {
let seg_prefixes = {}, acc = null;
for (let seg in page_segs)
seg_prefixes[acc = (acc == null ? seg : `${acc}-${seg}`)] = true;
for (let f in lsdir(`/www${media}/patches`) ?? [])
for (let f in lsdir(`/www${media}/patches`) ?? []) {
if (substr(f, -4) == '.css' && exists(seg_prefixes, substr(f, 0, -4)))
push(patch_files, substr(f, 0, -4));
else if (substr(f, -3) == '.js' && exists(seg_prefixes, substr(f, 0, -3)))
push(patch_scripts, substr(f, 0, -3));
}
sort(patch_files);
sort(patch_scripts);
}
let token_css_light = '', token_css_dark = '';
for (let key, val in tokens) {
@ -179,6 +183,9 @@
{% for (let patch in patch_files): %}
<link rel="stylesheet" href="{{ media }}/patches/{{ patch }}.css">
{% endfor %}
{% for (let patch in patch_scripts): %}
<script defer src="{{ media }}/patches/{{ patch }}.js"></script>
{% endfor %}
{% endif %}
{% if (font_preload): %}
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ font_preload }}">