feat(webui): add opt-in MaiBot appearance overlay
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user