🏅 Sync 2026-07-25 20:22:21

This commit is contained in:
github-actions[bot] committed 2026-07-25 20:22:21 +08:00
1 parent 9c077adbc3
commit db661eb60a
19 files changed
+1051 -118

No files matched your search

+20 -1
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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" {
+427 -1
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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,
+3
View File
@@ -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/
+1 -1
View File
@@ -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