mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 17:45:14 +08:00
🏅 Sync 2026-07-25 20:22:21
This commit is contained in:
1 parent
9c077adbc3
commit
db661eb60a
19 files changed
+1051
-118
No files matched your search
@@ -173,6 +173,22 @@ Two rules of thumb that follow from prefix matching:
|
||||
|
||||
> Unlike the `_`-prefixed partials (which are `@import`-only fragments), patch filenames have no `_` prefix — each is a real build entry that ships to `htdocs/`.
|
||||
|
||||
### 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**. Save the page's rendered HTML once, then develop against it with the theme live and hot-reloading.
|
||||
|
||||
- **Where snapshots live:** `.dev/mocks/*.html` (git-ignored — snapshots are large, device/fork-specific and go stale, so they stay local). The directory need not exist in a fresh clone; it's created on first capture.
|
||||
- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and press <kbd>Alt/Option+Shift+S</kbd> (or call `__auroraMockCapture()` in the console). The injected `scripts/mock-capture.client.js` helper POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — named after the page's `data-page`, doctype included, dev-only script tags stripped. (The endpoint only accepts requests carrying the helper's custom header, which cross-origin pages can't send without a CORS preflight this server never approves.) Manual capture still works: run `copy(document.documentElement.outerHTML)` in the DevTools console and save the paste as `.dev/mocks/<name>.html` — the filename is free; the page's real identity is the `data-page` attribute already in its `<body>`, which patch selectors match.
|
||||
- **Capture it from a device running _this_ theme — snapshots are not portable between themes.** A snapshot is a verbatim copy of a rendered page, so it hard-codes the theme that rendered it in three places: the stylesheet links (`/luci-static/aurora/main.css` plus that page's patch), the device's stored UCI token overrides in an inline `<style>`, and the theme's own header/nav markup. Drop a snapshot captured under `luci-theme-shadcn` in here (or vice versa) and the page renders **completely unstyled**, because a dev server only serves its own `/luci-static/<theme>/` prefix. The giveaway is a terminal line naming the other theme's stylesheet:
|
||||
```
|
||||
[Mocks] miss /luci-static/shadcn/main.css → 404 (mirror it at .dev/mocks/static/… to serve it)
|
||||
```
|
||||
Mirroring, which that generic hint suggests, is the wrong fix here — re-capture the page from a device running this theme. If you must reuse a foreign snapshot anyway, only the app's own content region means anything: point its stylesheet links at this theme, and delete the inline `<style>` block, or the captured device's colors override this checkout's tokens.
|
||||
- **View:** `pnpm dev`, then open <http://localhost:5173/mocks/> — an auto-generated index lists every snapshot with its `data-page` and age. The `mock-pages-plugin` (in `vite.config.ts`) serves each page with the Vite HMR client injected, so editing any theme source (`main.css`, a component, a `patches/*.css`, or served JS) triggers the usual full reload (see [Live Reload Behavior](#live-reload-behavior)). The snapshot keeps its absolute `/luci-static/…` links; theme CSS/JS, fonts and images resolve locally and compile on the fly. Serving prepends the `<!doctype html>` that `outerHTML` captures drop, so mocks render in standards mode exactly like the real page.
|
||||
- **Navigate between snapshots in place:** `scripts/mock-nav.client.js` (injected into every mock) resolves clicks on the snapshot's own LuCI links (`/cgi-bin/luci/…`) against the captured snapshots by `data-page` (exact match) and jumps straight to the matching mock — an app's tab bar or the sidebar works just like on the device. Uncaptured targets are blocked with a hint naming the missing snapshot instead of falling through to the proxy. A floating switcher (bottom-left) lists every snapshot, cycles with <kbd>[</kbd>/<kbd>]</kbd> (or its ‹/› buttons), and links back to the index.
|
||||
- **Third-party assets:** an app's own css/js the snapshot references (e.g. `qmodem-next.css`, or a device-only custom logo) isn't in this repo. To serve it, mirror its URL under `.dev/mocks/static/` (e.g. `.dev/mocks/static/luci-static/resources/qmodem/qmodem-next.css`); files there are served as-is (no HMR). Misses requested by a mock page 404 immediately — never proxied to the router, so mocks stay fully offline-capable — and each miss prints a one-time terminal hint with the exact mirror path. (CSS-initiated requests, e.g. nav icons, carry the stylesheet's URL as referer and can't be attributed to the mock page; any `/luci-static` request that falls through to the proxy is therefore bounded to 5s and answers 504 when the router is unreachable.) The theme still applies without them.
|
||||
- **No auth, no runtime:** a snapshot is static DOM, so `mock-pages-plugin` strips LuCI's runtime scripts (`luci.js`/`cbi.js`/`xhr.js` and `/cgi-bin/` endpoints) and injects a no-op `L`/`LuCI`/`XHR` stub before serving. Without this, LuCI boots, polls the backend, gets 403 (no session) and pops the "Session expired" modal. The trade-off: framework-dependent theme JS (e.g. `menu-aurora`) no-ops in mocks — the captured DOM is already rendered, so it still looks right. The theme's own inline scripts (dark mode, toolbar state) and any `src/media/` JS still run.
|
||||
|
||||
### Design Tokens
|
||||
|
||||
`src/media/_tokens.css` is **generated** — its header says "DO NOT EDIT". The source of truth is the standalone [`@eamonxg/aurora-tokens`](https://github.com/eamonxg/aurora-tokens) npm package, consumed here as a devDependency:
|
||||
@@ -284,12 +300,15 @@ luci-theme-aurora/
|
||||
├── .dev/ # Development environment
|
||||
│ ├── docs/ # Project documentation
|
||||
│ │ └── DEVELOPMENT.md # Development guide (this file)
|
||||
│ ├── mocks/ # Local page snapshots for /mocks/ (git-ignored, see Mock Pages)
|
||||
│ ├── public/aurora/ # Public static assets
|
||||
│ │ ├── fonts/ # Web fonts (Lato)
|
||||
│ │ └── images/ # Theme images + PWA icons
|
||||
│ ├── scripts/ # Build scripts
|
||||
│ ├── scripts/ # Build scripts + dev-server client helpers
|
||||
│ │ ├── clean.js # Build cleanup utility
|
||||
│ │ ├── gen-tokens.js # Regenerates src/media/_tokens.css from @eamonxg/aurora-tokens
|
||||
│ │ ├── mock-capture.client.js # Injected into proxied device pages — hotkey capture to /mocks/__save
|
||||
│ │ ├── mock-nav.client.js # Injected into /mocks/ pages — link takeover + floating switcher
|
||||
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── assets/icons/ # SVG icons
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Aurora dev helper — injected by the /cgi-bin dev proxy (vite.config.ts)
|
||||
* into real device-served LuCI pages. Never ships to the router.
|
||||
*
|
||||
* Alt/Option+Shift+S — or __auroraMockCapture() from the console — POSTs the
|
||||
* live DOM to /mocks/__save, which writes .dev/mocks/<data-page>.html for the
|
||||
* /mocks/ workflow (see "Mock Pages" in .dev/docs/DEVELOPMENT.md).
|
||||
*/
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
// Mock pages are served, not proxied, so this only runs on real pages —
|
||||
// the guard is just belt and braces against a stale captured tag.
|
||||
if (window.__auroraMockCapture || location.pathname.startsWith("/mocks/"))
|
||||
return;
|
||||
|
||||
let toastEl;
|
||||
let toastTimer;
|
||||
const toast = (text, ok) => {
|
||||
if (!toastEl) {
|
||||
toastEl = document.createElement("div");
|
||||
toastEl.style.cssText =
|
||||
"position:fixed;left:50%;bottom:32px;transform:translateX(-50%);" +
|
||||
"z-index:2147483647;padding:9px 14px;border-radius:10px;" +
|
||||
"font:13px/1.5 system-ui,-apple-system,sans-serif;" +
|
||||
"background:rgba(18,18,24,.9);box-shadow:0 8px 32px rgba(0,0,0,.35);" +
|
||||
"backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
|
||||
"transition:opacity 160ms ease;pointer-events:none;max-width:80vw";
|
||||
document.documentElement.appendChild(toastEl);
|
||||
}
|
||||
toastEl.textContent = text;
|
||||
toastEl.style.color = ok ? "#4ade80" : "#ffd166";
|
||||
toastEl.style.opacity = "1";
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (toastEl.style.opacity = "0"), 3000);
|
||||
};
|
||||
|
||||
const capture = async () => {
|
||||
try {
|
||||
const response = await fetch("/mocks/__save", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Aurora-Capture": "1",
|
||||
},
|
||||
body: "<!doctype html>\n" + document.documentElement.outerHTML,
|
||||
});
|
||||
const info = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(info.error || `HTTP ${response.status}`);
|
||||
toast(`已捕获 ${info.file} — 在 /mocks/ 打开`, true);
|
||||
} catch (err) {
|
||||
toast(`捕获失败:${(err && err.message) || err}`, false);
|
||||
}
|
||||
};
|
||||
|
||||
window.__auroraMockCapture = capture;
|
||||
|
||||
addEventListener("keydown", (event) => {
|
||||
if (
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.code === "KeyS"
|
||||
) {
|
||||
event.preventDefault();
|
||||
capture();
|
||||
}
|
||||
});
|
||||
|
||||
console.info(
|
||||
"[aurora] mock capture ready — Alt/Option+Shift+S(或 __auroraMockCapture())保存当前页到 .dev/mocks/",
|
||||
);
|
||||
})();
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Aurora dev helper — injected by mock-pages-plugin (vite.config.ts) into
|
||||
* every page served under /mocks/. Never ships to the router.
|
||||
*
|
||||
* 1. Navigation takeover: clicks on the snapshot's own LuCI links
|
||||
* (/cgi-bin/luci/…) resolve against the captured snapshots — matched by
|
||||
* each snapshot's <body data-page>, exact match — and jump to the matching
|
||||
* mock instead of falling through to the proxied router. Uncaptured
|
||||
* targets are blocked with a hint naming the missing snapshot.
|
||||
* 2. Floating switcher (bottom-left; the theme's own floating toolbar owns
|
||||
* the bottom-right corner). Shadow DOM, so theme/patch CSS can't restyle
|
||||
* it and its styles can't leak into the page under review. Lists every
|
||||
* snapshot, cycles with the ‹/› buttons or the [ and ] keys, links back
|
||||
* to the /mocks/ index.
|
||||
*
|
||||
* Data contract (injected inline before this script):
|
||||
* window.__AURORA_MOCKS__ = { current: "<file>", mocks: [{ file, page }] }
|
||||
*/
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const data = window.__AURORA_MOCKS__;
|
||||
if (!data || !Array.isArray(data.mocks) || !data.mocks.length) return;
|
||||
|
||||
const mocks = data.mocks;
|
||||
const byPage = new Map(
|
||||
mocks.filter((m) => m.page).map((m) => [m.page, m.file]),
|
||||
);
|
||||
const current = Math.max(
|
||||
mocks.findIndex((m) => m.file === data.current),
|
||||
0,
|
||||
);
|
||||
|
||||
const mockUrl = (file) => "/mocks/" + encodeURIComponent(file);
|
||||
const displayName = (file) => file.replace(/\.html$/, "");
|
||||
const escapeHtml = (s) =>
|
||||
s.replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
|
||||
// ---- floating switcher ---------------------------------------------------
|
||||
|
||||
const host = document.createElement("aurora-mock-nav");
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2147483000;
|
||||
font: 12px/1.5 system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 18, 24, 0.82);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.step {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.step:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
opacity: 1;
|
||||
}
|
||||
.name {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.name:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.count {
|
||||
opacity: 0.6;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
background: rgba(18, 18, 24, 0.92);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.panel a {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.panel a:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.panel a.current {
|
||||
color: #4ade80;
|
||||
}
|
||||
.panel a.current::before {
|
||||
content: "● ";
|
||||
font-size: 9px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px 2px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.foot a {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.foot a:hover {
|
||||
color: #fff;
|
||||
background: none;
|
||||
}
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
max-width: 320px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(18, 18, 24, 0.92);
|
||||
color: #ffd166;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
<div class="panel" hidden></div>
|
||||
<div class="toast"></div>
|
||||
<div class="pill">
|
||||
<button class="step prev" title="上一个快照 [">‹</button>
|
||||
<button class="name" title="全部快照"></button>
|
||||
<button class="step next" title="下一个快照 ]">›</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const panel = root.querySelector(".panel");
|
||||
const toastEl = root.querySelector(".toast");
|
||||
const nameBtn = root.querySelector(".name");
|
||||
|
||||
nameBtn.innerHTML =
|
||||
escapeHtml(displayName(data.current)) +
|
||||
`<span class="count">${current + 1}/${mocks.length}</span>`;
|
||||
|
||||
panel.innerHTML =
|
||||
mocks
|
||||
.map(
|
||||
(m) =>
|
||||
`<a href="${mockUrl(m.file)}"` +
|
||||
(m.file === data.current ? ' class="current"' : "") +
|
||||
` title="${escapeHtml(m.page || m.file)}">` +
|
||||
`${escapeHtml(displayName(m.file))}</a>`,
|
||||
)
|
||||
.join("") +
|
||||
'<div class="foot"><a href="/mocks/">全部快照 ↗</a><span>[ / ] 切换</span></div>';
|
||||
|
||||
const cycle = (step) => {
|
||||
if (mocks.length < 2) return;
|
||||
const next = mocks[(current + step + mocks.length) % mocks.length];
|
||||
location.href = mockUrl(next.file);
|
||||
};
|
||||
|
||||
root.querySelector(".prev").addEventListener("click", () => cycle(-1));
|
||||
root.querySelector(".next").addEventListener("click", () => cycle(1));
|
||||
nameBtn.addEventListener("click", () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!panel.hidden && !event.composedPath().includes(host)) {
|
||||
panel.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
let toastTimer;
|
||||
const toast = (text) => {
|
||||
toastEl.textContent = text;
|
||||
toastEl.classList.add("show");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2600);
|
||||
};
|
||||
|
||||
document.documentElement.appendChild(host);
|
||||
|
||||
// ---- navigation takeover -------------------------------------------------
|
||||
|
||||
const pageFromHref = (href) => {
|
||||
try {
|
||||
const url = new URL(href, location.href);
|
||||
if (url.origin !== location.origin) return null;
|
||||
const match = url.pathname.match(/^\/cgi-bin\/luci(?:$|\/(.*))/);
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match[1] || "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const anchor = target && target.closest("a[href]");
|
||||
if (!anchor) return;
|
||||
const page = pageFromHref(anchor.getAttribute("href"));
|
||||
if (page === null) return;
|
||||
// Never let a click inside a mock fall through to the proxied router.
|
||||
event.preventDefault();
|
||||
const file = byPage.get(page);
|
||||
if (file) location.href = mockUrl(file);
|
||||
else
|
||||
toast(
|
||||
`未捕获快照:${page || "/cgi-bin/luci"}(真机页 Alt/Option+Shift+S 可捕获)`,
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const isEditable = (el) =>
|
||||
el instanceof Element &&
|
||||
(el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
|
||||
|
||||
addEventListener("keydown", (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (isEditable(event.target)) return;
|
||||
if (event.key === "[") cycle(-1);
|
||||
else if (event.key === "]") cycle(1);
|
||||
});
|
||||
})();
|
||||
@@ -6,9 +6,14 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "./_tokens.css";
|
||||
|
||||
@import "./_reduced-motion.css";
|
||||
@import "./_base.css";
|
||||
|
||||
/* See main.css: keep dev-only tooling out of Tailwind's source detection. */
|
||||
@source not "../../vite.config.ts";
|
||||
@source not "../../scripts/*.client.js";
|
||||
|
||||
@custom-variant dark (&:where([data-darkmode=true], [data-darkmode=true] *));
|
||||
|
||||
@keyframes divider-in {
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Dev-only tooling holds prose and plain CSS whose bare words (static,
|
||||
transition, relative, …) Tailwind's automatic source detection would
|
||||
otherwise read as utility candidates and emit into this shipped stylesheet.
|
||||
Nothing here renders markup, so none of it belongs in the scan. */
|
||||
@source not "../../vite.config.ts";
|
||||
@source not "../../scripts/*.client.js";
|
||||
|
||||
@custom-variant dark (&:where([data-darkmode=true], [data-darkmode=true] *));
|
||||
|
||||
@plugin "tailwind-scrollbar" {
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import browserslist from "browserslist";
|
||||
import { exec } from "child_process";
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
||||
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import { browserslistToTargets } from "lightningcss";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "path";
|
||||
import { minify as terserMinify } from "terser";
|
||||
@@ -182,6 +183,420 @@ function createLocalServePlugin(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
// Mock pages: render saved LuCI page snapshots against the live theme, so a
|
||||
// third-party app's page can be styled without the app (or a device) installed.
|
||||
// Snapshots live in .dev/mocks/*.html and are served at /mocks/<name>.html
|
||||
// with the Vite HMR client injected, so theme edits (main.css, components,
|
||||
// patches/*.css, served JS) trigger the existing full-reload in handleHotUpdate.
|
||||
// Any third-party asset a snapshot needs (the app's own css/js, e.g.
|
||||
// qmodem-next.css) goes under .dev/mocks/static/ mirroring its /luci-static/…
|
||||
// URL and is served as-is (no HMR). Served snapshots additionally get
|
||||
// scripts/mock-nav.client.js (links between captured pages navigate in place,
|
||||
// plus a floating switcher), and proxied device pages get
|
||||
// scripts/mock-capture.client.js (hotkey → POST /mocks/__save captures the
|
||||
// open page as a snapshot). See "Mock Pages" in .dev/docs/DEVELOPMENT.md.
|
||||
const MOCK_ROUTE = "/mocks";
|
||||
const MOCKS_DIR = resolve(CURRENT_DIR, "mocks");
|
||||
const MOCKS_STATIC_DIR = join(MOCKS_DIR, "static");
|
||||
const MOCK_NAV_CLIENT = resolve(CURRENT_DIR, "scripts/mock-nav.client.js");
|
||||
const MOCK_CAPTURE_CLIENT = resolve(
|
||||
CURRENT_DIR,
|
||||
"scripts/mock-capture.client.js",
|
||||
);
|
||||
// A captured LuCI page is tens of KB — anything past this is not a page.
|
||||
const MOCK_SAVE_LIMIT = 20 * 1024 * 1024;
|
||||
|
||||
const MOCK_MIME: Record<string, string> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
function mockContentType(file: string): string {
|
||||
const ext = file.slice(file.lastIndexOf("."));
|
||||
return MOCK_MIME[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
// document.documentElement.outerHTML (the documented manual capture) drops the
|
||||
// doctype; without one the browser renders the mock in quirks mode and layout
|
||||
// no longer matches the real page. Every serve and save path funnels through
|
||||
// this.
|
||||
function ensureDoctype(html: string): string {
|
||||
return /^\s*<!doctype/i.test(html) ? html : `<!doctype html>\n${html}`;
|
||||
}
|
||||
|
||||
const escapeHtml = (s: string): string =>
|
||||
s.replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!,
|
||||
);
|
||||
|
||||
// Inject the Vite HMR client so a served snapshot joins the WS channel and
|
||||
// receives handleHotUpdate's full-reload broadcast on any theme source change.
|
||||
function injectHmrClient(html: string): string {
|
||||
if (html.includes("/@vite/client")) return html;
|
||||
const tag = `\n <script type="module" src="/@vite/client"></script>\n`;
|
||||
return html.includes("</head>")
|
||||
? html.replace("</head>", `${tag} </head>`)
|
||||
: tag + html;
|
||||
}
|
||||
|
||||
// A snapshot is static DOM. Left alone, LuCI's runtime (luci.js + the inline
|
||||
// `new LuCI(...)` bootstrap) boots, polls the backend, gets 403 (no session)
|
||||
// and pops the "Session expired" modal. Neutralise it for mock rendering:
|
||||
// strip the scripts that phone home (luci.js/cbi.js/xhr.js and /cgi-bin/
|
||||
// endpoints) and pre-define a no-op `L`/`LuCI`/`XHR` stub so the remaining
|
||||
// inline bootstraps (`new LuCI(...)`, `L.require(...)`, legacy `XHR.poll(...)`)
|
||||
// run harmlessly. Theme CSS/JS and the theme's own inline scripts (dark mode,
|
||||
// toolbar) are untouched; framework-dependent theme JS such as menu-aurora
|
||||
// simply no-ops — the captured DOM is already fully rendered, so a static
|
||||
// review still looks right.
|
||||
const MOCK_RUNTIME_GUARD = `<script>
|
||||
(function () {
|
||||
var stub = new Proxy(function () {}, {
|
||||
get: function () { return stub; },
|
||||
apply: function () { return stub; },
|
||||
construct: function () { return stub; },
|
||||
});
|
||||
window.L = stub;
|
||||
window.LuCI = stub;
|
||||
window.XHR = stub;
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
function neutralizeLuciRuntime(html: string): string {
|
||||
const stripped = html.replace(
|
||||
/<script\b[^>]*\bsrc="[^"]*(?:\/luci-static\/resources\/(?:luci|cbi|xhr)\.js|\/cgi-bin\/)[^"]*"[^>]*>\s*<\/script>/gi,
|
||||
"",
|
||||
);
|
||||
return stripped.includes("</head>")
|
||||
? stripped.replace(/<head\b[^>]*>/i, (m) => `${m}\n ${MOCK_RUNTIME_GUARD}`)
|
||||
: MOCK_RUNTIME_GUARD + stripped;
|
||||
}
|
||||
|
||||
// Snapshot inventory for the index and for in-mock navigation. A snapshot's
|
||||
// identity is its <body data-page> (filenames are free), so each file is
|
||||
// scanned for it once and re-read only when its mtime changes.
|
||||
interface MockEntry {
|
||||
file: string;
|
||||
page: string | null;
|
||||
mtimeMs: number;
|
||||
}
|
||||
const mockMeta = new Map<string, { mtimeMs: number; page: string | null }>();
|
||||
|
||||
function listMocks(): MockEntry[] {
|
||||
if (!existsSync(MOCKS_DIR)) return [];
|
||||
return readdirSync(MOCKS_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".html"))
|
||||
.map((d) => {
|
||||
const path = join(MOCKS_DIR, d.name);
|
||||
const mtimeMs = statSync(path).mtimeMs;
|
||||
let meta = mockMeta.get(d.name);
|
||||
if (!meta || meta.mtimeMs !== mtimeMs) {
|
||||
const page = readFileSync(path, "utf-8").match(
|
||||
/<body\b[^>]*\bdata-page="([^"]*)"/i,
|
||||
)?.[1];
|
||||
meta = { mtimeMs, page: page || null };
|
||||
mockMeta.set(d.name, meta);
|
||||
}
|
||||
return { file: d.name, page: meta.page, mtimeMs };
|
||||
})
|
||||
.sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
// Handing the snapshot list to the page lets mock-nav.client.js take over the
|
||||
// snapshot's own /cgi-bin/luci/ links (jump to the matching mock instead of
|
||||
// falling through to the proxied router) and render the floating switcher.
|
||||
function injectMockNav(html: string, current: string): string {
|
||||
const payload = JSON.stringify({
|
||||
current,
|
||||
mocks: listMocks().map(({ file, page }) => ({ file, page })),
|
||||
}).replace(/</g, "\\u003c");
|
||||
const tags =
|
||||
`<script>window.__AURORA_MOCKS__ = ${payload};</script>\n` +
|
||||
` <script defer src="${MOCK_ROUTE}/__nav.js"></script>\n `;
|
||||
return html.includes("</head>")
|
||||
? html.replace("</head>", `${tags}</head>`)
|
||||
: html + tags;
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage, limit: number): Promise<string> {
|
||||
return new Promise((resolveBody, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > limit) {
|
||||
reject(Object.assign(new Error("body too large"), { statusCode: 413 }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function relTime(mtimeMs: number): string {
|
||||
const mins = Math.round((Date.now() - mtimeMs) / 60000);
|
||||
if (mins < 60) return `${Math.max(mins, 0)}m`;
|
||||
if (mins < 60 * 24) return `${Math.round(mins / 60)}h`;
|
||||
return `${Math.round(mins / (60 * 24))}d`;
|
||||
}
|
||||
|
||||
function renderMockIndex(entries: MockEntry[]): string {
|
||||
const rows = entries.length
|
||||
? entries
|
||||
.map((e) => {
|
||||
const meta = [e.page ?? "no data-page", relTime(e.mtimeMs)]
|
||||
.map(escapeHtml)
|
||||
.join(" · ");
|
||||
return (
|
||||
`<li><a href="${MOCK_ROUTE}/${encodeURIComponent(e.file)}">` +
|
||||
`${escapeHtml(e.file)}<span class="meta">${meta}</span></a></li>`
|
||||
);
|
||||
})
|
||||
.join("\n ")
|
||||
: `<li class="empty">No snapshots yet — open a page through the dev proxy and press <code>Alt/Option+Shift+S</code>, or drop a page's HTML into <code>.dev/mocks/</code></li>`;
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Aurora mock pages</title>
|
||||
<style>
|
||||
body{font:15px/1.6 system-ui,-apple-system,sans-serif;max-width:680px;margin:48px auto;padding:0 20px;color:#1a1a1a}
|
||||
h1{font-size:20px;margin:0 0 4px}
|
||||
p.sub{color:#888;font-size:13px;margin:0 0 24px}
|
||||
ul{list-style:none;padding:0;margin:0}
|
||||
li{margin:8px 0}
|
||||
li a{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:8px 14px;border:1px solid #d0d0d0;border-radius:8px;text-decoration:none;color:#0a7d4b;font-weight:500}
|
||||
li a .meta{color:#888;font-size:12px;font-weight:400;white-space:nowrap}
|
||||
li a:hover{background:#f5f5f5}
|
||||
li.empty{color:#888}
|
||||
code{background:#f0f0f0;padding:1px 6px;border-radius:4px;font-size:13px}
|
||||
@media(prefers-color-scheme:dark){body{background:#111;color:#eee}li a{border-color:#333;color:#4ade80}li a:hover{background:#1c1c1c}code{background:#222}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Aurora mock pages</h1>
|
||||
<p class="sub">Saved snapshots served against the live theme. Edit theme CSS/JS → the open page hot-reloads; links between captured pages navigate in place.</p>
|
||||
<ul>
|
||||
${rows}
|
||||
</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function createMockPlugin(): Plugin {
|
||||
const sendJson = (res: ServerResponse, status: number, body: unknown) => {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
// Each missing mock asset gets one terminal hint, not one per reload.
|
||||
const mockMissLogged = new Set<string>();
|
||||
|
||||
return {
|
||||
name: "mock-pages-plugin",
|
||||
apply: "serve",
|
||||
enforce: "pre",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (!req.url) return next();
|
||||
const [pathname] = req.url.split("?");
|
||||
const inMocks =
|
||||
pathname === MOCK_ROUTE || pathname.startsWith(`${MOCK_ROUTE}/`);
|
||||
if (!inMocks && !pathname.startsWith("/luci-static/")) return next();
|
||||
|
||||
// Every branch answers inside this try: a stray request (malformed
|
||||
// %-encoding, a directory named *.html, …) must degrade to an error
|
||||
// response — thrown, it becomes an unhandled rejection and takes the
|
||||
// whole dev server down with it.
|
||||
try {
|
||||
// Dev-helper client scripts (kept as real files for editability).
|
||||
if (
|
||||
pathname === `${MOCK_ROUTE}/__nav.js` ||
|
||||
pathname === `${MOCK_ROUTE}/__capture.js`
|
||||
) {
|
||||
const file = pathname.endsWith("/__nav.js")
|
||||
? MOCK_NAV_CLIENT
|
||||
: MOCK_CAPTURE_CLIENT;
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.end(await readFile(file, "utf-8"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture endpoint, written to by mock-capture.client.js from
|
||||
// proxied device pages. The custom header doubles as a CORS gate: a
|
||||
// foreign origin can't send it without a preflight this server never
|
||||
// approves, so drive-by cross-site POSTs are rejected.
|
||||
if (pathname === `${MOCK_ROUTE}/__save`) {
|
||||
if (req.method !== "POST")
|
||||
return sendJson(res, 405, { error: "POST only" });
|
||||
if (req.headers["x-aurora-capture"] !== "1")
|
||||
return sendJson(res, 403, { error: "missing capture header" });
|
||||
const html = (await readRequestBody(req, MOCK_SAVE_LIMIT))
|
||||
// The live DOM carries the dev-only tags this server injected;
|
||||
// a snapshot must stay a clean capture of the real page.
|
||||
.replace(
|
||||
/<script[^>]*\bsrc="(?:\/@vite\/client|\/mocks\/__capture\.js)"[^>]*>\s*<\/script>\s*/gi,
|
||||
"",
|
||||
);
|
||||
const page =
|
||||
html.match(/<body\b[^>]*\bdata-page="([^"]*)"/i)?.[1] ?? "";
|
||||
const safe = page.replace(/[^\w.-]/g, "-").replace(/^\.+/, "");
|
||||
const name = `${safe.slice(0, 120) || `snapshot-${Date.now()}`}.html`;
|
||||
await mkdir(MOCKS_DIR, { recursive: true });
|
||||
await writeFile(
|
||||
join(MOCKS_DIR, name),
|
||||
ensureDoctype(html),
|
||||
"utf-8",
|
||||
);
|
||||
console.log(
|
||||
`${tag("Mocks")} captured ${name}${page ? ` (${page})` : ""}`,
|
||||
);
|
||||
return sendJson(res, 200, { file: name, page: page || null });
|
||||
}
|
||||
|
||||
// Index of available snapshots.
|
||||
if (pathname === MOCK_ROUTE || pathname === `${MOCK_ROUTE}/`) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.end(renderMockIndex(listMocks()));
|
||||
return;
|
||||
}
|
||||
|
||||
// A single snapshot: LuCI runtime neutralised, HMR client + in-mock
|
||||
// navigation injected, doctype restored.
|
||||
if (inMocks && pathname.endsWith(".html")) {
|
||||
const name = basename(decodeURIComponent(pathname));
|
||||
const file = resolve(MOCKS_DIR, name);
|
||||
if (
|
||||
!file.startsWith(MOCKS_DIR + sep) ||
|
||||
!existsSync(file) ||
|
||||
!statSync(file).isFile()
|
||||
) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
`<!doctype html><meta charset="utf-8"><p>Mock not found: ${escapeHtml(name)} — <a href="${MOCK_ROUTE}/">back to index</a>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const snapshot = await readFile(file, "utf-8");
|
||||
res.end(
|
||||
ensureDoctype(
|
||||
injectMockNav(
|
||||
injectHmrClient(neutralizeLuciRuntime(snapshot)),
|
||||
name,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Third-party static drop-ins the snapshot references (the app's own
|
||||
// css/js) — reached only when local-serve didn't already claim the
|
||||
// path.
|
||||
if (pathname.startsWith("/luci-static/")) {
|
||||
const file = resolve(
|
||||
MOCKS_STATIC_DIR,
|
||||
decodeURIComponent(pathname.slice(1)),
|
||||
);
|
||||
if (
|
||||
file.startsWith(MOCKS_STATIC_DIR + sep) &&
|
||||
existsSync(file) &&
|
||||
statSync(file).isFile()
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", mockContentType(file));
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.end(await readFile(file));
|
||||
return;
|
||||
}
|
||||
// A miss requested BY a mock page must fail fast, not fall
|
||||
// through to the device proxy: with no reachable router the
|
||||
// request just hangs, pinning the tab's load event for minutes
|
||||
// (device-only assets like a custom logo hit this). Real proxied
|
||||
// pages keep their normal fallthrough.
|
||||
const referer = req.headers.referer ?? "";
|
||||
if (
|
||||
new URL(referer, "http://_").pathname.startsWith(`${MOCK_ROUTE}/`)
|
||||
) {
|
||||
if (!mockMissLogged.has(pathname)) {
|
||||
mockMissLogged.add(pathname);
|
||||
console.log(
|
||||
`${tag("Mocks")} miss ${pathname} → 404 (mirror it at .dev/mocks/static${pathname} to serve it)`,
|
||||
);
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Whatever else reaches here on /luci-static/ is destined for the
|
||||
// device proxy. Static files answer in milliseconds on a real
|
||||
// router, so bound the wait: CSS-initiated requests (nav icons)
|
||||
// carry the stylesheet's URL as referer and dodge the mock 404
|
||||
// above — with no reachable device they would otherwise pin the
|
||||
// tab's load event for minutes. Vite's proxy error handler
|
||||
// tolerates an already-ended response, and /cgi-bin is untouched
|
||||
// (dynamic endpoints can be legitimately slow).
|
||||
const deadline = setTimeout(() => {
|
||||
if (!res.headersSent && res.writable) {
|
||||
console.log(
|
||||
`${tag("Proxy")} ${pathname} → 504 after 5s (router unreachable?)`,
|
||||
);
|
||||
res.statusCode = 504;
|
||||
res.end();
|
||||
}
|
||||
}, 5000);
|
||||
res.on("close", () => clearTimeout(deadline));
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err: any) {
|
||||
const status =
|
||||
err?.statusCode ?? (err instanceof URIError ? 400 : 500);
|
||||
console.error(`${tag("Mocks")} ${req.url}: ${err?.message ?? err}`);
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
if (pathname === `${MOCK_ROUTE}/__save`) {
|
||||
sendJson(res, status, { error: String(err?.message ?? err) });
|
||||
} else {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(status === 400 ? "Bad request" : "Mock route error");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const UT_TEMPLATE_DIR = resolve(PROJECT_ROOT, "ucode/template/themes/aurora");
|
||||
const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora";
|
||||
|
||||
@@ -371,6 +786,7 @@ export default defineConfig(({ mode }) => {
|
||||
tailwindcss(),
|
||||
createRedirectPlugin(),
|
||||
createLocalServePlugin(),
|
||||
createMockPlugin(),
|
||||
createUtSyncPlugin(OPENWRT_SSH_HOST),
|
||||
createLuciJsCompressPlugin(),
|
||||
],
|
||||
@@ -445,6 +861,16 @@ export default defineConfig(({ mode }) => {
|
||||
) {
|
||||
html = html.replace("</head>", `${client}\n\t</head>`);
|
||||
}
|
||||
// Real device pages also get the mock-capture helper:
|
||||
// Alt/Option+Shift+S → POST /mocks/__save stores the open
|
||||
// page as a snapshot for the /mocks/ workflow.
|
||||
const capture = `<script defer src="${MOCK_ROUTE}/__capture.js"></script>`;
|
||||
if (
|
||||
html.includes("</head>") &&
|
||||
!html.includes("/__capture.js")
|
||||
) {
|
||||
html = html.replace("</head>", `${capture}\n\t</head>`);
|
||||
}
|
||||
const { "transfer-encoding": _, ...headers } = proxyRes.headers;
|
||||
res.writeHead(status, {
|
||||
...headers,
|
||||
|
||||
@@ -17,3 +17,6 @@ docs/superpowers/
|
||||
|
||||
# On-device perf baselines — record device model and LAN address; keep local
|
||||
/.claude/skills/aurora-performance/baselines/
|
||||
|
||||
# Local page snapshots for the dev /mocks/ route — see .dev/docs/DEVELOPMENT.md
|
||||
/.dev/mocks/
|
||||
@@ -9,7 +9,7 @@ LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CS
|
||||
LUCI_DEPENDS:=+luci-base
|
||||
|
||||
PKG_VERSION:=1.1.2
|
||||
PKG_RELEASE:=54
|
||||
PKG_RELEASE:=55
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
LUCI_MINIFY_CSS:=
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! 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;--tw-leading:initial;--tw-content:"";--tw-font-weight:initial;--tw-duration:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}[data-page^=admin-modem-qmodem] .copyright-section{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline);background-color:var(--surface-sunken);text-align:center!important;color:var(--text-muted)!important}[data-page^=admin-modem-qmodem] fieldset.cbi-section>legend{float:left;margin-bottom:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--hairline);width:100%;padding-inline:0;padding-bottom:calc(var(--spacing) * 3);--tw-leading:var(--leading-tight,1.25);line-height:var(--leading-tight,1.25);color:var(--text)}[data-page^=admin-modem-qmodem] fieldset.cbi-section{display:flow-root}[data-page^=admin-modem-qmodem] fieldset.cbi-section>:not(legend){clear:both}[data-page^=admin-modem-qmodem] .cbi-section-table-titles.named:before{content:var(--tw-content);border-top-left-radius:calc(var(--radius-base) * 2);content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);background-color:var(--surface-sunken);border-bottom-style:var(--tw-border-style)!important;border-bottom-width:1px!important;border-color:var(--hairline)!important}[data-page^=admin-modem-qmodem-sms-conversation] #sms-messages-area{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline)!important;background-color:var(--surface-sunken)!important;color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-bubble{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline)!important;background-color:var(--surface-overlay)!important;color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-content{color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-meta{color:var(--text-muted)!important}[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li{cursor:pointer;border-radius:calc(var(--radius-base) * 4);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);text-align:center;font-size:var(--text-sm,.875rem);line-height:var(--tw-leading,var(--text-sm--line-height,calc(1.25 / .875)));--tw-font-weight:var(--font-weight-medium,500);font-weight:var(--font-weight-medium,500);white-space:nowrap;color:var(--text-muted);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s));--tw-duration:.15s;transition-duration:.15s}@media (hover:hover){[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li:hover{background-color:var(--hover-faint);color:var(--text)}}[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li.cbi-tab{background-color:var(--text);--tw-font-weight:var(--font-weight-semibold,600);font-weight:var(--font-weight-semibold,600);color:var(--surface);--tw-shadow:var(--app-shadow-sm);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}[data-page=admin-modem-qmodem-sms] .td>div{margin-top:calc(var(--spacing) * 1.5)!important;font-size:var(--text-xs,.75rem)!important;line-height:var(--tw-leading,var(--text-xs--line-height,calc(1 / .75)))!important;--tw-leading:var(--leading-relaxed,1.625)!important;line-height:var(--leading-relaxed,1.625)!important;color:var(--text-muted)!important}[data-page=admin-modem-qmodem-sms] .td>span[style*=margin-left]{color:var(--text);margin-left:calc(var(--spacing) * 2)!important}[data-page=admin-modem-qmodem-sms] .sms-conversations{margin-top:calc(var(--spacing) * 5);margin-bottom:0}[data-page=admin-modem-qmodem-sms] .sms-conversations>.cbi-section{border-style:var(--tw-border-style)!important;--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important;background-color:#0000!important;border-width:0!important;margin:0!important;padding:0!important}[data-page=admin-modem-qmodem-sms] .sms-conversations fieldset.cbi-section{margin-bottom:0}[data-page=admin-modem-qmodem-sms] .sms-conversations .table.cbi-Line truncated
|
||||
Reference in new issue
Block a user