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> & { background?: Partial; }; // ─── 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