feat(webui): add opt-in MaiBot appearance overlay

This commit is contained in:
DrSmoothl
2026-10-21 13:07:44 +08:00
parent 28f64113e7
commit 5cd4ae61dd
29 changed files with 1424 additions and 315 deletions
+2
View File
@@ -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.
+4
View File
@@ -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"
+100
View File
@@ -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('<head>')) {
throw new Error('WebUI HTML is missing <head>; cannot inject runtime target');
}
const json = JSON.stringify(defaultWebuiPresentation(target)).replaceAll('<', '\\u003c');
return html.replace(
'<head>',
`<head>\n <script id="${WEBUI_TARGET_BOOTSTRAP_ID}" type="application/json">${json}</script>`,
);
}
@@ -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 = '<html><head><script>readSavedAppearance()</script></head></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 <head>');
});
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();
}
});
});
+12 -24
View File
@@ -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<void> }> {
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 });
@@ -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);
});
}
@@ -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 = '<!doctype html><html><head><title>SnowLuma</title></head><body>app</body></html>';
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);
}
});
});
+111
View File
@@ -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.
+8
View File
@@ -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.
+3
View File
@@ -17,6 +17,9 @@
<script>
(function () {
try {
// Deployment overlays are prepared before React mounts. Do not flash
// a saved palette/font underneath an opted-in host presentation.
if (document.getElementById('snowluma-webui-target')) return;
var raw = localStorage.getItem('snowluma_ui_appearance');
var a = raw ? JSON.parse(raw) : {};
if (!a || typeof a !== 'object') a = {};
+29
View File
@@ -0,0 +1,29 @@
import type { WebuiPresentation, WebuiTarget } from '@snowluma/common/webui-target';
import type { UiAppearance } from '@/types';
export interface TargetAppearance {
css: string;
licenseUrl?: string;
resolve: (saved: UiAppearance, presentation: WebuiPresentation) => UiAppearance;
}
export function resolveAppearance(
saved: UiAppearance,
presentation: WebuiPresentation | null,
target: TargetAppearance | null,
): UiAppearance {
if (!presentation) {
return saved;
}
if (!target) {
throw new Error('Target appearance must be prepared before mounting ThemeProvider');
}
return target.resolve(saved, presentation);
}
// Exhaustive: adding a shared target requires a frontend implementation here.
export const targetLoaders = {
maibot: () => import('./targets/maibot').then((module) => module.maibotAppearance),
} satisfies Record<WebuiTarget, () => Promise<TargetAppearance>>;
+104
View File
@@ -0,0 +1,104 @@
import {
parseWebuiPresentation,
WEBUI_PRESENTATION_EVENT,
WEBUI_TARGET_BOOTSTRAP_ID,
type WebuiPresentation,
} from '@snowluma/common/webui-target';
import { createPresentationStore, type PresentationStore } from './store';
import { targetLoaders, type TargetAppearance } from './registry';
declare global {
interface Window {
/** The embedding host sets this before dispatching WEBUI_PRESENTATION_EVENT. */
__SNOWLUMA_HOST_APPEARANCE__?: unknown;
}
}
const bootstrap =
typeof document === 'undefined' ? null : document.getElementById(WEBUI_TARGET_BOOTSTRAP_ID);
const initial = bootstrap
? parseWebuiPresentation(JSON.parse(bootstrap.textContent || 'null'))
: null;
export const presentationStore = createPresentationStore(initial?.target ?? null);
let targetAppearance: TargetAppearance | null = null;
function acceptHostValue(store: PresentationStore, value: unknown): void {
try {
store.receive(value);
} catch (error) {
// Reject only the bad update; keep the last valid presentation and expose the failure.
console.error('[SnowLuma appearance] Rejected host update', error);
}
}
export function subscribeHostAppearance(host: Window, store: PresentationStore): () => void {
if (store.target === null) {
return () => {};
}
const receive = (event: Event) =>
acceptHostValue(store, (event as CustomEvent<unknown>).detail);
host.addEventListener(WEBUI_PRESENTATION_EVENT, receive);
// Covers messages sent before React subscribed, including while the lazy CSS was loading.
if (host.__SNOWLUMA_HOST_APPEARANCE__ !== undefined) {
acceptHostValue(store, host.__SNOWLUMA_HOST_APPEARANCE__);
}
return () => host.removeEventListener(WEBUI_PRESENTATION_EVENT, receive);
}
export function applyPresentationAttributes(presentation: WebuiPresentation | null): void {
const root = document.documentElement;
if (presentation) {
root.dataset.webuiTarget = presentation.target;
root.dataset.webuiStyle = presentation.style;
} else {
delete root.dataset.webuiTarget;
delete root.dataset.webuiStyle;
}
}
export function getTargetAppearance(): TargetAppearance | null {
return targetAppearance;
}
/** Called before mounting React; standalone deployments do not load any target assets. */
export async function prepareTargetAppearance(): Promise<void> {
const target = presentationStore.target;
if (target === null) {
return;
}
targetAppearance = await targetLoaders[target]();
const style = document.createElement('style');
style.id = 'snowluma-target-styles';
style.textContent = targetAppearance.css;
document.head.appendChild(style);
if (targetAppearance.licenseUrl) {
const license = document.createElement('link');
license.rel = 'license';
license.href = targetAppearance.licenseUrl;
document.head.appendChild(license);
}
if (window.__SNOWLUMA_HOST_APPEARANCE__ !== undefined) {
acceptHostValue(presentationStore, window.__SNOWLUMA_HOST_APPEARANCE__);
}
const presentation = presentationStore.getSnapshot();
applyPresentationAttributes(presentation);
if (presentation) {
const dark =
presentation.mode === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: presentation.mode === 'dark';
document.documentElement.classList.toggle('dark', dark);
document.documentElement.classList.toggle('light', !dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
}
}
+51
View File
@@ -0,0 +1,51 @@
import {
defaultWebuiPresentation,
parseWebuiPresentation,
type WebuiPresentation,
type WebuiTarget,
} from '@snowluma/common/webui-target';
/** Synchronous snapshots avoid stale async style loads winning a rapid host theme change. */
export function createPresentationStore(target: WebuiTarget | null) {
let snapshot: WebuiPresentation | null = target ? defaultWebuiPresentation(target) : null;
const listeners = new Set<() => void>();
return {
target,
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
if (target) {
listeners.add(listener);
}
return () => {
listeners.delete(listener);
};
},
receive(value: unknown) {
// Host code cannot opt a standalone deployment into a target.
if (target === null) {
return;
}
const update = parseWebuiPresentation(value);
if (update.target !== null && update.target !== target) {
throw new Error(
`Host appearance target does not match deployment target ${target}`,
);
}
const next = update.target === null ? null : update;
if (JSON.stringify(next) === JSON.stringify(snapshot)) {
return;
}
snapshot = next;
listeners.forEach((listener) => listener());
},
};
}
export type PresentationStore = ReturnType<typeof createPresentationStore>;
@@ -0,0 +1,93 @@
Copyright 2023 The Soft Type Project Authors (https://github.com/scfried/soft-type-jersey)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,233 @@
/* MaiBot millennium: plastic case, recessed screen, raised keys.
Only loaded for this target; every rule remains scoped so release is reversible. */
@font-face {
font-family: "SnowLuma Jersey 20";
src: url("./assets/Jersey20-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
size-adjust: 118%;
unicode-range: U+0000-024F;
}
html[data-webui-target="maibot"] {
--mil-shell: #ddd4bf;
--mil-panel: #ebe4d2;
--mil-hi: #f6f1e3;
--mil-lo: #bdb39c;
--mil-drop: #9c927b;
--mil-line: #5a544a;
--mil-ink: #4a443a;
--mil-print: #5f5849;
--mil-screen: #fbf9f0;
--mil-screen-ink: #3a3833;
--mil-key: #f3eee1;
--mil-key-side: #c2b8a1;
--mil-key-hi: #fffaf0;
--mil-key-hover: #fbf7ec;
--mil-on: #8fd6a0;
--mil-on-ink: #2f3a2b;
--mil-on-side: #5fa873;
--mil-accent-text: #236b43;
--background: var(--mil-shell);
--foreground: var(--mil-ink);
--card: var(--mil-panel);
--card-foreground: var(--mil-ink);
--popover: var(--mil-panel);
--popover-foreground: var(--mil-ink);
--primary: var(--mil-accent-text);
--primary-foreground: var(--mil-screen);
--secondary: var(--mil-key);
--secondary-foreground: var(--mil-ink);
--muted: var(--mil-shell);
--muted-foreground: var(--mil-print);
--accent: var(--mil-on);
--accent-foreground: var(--mil-on-ink);
--destructive: #b53724;
--destructive-foreground: #fffaf0;
--success: #2a7d50;
--warning: #8f4f18;
--log-info: #315b7c;
--log-success: #2a7d50;
--log-warn: #8f4f18;
--log-error: #b53724;
--border: var(--mil-lo);
--input: var(--mil-screen);
--ring: var(--mil-accent-text);
--sidebar: var(--mil-panel);
--sidebar-foreground: var(--mil-ink);
--sidebar-primary: var(--mil-accent-text);
--sidebar-primary-foreground: var(--mil-screen);
--sidebar-accent: var(--mil-on);
--sidebar-accent-foreground: var(--mil-on-ink);
--sidebar-border: var(--mil-lo);
--sidebar-ring: var(--mil-accent-text);
--shadow-card: inset 1px 1px var(--mil-hi), inset -1px -1px var(--mil-lo), 0 3px 0 var(--mil-drop);
--mil-key-shadow: inset 1px 1px var(--mil-key-hi), inset -1px -1px var(--mil-key-side), 0 2px 0 var(--mil-key-side);
--mil-screen-shadow: inset 1px 1px 2px var(--mil-lo), inset -1px -1px var(--mil-hi);
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
--radius-xl: 0px;
}
html[data-webui-target="maibot"].dark {
--mil-shell: #2f2c28;
--mil-panel: #3a3631;
--mil-hi: #4a453f;
--mil-lo: #2a2723;
--mil-drop: #1d1b18;
--mil-line: #171513;
--mil-ink: #ebe4d2;
--mil-print: #b5ad9a;
--mil-screen: #1e221e;
--mil-screen-ink: #e8e2cf;
--mil-key: #4d4841;
--mil-key-side: #2e2b27;
--mil-key-hi: #60594f;
--mil-key-hover: #595249;
--mil-accent-text: #8fd6a0;
--primary-foreground: #2f3a2b;
--sidebar-primary-foreground: #2f3a2b;
--border: #746c5d;
--sidebar-border: #746c5d;
--destructive: #f08a77;
--destructive-foreground: #352019;
--success: #8fd6a0;
--warning: #e5b278;
--log-info: #99bdd8;
--log-success: #8fd6a0;
--log-warn: #e5b278;
--log-error: #f08a77;
}
/* Preserve accessibility preferences above both target palettes. */
html[data-webui-target="maibot"][data-contrast="high"] {
--border: var(--mil-ink);
--sidebar-border: var(--mil-ink);
--muted-foreground: var(--mil-ink);
}
html[data-webui-target="maibot"] :is(.ui-card, [data-ui-surface="panel"], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]) {
border: 1px solid var(--mil-line);
border-radius: 0;
background: var(--mil-panel);
color: var(--mil-ink);
box-shadow: var(--shadow-card);
backdrop-filter: none;
}
html[data-webui-target="maibot"] [data-ui-surface="chrome"] {
background: var(--mil-panel);
box-shadow: inset 1px 1px var(--mil-hi), inset -1px -1px var(--mil-lo);
}
html[data-webui-target="maibot"] [data-ui-surface="canvas"] {
border-radius: 0;
box-shadow: none;
}
/* The desktop rail clips a fixed-width inner sidebar. Its separator must
belong to the animated outer edge, not the clipped inner panel. */
html[data-webui-target="maibot"] [data-ui-surface="sidebar"] {
border-inline-end: 1px solid var(--sidebar-border);
}
html[data-webui-target="maibot"] :is([data-ui-control], pre, table) {
border-radius: 0;
background: var(--mil-screen);
color: var(--mil-screen-ink);
box-shadow: var(--mil-screen-shadow);
caret-color: var(--mil-accent-text);
}
html[data-webui-target="maibot"] :is([data-ui-control], input, textarea):focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
html[data-webui-target="maibot"] :is(.ui-button, [role="tab"], .ui-nav-item) {
border: 1px solid var(--mil-line);
border-radius: 0;
background: var(--mil-key);
color: var(--mil-ink);
box-shadow: var(--mil-key-shadow);
}
html[data-webui-target="maibot"] :is(.ui-button, [role="tab"], .ui-nav-item):hover:not(:disabled) {
background: var(--mil-key-hover);
}
/* Keep the whole keycap inside the collapsed rail instead of cropping the
expanded key's right edge. Text stays mounted and retains its nowrap rules. */
html[data-webui-target="maibot"] nav[data-collapsed="true"] .ui-nav-item {
width: 3rem;
}
html[data-webui-target="maibot"] :is(.ui-button[data-variant="default"], [role="tab"][aria-selected="true"], .ui-nav-item[aria-current="page"]) {
background: var(--mil-on);
color: var(--mil-on-ink);
box-shadow: inset 1px 1px #c1ebcb, inset -1px -1px var(--mil-on-side), 0 2px 0 var(--mil-on-side);
}
html[data-webui-target="maibot"] .ui-button[data-variant="destructive"] {
background: var(--destructive);
color: var(--destructive-foreground);
}
html[data-webui-target="maibot"] .ui-button[data-variant="default"]:hover:not(:disabled) {
background: color-mix(in srgb, var(--mil-on) 85%, var(--mil-key-hi));
}
html[data-webui-target="maibot"] .ui-button[data-variant="destructive"]:hover:not(:disabled) {
background: color-mix(in srgb, var(--destructive) 90%, var(--mil-ink));
}
html[data-webui-target="maibot"] .ui-button:is([data-variant="ghost"], [data-variant="link"]) {
background: transparent;
border-color: transparent;
box-shadow: none;
}
html[data-webui-target="maibot"] :is(.ui-button, [role="tab"], .ui-nav-item):active:not(:disabled) {
box-shadow: var(--mil-screen-shadow);
scale: 1;
}
html[data-webui-target="maibot"] .ui-nav-item[aria-current="page"] :is(svg, span) {
color: var(--mil-on-ink);
}
html[data-webui-target="maibot"] .ui-nav-highlight {
display: none;
}
html[data-webui-target="maibot"] .ui-segmented {
border-radius: 0;
background: var(--mil-screen);
box-shadow: var(--mil-screen-shadow);
}
html[data-webui-target="maibot"] .ui-segmented-thumb {
border-radius: 0;
background: var(--mil-on);
box-shadow: var(--mil-key-shadow);
}
html[data-webui-target="maibot"] .ui-segmented-thumb span {
color: var(--mil-on-ink);
}
html[data-webui-target="maibot"] [data-ui-decoration] {
display: none;
}
html[data-webui-target="maibot"] ::selection {
background: var(--mil-on);
color: var(--mil-on-ink);
}
html[data-webui-target="maibot"] * {
scrollbar-color: var(--mil-print) var(--mil-shell);
}
@@ -0,0 +1,27 @@
import type { TargetAppearance } from '../registry';
import css from './maibot.css?inline';
import licenseUrl from './assets/Jersey20-OFL.txt?url';
export const maibotAppearance: TargetAppearance = {
css,
licenseUrl,
resolve: (saved, presentation) => ({
...saved,
mode: presentation.mode,
palette: 'default',
accentMode: 'preset',
accentScope: 'global',
darkIntensity: 'soft',
sidebarStyle: 'follow',
background: { ...saved.background, type: 'none' },
fontSans: 'custom',
fontSansCustom:
'"SnowLuma Jersey 20", system-ui, "PingFang SC", "Microsoft YaHei", sans-serif',
fontMono: 'default',
fontMonoCustom: '',
radius: 0,
customCss: '',
cssVars: {},
customPointerSystem: false,
}),
};
@@ -126,7 +126,7 @@ export function SegmentedControl({
<div
role="radiogroup"
aria-label={label}
className={`relative inline-block select-none rounded-[9px] border border-border bg-muted/70 p-[3px] shadow-[inset_0_1px_2px_rgb(0_0_0/0.07)] ${className}`}
className={`ui-segmented relative inline-block select-none rounded-[9px] border border-border bg-muted/70 p-[3px] shadow-[inset_0_1px_2px_rgb(0_0_0/0.07)] ${className}`}
>
<div
className="relative grid"
@@ -150,7 +150,7 @@ export function SegmentedControl({
<motion.div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 overflow-hidden rounded-[6px] bg-foreground text-background shadow-[0_1px_2px_rgb(0_0_0/0.28)]"
className="ui-segmented-thumb pointer-events-none absolute inset-y-0 left-0 overflow-hidden rounded-[6px] bg-foreground text-background shadow-[0_1px_2px_rgb(0_0_0/0.28)]"
style={{ width: `${100 / count}%`, x: thumbX }}
initial={false}
>
@@ -55,6 +55,7 @@ export function MainLayout({ status, onLogout, notice, children }: MainLayoutPro
{/* Desktop sidebar (hidden in kiosk) */}
{!isMobile && !kiosk && (
<motion.aside
data-ui-surface="sidebar"
initial={false}
animate={{ width: showCollapsed ? 64 : 248 }}
transition={
@@ -133,6 +134,7 @@ export function MainLayout({ status, onLogout, notice, children }: MainLayoutPro
Depth comes from a soft shadow + the canvas tone sitting on the
(sidebar-toned) chrome, not a border. */}
<div
data-ui-surface="canvas"
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden',
!isMobile && !kiosk && 'rounded-tl-2xl',
@@ -137,7 +137,10 @@ export function Sidebar({ collapsed = false, onItemClick }: SidebarProps) {
setNavItems(reconciled.map((i) => (i.id === id ? { ...i, visible: !i.visible } : i)));
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
<div
data-ui-surface="chrome"
className="flex h-full w-full flex-col overflow-hidden bg-sidebar text-sidebar-foreground"
>
{/* Brand — logo sits in a fixed 48px column centred in the collapsed
rail; the wordmark to its right is clipped (never unmounted) when the
rail narrows. */}
@@ -206,7 +209,7 @@ export function Sidebar({ collapsed = false, onItemClick }: SidebarProps) {
</Reorder.Group>
</div>
) : (
<nav className="flex flex-col gap-1 px-2 py-2">
<nav data-collapsed={collapsed} className="flex flex-col gap-1 px-2 py-2">
{orderedNav.map(({ to, label, icon: Icon, description }) => {
const isActive = pathname === to;
return (
@@ -217,7 +220,7 @@ export function Sidebar({ collapsed = false, onItemClick }: SidebarProps) {
onClick={onItemClick}
aria-current={isActive ? 'page' : undefined}
className={cn(
'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer outline-none',
'ui-nav-item group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer outline-none',
isActive
? 'text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-sidebar-accent/40 hover:text-foreground',
@@ -232,7 +235,7 @@ export function Sidebar({ collapsed = false, onItemClick }: SidebarProps) {
<motion.span
layoutId="sidebar-active-pill"
className={cn(
'absolute inset-y-0 rounded-lg bg-sidebar-accent',
'ui-nav-highlight absolute inset-y-0 rounded-lg bg-sidebar-accent',
collapsed ? 'left-0.5 w-11' : 'inset-x-0',
)}
transition={{
@@ -244,7 +247,7 @@ export function Sidebar({ collapsed = false, onItemClick }: SidebarProps) {
{!collapsed && (
<motion.span
layoutId="sidebar-active-bar"
className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-primary"
className="ui-nav-highlight absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-primary"
transition={{
type: 'spring',
stiffness: 380,
@@ -54,7 +54,10 @@ export function TopBar({
);
return (
<header className="z-30 flex min-h-16 shrink-0 items-center gap-[4px] bg-background px-[max(10px,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] pt-[env(safe-area-inset-top)] md:gap-2 md:px-4">
<header
data-ui-surface="chrome"
className="z-30 flex min-h-16 shrink-0 items-center gap-[4px] bg-background px-[max(10px,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] pt-[env(safe-area-inset-top)] md:gap-2 md:px-4"
>
{/* Mobile-only menu trigger. On desktop there's no collapse button — the
sidebar auto-expands on hover/focus, and its boundary with the content
is a soft surface-tone shift, not a hard border. */}
@@ -353,6 +353,7 @@ function LoginShell({
wide?: boolean;
children: ReactNode;
}) {
const { managedBy } = useTheme();
return (
<div
className={cn(
@@ -360,9 +361,10 @@ function LoginShell({
customBg ? 'bg-transparent' : 'bg-background',
)}
>
{fxOn && <LoginWaves />}
{fxOn && !managedBy && <LoginWaves />}
<div
data-ui-decoration=""
className="pointer-events-none absolute inset-0"
style={{
background:
@@ -393,6 +395,7 @@ function LoginShell({
</Card>
<motion.button
style={{ display: managedBy ? 'none' : undefined }}
type="button"
onClick={() => onHelpOpen(true)}
whileHover={reduce ? undefined : { scale: 1.04 }}
@@ -189,6 +189,7 @@ export function SettingsPage() {
function SettingsNav({ tab, onChange }: { tab: SettingsTab; onChange: (t: SettingsTab) => void }) {
return (
<nav
data-ui-surface="panel"
aria-label="设置导航"
className={cn(
'flex flex-wrap gap-1 rounded-xl border bg-card p-1.5',
@@ -518,7 +519,14 @@ function TopbarPanel() {
function AppearancePanel() {
const isDesktop = useDesktopSidebar();
const { appearance, setAppearance, uploadBackground, removeBackground, resolved } = useTheme();
const {
savedAppearance: appearance,
setAppearance,
uploadBackground,
removeBackground,
resolved,
managedBy,
} = useTheme();
const a = appearance;
// A Catppuccin flavor fixes its own light/dark + darkness, so the mode and
// intensity controls are inert while one is active.
@@ -560,205 +568,234 @@ function AppearancePanel() {
return (
<div className="flex flex-col gap-5">
<Group
title="主题"
icon={Sun}
description="配色方案、明暗模式与深色风格。所有改动立即生效并保存到服务器,跨设备同步。"
>
<SettingRow
label="配色方案"
hint="“默认”跟随下方明暗模式;Catppuccin 为整套配色,会自行决定明暗。"
layout="stack"
{managedBy && (
<p className="text-sm leading-relaxed text-muted-foreground" role="status">
外观跟随{managedBy}
。原有配色、字体和背景已保留,退出集成后恢复;缩放与无障碍设置仍可调整。
</p>
)}
<fieldset disabled={!!managedBy} className="contents" aria-label="主题、配色与背景">
<Group
title="主题"
icon={Sun}
description={
managedBy
? '下方显示已保留的个人设置;当前界面外观由宿主管理。'
: '配色方案、明暗模式与深色风格。所有改动立即生效并保存到服务器,跨设备同步。'
}
>
<div className="flex flex-wrap gap-2.5" role="radiogroup" aria-label="配色方案">
{PALETTE_OPTIONS.map((p) => {
const active = a.palette === p.id;
return (
<button
key={p.id}
type="button"
role="radio"
aria-checked={active}
aria-label={p.label}
onClick={() => setAppearance({ palette: p.id })}
className={cn(
'flex w-[5.5rem] flex-col items-center gap-1.5 rounded-xl border p-1.5 transition-[border-color,box-shadow] duration-150 ease-out cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40',
active
? 'border-primary ring-2 ring-primary/30'
: 'border-border hover:border-foreground/30',
)}
>
<span
className="flex h-11 w-full items-center justify-center rounded-lg"
style={{ backgroundColor: p.preview.bg }}
<SettingRow
label="配色方案"
hint="“默认”跟随下方明暗模式;Catppuccin 为整套配色,会自行决定明暗。"
layout="stack"
>
<div
className="flex flex-wrap gap-2.5"
role="radiogroup"
aria-label="配色方案"
>
{PALETTE_OPTIONS.map((p) => {
const active = a.palette === p.id;
return (
<button
key={p.id}
type="button"
role="radio"
aria-checked={active}
aria-label={p.label}
onClick={() => setAppearance({ palette: p.id })}
className={cn(
'flex w-[5.5rem] flex-col items-center gap-1.5 rounded-xl border p-1.5 transition-[border-color,box-shadow] duration-150 ease-out cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40',
active
? 'border-primary ring-2 ring-primary/30'
: 'border-border hover:border-foreground/30',
)}
>
<span
className="flex gap-1 rounded-md px-1.5 py-1 shadow-sm"
style={{ backgroundColor: p.preview.surface }}
className="flex h-11 w-full items-center justify-center rounded-lg"
style={{ backgroundColor: p.preview.bg }}
>
{p.preview.dots.map((d, i) => (
<span
key={i}
className="size-1.5 rounded-full"
style={{ backgroundColor: d }}
/>
))}
<span
className="flex gap-1 rounded-md px-1.5 py-1 shadow-sm"
style={{ backgroundColor: p.preview.surface }}
>
{p.preview.dots.map((d, i) => (
<span
key={i}
className="size-1.5 rounded-full"
style={{ backgroundColor: d }}
/>
))}
</span>
</span>
</span>
<span className="text-xs font-medium">{p.label}</span>
</button>
);
})}
</div>
</SettingRow>
<SettingRow
label="显示模式"
hint={paletteFixed ? '当前由 Catppuccin 配色决定明暗。' : undefined}
>
<Segmented
value={a.mode}
options={MODE_OPTIONS}
onChange={(mode) => setAppearance({ mode })}
disabled={paletteFixed}
/>
</SettingRow>
<SettingRow
label="深色强度"
hint={
paletteFixed
? 'Catppuccin 配色自带明暗,无需调节。'
: '“纯黑”将深色背景压到接近纯黑,适合 OLED 屏幕(仅深色模式下可见)。'
}
layout="stack"
>
<Segmented
value={a.darkIntensity}
options={DARK_INTENSITY_OPTIONS}
onChange={(darkIntensity) => setAppearance({ darkIntensity })}
disabled={paletteFixed}
/>
</SettingRow>
</Group>
<span className="text-xs font-medium">{p.label}</span>
</button>
);
})}
</div>
</SettingRow>
<SettingRow
label="显示模式"
hint={
managedBy
? '明暗模式跟随宿主。'
: paletteFixed
? '当前由 Catppuccin 配色决定明暗。'
: undefined
}
>
<Segmented
value={a.mode}
options={MODE_OPTIONS}
onChange={(mode) => setAppearance({ mode })}
disabled={paletteFixed}
/>
</SettingRow>
<SettingRow
label="深色强度"
hint={
managedBy
? '深色材质由宿主外观决定。'
: paletteFixed
? 'Catppuccin 配色自带明暗,无需调节。'
: '“纯黑”将深色背景压到接近纯黑,适合 OLED 屏幕(仅深色模式下可见)。'
}
layout="stack"
>
<Segmented
value={a.darkIntensity}
options={DARK_INTENSITY_OPTIONS}
onChange={(darkIntensity) => setAppearance({ darkIntensity })}
disabled={paletteFixed}
/>
</SettingRow>
</Group>
<Group title="强调色" icon={Palette} description="按钮、链接与高亮使用的主色调。">
<SettingRow label="预设色" layout="stack">
<div className="flex flex-wrap items-center gap-2">
{ACCENTS.map((spec) => {
const active = a.accentMode === 'preset' && a.accentPreset === spec.id;
return (
<button
key={spec.id}
type="button"
onClick={() =>
setAppearance({
accentMode: 'preset',
accentPreset: spec.id,
})
}
title={spec.label}
aria-label={spec.label}
className={cn(
'relative flex size-9 items-center justify-center rounded-full border transition-transform cursor-pointer hover:scale-105',
active
? 'border-foreground/30 ring-2 ring-primary'
: 'border-border',
)}
style={{ backgroundColor: spec.swatch }}
>
{active && (
<Check
className="size-4 text-white drop-shadow-sm"
strokeWidth={3}
/>
)}
</button>
);
})}
<label
title="自定义颜色"
className={cn(
'relative flex size-9 cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-transform hover:scale-105',
a.accentMode === 'custom'
? 'border-foreground/30 ring-2 ring-primary'
: 'border-border',
)}
style={{
background:
<Group title="强调色" icon={Palette} description="按钮、链接与高亮使用的主色调。">
<SettingRow label="预设色" layout="stack">
<div className="flex flex-wrap items-center gap-2">
{ACCENTS.map((spec) => {
const active =
a.accentMode === 'preset' && a.accentPreset === spec.id;
return (
<button
key={spec.id}
type="button"
onClick={() =>
setAppearance({
accentMode: 'preset',
accentPreset: spec.id,
})
}
title={spec.label}
aria-label={spec.label}
className={cn(
'relative flex size-9 items-center justify-center rounded-full border transition-transform cursor-pointer hover:scale-105',
active
? 'border-foreground/30 ring-2 ring-primary'
: 'border-border',
)}
style={{ backgroundColor: spec.swatch }}
>
{active && (
<Check
className="size-4 text-white drop-shadow-sm"
strokeWidth={3}
/>
)}
</button>
);
})}
<label
title="自定义颜色"
className={cn(
'relative flex size-9 cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-transform hover:scale-105',
a.accentMode === 'custom'
? a.accentCustom
: 'conic-gradient(from 0deg, #f43f5e, #f59e0b, #10b981, #0ea5e9, #8b5cf6, #f43f5e)',
}}
>
<input
type="color"
value={toColorInputValue(a.accentCustom)}
onChange={(e) => pickCustomAccent(e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label="自定义强调色"
/>
{a.accentMode === 'custom' && (
<Check
className="pointer-events-none size-4 text-white drop-shadow-sm"
strokeWidth={3}
? 'border-foreground/30 ring-2 ring-primary'
: 'border-border',
)}
style={{
background:
a.accentMode === 'custom'
? a.accentCustom
: 'conic-gradient(from 0deg, #f43f5e, #f59e0b, #10b981, #0ea5e9, #8b5cf6, #f43f5e)',
}}
>
<input
type="color"
value={toColorInputValue(a.accentCustom)}
onChange={(e) => pickCustomAccent(e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label="自定义强调色"
/>
)}
</label>
</div>
</SettingRow>
<SettingRow
label="应用范围"
hint="“仅侧栏”时,按钮等全局元素保持默认蓝,强调色只染侧栏选中项。"
>
<Segmented
value={a.accentScope}
options={SCOPE_OPTIONS}
onChange={(accentScope) => setAppearance({ accentScope })}
/>
</SettingRow>
<SettingRow label="侧栏样式">
<Segmented
value={a.sidebarStyle}
options={SIDEBAR_STYLE_OPTIONS}
onChange={(sidebarStyle) => setAppearance({ sidebarStyle })}
/>
</SettingRow>
</Group>
<BackgroundCard
appearance={a}
setAppearance={setAppearance}
uploadBackground={uploadBackground}
removeBackground={removeBackground}
/>
{a.accentMode === 'custom' && (
<Check
className="pointer-events-none size-4 text-white drop-shadow-sm"
strokeWidth={3}
/>
)}
</label>
</div>
</SettingRow>
<SettingRow
label="应用范围"
hint="“仅侧栏”时,按钮等全局元素保持默认蓝,强调色只染侧栏选中项。"
>
<Segmented
value={a.accentScope}
options={SCOPE_OPTIONS}
onChange={(accentScope) => setAppearance({ accentScope })}
/>
</SettingRow>
<SettingRow label="侧栏样式">
<Segmented
value={a.sidebarStyle}
options={SIDEBAR_STYLE_OPTIONS}
onChange={(sidebarStyle) => setAppearance({ sidebarStyle })}
/>
</SettingRow>
</Group>
<BackgroundCard
appearance={a}
setAppearance={setAppearance}
uploadBackground={uploadBackground}
removeBackground={removeBackground}
/>
</fieldset>
<Group title="排版与界面" icon={Palette} description="字体、圆角、缩放与密度。">
<SettingRow label="界面字体" layout="stack">
<FontField
value={a.fontSans}
custom={a.fontSansCustom}
presets={FONT_SANS_OPTIONS}
onChange={(fontSans) => setAppearance({ fontSans })}
onCustomChange={(fontSansCustom) => setAppearance({ fontSansCustom })}
placeholder="例如:LXGW WenKai, PingFang SC"
/>
</SettingRow>
<SettingRow label="等宽字体" hint="用于日志与代码。" layout="stack">
<FontField
value={a.fontMono}
custom={a.fontMonoCustom}
presets={FONT_MONO_OPTIONS}
onChange={(fontMono) => setAppearance({ fontMono })}
onCustomChange={(fontMonoCustom) => setAppearance({ fontMonoCustom })}
placeholder="例如:Cascadia Code, Sarasa Mono SC"
/>
</SettingRow>
<SettingRow label="圆角">
<Segmented
value={a.radius}
options={RADIUS_OPTIONS.map((r) => ({ value: r.value, label: r.label }))}
onChange={(radius) => setAppearance({ radius })}
/>
</SettingRow>
<fieldset disabled={!!managedBy} className="contents" aria-label="字体与圆角">
<SettingRow label="界面字体" layout="stack">
<FontField
value={a.fontSans}
custom={a.fontSansCustom}
presets={FONT_SANS_OPTIONS}
onChange={(fontSans) => setAppearance({ fontSans })}
onCustomChange={(fontSansCustom) => setAppearance({ fontSansCustom })}
placeholder="例如:LXGW WenKai, PingFang SC"
/>
</SettingRow>
<SettingRow label="等宽字体" hint="用于日志与代码。" layout="stack">
<FontField
value={a.fontMono}
custom={a.fontMonoCustom}
presets={FONT_MONO_OPTIONS}
onChange={(fontMono) => setAppearance({ fontMono })}
onCustomChange={(fontMonoCustom) => setAppearance({ fontMonoCustom })}
placeholder="例如:Cascadia Code, Sarasa Mono SC"
/>
</SettingRow>
<SettingRow label="圆角">
<Segmented
value={a.radius}
options={RADIUS_OPTIONS.map((r) => ({
value: r.value,
label: r.label,
}))}
onChange={(radius) => setAppearance({ radius })}
/>
</SettingRow>
</fieldset>
<SliderSettingRow
label="界面缩放"
hint="整体放大或缩小界面,适合高分屏或视力需要。"
@@ -779,10 +816,12 @@ function AppearancePanel() {
</SettingRow>
</Group>
<ThemeVarsPanel
cssVars={a.cssVars}
onChange={(cssVars) => setAppearance({ cssVars })}
/>
<fieldset disabled={!!managedBy} className="contents" aria-label="主题变量">
<ThemeVarsPanel
cssVars={a.cssVars}
onChange={(cssVars) => setAppearance({ cssVars })}
/>
</fieldset>
<TopbarPanel />
@@ -816,6 +855,7 @@ function AppearancePanel() {
>
<ToggleSwitch
value={a.customPointerSystem}
disabled={!!managedBy}
onChange={(customPointerSystem) => setAppearance({ customPointerSystem })}
ariaLabel="自定义光标"
/>
@@ -1432,7 +1472,7 @@ function validateCss(css: string): string | null {
}
function AdvancedPanel() {
const { appearance, setAppearance } = useTheme();
const { savedAppearance: appearance, setAppearance, managedBy } = useTheme();
const { runAction } = useActionFeedback();
const cssRef = useRef<HTMLTextAreaElement>(null);
const [cssWarn, setCssWarn] = useState<string | null>(null);
@@ -1572,77 +1612,83 @@ function AdvancedPanel() {
return (
<div className="flex flex-col gap-5">
<Card>
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-[15px]">
<Code2 className="size-4 text-primary" /> 自定义 CSS
</CardTitle>
<CardDescription className="text-[12px] leading-relaxed">
高级用户可注入自定义样式,登录后全局生效(登录页不受影响)。
若改坏了界面,在地址后加{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-meta">
?safe-mode=1
</code>{' '}
可临时禁用自定义 CSS 进来修复。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-xs text-muted-foreground">插入模板:</span>
{CSS_SNIPPETS.map((s) => (
<button
key={s.label}
type="button"
onClick={() => insertSnippet(s.css)}
className="inline-flex items-center gap-1 rounded-md border bg-muted/40 px-2 py-1 text-xs font-medium text-muted-foreground transition-colors cursor-pointer outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40"
>
<Plus className="size-3" /> {s.label}
</button>
))}
</div>
<Textarea
ref={cssRef}
value={appearance.customCss}
onChange={(e) => {
setAppearance({ customCss: e.target.value });
{managedBy && (
<p className="text-sm text-muted-foreground">
自定义 CSS 已保留,跟随{managedBy}外观期间暂停应用。
</p>
)}
<fieldset disabled={!!managedBy} className="contents" aria-label="自定义 CSS">
<Card>
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-[15px]">
<Code2 className="size-4 text-primary" /> 自定义 CSS
</CardTitle>
<CardDescription className="text-[12px] leading-relaxed">
高级用户可注入自定义样式,登录后全局生效(登录页不受影响)。
若改坏了界面,在地址后加{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-meta">
?safe-mode=1
</code>{' '}
可临时禁用自定义 CSS 进来修复。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-xs text-muted-foreground">插入模板:</span>
{CSS_SNIPPETS.map((s) => (
<button
key={s.label}
type="button"
onClick={() => insertSnippet(s.css)}
className="inline-flex items-center gap-1 rounded-md border bg-muted/40 px-2 py-1 text-xs font-medium text-muted-foreground transition-colors cursor-pointer outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40"
>
<Plus className="size-3" /> {s.label}
</button>
))}
</div>
<Textarea
ref={cssRef}
value={appearance.customCss}
onChange={(e) => {
setAppearance({ customCss: e.target.value });
if (cssWarn) {
setCssWarn(null);
}
}}
onBlur={(e) => setCssWarn(validateCss(e.target.value))}
maxLength={50000}
spellCheck={false}
placeholder={
'/* 例如:放大侧栏字号 */\n.text-sidebar-foreground { font-size: 1.05em; }'
}
className="h-64 resize-y rounded-lg bg-card/40 p-3 font-mono text-base leading-relaxed md:text-[12px]"
/>
{cssWarn && (
<p className="flex items-start gap-1.5 text-xs text-warning">
<AlertTriangle className="mt-px size-3.5 shrink-0" /> {cssWarn}
</p>
)}
<div className="flex items-center justify-between gap-2">
<span className="text-meta text-muted-foreground">
{appearance.customCss.length} / 50000 字符
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setAppearance({ customCss: '' });
setCssWarn(null);
if (cssWarn) {
setCssWarn(null);
}
}}
disabled={!appearance.customCss}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" /> 清空
</Button>
</div>
</CardContent>
</Card>
onBlur={(e) => setCssWarn(validateCss(e.target.value))}
maxLength={50000}
spellCheck={false}
placeholder={
'/* 例如:放大侧栏字号 */\n.text-sidebar-foreground { font-size: 1.05em; }'
}
className="h-64 resize-y rounded-lg bg-card/40 p-3 font-mono text-base leading-relaxed md:text-[12px]"
/>
{cssWarn && (
<p className="flex items-start gap-1.5 text-xs text-warning">
<AlertTriangle className="mt-px size-3.5 shrink-0" /> {cssWarn}
</p>
)}
<div className="flex items-center justify-between gap-2">
<span className="text-meta text-muted-foreground">
{appearance.customCss.length} / 50000 字符
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setAppearance({ customCss: '' });
setCssWarn(null);
}}
disabled={!appearance.customCss}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" /> 清空
</Button>
</div>
</CardContent>
</Card>
</fieldset>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-[15px]">备份与重置</CardTitle>
@@ -10,12 +10,12 @@ const OPTIONS: { mode: ThemeMode; icon: typeof Sun; label: string }[] = [
];
export function ThemeToggle({ className }: { className?: string }) {
const { appearance, mode, setMode } = useTheme();
const { appearance, mode, setMode, managedBy } = useTheme();
// A Catppuccin flavor fixes light/dark, so this quick toggle would be inert
// (and its pill would desync from the forced scheme) — hide it. Light/dark is
// then changed by switching the palette back to “默认” in settings.
if (paletteResolved(appearance.palette)) {
if (managedBy || paletteResolved(appearance.palette)) {
return null;
}
@@ -50,6 +50,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(buttonVariants({ variant, size, press: !isStatic }), className)}
ref={ref}
data-press-scale={isStatic ? undefined : ''}
data-variant={variant ?? 'default'}
{...props}
/>
);
+46 -10
View File
@@ -7,6 +7,7 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from 'react';
import { flushSync } from 'react-dom';
@@ -14,6 +15,14 @@ import { MotionConfig } from 'motion/react';
import { actionErrorMessage, useActionFeedback } from '@/contexts/ActionFeedbackContext';
import { webuiSessionHeaders } from '@/lib/api/session-headers';
import type { Palette, ThemeMode, UiAppearance, UiBackground } from '@/types';
import { WEBUI_TARGETS, type WebuiPresentation } from '@snowluma/common/webui-target';
import { resolveAppearance } from '@/appearance/registry';
import {
applyPresentationAttributes,
getTargetAppearance,
presentationStore,
subscribeHostAppearance,
} from '@/appearance/runtime';
// Re-export the appearance value types so consumers (settings page etc.) can
// import them straight from the context module.
@@ -745,8 +754,13 @@ function cssVarsCss(a: UiAppearance): string {
return decls ? `:root{${decls}}` : '';
}
function applyAppearance(a: UiAppearance, resolved: 'light' | 'dark'): void {
function applyAppearance(
a: UiAppearance,
resolved: 'light' | 'dark',
presentation: WebuiPresentation | null,
): void {
const root = document.documentElement;
applyPresentationAttributes(presentation);
root.classList.remove('light', 'dark');
root.classList.add(resolved);
@@ -811,7 +825,9 @@ function applyAppearance(a: UiAppearance, resolved: 'light' | 'dark'): void {
// Palette (base/surface tokens) first, then accent (--primary/--ring) — a
// disjoint var set, layered after so it composes with any flavor. The
// operator's variable-panel overrides come last so they win over both.
el.textContent = `${paletteVarsCss(a)}\n${accentVarsCss(a)}\n${cssVarsCss(a)}`;
el.textContent = presentation
? ''
: `${paletteVarsCss(a)}\n${accentVarsCss(a)}\n${cssVarsCss(a)}`;
// Custom CSS — appended AFTER the accent block so it is the last style in
// <head> and can override anything. Skipped under ?safe-mode=1 (escape
@@ -1078,7 +1094,12 @@ function writeCache(a: UiAppearance): void {
// ─── Context ───────────────────────────────────────────────────────────────
interface ThemeContextValue {
/** Effective presentation for components. Never persist this object. */
appearance: UiAppearance;
/** User preferences underneath the deployment overlay. Only these are persisted. */
savedAppearance: UiAppearance;
presentation: WebuiPresentation | null;
managedBy: string | null;
/** True once the first server load attempt has resolved. */
ready: boolean;
resolved: 'light' | 'dark';
@@ -1112,6 +1133,14 @@ function getSystemTheme(): 'light' | 'dark' {
export function ThemeProvider({ children }: { children: ReactNode }) {
const { runAction, startAction } = useActionFeedback();
const [appearance, setAppearanceState] = useState<UiAppearance>(readCache);
const presentation = useSyncExternalStore(
presentationStore.subscribe,
presentationStore.getSnapshot,
);
const effectiveAppearance = useMemo(
() => resolveAppearance(appearance, presentation, getTargetAppearance()),
[appearance, presentation],
);
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const [ready, setReady] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1123,8 +1152,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// A Catppuccin flavor fixes light/dark; otherwise honor mode (+ system).
const resolved: 'light' | 'dark' =
paletteResolved(appearance.palette) ??
(appearance.mode === 'system' ? systemTheme : appearance.mode);
paletteResolved(effectiveAppearance.palette) ??
(effectiveAppearance.mode === 'system' ? systemTheme : effectiveAppearance.mode);
useLayoutEffect(() => subscribeHostAppearance(window, presentationStore), []);
// Track the OS theme for `mode: 'system'`.
useEffect(() => {
@@ -1167,11 +1198,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// application doesn't flash. The pre-mount flash is handled by the inline
// bootstrap script in index.html.
useLayoutEffect(() => {
applyAppearance(appearance, resolved);
}, [appearance, resolved]);
applyAppearance(effectiveAppearance, resolved, presentation);
}, [effectiveAppearance, resolved, presentation]);
useLayoutEffect(() => {
applyBackgroundLayer(appearance);
}, [appearance]);
applyBackgroundLayer(effectiveAppearance);
}, [effectiveAppearance]);
// Cancel a pending save when the provider unmounts.
useEffect(
@@ -1358,7 +1389,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useMemo<ThemeContextValue>(
() => ({
appearance,
appearance: effectiveAppearance,
savedAppearance: appearance,
presentation,
managedBy: presentation ? WEBUI_TARGETS[presentation.target].label : null,
ready,
resolved,
setAppearance,
@@ -1366,12 +1400,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
removeBackground,
formatClock,
reloadAppearance,
mode: appearance.mode,
mode: effectiveAppearance.mode,
setMode: (m: ThemeMode) => setAppearance({ mode: m }),
pollInterval: appearance.pollInterval,
}),
[
appearance,
effectiveAppearance,
presentation,
ready,
resolved,
setAppearance,
+15 -5
View File
@@ -2,9 +2,19 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { prepareTargetAppearance } from './appearance/runtime';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
const root = document.getElementById('root')!;
void prepareTargetAppearance()
.then(() => {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
})
.catch((error: unknown) => {
console.error('[SnowLuma appearance] Startup failed', error);
root.textContent = '界面外观加载失败,请刷新重试。详细原因见浏览器控制台。';
});
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest';
import { defaultWebuiPresentation, WEBUI_PRESENTATION_EVENT } from '@snowluma/common/webui-target';
import { createPresentationStore } from '../src/appearance/store';
import { subscribeHostAppearance } from '../src/appearance/runtime';
import { resolveAppearance } from '../src/appearance/registry';
import { maibotAppearance } from '../src/appearance/targets/maibot';
import { DEFAULT_APPEARANCE } from '../src/contexts/ThemeContext';
const initial = defaultWebuiPresentation('maibot');
describe('reversible presentation overlay', () => {
it('retains saved appearance, wallpaper, custom CSS and accessibility preferences', () => {
const saved = structuredClone(DEFAULT_APPEARANCE);
Object.assign(saved, {
customCss: 'body { color: red; }',
palette: 'catppuccin-mocha',
radius: 1,
fontSansCustom: 'My Font',
customPointerSystem: true,
reduceMotion: true,
disableMotion: true,
highContrast: true,
uiScale: 1.2,
});
saved.background.type = 'image';
saved.background.hasImage = true;
const before = structuredClone(saved);
const effective = resolveAppearance(saved, { ...initial, mode: 'light' }, maibotAppearance);
expect(effective).toMatchObject({
mode: 'light',
palette: 'default',
radius: 0,
customCss: '',
background: { type: 'none' },
customPointerSystem: false,
reduceMotion: true,
disableMotion: true,
highContrast: true,
uiScale: 1.2,
});
expect(saved).toEqual(before);
expect(resolveAppearance(saved, null, maibotAppearance)).toBe(saved);
expect(resolveAppearance(saved, null, null)).toBe(saved);
});
it('cannot enable a target through host messages on a standalone deployment', () => {
const store = createPresentationStore(null);
const host = new EventTarget() as Window;
const add = vi.spyOn(host, 'addEventListener');
const cleanup = subscribeHostAppearance(host, store);
store.receive(initial);
expect(store.getSnapshot()).toBeNull();
expect(add).not.toHaveBeenCalled();
cleanup();
});
it('applies the latest of rapid updates synchronously, deduplicates, releases and restores', () => {
const store = createPresentationStore('maibot');
const notify = vi.fn();
const cleanup = store.subscribe(notify);
store.receive(initial);
expect(notify).not.toHaveBeenCalled();
store.receive({ ...initial, mode: 'dark' });
store.receive({ ...initial, mode: 'light' });
expect(store.getSnapshot()?.mode).toBe('light');
store.receive({ version: 1, target: null });
expect(store.getSnapshot()).toBeNull();
store.receive(initial);
expect(store.getSnapshot()).toEqual(initial);
cleanup();
store.receive({ ...initial, mode: 'dark' });
expect(notify).toHaveBeenCalledTimes(4);
});
it('reads pre-mount host state, reports invalid input and cleans up subscriptions', () => {
const store = createPresentationStore('maibot');
const host = new EventTarget() as Window;
host.__SNOWLUMA_HOST_APPEARANCE__ = { ...initial, mode: 'dark' };
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const cleanup = subscribeHostAppearance(host, store);
try {
expect(store.getSnapshot()?.mode).toBe('dark');
host.dispatchEvent(
new CustomEvent(WEBUI_PRESENTATION_EVENT, {
detail: { ...initial, mode: 'invalid' },
}),
);
expect(error).toHaveBeenCalledOnce();
expect(store.getSnapshot()?.mode).toBe('dark');
host.dispatchEvent(
new CustomEvent(WEBUI_PRESENTATION_EVENT, {
detail: { ...initial, mode: 'light' },
}),
);
expect(store.getSnapshot()?.mode).toBe('light');
cleanup();
host.dispatchEvent(new CustomEvent(WEBUI_PRESENTATION_EVENT, { detail: initial }));
expect(store.getSnapshot()?.mode).toBe('light');
} finally {
cleanup();
error.mockRestore();
}
});
});
+15 -1
View File
@@ -3,11 +3,25 @@ import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { injectWebuiTarget, resolveWebuiTarget } from '@snowluma/common/webui-target';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [tailwindcss(), react()],
plugins: [
tailwindcss(),
react(),
{
name: 'webui-runtime-target',
// Production builds are target-neutral. Core injects the deployment at request time.
apply: 'serve',
transformIndexHtml: {
order: 'pre',
handler: (html) =>
injectWebuiTarget(html, resolveWebuiTarget(process.env.SNOWLUMA_WEBUI_TARGET)),
},
},
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),