diff --git a/AGENTS.md b/AGENTS.md
index 0cce7035..a020ed87 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,6 +78,8 @@ Use focused tests while iterating. Before handing off a code change, run the ful
`pnpm dev` starts Core and can attach to local QQ processes; use it only when runtime testing is intended. `pnpm dev:web` starts the frontend, which expects a WebUI backend. Follow the existing test fakes for isolated work rather than starting a live account unnecessarily.
+WebUI deployment targets use a reversible presentation overlay. Read [the host appearance contract](packages/webui/INTEGRATIONS.md) before changing it. Keep target validation in `@snowluma/common/webui-target`, implementations in the frontend target registry, and saved preferences separate from effective appearance. Default deployments must not load target assets or host listeners; do not put target-name branches in business pages.
+
## Implementation rules
- **Fix the cause.** Reproduce failures at the owning layer and add a regression test for the observable behavior. Avoid account-, filename-, or example-specific patches.
diff --git a/packages/common/package.json b/packages/common/package.json
index 6f8d87d9..8f99da4a 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -8,6 +8,10 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
+ "./webui-target": {
+ "types": "./src/webui-target.ts",
+ "default": "./src/webui-target.ts"
+ },
"./update-source": {
"types": "./src/update-source.ts",
"default": "./src/update-source.ts"
diff --git a/packages/common/src/webui-target.ts b/packages/common/src/webui-target.ts
new file mode 100644
index 00000000..1c2ab81c
--- /dev/null
+++ b/packages/common/src/webui-target.ts
@@ -0,0 +1,100 @@
+/** Browser-safe contract shared by startup, the WebUI and embedding hosts.
+ * Targets are deployment choices; they never belong in the saved UI preferences. */
+export const WEBUI_TARGETS = {
+ maibot: {
+ label: '麦麦 / 一键包',
+ styles: ['millennium'],
+ defaultStyle: 'millennium',
+ },
+} as const;
+
+export type WebuiTarget = keyof typeof WEBUI_TARGETS;
+export type WebuiColorMode = 'light' | 'dark' | 'system';
+
+export type WebuiPresentation = {
+ [T in WebuiTarget]: {
+ version: 1;
+ target: T;
+ style: (typeof WEBUI_TARGETS)[T]['styles'][number];
+ mode: WebuiColorMode;
+ };
+}[WebuiTarget];
+
+/** null releases the override for this document, without changing deployment opt-in. */
+export type WebuiPresentationUpdate = WebuiPresentation | { version: 1; target: null };
+export const WEBUI_PRESENTATION_EVENT = 'snowluma:host-appearance';
+export const WEBUI_TARGET_BOOTSTRAP_ID = 'snowluma-webui-target';
+
+export function resolveWebuiTarget(raw: string | undefined): WebuiTarget | null {
+ if (raw === undefined || raw.trim() === '') {
+ return null;
+ }
+
+ const value = raw.trim();
+
+ if (!Object.hasOwn(WEBUI_TARGETS, value)) {
+ throw new Error(
+ `Invalid SNOWLUMA_WEBUI_TARGET: ${JSON.stringify(value)}. Expected ${Object.keys(WEBUI_TARGETS).join(', ')} or an empty value.`,
+ );
+ }
+
+ return value as WebuiTarget;
+}
+
+export function defaultWebuiPresentation(target: WebuiTarget): WebuiPresentation {
+ return { version: 1, target, style: WEBUI_TARGETS[target].defaultStyle, mode: 'system' };
+}
+
+export function parseWebuiPresentation(value: unknown): WebuiPresentationUpdate {
+ if (
+ !value ||
+ typeof value !== 'object' ||
+ !('version' in value) ||
+ value.version !== 1 ||
+ !('target' in value)
+ ) {
+ throw new TypeError('Invalid host appearance: expected a version 1 presentation');
+ }
+
+ if (value.target === null) {
+ return { version: 1, target: null };
+ }
+
+ if (typeof value.target !== 'string' || !Object.hasOwn(WEBUI_TARGETS, value.target)) {
+ throw new TypeError('Invalid host appearance target');
+ }
+
+ const target = value.target as WebuiTarget;
+ const styles: readonly string[] = WEBUI_TARGETS[target].styles;
+
+ if (!('style' in value) || typeof value.style !== 'string' || !styles.includes(value.style)) {
+ throw new TypeError(`Invalid host appearance style for ${target}`);
+ }
+
+ if (
+ !('mode' in value) ||
+ (value.mode !== 'light' && value.mode !== 'dark' && value.mode !== 'system')
+ ) {
+ throw new TypeError('Invalid host appearance color mode');
+ }
+
+ // Both discriminants have been checked against the same target registry.
+ return { version: 1, target, style: value.style, mode: value.mode } as WebuiPresentation;
+}
+
+/** Inject before the cached pre-paint theme script. Default HTML stays byte-for-byte intact. */
+export function injectWebuiTarget(html: string, target: WebuiTarget | null): string {
+ if (target === null) {
+ return html;
+ }
+
+ if (!html.includes('
')) {
+ throw new Error('WebUI HTML is missing ; cannot inject runtime target');
+ }
+
+ const json = JSON.stringify(defaultWebuiPresentation(target)).replaceAll('<', '\\u003c');
+ return html.replace(
+ '',
+ `\n `,
+ );
+}
diff --git a/packages/common/tests/webui-target.test.ts b/packages/common/tests/webui-target.test.ts
new file mode 100644
index 00000000..67578702
--- /dev/null
+++ b/packages/common/tests/webui-target.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import {
+ defaultWebuiPresentation,
+ injectWebuiTarget,
+ parseWebuiPresentation,
+ resolveWebuiTarget,
+} from '../src/webui-target';
+
+describe('deployment presentation contract', () => {
+ it('requires explicit opt-in and rejects misspelled deployments', () => {
+ expect(resolveWebuiTarget(undefined)).toBeNull();
+ expect(resolveWebuiTarget(' ')).toBeNull();
+ expect(resolveWebuiTarget('maibot')).toBe('maibot');
+ expect(() => resolveWebuiTarget('mai-bot')).toThrow('SNOWLUMA_WEBUI_TARGET');
+ expect(() => resolveWebuiTarget('__proto__')).toThrow('SNOWLUMA_WEBUI_TARGET');
+ });
+
+ it('keeps the unconfigured document intact and injects before cached appearance', () => {
+ const html = '';
+ expect(injectWebuiTarget(html, null)).toBe(html);
+ const injected = injectWebuiTarget(html, 'maibot');
+ expect(injected.indexOf('snowluma-webui-target')).toBeLessThan(
+ injected.indexOf('readSavedAppearance'),
+ );
+ expect(injected).toContain(JSON.stringify(defaultWebuiPresentation('maibot')));
+ expect(() => injectWebuiTarget('broken html', 'maibot')).toThrow('missing ');
+ });
+
+ it('validates the version, target, style and color mode without accepting arbitrary CSS', () => {
+ const valid = defaultWebuiPresentation('maibot');
+ expect(parseWebuiPresentation(valid)).toEqual(valid);
+ expect(
+ parseWebuiPresentation({ ...valid, mode: 'dark', css: 'body{display:none}' }),
+ ).toEqual({ ...valid, mode: 'dark' });
+ expect(parseWebuiPresentation({ version: 1, target: null })).toEqual({
+ version: 1,
+ target: null,
+ });
+
+ for (const input of [
+ null,
+ {},
+ { ...valid, version: 2 },
+ { ...valid, target: 'other' },
+ { ...valid, style: 'modern' },
+ { ...valid, mode: 'blue' },
+ ]) {
+ expect(() => parseWebuiPresentation(input)).toThrow();
+ }
+ });
+});
diff --git a/packages/core/src/webui/server.ts b/packages/core/src/webui/server.ts
index 5fd9eb74..c3bc43a8 100644
--- a/packages/core/src/webui/server.ts
+++ b/packages/core/src/webui/server.ts
@@ -1,6 +1,7 @@
import { APP_VERSION } from '@snowluma/common/version';
import { serve } from '@hono/node-server';
-import { serveStatic } from '@hono/node-server/serve-static';
+import { resolveWebuiTarget } from '@snowluma/common/webui-target';
+import { registerStaticFrontend } from './static-frontend';
import type { HookManager, HookProcessInfo } from '@snowluma/bridge';
import { getRuntimeIntegrity, hasLowProcessIntegrity } from '@snowluma/bridge';
import {
@@ -636,6 +637,15 @@ export async function initWebUI(
updateRoot?: string;
} = {},
): Promise<{ port: number; stop: () => Promise }> {
+ const webuiTarget = resolveWebuiTarget(process.env.SNOWLUMA_WEBUI_TARGET);
+
+ if (webuiTarget) {
+ log.info(
+ 'presentation target enabled: %s (runtime override; saved appearance is preserved)',
+ webuiTarget,
+ );
+ }
+
const host = listener.host || '127.0.0.1';
const tlsEnabled = listener.tlsEnabled === true;
const bootCheck = checkListenerExposure({
@@ -2267,29 +2277,7 @@ export async function initWebUI(
// Build path is relative to the bundled / dev __dirname. SPA fallback to
// index.html so client-side routes (if any) keep working.
const staticRoot = path.resolve(__dirname, 'client');
- app.use('/*', serveStatic({ root: staticRoot }));
-
- const indexHtmlPath = path.join(staticRoot, 'index.html');
- app.get('*', (c) => {
- // SPA fallback: only for navigations that didn't hit a static asset.
- if (
- c.req.path.startsWith('/api/') ||
- c.req.path.startsWith('/avatar/') ||
- c.req.path.startsWith('/ui-asset/')
- ) {
- return c.text('not found', 404);
- }
-
- if (existsSync(indexHtmlPath)) {
- const html = readFileSync(indexHtmlPath, 'utf8');
- return c.html(html);
- }
-
- return c.text(
- 'WebUI client bundle not found. Run `pnpm --filter webui build` (or use the dev server on :5178).',
- 404,
- );
- });
+ registerStaticFrontend(app, staticRoot, webuiTarget);
const finalPort = await findAvailablePort(desiredPort, { host });
diff --git a/packages/core/src/webui/static-frontend.ts b/packages/core/src/webui/static-frontend.ts
new file mode 100644
index 00000000..da64f2bd
--- /dev/null
+++ b/packages/core/src/webui/static-frontend.ts
@@ -0,0 +1,34 @@
+import { existsSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import type { Hono, Context } from 'hono';
+import { serveStatic } from '@hono/node-server/serve-static';
+import { injectWebuiTarget, type WebuiTarget } from '@snowluma/common/webui-target';
+
+/** All document entry paths share runtime injection, including direct index.html requests. */
+export function registerStaticFrontend(app: Hono, root: string, target: WebuiTarget | null): void {
+ const indexPath = path.join(root, 'index.html');
+
+ const document = (c: Context) => {
+ if (!existsSync(indexPath)) {
+ return c.text(
+ 'WebUI client bundle not found. Run `pnpm --filter webui build` (or use the dev server on :5178).',
+ 404,
+ );
+ }
+
+ // A packaged frontend can be reused with a different deployment target on restart.
+ c.header('Cache-Control', 'no-store');
+ return c.html(injectWebuiTarget(readFileSync(indexPath, 'utf8'), target));
+ };
+
+ app.get('/', document);
+ app.get('/index.html', document);
+ app.use('/*', serveStatic({ root }));
+ app.get('*', (c) => {
+ if (['/api/', '/avatar/', '/ui-asset/'].some((prefix) => c.req.path.startsWith(prefix))) {
+ return c.text('not found', 404);
+ }
+
+ return document(c);
+ });
+}
diff --git a/packages/core/tests/webui-static-frontend.test.ts b/packages/core/tests/webui-static-frontend.test.ts
new file mode 100644
index 00000000..79fc522f
--- /dev/null
+++ b/packages/core/tests/webui-static-frontend.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { Hono } from 'hono';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { registerStaticFrontend } from '../src/webui/static-frontend';
+
+let root: string;
+const html = 'SnowLumaapp';
+
+beforeEach(() => {
+ root = mkdtempSync(path.join(tmpdir(), 'snowluma-presentation-'));
+ writeFileSync(path.join(root, 'index.html'), html);
+ writeFileSync(path.join(root, 'asset.js'), 'window.asset = true;');
+});
+
+afterEach(() => rmSync(root, { recursive: true, force: true }));
+
+describe('runtime target document delivery', () => {
+ it.each(['/', '/index.html', '/settings?tab=appearance'])(
+ 'injects on %s before authentication',
+ async (url) => {
+ const app = new Hono();
+ registerStaticFrontend(app, root, 'maibot');
+ const response = await app.request(url);
+ expect(response.status).toBe(200);
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ expect(await response.text()).toContain('"target":"maibot"');
+ },
+ );
+
+ it('reuses the exact same built frontend for an unconfigured deployment', async () => {
+ const app = new Hono();
+ registerStaticFrontend(app, root, null);
+ expect(await (await app.request('/index.html')).text()).toBe(html);
+ });
+
+ it('does not inject into assets or API error responses', async () => {
+ const app = new Hono();
+ registerStaticFrontend(app, root, 'maibot');
+ expect(await (await app.request('/asset.js')).text()).toBe('window.asset = true;');
+
+ for (const url of ['/api/missing', '/avatar/missing', '/ui-asset/missing']) {
+ expect((await app.request(url)).status).toBe(404);
+ }
+ });
+});
diff --git a/packages/webui/INTEGRATIONS.md b/packages/webui/INTEGRATIONS.md
new file mode 100644
index 00000000..cb11cf85
--- /dev/null
+++ b/packages/webui/INTEGRATIONS.md
@@ -0,0 +1,111 @@
+# Host appearance overlays
+
+Set `SNOWLUMA_WEBUI_TARGET=maibot` on the **SnowLuma process** to use MaiBot's
+millennium appearance. The same release bundle supports standalone and embedded
+deployments. An unset or empty value keeps the existing SnowLuma experience;
+unknown values fail WebUI startup with an explicit diagnostic.
+
+This first adapter implements **millennium only**, in light and dark modes.
+Other MaiBot styles are not implemented. There is no new appearance selector in
+SnowLuma. The host owns theme changes; existing theme controls are unavailable
+while managed, and the settings page explains which preferences are preserved.
+
+## Runtime and ownership
+
+1. Core reads the environment at startup. [Static document delivery](../core/src/webui/static-frontend.ts)
+ injects a validated, public JSON bootstrap into `/`, `/index.html` and SPA
+ fallback documents. Documents use `Cache-Control: no-store`. No secrets or
+ arbitrary environment variables reach the browser.
+2. [Frontend bootstrap](src/appearance/runtime.ts) loads only the selected
+ target's assets before mounting React. Standalone deployments load no target
+ stylesheet, font or host listener. Vite development serves the same bootstrap
+ when its process has the environment variable; production builds remain neutral.
+3. [ThemeContext](src/contexts/ThemeContext.tsx) keeps `savedAppearance` separate
+ from effective `appearance`. Only saved preferences enter localStorage or
+ `config/ui.json`. Target updates never invoke the persistence API.
+4. The target owns colors, fonts, radius, wallpaper, CSS variable overrides,
+ custom CSS and custom cursor presentation. These saved values are preserved
+ but suspended. UI scale, density, motion/accessibility, navigation layout and
+ data preferences remain available. Safe-mode restrictions still apply when
+ returning to native appearance.
+5. A single layout effect applies effective appearance. Changes do not reload
+ the document, change React keys or restart sessions. All target rules are
+ scoped to `html[data-webui-target]`, including portals under `body`.
+
+## Host protocol, version 1
+
+The contract lives in [common/webui-target.ts](../common/src/webui-target.ts).
+The deployment environment is an opt-in, **not a live transport**. Existing
+launchers need to implement the sender below; merely setting the environment
+variable cannot convey another process's current theme.
+
+A same-document embedding bridge (for example Electron `executeJavaScript`)
+publishes the latest snapshot before dispatching the event:
+
+```js
+const appearance = {
+ version: 1,
+ target: 'maibot',
+ style: 'millennium',
+ mode: 'dark', // 'light' | 'dark' | 'system'
+};
+window.__SNOWLUMA_HOST_APPEARANCE__ = appearance;
+window.dispatchEvent(new CustomEvent('snowluma:host-appearance', { detail: appearance }));
+```
+
+The snapshot handles messages sent before React or the lazy asset loader is
+ready. Publish on each guest `dom-ready` and whenever the host's resolved
+brightness changes. A host that already follows MaiBot should forward its own
+resolved `light`/`dark` state; this avoids competing sources of truth. Validate
+the guest's expected origin before injecting, serialize values with
+`JSON.stringify`, and do not enable Node integration in the guest.
+
+Send `{ version: 1, target: null }` using the same mechanism to release the
+override for the current document. The original appearance returns immediately.
+The opted-in document keeps its host subscription so the host can reapply the
+target later. Listeners are released on provider unmount. This does not change
+the startup environment or enable a target in a standalone deployment.
+
+The current adapter starts with millennium and system brightness before a host
+snapshot arrives. The last valid snapshot remains active if the host stops
+sending. Snapshots are not persisted or shared across origins/tabs. A reload
+requires the host to publish again. Unsupported versions, targets, styles or
+modes are rejected with `[SnowLuma appearance]` console diagnostics; the previous
+valid snapshot remains. The host cannot supply CSS, URLs or executable content
+through this protocol. No unauthenticated HTTP write endpoint is introduced.
+
+## Adding a target
+
+- Add its identifier, styles and default style to `WEBUI_TARGETS` in the shared
+ contract. Keep validation at this boundary; do not accept arbitrary strings.
+- Add one lazy loader to the exhaustive [frontend registry](src/appearance/registry.ts).
+ Implement a pure saved-to-effective resolver and scoped stylesheet under
+ `src/appearance/targets`. Keep any fonts local with their redistribution license.
+- Use semantic color variables, shared component classes and neutral
+ `data-ui-surface` markers. Do not add target-name checks to business pages.
+- Never persist resolved overrides, mutate saved preferences, introduce a
+ second DOM theme writer, or remount the application to switch presentation.
+- Test default asset/listener isolation, initial document routes, malformed
+ messages, rapid updates, cleanup and exact restoration of saved preferences.
+ Verify forms, focus, scroll, portals and mobile layout with an isolated backend.
+ Electron integration needs a separate real-host check.
+
+## Sources and checks
+
+The adapter follows the main MaiBot repository's
+[millennium stylesheet](https://github.com/Mai-with-u/MaiBot/blob/5c7637e6913c84d1c3d5e742fa9e95a7e70d3501/dashboard/src/styles/millennium.css),
+not the archived standalone WebUI. Colors/materials also match the
+[OneKey shell](https://github.com/Mai-with-u/MaiBotOneKey/blob/e8073b0bce35c8c4da973b06156602e14c42fee5/src/renderer/src/styles/millennium.css).
+The bundled Jersey 20 font is from that MaiBot revision, copyright The Soft Type
+Project Authors, under the [SIL Open Font License](src/appearance/targets/assets/Jersey20-OFL.txt).
+Its license is emitted alongside the font in production assets.
+
+Focused checks (from the repository root):
+
+```sh
+pnpm --filter @snowluma/common exec vitest run tests/webui-target.test.ts
+pnpm --filter @snowluma/core exec vitest run tests/webui-static-frontend.test.ts
+pnpm --filter webui exec vitest run tests/target-appearance.test.ts
+```
+
+Run the normal workspace checks from `AGENTS.md` before handing off changes.
diff --git a/packages/webui/README.md b/packages/webui/README.md
index c6b7c9ef..d84dd902 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -3,6 +3,14 @@
SnowLuma's browser console is a Vite + React application for managing runtime
status, OneBot configuration, logs, storage, and server settings.
+## Host appearance integration
+
+`SNOWLUMA_WEBUI_TARGET=maibot` opts a deployment into a reversible millennium
+appearance overlay. Standalone defaults are unchanged. Hosts drive live light/dark
+updates without saving over personal preferences or adding another selector.
+See [the integration contract and extension guide](INTEGRATIONS.md) before changing
+target startup, theme ownership, host messaging or scoped styles.
+
## Interaction conventions
- Destructive actions and second confirmations stay in modal dialogs.
diff --git a/packages/webui/index.html b/packages/webui/index.html
index 983febae..80cc5386 100644
--- a/packages/webui/index.html
+++ b/packages/webui/index.html
@@ -17,6 +17,9 @@