Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
1452 lines
49 KiB
TypeScript
1452 lines
49 KiB
TypeScript
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
useSyncExternalStore,
|
||
type ReactNode,
|
||
} from 'react';
|
||
import { flushSync } from 'react-dom';
|
||
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.
|
||
export type {
|
||
AccentMode,
|
||
AccentScope,
|
||
BackgroundType,
|
||
DarkIntensity,
|
||
Density,
|
||
Palette,
|
||
SidebarStyle,
|
||
ThemeMode,
|
||
TimeFormat,
|
||
UiAppearance,
|
||
} from '@/types';
|
||
|
||
/** A partial appearance update; `background` may itself be a partial patch. */
|
||
export type AppearancePatch = Partial<Omit<UiAppearance, 'background'>> & {
|
||
background?: Partial<UiBackground>;
|
||
};
|
||
|
||
// ─── Frontend catalogues (the server stores only ids; the visuals live here) ───
|
||
|
||
export type AccentColor =
|
||
'lavender' | 'sky' | 'blue' | 'violet' | 'rose' | 'emerald' | 'amber' | 'orange';
|
||
|
||
export interface AccentSpec {
|
||
id: AccentColor;
|
||
label: string;
|
||
swatch: string;
|
||
light: { primary: string; ring: string };
|
||
dark: { primary: string; ring: string };
|
||
}
|
||
|
||
export const ACCENTS: AccentSpec[] = [
|
||
{
|
||
id: 'lavender',
|
||
label: '薰衣草',
|
||
swatch: '#8561bd',
|
||
light: { primary: 'oklch(57% 0.14 300)', ring: 'oklch(57% 0.14 300)' },
|
||
dark: { primary: 'oklch(78% 0.115 300)', ring: 'oklch(78% 0.115 300)' },
|
||
},
|
||
{
|
||
id: 'sky',
|
||
label: '天蓝',
|
||
swatch: '#38bdf8',
|
||
light: { primary: 'oklch(68.5% 0.155 230)', ring: 'oklch(68.5% 0.155 230)' },
|
||
dark: { primary: 'oklch(75% 0.14 230)', ring: 'oklch(75% 0.14 230)' },
|
||
},
|
||
{
|
||
id: 'blue',
|
||
label: '靛蓝',
|
||
swatch: '#3b82f6',
|
||
light: { primary: 'oklch(60% 0.18 258)', ring: 'oklch(60% 0.18 258)' },
|
||
dark: { primary: 'oklch(70% 0.16 258)', ring: 'oklch(70% 0.16 258)' },
|
||
},
|
||
{
|
||
id: 'violet',
|
||
label: '紫罗兰',
|
||
swatch: '#8b5cf6',
|
||
light: { primary: 'oklch(60% 0.2 290)', ring: 'oklch(60% 0.2 290)' },
|
||
dark: { primary: 'oklch(72% 0.17 290)', ring: 'oklch(72% 0.17 290)' },
|
||
},
|
||
{
|
||
id: 'rose',
|
||
label: '玫瑰',
|
||
swatch: '#f43f5e',
|
||
light: { primary: 'oklch(63% 0.21 18)', ring: 'oklch(63% 0.21 18)' },
|
||
dark: { primary: 'oklch(72% 0.18 18)', ring: 'oklch(72% 0.18 18)' },
|
||
},
|
||
{
|
||
id: 'emerald',
|
||
label: '翡翠',
|
||
swatch: '#10b981',
|
||
light: { primary: 'oklch(64% 0.16 162)', ring: 'oklch(64% 0.16 162)' },
|
||
dark: { primary: 'oklch(74% 0.15 162)', ring: 'oklch(74% 0.15 162)' },
|
||
},
|
||
{
|
||
id: 'amber',
|
||
label: '琥珀',
|
||
swatch: '#f59e0b',
|
||
light: { primary: 'oklch(72% 0.17 70)', ring: 'oklch(72% 0.17 70)' },
|
||
dark: { primary: 'oklch(78% 0.16 70)', ring: 'oklch(78% 0.16 70)' },
|
||
},
|
||
{
|
||
id: 'orange',
|
||
label: '夕橙',
|
||
swatch: '#f97316',
|
||
light: { primary: 'oklch(67% 0.2 45)', ring: 'oklch(67% 0.2 45)' },
|
||
dark: { primary: 'oklch(74% 0.18 45)', ring: 'oklch(74% 0.18 45)' },
|
||
},
|
||
];
|
||
|
||
export const RADIUS_OPTIONS = [
|
||
{ value: 0.375, label: '紧凑' },
|
||
{ value: 0.5, label: '默认' },
|
||
{ value: 0.75, label: '舒适' },
|
||
{ value: 1.0, label: '圆润' },
|
||
] as const;
|
||
|
||
export const POLL_INTERVAL_OPTIONS = [
|
||
{ value: 1000, label: '1 秒(实时)' },
|
||
{ value: 3000, label: '3 秒(默认)' },
|
||
{ value: 5000, label: '5 秒(节能)' },
|
||
{ value: 10000, label: '10 秒(省电)' },
|
||
{ value: 0, label: '已暂停' },
|
||
] as const;
|
||
|
||
export interface FontSpec {
|
||
id: string;
|
||
label: string;
|
||
stack: string;
|
||
}
|
||
|
||
export const FONT_SANS_OPTIONS: FontSpec[] = [
|
||
{
|
||
id: 'default',
|
||
label: '默认(本地系统)',
|
||
stack: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif",
|
||
},
|
||
{
|
||
id: 'system',
|
||
label: '平台原生',
|
||
stack: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||
},
|
||
{
|
||
id: 'rounded',
|
||
label: '圆润(平台字体)',
|
||
stack: "ui-rounded, 'SF Pro Rounded', system-ui, sans-serif",
|
||
},
|
||
{ id: 'serif', label: '衬线(本地字体)', stack: "Georgia, 'Songti SC', 'STSong', serif" },
|
||
];
|
||
|
||
export const FONT_MONO_OPTIONS: FontSpec[] = [
|
||
{
|
||
id: 'default',
|
||
label: '默认(系统等宽)',
|
||
stack: "ui-monospace, 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
|
||
},
|
||
{ id: 'system', label: '平台等宽', stack: 'ui-monospace, Menlo, Consolas, monospace' },
|
||
];
|
||
|
||
export interface GradientSpec {
|
||
id: string;
|
||
label: string;
|
||
css: string;
|
||
}
|
||
|
||
export const GRADIENT_OPTIONS: GradientSpec[] = [
|
||
{
|
||
id: 'aurora',
|
||
label: '极光',
|
||
css: 'linear-gradient(135deg, #1e3a8a 0%, #0ea5e9 50%, #22d3ee 100%)',
|
||
},
|
||
{
|
||
id: 'sunset',
|
||
label: '日落',
|
||
css: 'linear-gradient(135deg, #f97316 0%, #db2777 50%, #7c3aed 100%)',
|
||
},
|
||
{
|
||
id: 'forest',
|
||
label: '森野',
|
||
css: 'linear-gradient(135deg, #064e3b 0%, #10b981 55%, #84cc16 100%)',
|
||
},
|
||
{
|
||
id: 'dusk',
|
||
label: '暮色',
|
||
css: 'linear-gradient(160deg, #0f172a 0%, #334155 55%, #64748b 100%)',
|
||
},
|
||
{
|
||
id: 'rose',
|
||
label: '霞光',
|
||
css: 'linear-gradient(135deg, #9f1239 0%, #fb7185 55%, #fda4af 100%)',
|
||
},
|
||
];
|
||
|
||
export const UI_SCALE = { min: 0.9, max: 1.2, step: 0.05 } as const;
|
||
|
||
// ─── Defaults (mirror core/src/webui/ui-config.ts) ─────────────────────────
|
||
|
||
export const DEFAULT_APPEARANCE: UiAppearance = {
|
||
mode: 'system',
|
||
accentMode: 'preset',
|
||
accentPreset: 'lavender',
|
||
accentCustom: '#8561bd',
|
||
accentScope: 'global',
|
||
darkIntensity: 'soft',
|
||
palette: 'default',
|
||
sidebarStyle: 'follow',
|
||
background: {
|
||
type: 'none',
|
||
color: '#b99ae6',
|
||
gradient: 'none',
|
||
imageOpacity: 0.15,
|
||
imageBlur: 0,
|
||
hasImage: false,
|
||
imageMime: '',
|
||
imageVersion: 0,
|
||
},
|
||
fontSans: 'default',
|
||
fontSansCustom: '',
|
||
fontMono: 'default',
|
||
fontMonoCustom: '',
|
||
uiScale: 1,
|
||
radius: 0.75,
|
||
density: 'cozy',
|
||
reduceMotion: false,
|
||
disableMotion: false,
|
||
customPointerSystem: false,
|
||
customContextMenu: true,
|
||
highContrast: false,
|
||
sidebarPinned: false,
|
||
timeFormat: '24h',
|
||
pollInterval: 3000,
|
||
customCss: '',
|
||
cssVars: {},
|
||
};
|
||
|
||
/** The sentinel `fontSans`/`fontMono` id selecting the free-form custom family. */
|
||
export const FONT_CUSTOM_ID = 'custom';
|
||
|
||
const LS_CACHE = 'snowluma_ui_appearance';
|
||
const TOKEN_KEY = 'snowluma_token';
|
||
|
||
// ─── Colour helpers (custom hex accent → readable foreground) ──────────────
|
||
|
||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||
const m = hex.trim().replace('#', '');
|
||
|
||
if (m.length === 3) {
|
||
const r = parseInt(m[0] + m[0], 16),
|
||
g = parseInt(m[1] + m[1], 16),
|
||
b = parseInt(m[2] + m[2], 16);
|
||
return Number.isNaN(r + g + b) ? null : { r, g, b };
|
||
}
|
||
|
||
if (m.length === 6 || m.length === 8) {
|
||
const r = parseInt(m.slice(0, 2), 16),
|
||
g = parseInt(m.slice(2, 4), 16),
|
||
b = parseInt(m.slice(4, 6), 16);
|
||
return Number.isNaN(r + g + b) ? null : { r, g, b };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Pick black or white text for a given accent so labels stay legible. */
|
||
function readableForeground(hex: string): string {
|
||
const rgb = hexToRgb(hex);
|
||
|
||
if (!rgb) {
|
||
return '#ffffff';
|
||
}
|
||
|
||
// Relative luminance (sRGB approximation).
|
||
const lum = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
|
||
return lum > 0.6 ? '#0b0d12' : '#ffffff';
|
||
}
|
||
|
||
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||
|
||
/** A hex colour safe to interpolate into CSS text, else the fallback. Guards
|
||
* the localStorage cache (writable by anyone with local access) from
|
||
* injecting arbitrary CSS through the accent <style> block. */
|
||
function safeHex(value: unknown, fallback: string): string {
|
||
return typeof value === 'string' && HEX_RE.test(value.trim()) ? value.trim() : fallback;
|
||
}
|
||
|
||
/** Lighten a hex colour toward white by `ratio` (0..1) for dark-mode accents. */
|
||
function lightenHex(hex: string, ratio: number): string {
|
||
const rgb = hexToRgb(hex);
|
||
|
||
if (!rgb) {
|
||
return hex;
|
||
}
|
||
|
||
const mix = (c: number) => Math.round(c + (255 - c) * ratio);
|
||
const to2 = (n: number) => n.toString(16).padStart(2, '0');
|
||
return `#${to2(mix(rgb.r))}${to2(mix(rgb.g))}${to2(mix(rgb.b))}`;
|
||
}
|
||
|
||
// ─── Apply appearance → DOM ────────────────────────────────────────────────
|
||
|
||
// Selected-state surface tints derived from the accent, so the sidebar's
|
||
// active item (--sidebar-accent) and the settings rail's active row (--accent)
|
||
// follow the chosen accent — not just primary/ring. Default palette only; the
|
||
// Catppuccin/Rosé Pine/… palettes own their own --accent/--sidebar-accent.
|
||
function accentSurfaces(hex: string, global: boolean, mode: 'light' | 'dark'): string {
|
||
const bgMix = mode === 'light' ? 16 : 26; // accent over the canvas → --accent
|
||
const sbMix = mode === 'light' ? 18 : 28; // accent over the sidebar → --sidebar-accent
|
||
const fgMix = 72; // accent-forward text legible on those tints
|
||
const sidebar =
|
||
`--sidebar-accent:color-mix(in oklab, ${hex} ${sbMix}%, var(--sidebar));` +
|
||
`--sidebar-accent-foreground:color-mix(in oklab, ${hex} ${fgMix}%, var(--foreground));`;
|
||
|
||
if (!global) {
|
||
return sidebar;
|
||
}
|
||
|
||
return (
|
||
`--accent:color-mix(in oklab, ${hex} ${bgMix}%, var(--background));` +
|
||
`--accent-foreground:color-mix(in oklab, ${hex} ${fgMix}%, var(--foreground));` +
|
||
sidebar
|
||
);
|
||
}
|
||
|
||
function accentVarsCss(a: UiAppearance): string {
|
||
const global = a.accentScope === 'global';
|
||
// Only the default palette gets accent-tinted surfaces (others own theirs).
|
||
const surf = a.palette === 'default';
|
||
|
||
if (a.accentMode === 'custom') {
|
||
const hex = safeHex(a.accentCustom, DEFAULT_APPEARANCE.accentCustom);
|
||
const lightFg = readableForeground(hex);
|
||
// Dark mode: lighten the accent for contrast on dark surfaces, and pick
|
||
// the foreground from the *lightened* colour (a light accent needs dark text).
|
||
const darkHex = lightenHex(hex, 0.18);
|
||
const darkFg = readableForeground(darkHex);
|
||
const lightCore = global
|
||
? `--primary:${hex};--primary-foreground:${lightFg};--ring:${hex};--sidebar-primary:${hex};--sidebar-primary-foreground:${lightFg};--sidebar-ring:${hex};`
|
||
: `--sidebar-primary:${hex};--sidebar-primary-foreground:${lightFg};--sidebar-ring:${hex};`;
|
||
const darkCore = global
|
||
? `--primary:${darkHex};--primary-foreground:${darkFg};--ring:${darkHex};--sidebar-primary:${darkHex};--sidebar-primary-foreground:${darkFg};--sidebar-ring:${darkHex};`
|
||
: `--sidebar-primary:${darkHex};--sidebar-primary-foreground:${darkFg};--sidebar-ring:${darkHex};`;
|
||
const lightBlock = lightCore + (surf ? accentSurfaces(hex, global, 'light') : '');
|
||
const darkBlock = darkCore + (surf ? accentSurfaces(darkHex, global, 'dark') : '');
|
||
return `:root{${lightBlock}}\n.dark{${darkBlock}}`;
|
||
}
|
||
|
||
const spec = ACCENTS.find((x) => x.id === a.accentPreset) ?? ACCENTS[0];
|
||
const lightCore = global
|
||
? `--primary:${spec.light.primary};--ring:${spec.light.ring};--sidebar-primary:${spec.light.primary};--sidebar-ring:${spec.light.ring};`
|
||
: `--sidebar-primary:${spec.light.primary};--sidebar-ring:${spec.light.ring};`;
|
||
const darkCore = global
|
||
? `--primary:${spec.dark.primary};--ring:${spec.dark.ring};--sidebar-primary:${spec.dark.primary};--sidebar-ring:${spec.dark.ring};`
|
||
: `--sidebar-primary:${spec.dark.primary};--sidebar-ring:${spec.dark.ring};`;
|
||
// Presets keep the base token's primary-foreground (designed for these hues).
|
||
const light = lightCore + (surf ? accentSurfaces(spec.light.primary, global, 'light') : '');
|
||
const dark = darkCore + (surf ? accentSurfaces(spec.dark.primary, global, 'dark') : '');
|
||
return `:root{${light}}\n.dark{${dark}}`;
|
||
}
|
||
|
||
// ─── Full color-scheme palettes (surface/base token sets) ──────────────────
|
||
// Soft, complete palettes (Catppuccin, Rosé Pine, Nord, Everforest) mapped onto
|
||
// our token names. Accent (--primary/--ring) is intentionally left to the accent
|
||
// system so the two compose. Applied via the injected theme stylesheet over the
|
||
// base :root/.dark (see applyAppearance); darkIntensity is forced off for these
|
||
// (their darkness is part of the palette).
|
||
type PaletteVars = Record<string, string>;
|
||
|
||
interface PaletteInput {
|
||
bg: string;
|
||
fg: string;
|
||
card: string;
|
||
muted: string;
|
||
mutedFg: string;
|
||
accent: string;
|
||
border: string;
|
||
sidebar: string;
|
||
sidebarAccent: string;
|
||
destructive: string;
|
||
destructiveFg: string;
|
||
success: string;
|
||
warning: string;
|
||
}
|
||
|
||
// Expand a palette's semantic colors into the full token set (all *-foreground
|
||
// surfaces share the palette text; popover=card, input/sidebar-border=border).
|
||
function pal(c: PaletteInput): PaletteVars {
|
||
return {
|
||
'--background': c.bg,
|
||
'--foreground': c.fg,
|
||
'--card': c.card,
|
||
'--card-foreground': c.fg,
|
||
'--popover': c.card,
|
||
'--popover-foreground': c.fg,
|
||
'--secondary': c.muted,
|
||
'--secondary-foreground': c.fg,
|
||
'--muted': c.muted,
|
||
'--muted-foreground': c.mutedFg,
|
||
'--accent': c.accent,
|
||
'--accent-foreground': c.fg,
|
||
'--destructive': c.destructive,
|
||
'--destructive-foreground': c.destructiveFg,
|
||
'--success': c.success,
|
||
'--warning': c.warning,
|
||
'--border': c.border,
|
||
'--input': c.border,
|
||
'--sidebar': c.sidebar,
|
||
'--sidebar-foreground': c.fg,
|
||
'--sidebar-accent': c.sidebarAccent,
|
||
'--sidebar-accent-foreground': c.fg,
|
||
'--sidebar-border': c.border,
|
||
};
|
||
}
|
||
|
||
/** Light-flavored palettes; every other non-default palette is dark. */
|
||
const LIGHT_PALETTES = new Set<Palette>(['catppuccin-latte', 'rose-pine-dawn', 'everforest-light']);
|
||
|
||
const PALETTES: Record<Exclude<Palette, 'default'>, PaletteVars> = {
|
||
'catppuccin-latte': pal({
|
||
bg: '#eff1f5',
|
||
fg: '#4c4f69',
|
||
card: '#ffffff',
|
||
muted: '#e6e9ef',
|
||
mutedFg: '#6c6f85',
|
||
accent: '#dce0e8',
|
||
border: '#ccd0da',
|
||
sidebar: '#e6e9ef',
|
||
sidebarAccent: '#dce0e8',
|
||
destructive: '#d20f39',
|
||
destructiveFg: '#ffffff',
|
||
success: '#40a02b',
|
||
warning: '#df8e1d',
|
||
}),
|
||
'catppuccin-frappe': pal({
|
||
bg: '#303446',
|
||
fg: '#c6d0f5',
|
||
card: '#414559',
|
||
muted: '#414559',
|
||
mutedFg: '#a5adce',
|
||
accent: '#51576d',
|
||
border: '#51576d',
|
||
sidebar: '#292c3c',
|
||
sidebarAccent: '#414559',
|
||
destructive: '#e78284',
|
||
destructiveFg: '#232634',
|
||
success: '#a6d189',
|
||
warning: '#e5c890',
|
||
}),
|
||
'catppuccin-macchiato': pal({
|
||
bg: '#24273a',
|
||
fg: '#cad3f5',
|
||
card: '#363a4f',
|
||
muted: '#363a4f',
|
||
mutedFg: '#a5adcb',
|
||
accent: '#494d64',
|
||
border: '#494d64',
|
||
sidebar: '#1e2030',
|
||
sidebarAccent: '#363a4f',
|
||
destructive: '#ed8796',
|
||
destructiveFg: '#181926',
|
||
success: '#a6da95',
|
||
warning: '#eed49f',
|
||
}),
|
||
'catppuccin-mocha': pal({
|
||
bg: '#1e1e2e',
|
||
fg: '#cdd6f4',
|
||
card: '#313244',
|
||
muted: '#313244',
|
||
mutedFg: '#a6adc8',
|
||
accent: '#45475a',
|
||
border: '#45475a',
|
||
sidebar: '#181825',
|
||
sidebarAccent: '#313244',
|
||
destructive: '#f38ba8',
|
||
destructiveFg: '#11111b',
|
||
success: '#a6e3a1',
|
||
warning: '#f9e2af',
|
||
}),
|
||
// Rosé Pine — soft rosy/iris dark, plus the lighter Moon and the light Dawn.
|
||
'rose-pine': pal({
|
||
bg: '#191724',
|
||
fg: '#e0def4',
|
||
card: '#1f1d2e',
|
||
muted: '#26233a',
|
||
mutedFg: '#908caa',
|
||
accent: '#403d52',
|
||
border: '#403d52',
|
||
sidebar: '#1f1d2e',
|
||
sidebarAccent: '#26233a',
|
||
destructive: '#eb6f92',
|
||
destructiveFg: '#191724',
|
||
success: '#9ccfd8',
|
||
warning: '#f6c177',
|
||
}),
|
||
'rose-pine-moon': pal({
|
||
bg: '#232136',
|
||
fg: '#e0def4',
|
||
card: '#2a273f',
|
||
muted: '#393552',
|
||
mutedFg: '#908caa',
|
||
accent: '#44415a',
|
||
border: '#44415a',
|
||
sidebar: '#2a273f',
|
||
sidebarAccent: '#393552',
|
||
destructive: '#eb6f92',
|
||
destructiveFg: '#232136',
|
||
success: '#9ccfd8',
|
||
warning: '#f6c177',
|
||
}),
|
||
'rose-pine-dawn': pal({
|
||
bg: '#faf4ed',
|
||
fg: '#575279',
|
||
card: '#fffaf3',
|
||
muted: '#f2e9e1',
|
||
mutedFg: '#797593',
|
||
accent: '#dfdad9',
|
||
border: '#dfdad9',
|
||
sidebar: '#f2e9e1',
|
||
sidebarAccent: '#dfdad9',
|
||
destructive: '#b4637a',
|
||
destructiveFg: '#faf4ed',
|
||
success: '#286983',
|
||
warning: '#ea9d34',
|
||
}),
|
||
// Nord — cool arctic dark.
|
||
nord: pal({
|
||
bg: '#2e3440',
|
||
fg: '#e5e9f0',
|
||
card: '#3b4252',
|
||
muted: '#3b4252',
|
||
mutedFg: '#9aa1b2',
|
||
accent: '#434c5e',
|
||
border: '#434c5e',
|
||
sidebar: '#2b303b',
|
||
sidebarAccent: '#434c5e',
|
||
destructive: '#bf616a',
|
||
destructiveFg: '#eceff4',
|
||
success: '#a3be8c',
|
||
warning: '#ebcb8b',
|
||
}),
|
||
// Everforest — soft warm green, dark + light.
|
||
'everforest-dark': pal({
|
||
bg: '#2d353b',
|
||
fg: '#d3c6aa',
|
||
card: '#343f44',
|
||
muted: '#343f44',
|
||
mutedFg: '#9da9a0',
|
||
accent: '#3d484d',
|
||
border: '#475258',
|
||
sidebar: '#232a2e',
|
||
sidebarAccent: '#3d484d',
|
||
destructive: '#e67e80',
|
||
destructiveFg: '#2d353b',
|
||
success: '#a7c080',
|
||
warning: '#dbbc7f',
|
||
}),
|
||
'everforest-light': pal({
|
||
bg: '#efebd4',
|
||
fg: '#5c6a72',
|
||
card: '#fdf6e3',
|
||
muted: '#e6e2cc',
|
||
mutedFg: '#829181',
|
||
accent: '#e0dcc7',
|
||
border: '#e0dcc7',
|
||
sidebar: '#f4f0d9',
|
||
sidebarAccent: '#e6e2cc',
|
||
destructive: '#f85552',
|
||
destructiveFg: '#fdf6e3',
|
||
success: '#8da101',
|
||
warning: '#dfa000',
|
||
}),
|
||
};
|
||
|
||
/** A palette fixes light/dark; 'default' defers to mode. */
|
||
export function paletteResolved(p: Palette): 'light' | 'dark' | null {
|
||
if (p === 'default') {
|
||
return null;
|
||
}
|
||
|
||
return LIGHT_PALETTES.has(p) ? 'light' : 'dark';
|
||
}
|
||
|
||
export interface PaletteSpec {
|
||
id: Palette;
|
||
label: string;
|
||
/** Picker preview: surface tile + three signature accent dots. */
|
||
preview: { bg: string; surface: string; dots: [string, string, string] };
|
||
}
|
||
|
||
export const PALETTE_OPTIONS: PaletteSpec[] = [
|
||
{
|
||
id: 'default',
|
||
label: '默认',
|
||
preview: { bg: '#f1f5f9', surface: '#ffffff', dots: ['#8561bd', '#10b981', '#f43f5e'] },
|
||
},
|
||
{
|
||
id: 'catppuccin-latte',
|
||
label: 'Latte',
|
||
preview: { bg: '#eff1f5', surface: '#ffffff', dots: ['#8839ef', '#40a02b', '#d20f39'] },
|
||
},
|
||
{
|
||
id: 'catppuccin-frappe',
|
||
label: 'Frappé',
|
||
preview: { bg: '#303446', surface: '#414559', dots: ['#ca9ee6', '#a6d189', '#e78284'] },
|
||
},
|
||
{
|
||
id: 'catppuccin-macchiato',
|
||
label: 'Macchiato',
|
||
preview: { bg: '#24273a', surface: '#363a4f', dots: ['#c6a0f6', '#a6da95', '#ed8796'] },
|
||
},
|
||
{
|
||
id: 'catppuccin-mocha',
|
||
label: 'Mocha',
|
||
preview: { bg: '#1e1e2e', surface: '#313244', dots: ['#cba6f7', '#a6e3a1', '#f38ba8'] },
|
||
},
|
||
{
|
||
id: 'rose-pine',
|
||
label: 'Rosé Pine',
|
||
preview: { bg: '#191724', surface: '#1f1d2e', dots: ['#c4a7e7', '#9ccfd8', '#eb6f92'] },
|
||
},
|
||
{
|
||
id: 'rose-pine-moon',
|
||
label: 'Rosé Pine Moon',
|
||
preview: { bg: '#232136', surface: '#2a273f', dots: ['#c4a7e7', '#9ccfd8', '#eb6f92'] },
|
||
},
|
||
{
|
||
id: 'rose-pine-dawn',
|
||
label: 'Rosé Pine Dawn',
|
||
preview: { bg: '#faf4ed', surface: '#fffaf3', dots: ['#907aa9', '#286983', '#b4637a'] },
|
||
},
|
||
{
|
||
id: 'nord',
|
||
label: 'Nord',
|
||
preview: { bg: '#2e3440', surface: '#3b4252', dots: ['#81a1c1', '#a3be8c', '#bf616a'] },
|
||
},
|
||
{
|
||
id: 'everforest-dark',
|
||
label: 'Everforest 暗',
|
||
preview: { bg: '#2d353b', surface: '#343f44', dots: ['#a7c080', '#7fbbb3', '#e67e80'] },
|
||
},
|
||
{
|
||
id: 'everforest-light',
|
||
label: 'Everforest 亮',
|
||
preview: { bg: '#efebd4', surface: '#fdf6e3', dots: ['#8da101', '#3a94c5', '#f85552'] },
|
||
},
|
||
];
|
||
|
||
function paletteVarsCss(a: UiAppearance): string {
|
||
if (a.palette === 'default') {
|
||
return '';
|
||
}
|
||
|
||
const vars = PALETTES[a.palette];
|
||
|
||
if (!vars) {
|
||
return '';
|
||
}
|
||
|
||
const body = Object.entries(vars)
|
||
.map(([k, v]) => `${k}:${v};`)
|
||
.join('');
|
||
// Light palette → :root; dark → .dark (resolved is forced to match in the
|
||
// provider, so the selector always matches the active scheme).
|
||
const selector = LIGHT_PALETTES.has(a.palette) ? ':root' : '.dark';
|
||
return `${selector}{${body}}`;
|
||
}
|
||
|
||
// Guards the locally-tamperable cache from injecting arbitrary CSS through the
|
||
// `--font-sans`/`--font-mono` declaration. Mirrors the server's fontFamilyOr.
|
||
const FONT_FAMILY_RE = /^[\p{L}\p{N}\s,'"._-]+$/u;
|
||
|
||
function safeFontFamily(value: string): string | null {
|
||
const v = value.trim();
|
||
|
||
if (v.length === 0 || v.length > 200 || !FONT_FAMILY_RE.test(v)) {
|
||
return null;
|
||
}
|
||
|
||
return v;
|
||
}
|
||
|
||
/** Resolve the active font stack: a preset's stack, or — when `id === 'custom'`
|
||
* and the free-form family is valid — that family with a generic fallback
|
||
* appended (so a missing font still degrades gracefully). */
|
||
function resolveFontStack(
|
||
options: FontSpec[],
|
||
id: string,
|
||
custom: string,
|
||
generic: string,
|
||
): string {
|
||
if (id === FONT_CUSTOM_ID) {
|
||
const fam = safeFontFamily(custom);
|
||
|
||
if (fam) {
|
||
return `${fam}, ${generic}`;
|
||
}
|
||
|
||
return options[0].stack; // 'custom' selected but empty/invalid → default preset
|
||
}
|
||
|
||
return (options.find((f) => f.id === id) ?? options[0]).stack;
|
||
}
|
||
|
||
/** Build the `:root{ --token: value; … }` block from whitelisted cssVar
|
||
* overrides. Re-validates keys/values defensively (the cache is tamperable;
|
||
* the server is the real validator). Applied last so it wins over palette. */
|
||
const CSS_VAR_WHITELIST = new Set<string>([
|
||
'--background',
|
||
'--foreground',
|
||
'--card',
|
||
'--card-foreground',
|
||
'--popover',
|
||
'--popover-foreground',
|
||
'--primary',
|
||
'--primary-foreground',
|
||
'--secondary',
|
||
'--secondary-foreground',
|
||
'--muted',
|
||
'--muted-foreground',
|
||
'--accent',
|
||
'--accent-foreground',
|
||
'--destructive',
|
||
'--destructive-foreground',
|
||
'--success',
|
||
'--warning',
|
||
'--border',
|
||
'--input',
|
||
'--ring',
|
||
'--sidebar',
|
||
'--sidebar-foreground',
|
||
'--sidebar-primary',
|
||
'--sidebar-primary-foreground',
|
||
'--sidebar-accent',
|
||
'--sidebar-accent-foreground',
|
||
'--sidebar-border',
|
||
'--sidebar-ring',
|
||
]);
|
||
const CSS_COLOR_RE =
|
||
/^(?:#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|[a-zA-Z]{1,24}|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix)\([a-zA-Z0-9.,%/\s#-]{1,120}\))$/;
|
||
|
||
function cssVarsCss(a: UiAppearance): string {
|
||
const vars = a.cssVars;
|
||
|
||
if (!vars || typeof vars !== 'object') {
|
||
return '';
|
||
}
|
||
|
||
const decls = Object.entries(vars)
|
||
.filter(
|
||
([k, v]) =>
|
||
CSS_VAR_WHITELIST.has(k) && typeof v === 'string' && CSS_COLOR_RE.test(v.trim()),
|
||
)
|
||
.map(([k, v]) => `${k}:${v.trim()};`)
|
||
.join('');
|
||
return decls ? `:root{${decls}}` : '';
|
||
}
|
||
|
||
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);
|
||
root.setAttribute('data-theme', resolved);
|
||
root.style.colorScheme = resolved;
|
||
|
||
root.setAttribute('data-density', a.density);
|
||
// A Catppuccin flavor owns its own darkness, so the OLED "black" override
|
||
// must not fight it — force a neutral intensity (it has no CSS rule) so the
|
||
// injected palette wins over base .dark.
|
||
root.setAttribute('data-dark-intensity', a.palette === 'default' ? a.darkIntensity : 'soft');
|
||
root.setAttribute('data-palette', a.palette);
|
||
root.setAttribute('data-sidebar-style', a.sidebarStyle);
|
||
root.setAttribute('data-contrast', a.highContrast ? 'high' : 'normal');
|
||
// 减弱动效 OR 关闭全部动效 both engage the reduce-motion CSS/Framer layer;
|
||
// 关闭全部动效 additionally sets data-no-motion (kills CSS animations outright
|
||
// and gates Framer entrance fades that reduce-motion leaves on).
|
||
const systemReduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||
root.setAttribute(
|
||
'data-reduce-motion',
|
||
a.reduceMotion || a.disableMotion || systemReduce ? '1' : '0',
|
||
);
|
||
root.setAttribute('data-no-motion', a.disableMotion ? '1' : '0');
|
||
// Mode-independent vars go inline on :root.
|
||
root.style.setProperty('--radius', `${a.radius}rem`);
|
||
root.style.setProperty(
|
||
'--font-sans',
|
||
resolveFontStack(FONT_SANS_OPTIONS, a.fontSans, a.fontSansCustom, 'system-ui, sans-serif'),
|
||
);
|
||
root.style.setProperty(
|
||
'--font-mono',
|
||
resolveFontStack(
|
||
FONT_MONO_OPTIONS,
|
||
a.fontMono,
|
||
a.fontMonoCustom,
|
||
'ui-monospace, monospace',
|
||
),
|
||
);
|
||
// UI scale: scale the root font-size so all rem-based sizing tracks it.
|
||
// Clamp defensively (the localStorage cache is locally tamperable; the
|
||
// server already bounds this to 0.9..1.2 for its own values).
|
||
const scale = Math.min(2, Math.max(0.5, Number.isFinite(a.uiScale) ? a.uiScale : 1));
|
||
root.style.fontSize = `${Math.round(16 * scale * 100) / 100}px`;
|
||
|
||
// Accent differs by light/dark, so it needs a stylesheet with a `.dark` rule.
|
||
const styleId = 'snowluma-theme-overrides';
|
||
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
||
|
||
if (!el) {
|
||
el = document.createElement('style');
|
||
el.id = styleId;
|
||
document.head.appendChild(el);
|
||
}
|
||
|
||
// 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 = 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
|
||
// hatch) so a broken rule can't lock the operator out of fixing it.
|
||
const cssId = 'snowluma-custom-css';
|
||
let cssEl = document.getElementById(cssId) as HTMLStyleElement | null;
|
||
// Apply custom CSS ONLY when authed — the cache (snowluma_ui_appearance)
|
||
// may hold customCss from a prior session, and applyAppearance runs at boot
|
||
// before auth; without this gate that cached CSS would hit the login page
|
||
// (bypassing the server-side strip) and could lock the operator out.
|
||
const css = isSafeMode() || !authToken() ? '' : a.customCss || '';
|
||
|
||
if (css) {
|
||
if (!cssEl) {
|
||
cssEl = document.createElement('style');
|
||
cssEl.id = cssId;
|
||
document.head.appendChild(cssEl);
|
||
}
|
||
|
||
if (cssEl.textContent !== css) {
|
||
cssEl.textContent = css;
|
||
}
|
||
} else if (cssEl) {
|
||
cssEl.textContent = '';
|
||
}
|
||
|
||
// Read the rendered canvas after palette/accent/custom overrides are in
|
||
// place, so the browser chrome follows the current (not previous) theme.
|
||
const themeMeta = document.querySelector('meta[name="theme-color"]');
|
||
|
||
if (themeMeta) {
|
||
themeMeta.setAttribute('content', getComputedStyle(document.body).backgroundColor);
|
||
}
|
||
}
|
||
|
||
/** Manage the fixed full-viewport background layer behind the app. */
|
||
function applyBackgroundLayer(a: UiAppearance): void {
|
||
const id = 'snowluma-bg-layer';
|
||
let layer = document.getElementById(id) as HTMLDivElement | null;
|
||
const bg = a.background;
|
||
|
||
if (bg.type === 'none') {
|
||
if (layer) {
|
||
layer.style.display = 'none';
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (!layer) {
|
||
layer = document.createElement('div');
|
||
layer.id = id;
|
||
layer.setAttribute('aria-hidden', 'true');
|
||
// Behind app content, ignores pointer events, fixed to the viewport.
|
||
layer.style.position = 'fixed';
|
||
layer.style.inset = '0';
|
||
layer.style.zIndex = '-1';
|
||
layer.style.pointerEvents = 'none';
|
||
layer.style.backgroundSize = 'cover';
|
||
layer.style.backgroundPosition = 'center';
|
||
layer.style.backgroundRepeat = 'no-repeat';
|
||
layer.style.overflow = 'hidden';
|
||
document.body.insertBefore(layer, document.body.firstChild);
|
||
}
|
||
|
||
layer.style.display = 'block';
|
||
layer.style.filter = '';
|
||
layer.style.transform = '';
|
||
|
||
if (bg.type !== 'image') {
|
||
layer.replaceChildren();
|
||
}
|
||
|
||
if (bg.type === 'solid') {
|
||
layer.style.backgroundColor = bg.color;
|
||
layer.style.backgroundImage = 'none';
|
||
} else if (bg.type === 'gradient') {
|
||
const g = GRADIENT_OPTIONS.find((x) => x.id === bg.gradient) ?? GRADIENT_OPTIONS[0];
|
||
layer.style.backgroundColor = 'transparent';
|
||
layer.style.backgroundImage = g.css;
|
||
} else if (bg.type === 'image' && bg.hasImage) {
|
||
// Keep url() and the readability overlay on separate nodes. WebKit drops
|
||
// a whole `background-image` when color-mix(oklab, oklch-var) sits inside
|
||
// a gradient that is comma-stacked with a url().
|
||
layer.style.backgroundColor = 'transparent';
|
||
layer.style.backgroundImage = 'none';
|
||
layer.replaceChildren();
|
||
const image = document.createElement('div');
|
||
image.style.position = 'absolute';
|
||
image.style.inset = '-6%';
|
||
image.style.backgroundImage = `url("/ui-asset/background?v=${bg.imageVersion}")`;
|
||
image.style.backgroundSize = 'cover';
|
||
image.style.backgroundPosition = 'center';
|
||
|
||
if (bg.imageBlur > 0) {
|
||
image.style.filter = `blur(${bg.imageBlur}px)`;
|
||
}
|
||
|
||
const veil = document.createElement('div');
|
||
veil.style.position = 'absolute';
|
||
veil.style.inset = '0';
|
||
veil.style.backgroundColor = `color-mix(in oklab, var(--background) ${Math.round(bg.imageOpacity * 100)}%, transparent)`;
|
||
layer.append(image, veil);
|
||
} else {
|
||
// type === 'image' but no image on disk → nothing to show.
|
||
layer.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ─── Server transport (ThemeProvider sits outside ApiProvider, so it uses a
|
||
// direct fetch with the bearer token from localStorage) ─────────────────
|
||
|
||
function authToken(): string | null {
|
||
try {
|
||
return localStorage.getItem(TOKEN_KEY);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function withAppearanceDefaults(value: Partial<UiAppearance>): UiAppearance {
|
||
return {
|
||
...DEFAULT_APPEARANCE,
|
||
...value,
|
||
background: {
|
||
...DEFAULT_APPEARANCE.background,
|
||
...value.background,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function fetchAppearance(): Promise<UiAppearance | null> {
|
||
const token = authToken();
|
||
|
||
try {
|
||
// Authed: fetch the full config so we get `customCss` (which the public
|
||
// subset strips). Pre-auth (login page): the cosmetic public subset only.
|
||
if (token) {
|
||
const res = await fetch('/api/ui', { headers: webuiSessionHeaders(token) });
|
||
|
||
if (res.ok) {
|
||
const data = (await res.json()) as {
|
||
config?: { appearance?: Partial<UiAppearance> };
|
||
};
|
||
|
||
if (data.config?.appearance) {
|
||
return withAppearanceDefaults(data.config.appearance);
|
||
}
|
||
}
|
||
// 401/expired → fall through to the public subset.
|
||
}
|
||
|
||
const res = await fetch('/api/ui/public');
|
||
|
||
if (!res.ok) {
|
||
return null;
|
||
}
|
||
|
||
const data = (await res.json()) as { appearance?: Partial<UiAppearance> };
|
||
return data.appearance ? withAppearanceDefaults(data.appearance) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** `?safe-mode=1` disables custom CSS so a broken rule can't lock the operator
|
||
* out. Sticky for the session (sessionStorage) so it survives in-app
|
||
* navigation — and so editing the CSS to fix it doesn't re-inject the broken
|
||
* rule mid-fix. Clears when the tab/session closes. */
|
||
function isSafeMode(): boolean {
|
||
try {
|
||
if (new URLSearchParams(window.location.search).get('safe-mode') === '1') {
|
||
sessionStorage.setItem('snowluma_safe_mode', '1');
|
||
return true;
|
||
}
|
||
|
||
return sessionStorage.getItem('snowluma_safe_mode') === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function persistAppearance(appearance: UiAppearance): Promise<void> {
|
||
const token = authToken();
|
||
|
||
if (!token) {
|
||
return;
|
||
} // pre-auth: local cache only (no settings UI exists there anyway)
|
||
|
||
const response = await fetch('/api/ui', {
|
||
method: 'POST',
|
||
headers: webuiSessionHeaders(token, { 'Content-Type': 'application/json' }),
|
||
// Section-level save: only `appearance`; the server preserves `layout`.
|
||
body: JSON.stringify({ appearance }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const payload = (await response.json().catch(() => ({}))) as {
|
||
message?: string;
|
||
error?: string;
|
||
};
|
||
throw new Error(
|
||
payload.message || payload.error || `外观保存失败(HTTP ${response.status})`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── Light/dark reveal (View Transitions API) ──────────────────────────────
|
||
// Where the user last clicked, so the theme reveal emanates from there.
|
||
let lastPointer: { x: number; y: number } | null = null;
|
||
|
||
/** Run `apply` (a synchronous state commit) inside a circular light/dark
|
||
* reveal centered on the last click. flushSync forces the DOM to update inside
|
||
* the transition callback so the "after" snapshot is captured. Falls back to
|
||
* an instant apply where View Transitions aren't supported (e.g. Firefox). */
|
||
function runThemeReveal(apply: () => void): void {
|
||
const doc = document as Document & {
|
||
startViewTransition?: (cb: () => void) => { finished?: Promise<unknown> };
|
||
};
|
||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
if (
|
||
typeof doc.startViewTransition !== 'function' ||
|
||
document.visibilityState === 'hidden' ||
|
||
reduced
|
||
) {
|
||
apply();
|
||
return;
|
||
}
|
||
|
||
const root = document.documentElement;
|
||
const x = lastPointer?.x ?? window.innerWidth / 2;
|
||
const y = lastPointer?.y ?? window.innerHeight / 2;
|
||
const r = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
|
||
root.style.setProperty('--vt-x', `${x}px`);
|
||
root.style.setProperty('--vt-y', `${y}px`);
|
||
root.style.setProperty('--vt-r', `${r}px`);
|
||
|
||
try {
|
||
const transition = doc.startViewTransition(() => flushSync(apply));
|
||
void (transition?.finished ?? Promise.resolve()).catch(() => {});
|
||
} catch {
|
||
apply();
|
||
}
|
||
}
|
||
|
||
function readCache(): UiAppearance {
|
||
try {
|
||
const raw = localStorage.getItem(LS_CACHE);
|
||
|
||
if (!raw) {
|
||
return DEFAULT_APPEARANCE;
|
||
}
|
||
|
||
const parsed = JSON.parse(raw) as Partial<UiAppearance>;
|
||
// Shallow merge over defaults so a cache written by an older build still
|
||
// yields a complete object (the server is the real validator).
|
||
return withAppearanceDefaults(parsed);
|
||
} catch {
|
||
return DEFAULT_APPEARANCE;
|
||
}
|
||
}
|
||
|
||
function writeCache(a: UiAppearance): void {
|
||
try {
|
||
localStorage.setItem(LS_CACHE, JSON.stringify(a));
|
||
} catch {
|
||
/* quota / private mode */
|
||
}
|
||
}
|
||
|
||
// ─── 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';
|
||
/** Merge a partial appearance: applies instantly, caches, debounced-persists. */
|
||
setAppearance: (patch: AppearancePatch) => void;
|
||
/** Upload a wallpaper (PNG/JPEG/WebP, ≤5MB). Throws on failure. */
|
||
uploadBackground: (file: File) => Promise<void>;
|
||
/** Remove the wallpaper. */
|
||
removeBackground: () => Promise<void>;
|
||
/** Format a timestamp per the configured 12h/24h preference. */
|
||
formatClock: (input: string | number | Date) => string;
|
||
/** Re-fetch appearance from the server (e.g. after login, so the authed
|
||
* `customCss` loads). */
|
||
reloadAppearance: () => Promise<void>;
|
||
// Common appearance controls.
|
||
mode: ThemeMode;
|
||
setMode: (m: ThemeMode) => void;
|
||
pollInterval: number;
|
||
}
|
||
|
||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||
|
||
function getSystemTheme(): 'light' | 'dark' {
|
||
if (typeof window === 'undefined') {
|
||
return 'light';
|
||
}
|
||
|
||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
|
||
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);
|
||
// Mirror of the latest appearance so the (pure) setAppearance can read the
|
||
// current value without an impure state-updater closure (StrictMode-safe).
|
||
// Synced via a layout effect (not during render) + written synchronously by
|
||
// the mutators themselves so consecutive calls in one tick still compose.
|
||
const appearanceRef = useRef(appearance);
|
||
|
||
// A Catppuccin flavor fixes light/dark; otherwise honor mode (+ system).
|
||
const resolved: 'light' | 'dark' =
|
||
paletteResolved(effectiveAppearance.palette) ??
|
||
(effectiveAppearance.mode === 'system' ? systemTheme : effectiveAppearance.mode);
|
||
|
||
useLayoutEffect(() => subscribeHostAppearance(window, presentationStore), []);
|
||
|
||
// Track the OS theme for `mode: 'system'`.
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||
const handler = (e: MediaQueryListEvent) => setSystemTheme(e.matches ? 'dark' : 'light');
|
||
mq.addEventListener('change', handler);
|
||
return () => mq.removeEventListener('change', handler);
|
||
}, []);
|
||
|
||
// Load the server appearance, retaining the cache if it is unavailable.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
const server = await fetchAppearance();
|
||
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
const next = server ?? readCache();
|
||
|
||
setAppearanceState(next);
|
||
writeCache(next);
|
||
setReady(true);
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
// Keep the mutator-facing ref in sync after any state change (init load,
|
||
// system-theme flip, upload/delete). Written in an effect, never during render.
|
||
useLayoutEffect(() => {
|
||
appearanceRef.current = appearance;
|
||
}, [appearance]);
|
||
|
||
// Apply to the DOM before paint (useLayoutEffect) so the post-mount
|
||
// application doesn't flash. The pre-mount flash is handled by the inline
|
||
// bootstrap script in index.html.
|
||
useLayoutEffect(() => {
|
||
applyAppearance(effectiveAppearance, resolved, presentation);
|
||
}, [effectiveAppearance, resolved, presentation]);
|
||
useLayoutEffect(() => {
|
||
applyBackgroundLayer(effectiveAppearance);
|
||
}, [effectiveAppearance]);
|
||
|
||
// Cancel a pending save when the provider unmounts.
|
||
useEffect(
|
||
() => () => {
|
||
if (saveTimer.current) {
|
||
clearTimeout(saveTimer.current);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// Track the last click so the light/dark reveal can emanate from it.
|
||
useEffect(() => {
|
||
const onDown = (e: PointerEvent) => {
|
||
lastPointer = { x: e.clientX, y: e.clientY };
|
||
};
|
||
|
||
window.addEventListener('pointerdown', onDown, { capture: true });
|
||
return () => window.removeEventListener('pointerdown', onDown, { capture: true });
|
||
}, []);
|
||
|
||
const commit = useCallback(
|
||
(patch: AppearancePatch) => {
|
||
const prev = appearanceRef.current;
|
||
const next: UiAppearance = {
|
||
...prev,
|
||
...patch,
|
||
background: patch.background
|
||
? { ...prev.background, ...patch.background }
|
||
: prev.background,
|
||
};
|
||
appearanceRef.current = next;
|
||
setAppearanceState(next);
|
||
writeCache(next);
|
||
|
||
if (saveTimer.current) {
|
||
clearTimeout(saveTimer.current);
|
||
}
|
||
|
||
saveTimer.current = setTimeout(() => {
|
||
saveTimer.current = null;
|
||
void persistAppearance(next).catch((error) => {
|
||
console.error('persist appearance failed', error);
|
||
startAction({ title: '外观同步失败' }).fail(
|
||
`当前修改未保存:${actionErrorMessage(error)}`,
|
||
);
|
||
});
|
||
}, 400);
|
||
},
|
||
[startAction],
|
||
);
|
||
|
||
const setAppearance = useCallback(
|
||
(patch: AppearancePatch) => {
|
||
const prev = appearanceRef.current;
|
||
// Animate a circular light/dark reveal only for brightness/scheme changes
|
||
// (mode, palette, dark intensity) — and never when motion is reduced/off.
|
||
const schemeChange =
|
||
(patch.mode !== undefined && patch.mode !== prev.mode) ||
|
||
(patch.palette !== undefined && patch.palette !== prev.palette) ||
|
||
(patch.darkIntensity !== undefined && patch.darkIntensity !== prev.darkIntensity);
|
||
|
||
if (schemeChange && !prev.reduceMotion && !prev.disableMotion) {
|
||
runThemeReveal(() => commit(patch));
|
||
} else {
|
||
commit(patch);
|
||
}
|
||
},
|
||
[commit],
|
||
);
|
||
|
||
const uploadBackground = useMemo(
|
||
() => async (file: File) =>
|
||
runAction(
|
||
{
|
||
title: '正在上传背景',
|
||
detail: file.name,
|
||
successTitle: '背景已更新',
|
||
successDetail: file.name,
|
||
errorTitle: '背景上传失败',
|
||
},
|
||
async () => {
|
||
const token = authToken();
|
||
|
||
if (!token) {
|
||
throw new Error('未登录');
|
||
}
|
||
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const res = await fetch('/api/ui/background', {
|
||
method: 'POST',
|
||
headers: webuiSessionHeaders(token),
|
||
body: form,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const e = (await res.json().catch(() => ({}))) as { message?: string };
|
||
throw new Error(e.message || '上传失败');
|
||
}
|
||
|
||
const data = (await res.json()) as { config?: { appearance?: UiAppearance } };
|
||
const ap = data.config?.appearance;
|
||
|
||
if (ap) {
|
||
appearanceRef.current = ap;
|
||
setAppearanceState(ap);
|
||
writeCache(ap);
|
||
}
|
||
},
|
||
),
|
||
[runAction],
|
||
);
|
||
|
||
const removeBackground = useMemo(
|
||
() => async () =>
|
||
runAction(
|
||
{
|
||
title: '正在删除背景',
|
||
detail: '移除已上传的壁纸',
|
||
successTitle: '背景已删除',
|
||
successDetail: '已恢复为无壁纸状态',
|
||
errorTitle: '背景删除失败',
|
||
},
|
||
async () => {
|
||
const token = authToken();
|
||
|
||
if (!token) {
|
||
throw new Error('未登录');
|
||
}
|
||
|
||
const res = await fetch('/api/ui/background', {
|
||
method: 'DELETE',
|
||
headers: webuiSessionHeaders(token),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error('删除失败');
|
||
}
|
||
|
||
const data = (await res.json()) as { config?: { appearance?: UiAppearance } };
|
||
const ap = data.config?.appearance;
|
||
|
||
if (ap) {
|
||
appearanceRef.current = ap;
|
||
setAppearanceState(ap);
|
||
writeCache(ap);
|
||
}
|
||
},
|
||
),
|
||
[runAction],
|
||
);
|
||
|
||
const formatClock = useMemo(
|
||
() => (input: string | number | Date) => {
|
||
const d = input instanceof Date ? input : new Date(input);
|
||
|
||
if (Number.isNaN(d.getTime())) {
|
||
return String(input);
|
||
}
|
||
|
||
return d.toLocaleTimeString(undefined, {
|
||
hour12: appearance.timeFormat === '12h',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
});
|
||
},
|
||
[appearance.timeFormat],
|
||
);
|
||
|
||
const reloadAppearance = useCallback(async () => {
|
||
// Post-auth, the only field the pre-auth public subset lacked is
|
||
// `customCss`. Merge JUST that (via setAppearance, which preserves every
|
||
// other field — incl. a pre-auth localStorage migration — and write-through
|
||
// persists). A full overwrite here would clobber that migration with the
|
||
// server's not-yet-persisted default.
|
||
const server = await fetchAppearance();
|
||
|
||
if (server && server.customCss !== appearanceRef.current.customCss) {
|
||
setAppearance({ customCss: server.customCss });
|
||
}
|
||
}, [setAppearance]);
|
||
|
||
const value = useMemo<ThemeContextValue>(
|
||
() => ({
|
||
appearance: effectiveAppearance,
|
||
savedAppearance: appearance,
|
||
presentation,
|
||
managedBy: presentation ? WEBUI_TARGETS[presentation.target].label : null,
|
||
ready,
|
||
resolved,
|
||
setAppearance,
|
||
uploadBackground,
|
||
removeBackground,
|
||
formatClock,
|
||
reloadAppearance,
|
||
mode: effectiveAppearance.mode,
|
||
setMode: (m: ThemeMode) => setAppearance({ mode: m }),
|
||
pollInterval: appearance.pollInterval,
|
||
}),
|
||
[
|
||
appearance,
|
||
effectiveAppearance,
|
||
presentation,
|
||
ready,
|
||
resolved,
|
||
setAppearance,
|
||
uploadBackground,
|
||
removeBackground,
|
||
formatClock,
|
||
reloadAppearance,
|
||
],
|
||
);
|
||
|
||
return (
|
||
<ThemeContext.Provider value={value}>
|
||
<MotionConfig
|
||
reducedMotion={
|
||
appearance.reduceMotion || appearance.disableMotion ? 'always' : 'user'
|
||
}
|
||
>
|
||
{children}
|
||
</MotionConfig>
|
||
</ThemeContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useTheme(): ThemeContextValue {
|
||
const ctx = useContext(ThemeContext);
|
||
|
||
if (!ctx) {
|
||
throw new Error('useTheme must be used within ThemeProvider');
|
||
}
|
||
|
||
return ctx;
|
||
}
|