mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(theme): 新增自定义主题与多套低刺激预设
- 支持导入、管理和持久化自定义 CSS 主题 - 新增六套内置主题并提供低刺激舒适暗色 - 完善 V2 主题令牌、多语言、可访问性与回归测试
This commit is contained in:
@@ -89,11 +89,24 @@ describe('settings center layout', () => {
|
||||
expect(appSource).toContain("gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0'");
|
||||
expect(appSource).toContain('className="gonavi-theme-settings"');
|
||||
expect(appSource).toContain('ThemeSettingsSlider');
|
||||
expect(appSource).toContain("t('app.theme.custom.title')");
|
||||
expect(appSource).toContain('<CustomThemeManager />');
|
||||
expect(appSource).toContain('<CustomThemeManager legacyMode />');
|
||||
expect(appSource).toContain("value: 'workspace'");
|
||||
expect(appSource).toContain('gonavi-settings-tabs');
|
||||
expect(appSource).toContain('setThemeModalSection(item.value)');
|
||||
});
|
||||
|
||||
it('resolves custom-theme base mode synchronously and bridges its surfaces into Ant Design', () => {
|
||||
expect(appSource).toContain("const resolvedThemeMode = effectiveThemePreference === 'system'");
|
||||
expect(appSource).toContain("const darkMode = resolvedThemeMode === 'dark';");
|
||||
expect(appSource).toContain('const customThemeStyleContextKey = `${resolvedThemeMode}:${appearance.uiVersion}`;');
|
||||
expect(appSource).toContain('colorBgContainer: (isV2Ui ? v2AntBgContainer : undefined)');
|
||||
expect(appSource).toContain('colorBgElevated: (isV2Ui ? v2AntBgElevated : undefined)');
|
||||
expect(appSource).toContain('colorTextSecondary: v2AntTextSecondary');
|
||||
expect(appSource).toContain('rowHoverBg: (isV2Ui ? v2AntRowHoverBg : undefined)');
|
||||
});
|
||||
|
||||
it('opens theme, AI, and about entries inside settings center detail panes', () => {
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'theme')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'ai')");
|
||||
|
||||
@@ -261,13 +261,17 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain("darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'");
|
||||
});
|
||||
|
||||
it('keeps tool center and settings v2 accents on the green palette instead of legacy yellow or blue tokens', () => {
|
||||
expect(appSource).toContain("const v2AntPrimaryColor = darkMode ? '#22c55e' : '#16a34a';");
|
||||
it('keeps the green v2 accent as fallback while allowing custom CSS tokens to override it', () => {
|
||||
expect(appSource).toContain("const v2AntPrimaryColor = customThemeAntTokens.primary ?? (darkMode ? '#22c55e' : '#16a34a');");
|
||||
expect(appSource).toContain("const v2AntPrimaryContrastColor = customThemeAntTokens.primaryContrast ?? '#ffffff';");
|
||||
expect(appSource).toContain('extractCustomThemeAntTokens(activeCustomTheme.css)');
|
||||
expect(appSource).toContain('resolveAvailableCustomTheme(customThemes, activeCustomThemeId)');
|
||||
expect(appSource).toContain('colorTextLightSolid: isV2Ui ? v2AntPrimaryContrastColor');
|
||||
expect(appSource).toContain("colorPrimary: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff')");
|
||||
expect(appSource).toContain("background: active\n ? overlayTheme.selectedBg");
|
||||
expect(appSource).toContain("background: active\n ? overlayTheme.selectedText");
|
||||
expect(appSource).toContain("background: active\n ? overlayTheme.iconBg");
|
||||
expect(appSource).toContain("color: active\n ? overlayTheme.iconColor");
|
||||
expect(appSource).toMatch(/background:\s*active\s*\?\s*overlayTheme\.selectedBg/);
|
||||
expect(appSource).toMatch(/background:\s*active\s*\?\s*overlayTheme\.selectedText/);
|
||||
expect(appSource).toMatch(/background:\s*active\s*\?\s*overlayTheme\.iconBg/);
|
||||
expect(appSource).toMatch(/color:\s*active\s*\?\s*overlayTheme\.iconColor/);
|
||||
expect(appSource).toContain("background: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)')");
|
||||
expect(appSource).toContain("color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff')");
|
||||
});
|
||||
@@ -368,7 +372,7 @@ describe('tool center menu entries', () => {
|
||||
['newConnection', 'handleCreateConnection();'],
|
||||
['toggleAIPanel', 'toggleAIPanel();'],
|
||||
['toggleLogPanel', 'handleToggleLogPanel();'],
|
||||
['toggleTheme', 'setThemePreference('],
|
||||
['toggleTheme', 'selectPresetTheme('],
|
||||
['openShortcutManager', 'setIsShortcutModalOpen(true);'],
|
||||
['toggleMacFullscreen', 'handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'],
|
||||
['resetWindowZoom', 'handleManualResetWindowZoom();'],
|
||||
|
||||
@@ -28,13 +28,19 @@ import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModa
|
||||
import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal';
|
||||
import LanguageSettingsPanel from './components/LanguageSettingsPanel';
|
||||
import WebAuthSettingsPanel from './components/WebAuthSettingsPanel';
|
||||
import CustomThemeManager from './components/settings/CustomThemeManager';
|
||||
import CustomThemeStyleHost, {
|
||||
type CustomThemeAntTokenSnapshot,
|
||||
} from './components/theme/CustomThemeStyleHost';
|
||||
import {
|
||||
DEFAULT_APPEARANCE,
|
||||
MAX_V2_SIDEBAR_RAIL_SCALE,
|
||||
MIN_V2_SIDEBAR_RAIL_SCALE,
|
||||
sanitizeV2SidebarRailScale,
|
||||
type ThemePreference,
|
||||
useStore,
|
||||
} from './store';
|
||||
import { useCustomThemeStore } from './customThemeStore';
|
||||
import { GlobalProxyConfig, SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isMacLikePlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { buildFontFamilyOptions, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, getLinuxCJKFontInstallHint, matchFontFamilyOption, resolveMonoFontFamily, resolveUIFontFamily, sanitizeFontFamilyInput, type FontFamilyOption, type InstalledFontFamily } from './utils/fontFamilies';
|
||||
@@ -66,6 +72,10 @@ import {
|
||||
normalizeConnectionPackagePassword,
|
||||
} from './utils/connectionExport';
|
||||
import { downloadBrowserTextFile } from './utils/browserFileTransfer';
|
||||
import {
|
||||
extractCustomThemeAntTokens,
|
||||
} from './utils/customTheme';
|
||||
import { resolveAvailableCustomTheme } from './utils/customThemePresets';
|
||||
import {
|
||||
mergeRedisDbAliases,
|
||||
sanitizeRedisDbAliases,
|
||||
@@ -634,6 +644,9 @@ function App() {
|
||||
const themePreference = useStore(state => state.themePreference);
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
const setThemePreference = useStore(state => state.setThemePreference);
|
||||
const customThemes = useCustomThemeStore(state => state.themes);
|
||||
const activeCustomThemeId = useCustomThemeStore(state => state.activeThemeId);
|
||||
const selectCustomTheme = useCustomThemeStore(state => state.selectCustomTheme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const setAppearance = useStore(state => state.setAppearance);
|
||||
const uiScale = useStore(state => state.uiScale);
|
||||
@@ -652,7 +665,27 @@ function App() {
|
||||
const updateShortcut = useStore(state => state.updateShortcut);
|
||||
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
|
||||
const [systemThemeMode, setSystemThemeMode] = useState<'light' | 'dark'>(() => getSystemThemeMode());
|
||||
const darkMode = themeMode === 'dark';
|
||||
const activeCustomTheme = useMemo(
|
||||
() => resolveAvailableCustomTheme(customThemes, activeCustomThemeId),
|
||||
[activeCustomThemeId, customThemes],
|
||||
);
|
||||
const effectiveThemePreference = activeCustomTheme?.baseMode ?? themePreference;
|
||||
const resolvedThemeMode = effectiveThemePreference === 'system'
|
||||
? systemThemeMode
|
||||
: effectiveThemePreference;
|
||||
const darkMode = resolvedThemeMode === 'dark';
|
||||
const sourceCustomThemeAntTokens = useMemo(
|
||||
() => activeCustomTheme ? extractCustomThemeAntTokens(activeCustomTheme.css) : {},
|
||||
[activeCustomTheme],
|
||||
);
|
||||
const [computedCustomThemeAntTokens, setComputedCustomThemeAntTokens] = useState<CustomThemeAntTokenSnapshot | null>(null);
|
||||
const customThemeStyleContextKey = `${resolvedThemeMode}:${appearance.uiVersion}`;
|
||||
const customThemeAntTokens = activeCustomTheme
|
||||
&& computedCustomThemeAntTokens?.themeId === activeCustomTheme.id
|
||||
&& computedCustomThemeAntTokens.themeRevision === activeCustomTheme.updatedAt
|
||||
&& computedCustomThemeAntTokens.contextKey === customThemeStyleContextKey
|
||||
? computedCustomThemeAntTokens.tokens
|
||||
: sourceCustomThemeAntTokens;
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
|
||||
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
|
||||
@@ -740,20 +773,28 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const resolvedTheme = themePreference === 'system' ? systemThemeMode : themePreference;
|
||||
if (themeMode !== resolvedTheme) {
|
||||
setTheme(resolvedTheme);
|
||||
if (themeMode !== resolvedThemeMode) {
|
||||
setTheme(resolvedThemeMode);
|
||||
}
|
||||
if (themePreference === 'system') {
|
||||
if (effectiveThemePreference === 'system') {
|
||||
void safeWindowRuntimeCall(() => WindowSetSystemDefaultTheme(), undefined);
|
||||
return;
|
||||
}
|
||||
if (resolvedTheme === 'dark') {
|
||||
if (resolvedThemeMode === 'dark') {
|
||||
void safeWindowRuntimeCall(() => WindowSetDarkTheme(), undefined);
|
||||
return;
|
||||
}
|
||||
void safeWindowRuntimeCall(() => WindowSetLightTheme(), undefined);
|
||||
}, [setTheme, systemThemeMode, themeMode, themePreference]);
|
||||
}, [effectiveThemePreference, resolvedThemeMode, setTheme, themeMode]);
|
||||
const selectPresetTheme = useCallback((preference: ThemePreference) => {
|
||||
// Custom CSS is an independent skin layer. Selecting a built-in preset
|
||||
// first disables that layer, then preserves the existing 3-mode contract.
|
||||
if (activeCustomTheme) {
|
||||
const result = selectCustomTheme(null);
|
||||
if (!result.ok) message.warning(t('app.theme.custom.error.storage_failed'));
|
||||
}
|
||||
setThemePreference(preference);
|
||||
}, [activeCustomTheme, selectCustomTheme, setThemePreference, t]);
|
||||
const setTabDisplaySettings = useCallback((settings: Partial<TabDisplaySettings>) => {
|
||||
setAppearance({
|
||||
tabDisplay: applyTabDisplaySettingsPatch(tabDisplaySettings, settings),
|
||||
@@ -3615,6 +3656,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
document.body.style.backgroundColor = 'transparent';
|
||||
document.body.style.color = darkMode ? '#ffffff' : '#000000';
|
||||
document.documentElement.style.colorScheme = darkMode ? 'dark' : 'light';
|
||||
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
document.body.setAttribute('data-ui-version', appearance.uiVersion);
|
||||
document.body.setAttribute('data-platform', runtimePlatform || '');
|
||||
@@ -3768,7 +3810,7 @@ function App() {
|
||||
handleToggleLogPanel();
|
||||
break;
|
||||
case 'toggleTheme':
|
||||
setThemePreference(themeMode === 'dark' ? 'light' : 'dark');
|
||||
selectPresetTheme(themeMode === 'dark' ? 'light' : 'dark');
|
||||
break;
|
||||
case 'openShortcutManager':
|
||||
setIsShortcutModalOpen(true);
|
||||
@@ -3788,7 +3830,7 @@ function App() {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalShortcut, true);
|
||||
};
|
||||
}, [activeShortcutPlatform, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, shortcutOptions, switchActiveTabByOffset, themeMode, setThemePreference, toggleAIPanel, useNativeMacWindowControls]);
|
||||
}, [activeShortcutPlatform, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, selectPresetTheme, shortcutOptions, switchActiveTabByOffset, themeMode, toggleAIPanel, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturingShortcutAction) {
|
||||
@@ -3870,16 +3912,25 @@ function App() {
|
||||
const resizeGuideColor = isV2Ui
|
||||
? 'var(--gn-accent, #16a34a)'
|
||||
: (darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)');
|
||||
const v2AntPrimaryColor = darkMode ? '#22c55e' : '#16a34a';
|
||||
const v2AntPrimaryHoverColor = darkMode ? '#4ade80' : '#15803d';
|
||||
const v2AntPrimaryActiveColor = darkMode ? '#16a34a' : '#166534';
|
||||
const v2AntPrimaryBgColor = darkMode ? 'rgba(34, 197, 94, 0.20)' : '#dcfce7';
|
||||
const v2AntPrimaryBgHoverColor = darkMode ? 'rgba(34, 197, 94, 0.28)' : '#bbf7d0';
|
||||
const v2AntPrimaryBorderColor = darkMode ? 'rgba(34, 197, 94, 0.42)' : '#86efac';
|
||||
const v2AntPrimaryBorderHoverColor = darkMode ? 'rgba(74, 222, 128, 0.58)' : '#4ade80';
|
||||
const v2AntControlActiveBg = darkMode ? 'rgba(34, 197, 94, 0.16)' : 'rgba(34, 197, 94, 0.10)';
|
||||
const v2AntControlActiveHoverBg = darkMode ? 'rgba(34, 197, 94, 0.24)' : 'rgba(34, 197, 94, 0.16)';
|
||||
const v2AntControlOutline = darkMode ? 'rgba(34, 197, 94, 0.42)' : 'rgba(22, 163, 74, 0.22)';
|
||||
const v2AntPrimaryColor = customThemeAntTokens.primary ?? (darkMode ? '#22c55e' : '#16a34a');
|
||||
const v2AntPrimaryContrastColor = customThemeAntTokens.primaryContrast ?? '#ffffff';
|
||||
const v2AntPrimaryHoverColor = customThemeAntTokens.primaryHover ?? (darkMode ? '#4ade80' : '#15803d');
|
||||
const v2AntPrimaryActiveColor = customThemeAntTokens.primaryActive ?? (darkMode ? '#16a34a' : '#166534');
|
||||
const v2AntPrimaryBgColor = customThemeAntTokens.primaryBg ?? (darkMode ? 'rgba(34, 197, 94, 0.20)' : '#dcfce7');
|
||||
const v2AntPrimaryBgHoverColor = customThemeAntTokens.primaryBgHover ?? (darkMode ? 'rgba(34, 197, 94, 0.28)' : '#bbf7d0');
|
||||
const v2AntPrimaryBorderColor = customThemeAntTokens.primaryBorder ?? (darkMode ? 'rgba(34, 197, 94, 0.42)' : '#86efac');
|
||||
const v2AntPrimaryBorderHoverColor = customThemeAntTokens.primaryBorderHover ?? (darkMode ? 'rgba(74, 222, 128, 0.58)' : '#4ade80');
|
||||
const v2AntControlActiveBg = customThemeAntTokens.controlActiveBg ?? (darkMode ? 'rgba(34, 197, 94, 0.16)' : 'rgba(34, 197, 94, 0.10)');
|
||||
const v2AntControlActiveHoverBg = customThemeAntTokens.controlActiveHoverBg ?? (darkMode ? 'rgba(34, 197, 94, 0.24)' : 'rgba(34, 197, 94, 0.16)');
|
||||
const v2AntControlOutline = customThemeAntTokens.controlOutline ?? (darkMode ? 'rgba(34, 197, 94, 0.42)' : 'rgba(22, 163, 74, 0.22)');
|
||||
const v2AntBgContainer = customThemeAntTokens.bgContainer;
|
||||
const v2AntBgElevated = customThemeAntTokens.bgElevated;
|
||||
const v2AntFillAlter = customThemeAntTokens.fillAlter;
|
||||
const v2AntTextPrimary = customThemeAntTokens.textPrimary;
|
||||
const v2AntTextSecondary = customThemeAntTokens.textSecondary;
|
||||
const v2AntBorder = customThemeAntTokens.border;
|
||||
const v2AntRowHoverBg = customThemeAntTokens.rowHoverBg;
|
||||
const v2AntInfoColor = customThemeAntTokens.info ?? v2AntPrimaryColor;
|
||||
const antdTheme = useMemo(() => ({
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
@@ -3892,19 +3943,26 @@ function App() {
|
||||
controlHeightSM: tokenControlHeightSM,
|
||||
controlHeightLG: tokenControlHeightLG,
|
||||
colorBgLayout: 'transparent',
|
||||
colorBgContainer: darkMode
|
||||
colorBgContainer: (isV2Ui ? v2AntBgContainer : undefined) ?? (darkMode
|
||||
? `rgba(29, 29, 29, ${effectiveOpacity})`
|
||||
: `rgba(255, 255, 255, ${effectiveOpacity})`,
|
||||
colorBgElevated: darkMode
|
||||
: `rgba(255, 255, 255, ${effectiveOpacity})`),
|
||||
colorBgElevated: (isV2Ui ? v2AntBgElevated : undefined) ?? (darkMode
|
||||
? '#1f1f1f'
|
||||
: '#ffffff',
|
||||
colorFillAlter: darkMode
|
||||
: '#ffffff'),
|
||||
colorFillAlter: (isV2Ui ? v2AntFillAlter : undefined) ?? (darkMode
|
||||
? `rgba(38, 38, 38, ${effectiveOpacity})`
|
||||
: `rgba(250, 250, 250, ${effectiveOpacity})`,
|
||||
: `rgba(250, 250, 250, ${effectiveOpacity})`),
|
||||
...(isV2Ui && v2AntTextPrimary ? { colorText: v2AntTextPrimary } : {}),
|
||||
...(isV2Ui && v2AntTextSecondary ? { colorTextSecondary: v2AntTextSecondary } : {}),
|
||||
...(isV2Ui && v2AntBorder ? {
|
||||
colorBorder: v2AntBorder,
|
||||
colorBorderSecondary: v2AntBorder,
|
||||
} : {}),
|
||||
colorPrimary: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff'),
|
||||
colorTextLightSolid: isV2Ui ? v2AntPrimaryContrastColor : '#ffffff',
|
||||
colorPrimaryHover: isV2Ui ? v2AntPrimaryHoverColor : (darkMode ? '#ffd666' : '#4096ff'),
|
||||
colorPrimaryActive: isV2Ui ? v2AntPrimaryActiveColor : (darkMode ? '#d8a93b' : '#0958d9'),
|
||||
colorInfo: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff'),
|
||||
colorInfo: isV2Ui ? v2AntInfoColor : (darkMode ? '#f6c453' : '#1677ff'),
|
||||
colorLink: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'),
|
||||
colorLinkHover: isV2Ui ? v2AntPrimaryHoverColor : (darkMode ? '#ffe58f' : '#4096ff'),
|
||||
colorLinkActive: isV2Ui ? v2AntPrimaryActiveColor : (darkMode ? '#d8a93b' : '#0958d9'),
|
||||
@@ -3925,7 +3983,8 @@ function App() {
|
||||
},
|
||||
Table: {
|
||||
headerBg: 'transparent',
|
||||
rowHoverBg: darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
rowHoverBg: (isV2Ui ? v2AntRowHoverBg : undefined)
|
||||
?? (darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'),
|
||||
},
|
||||
Tabs: {
|
||||
cardBg: 'transparent',
|
||||
@@ -3939,16 +3998,25 @@ function App() {
|
||||
darkMode,
|
||||
effectiveOpacity,
|
||||
isV2Ui,
|
||||
v2AntBgContainer,
|
||||
v2AntBgElevated,
|
||||
v2AntBorder,
|
||||
v2AntControlActiveBg,
|
||||
v2AntControlActiveHoverBg,
|
||||
v2AntControlOutline,
|
||||
v2AntFillAlter,
|
||||
v2AntInfoColor,
|
||||
v2AntPrimaryActiveColor,
|
||||
v2AntPrimaryBgColor,
|
||||
v2AntPrimaryBgHoverColor,
|
||||
v2AntPrimaryBorderColor,
|
||||
v2AntPrimaryBorderHoverColor,
|
||||
v2AntPrimaryColor,
|
||||
v2AntPrimaryContrastColor,
|
||||
v2AntPrimaryHoverColor,
|
||||
v2AntRowHoverBg,
|
||||
v2AntTextPrimary,
|
||||
v2AntTextSecondary,
|
||||
tokenControlHeight,
|
||||
tokenControlHeightLG,
|
||||
tokenControlHeightSM,
|
||||
@@ -4831,7 +4899,7 @@ function App() {
|
||||
{ key: 'dark' as const, label: t('app.theme.mode.dark.label'), preview: 'dark' as const },
|
||||
{ key: 'system' as const, label: t('app.theme.mode.system.label'), preview: 'system' as const },
|
||||
]).map((item) => {
|
||||
const active = themePreference === item.key;
|
||||
const active = !activeCustomTheme && themePreference === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
@@ -4839,7 +4907,7 @@ function App() {
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={`gonavi-settings-mode-tile${active ? ' is-active' : ''}`}
|
||||
onClick={() => setThemePreference(item.key)}
|
||||
onClick={() => selectPresetTheme(item.key)}
|
||||
>
|
||||
{renderThemeModePreview(item.preview)}
|
||||
<div className="gonavi-settings-mode-meta">
|
||||
@@ -4851,6 +4919,10 @@ function App() {
|
||||
})}
|
||||
</div>,
|
||||
)}
|
||||
{renderThemeSettingsSection(
|
||||
t('app.theme.custom.title'),
|
||||
<CustomThemeManager />,
|
||||
)}
|
||||
{renderThemeSettingsSection(
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{t('app.theme.ui_version.title')}</span>
|
||||
@@ -5684,12 +5756,12 @@ function App() {
|
||||
{ key: 'dark', label: t('app.theme.mode.dark.label'), description: t('app.theme.mode.dark.description') },
|
||||
{ key: 'system', label: t('app.theme.mode.system.label'), description: t('app.theme.mode.system.description') },
|
||||
].map((item) => {
|
||||
const active = themePreference === item.key;
|
||||
const active = !activeCustomTheme && themePreference === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setThemePreference(item.key as 'light' | 'dark' | 'system')}
|
||||
onClick={() => selectPresetTheme(item.key as ThemePreference)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '14px 14px',
|
||||
@@ -5720,6 +5792,10 @@ function App() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('app.theme.custom.title')}</div>
|
||||
<CustomThemeManager legacyMode />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
@@ -6462,6 +6538,10 @@ function App() {
|
||||
componentSize={appComponentSize}
|
||||
theme={antdTheme}
|
||||
>
|
||||
<CustomThemeStyleHost
|
||||
contextKey={customThemeStyleContextKey}
|
||||
onAntTokensChange={setComputedCustomThemeAntTokens}
|
||||
/>
|
||||
<Layout style={{
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -16,12 +16,15 @@ describe('UI version switch placement', () => {
|
||||
it('keeps light/dark first with compact previews and UI version preview tiles', () => {
|
||||
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
|
||||
const lightThemeIndex = appSource.indexOf("t('app.theme.mode.light.label')", themeBranchIndex);
|
||||
const customThemeIndex = appSource.indexOf("t('app.theme.custom.title')", lightThemeIndex);
|
||||
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex);
|
||||
const appearanceBranchIndex = appSource.indexOf(") : themeModalSection === 'appearance' ? (", themeBranchIndex);
|
||||
const macWindowIndex = appSource.indexOf("t('app.theme.mac_window.title')");
|
||||
|
||||
expect(themeBranchIndex).toBeGreaterThan(-1);
|
||||
expect(lightThemeIndex).toBeGreaterThan(themeBranchIndex);
|
||||
expect(customThemeIndex).toBeGreaterThan(lightThemeIndex);
|
||||
expect(customThemeIndex).toBeLessThan(uiVersionIndex);
|
||||
expect(uiVersionIndex).toBeGreaterThan(lightThemeIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex);
|
||||
expect(macWindowIndex).toBeGreaterThan(uiVersionIndex);
|
||||
@@ -50,6 +53,8 @@ describe('UI version switch placement', () => {
|
||||
expect(appSource).toContain('gonavi-settings-tabs');
|
||||
expect(appSource).toContain('gonavi-settings-tab');
|
||||
expect(appSource).toContain('gonavi-settings-pill');
|
||||
expect(appSource).toContain('<CustomThemeManager />');
|
||||
expect(appSource).toContain('<CustomThemeManager legacyMode />');
|
||||
// 旧版布局仍保留侧栏导航
|
||||
expect(appSource).toContain("gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0'");
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ const LogPanel: React.FC<LogPanelProps> = ({
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
|
||||
@@ -42,17 +43,27 @@ const LogPanel: React.FC<LogPanelProps> = ({
|
||||
const bgMain = getBg('#1d1d1d');
|
||||
const shellOpacity = darkMode ? Math.max(0.18, opacity * 0.82) : Math.max(0.28, opacity * 0.92);
|
||||
const shellOpacityStrong = darkMode ? Math.max(0.22, opacity * 0.9) : Math.max(0.34, opacity * 0.96);
|
||||
const panelDividerColor = darkMode
|
||||
const panelDividerColor = isV2Ui ? 'var(--gn-br-2)' : (darkMode
|
||||
? `rgba(255,255,255,${Math.max(0.04, opacity * 0.10)})`
|
||||
: `rgba(0,0,0,${Math.max(0.04, opacity * 0.08)})`;
|
||||
const panelMutedTextColor = darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)';
|
||||
const panelShellBg = darkMode
|
||||
: `rgba(0,0,0,${Math.max(0.04, opacity * 0.08)})`);
|
||||
const panelMutedTextColor = isV2Ui
|
||||
? 'var(--gn-fg-4)'
|
||||
: (darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)');
|
||||
const panelPrimaryTextColor = isV2Ui
|
||||
? 'var(--gn-fg-1)'
|
||||
: (darkMode ? '#f5f7ff' : '#162033');
|
||||
const panelShellBg = isV2Ui ? 'var(--gn-bg-panel)' : (darkMode
|
||||
? `linear-gradient(180deg, rgba(15,20,30,${shellOpacity}) 0%, rgba(9,13,22,${shellOpacityStrong}) 100%)`
|
||||
: `linear-gradient(180deg, rgba(255,255,255,${shellOpacityStrong}) 0%, rgba(246,248,252,${shellOpacity}) 100%)`;
|
||||
const panelAccentColor = darkMode ? '#ffd666' : '#1677ff';
|
||||
const panelShadow = darkMode
|
||||
: `linear-gradient(180deg, rgba(255,255,255,${shellOpacityStrong}) 0%, rgba(246,248,252,${shellOpacity}) 100%)`);
|
||||
const panelAccentColor = isV2Ui ? 'var(--gn-accent)' : (darkMode ? '#ffd666' : '#1677ff');
|
||||
const panelAccentSoftBg = isV2Ui
|
||||
? 'var(--gn-accent-soft)'
|
||||
: (darkMode
|
||||
? `rgba(255,214,102,${Math.max(0.10, Math.min(0.18, opacity * 0.18))})`
|
||||
: `rgba(24,144,255,${Math.max(0.08, Math.min(0.16, opacity * 0.16))})`);
|
||||
const panelShadow = isV2Ui ? 'var(--gn-shadow-md)' : (darkMode
|
||||
? `0 12px 28px rgba(0,0,0,${Math.max(0.05, opacity * 0.18)})`
|
||||
: `0 12px 24px rgba(15,23,42,${Math.max(0.02, opacity * 0.08)})`;
|
||||
: `0 12px 24px rgba(15,23,42,${Math.max(0.02, opacity * 0.08)})`);
|
||||
const logScrollbarThumb = darkMode
|
||||
? `rgba(255, 255, 255, ${Math.max(0.18, opacity * 0.34)})`
|
||||
: `rgba(0, 0, 0, ${Math.max(0.12, opacity * 0.26)})`;
|
||||
@@ -201,9 +212,7 @@ const LogPanel: React.FC<LogPanelProps> = ({
|
||||
borderRadius: 8,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: darkMode
|
||||
? `rgba(255,214,102,${Math.max(0.08, Math.min(0.14, opacity * 0.14))})`
|
||||
: `rgba(24,144,255,${Math.max(0.06, Math.min(0.12, opacity * 0.12))})`,
|
||||
background: panelAccentSoftBg,
|
||||
color: panelAccentColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
@@ -211,7 +220,7 @@ const LogPanel: React.FC<LogPanelProps> = ({
|
||||
<BugOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: darkMode ? '#f5f7ff' : '#162033' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: panelPrimaryTextColor }}>
|
||||
{t('log_panel.description')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: panelMutedTextColor }}>
|
||||
@@ -324,11 +333,11 @@ const LogPanel: React.FC<LogPanelProps> = ({
|
||||
minHeight: 48
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? `rgba(255,214,102,${Math.max(0.10, Math.min(0.18, opacity * 0.18))})` : `rgba(24,144,255,${Math.max(0.08, Math.min(0.16, opacity * 0.16))})`, color: panelAccentColor, flexShrink: 0 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: panelAccentSoftBg, color: panelAccentColor, flexShrink: 0 }}>
|
||||
<BugOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, color: darkMode ? '#f5f7ff' : '#162033' }}>{t('log_panel.title')}</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, color: panelPrimaryTextColor }}>{t('log_panel.title')}</div>
|
||||
<div style={{ fontSize: 12, color: panelMutedTextColor }}>{t('log_panel.description')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
540
frontend/src/components/settings/CustomThemeManager.css
Normal file
540
frontend/src/components/settings/CustomThemeManager.css
Normal file
@@ -0,0 +1,540 @@
|
||||
.gonavi-custom-theme-manager {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
color: var(--gn-settings-fg, var(--gn-fg-1, inherit));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-library {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-section-heading > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-section-heading strong,
|
||||
.gonavi-custom-theme-my-title {
|
||||
color: var(--gn-settings-fg, var(--gn-fg-1, inherit));
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-section-heading > div > span {
|
||||
color: var(--gn-settings-muted, var(--gn-fg-4, #64748b));
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 9px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gn-settings-line, var(--gn-br-2, #e2e8f0));
|
||||
border-radius: 11px;
|
||||
background: var(--gn-settings-card-bg, var(--gn-bg-panel, #fff));
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-card:hover {
|
||||
border-color: color-mix(in srgb, var(--gonavi-preset-accent) 52%, var(--gn-settings-line, #e2e8f0));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-card:focus-within,
|
||||
.gonavi-custom-theme-preset-card.is-active {
|
||||
border-color: var(--gonavi-preset-accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gonavi-preset-accent) 42%, transparent);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 30% 1fr;
|
||||
grid-template-rows: 13px 50px;
|
||||
height: 63px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--gonavi-preset-text) 12%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--gonavi-preset-app);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-preview-chrome {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--gonavi-preset-chrome);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-preview-sidebar {
|
||||
background: color-mix(in srgb, var(--gonavi-preset-panel) 82%, var(--gonavi-preset-app));
|
||||
border-right: 1px solid color-mix(in srgb, var(--gonavi-preset-text) 10%, transparent);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-preview-content {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: var(--gonavi-preset-panel);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-preview-content > i {
|
||||
display: block;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--gonavi-preset-muted);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-preview-content > i:first-child {
|
||||
width: 46%;
|
||||
background: var(--gonavi-preset-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-preview-content > i:last-child {
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-name-row > strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--gn-settings-fg, var(--gn-fg-1, inherit));
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-name-row .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
font-size: 10.5px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-badge {
|
||||
border-color: color-mix(in srgb, var(--gonavi-preset-accent) 32%, var(--gn-settings-line, #e2e8f0));
|
||||
background: color-mix(in srgb, var(--gonavi-preset-accent) 10%, var(--gn-settings-chip-bg, #f8fafc));
|
||||
color: var(--gn-settings-muted, var(--gn-fg-3, #475569));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-badge.is-active {
|
||||
border-color: color-mix(in srgb, var(--gonavi-preset-accent) 52%, var(--gn-settings-line, #e2e8f0));
|
||||
background: color-mix(in srgb, var(--gonavi-preset-accent) 16%, var(--gn-settings-chip-bg, #f8fafc));
|
||||
color: var(--gn-settings-fg, var(--gn-fg-1, inherit));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-description {
|
||||
display: -webkit-box;
|
||||
min-height: 32px;
|
||||
overflow: hidden;
|
||||
color: var(--gn-settings-muted, var(--gn-fg-4, #64748b));
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-mode {
|
||||
justify-self: start;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--gn-settings-chip-bg, var(--gn-bg-panel-2, #f8fafc));
|
||||
color: var(--gn-settings-muted, var(--gn-fg-4, #64748b));
|
||||
font-size: 10.5px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
body[data-theme='light'] .gonavi-custom-theme-manager.is-legacy {
|
||||
--gn-settings-fg: #162033;
|
||||
--gn-settings-muted: rgba(16, 24, 40, 0.58);
|
||||
--gn-settings-line: rgba(16, 24, 40, 0.10);
|
||||
--gn-settings-card-bg: rgba(255, 255, 255, 0.74);
|
||||
--gn-settings-chip-bg: rgba(16, 24, 40, 0.04);
|
||||
--gn-settings-accent: #1677ff;
|
||||
--gn-settings-accent-soft: rgba(24, 144, 255, 0.12);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-custom-theme-manager.is-legacy {
|
||||
--gn-settings-fg: #f5f7ff;
|
||||
--gn-settings-muted: rgba(255, 255, 255, 0.58);
|
||||
--gn-settings-line: rgba(255, 255, 255, 0.10);
|
||||
--gn-settings-card-bg: rgba(255, 255, 255, 0.035);
|
||||
--gn-settings-chip-bg: rgba(255, 255, 255, 0.06);
|
||||
--gn-settings-accent: #ffd666;
|
||||
--gn-settings-accent-soft: rgba(255, 214, 102, 0.14);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-intro {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-description,
|
||||
.gonavi-custom-theme-count,
|
||||
.gonavi-custom-theme-active-copy > span,
|
||||
.gonavi-custom-theme-file-name,
|
||||
.gonavi-custom-theme-base-mode-label {
|
||||
color: var(--gn-settings-muted, var(--gn-fg-4, #64748b));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-description {
|
||||
max-width: 62ch;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-count {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-toolbar-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-legacy-note {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--gn-warn, #d97706) 28%, transparent);
|
||||
border-radius: 9px;
|
||||
background: var(--gn-warn-soft, rgba(245, 158, 11, 0.12));
|
||||
color: var(--gn-settings-fg, var(--gn-fg-2, #334155));
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-safety-note {
|
||||
padding: 0 2px;
|
||||
color: var(--gn-settings-muted, var(--gn-fg-4, #64748b));
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-active {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--gn-settings-line, var(--gn-br-1, #e2e8f0));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--gn-settings-card-bg, var(--gn-bg-panel, #fff)) 94%, var(--gn-settings-accent, var(--gn-accent, #16a34a)) 6%);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-active-icon {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: var(--gn-settings-accent-soft, var(--gn-accent-soft, #dcfce7));
|
||||
color: var(--gn-settings-accent, var(--gn-accent, #16a34a));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-active-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-active-copy > strong,
|
||||
.gonavi-custom-theme-active-copy > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-active-copy > strong {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-active-copy > span {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-empty {
|
||||
padding: 18px 12px 10px;
|
||||
border: 1px dashed var(--gn-settings-line, var(--gn-br-2, #cbd5e1));
|
||||
border-radius: 12px;
|
||||
background: var(--gn-settings-card-bg, var(--gn-bg-panel, #fff));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gn-settings-line, var(--gn-br-2, #e2e8f0));
|
||||
border-radius: 12px;
|
||||
background: var(--gn-settings-card-bg, var(--gn-bg-panel, #fff));
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card:hover {
|
||||
border-color: color-mix(in srgb, var(--gonavi-custom-theme-preview-accent, var(--gn-accent, #16a34a)) 42%, var(--gn-settings-line, #e2e8f0));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card.is-active {
|
||||
border-color: var(--gonavi-custom-theme-preview-accent, var(--gn-settings-accent, var(--gn-accent, #16a34a)));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gonavi-custom-theme-preview-accent, var(--gn-accent, #16a34a)) 38%, transparent);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-select {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 11px 12px 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-radio {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-select:focus-within,
|
||||
.gonavi-custom-theme-card-actions .ant-btn:focus-visible,
|
||||
.gonavi-custom-theme-toolbar-actions .ant-btn:focus-visible,
|
||||
.gonavi-custom-theme-active .ant-btn:focus-visible,
|
||||
.gonavi-custom-theme-rename .ant-btn:focus-visible {
|
||||
outline: 2px solid var(--gn-settings-accent, var(--gn-accent, #16a34a));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 1.35fr 0.55fr;
|
||||
gap: 5px;
|
||||
height: 34px;
|
||||
padding: 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gn-settings-line, var(--gn-br-1, #e2e8f0));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--gonavi-custom-theme-preview-accent, #16a34a) 8%, var(--gn-settings-chip-bg, #f8fafc));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preview > span {
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--gonavi-custom-theme-preview-accent, #16a34a) 22%, var(--gn-settings-card-bg, #fff));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preview > span:nth-child(2) {
|
||||
background: color-mix(in srgb, var(--gonavi-custom-theme-preview-accent, #16a34a) 46%, var(--gn-settings-card-bg, #fff));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preview > span:nth-child(3) {
|
||||
background: var(--gonavi-custom-theme-preview-accent, #16a34a);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--gn-settings-fg, var(--gn-fg-1, inherit));
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--gn-font-mono, ui-monospace, monospace);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card-controls,
|
||||
.gonavi-custom-theme-rename {
|
||||
min-width: 0;
|
||||
margin: 0 12px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid var(--gn-settings-line, var(--gn-br-1, #e2e8f0));
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-base-mode-label {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-base-mode {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-rename {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
padding: 4px 8px 7px;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-card-actions .ant-btn-dangerous {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.gonavi-custom-theme-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-toolbar-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.gonavi-custom-theme-active {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-active > .ant-btn {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-toolbar-actions > .ant-btn,
|
||||
.gonavi-custom-theme-toolbar-actions > .ant-tooltip-disabled-compatible-wrapper {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gonavi-custom-theme-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.gonavi-custom-theme-preset-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
45
frontend/src/components/settings/CustomThemeManager.test.ts
Normal file
45
frontend/src/components/settings/CustomThemeManager.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./CustomThemeManager.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const styles = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./CustomThemeManager.css', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('CustomThemeManager', () => {
|
||||
it('supports the complete import and management workflow', () => {
|
||||
expect(source).toContain('accept=".css,text/css"');
|
||||
expect(source).toContain("requestFile({ kind: 'import' })");
|
||||
expect(source).toContain("requestFile({ kind: 'replace', themeId: theme.id })");
|
||||
expect(source).toContain('selectCustomTheme(theme.id)');
|
||||
expect(source).toContain('selectCustomTheme(null)');
|
||||
expect(source).toContain('updateCustomTheme(theme.id, { baseMode })');
|
||||
expect(source).toContain('removeCustomTheme(theme.id)');
|
||||
expect(source).toContain('CUSTOM_THEME_TEMPLATE');
|
||||
});
|
||||
|
||||
it('provides accessible selection, destructive confirmation, and responsive layout', () => {
|
||||
expect(source).toContain('<fieldset className="gonavi-custom-theme-library">');
|
||||
expect(source).toContain('<legend className="gonavi-custom-theme-sr-only">');
|
||||
expect(source).toContain('BUILTIN_CUSTOM_THEME_PRESETS.map((preset) =>');
|
||||
expect(source).toContain('type="radio"');
|
||||
expect(source).toContain('name="gonavi-custom-theme-selection"');
|
||||
expect(source).toContain('checked={active}');
|
||||
expect(source).toContain('onChange={() => handleSelect(theme)}');
|
||||
expect(source).toContain('onChange={() => handleSelect(preset, displayName)}');
|
||||
expect(source).toContain('const builtinThemeTitleId = useId();');
|
||||
expect(source).toContain('aria-describedby={`${descriptionId} ${modeId}`}');
|
||||
expect(source).toContain('<Popconfirm');
|
||||
expect(source).toContain("if (event.key === 'Escape')");
|
||||
expect(source).toContain('event.stopPropagation()');
|
||||
expect(styles).toContain('.gonavi-custom-theme-select:focus-within');
|
||||
expect(styles).toContain('grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));');
|
||||
expect(styles).toContain('@media (max-width: 760px)');
|
||||
expect(styles).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
});
|
||||
588
frontend/src/components/settings/CustomThemeManager.tsx
Normal file
588
frontend/src/components/settings/CustomThemeManager.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
FileSyncOutlined,
|
||||
SaveOutlined,
|
||||
StopOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Input, Popconfirm, Select, Tag, Tooltip, message } from 'antd';
|
||||
import type { ChangeEvent, CSSProperties, FormEvent } from 'react';
|
||||
import { useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCustomThemeStore, type CustomThemeStoreResult } from '../../customThemeStore';
|
||||
import { useI18n } from '../../i18n/provider';
|
||||
import { isMacLikePlatform } from '../../utils/appearance';
|
||||
import {
|
||||
CUSTOM_THEME_MAX_BYTES,
|
||||
CUSTOM_THEME_MAX_COUNT,
|
||||
CUSTOM_THEME_TEMPLATE,
|
||||
deriveCustomThemeName,
|
||||
extractCustomThemeAntTokens,
|
||||
type CustomThemeBaseMode,
|
||||
type CustomThemeDefinition,
|
||||
} from '../../utils/customTheme';
|
||||
import {
|
||||
BUILTIN_CUSTOM_THEME_PRESETS,
|
||||
resolveAvailableCustomTheme,
|
||||
resolveBuiltinCustomThemePreset,
|
||||
} from '../../utils/customThemePresets';
|
||||
import './CustomThemeManager.css';
|
||||
|
||||
type FileAction =
|
||||
| { kind: 'import' }
|
||||
| { kind: 'replace'; themeId: string };
|
||||
|
||||
const STORE_ERROR_I18N_KEYS: Record<string, string> = {
|
||||
empty: 'app.theme.custom.error.empty',
|
||||
'too-large': 'app.theme.custom.error.too_large',
|
||||
'invalid-syntax': 'app.theme.custom.error.invalid_syntax',
|
||||
'unsafe-import': 'app.theme.custom.error.unsafe_import',
|
||||
'unsafe-url': 'app.theme.custom.error.unsafe_url',
|
||||
'unsafe-font-face': 'app.theme.custom.error.unsafe_font_face',
|
||||
'unsafe-legacy-script': 'app.theme.custom.error.unsafe_script',
|
||||
'max-count': 'app.theme.custom.error.max_count',
|
||||
'max-total-size': 'app.theme.custom.error.max_total_size',
|
||||
'not-found': 'app.theme.custom.error.not_found',
|
||||
'storage-failed': 'app.theme.custom.error.storage_failed',
|
||||
};
|
||||
|
||||
const readFileAsText = async (file: File): Promise<string> => {
|
||||
if (typeof file.text === 'function') return file.text();
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.readAsText(file, 'utf-8');
|
||||
});
|
||||
};
|
||||
|
||||
const triggerTextDownload = (fileName: string, content: string): boolean => {
|
||||
if (
|
||||
typeof document === 'undefined'
|
||||
|| typeof URL === 'undefined'
|
||||
|| typeof URL.createObjectURL !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const blob = new Blob([content], { type: 'text/css;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveThemeAccent = (theme: CustomThemeDefinition): string => (
|
||||
extractCustomThemeAntTokens(theme.css).primary || '#16a34a'
|
||||
);
|
||||
|
||||
export type CustomThemeManagerProps = {
|
||||
legacyMode?: boolean;
|
||||
};
|
||||
|
||||
export default function CustomThemeManager({ legacyMode = false }: CustomThemeManagerProps) {
|
||||
const { language, t } = useI18n();
|
||||
const builtinThemeTitleId = useId();
|
||||
const themes = useCustomThemeStore((state) => state.themes);
|
||||
const activeThemeId = useCustomThemeStore((state) => state.activeThemeId);
|
||||
const importCustomTheme = useCustomThemeStore((state) => state.importCustomTheme);
|
||||
const updateCustomTheme = useCustomThemeStore((state) => state.updateCustomTheme);
|
||||
const selectCustomTheme = useCustomThemeStore((state) => state.selectCustomTheme);
|
||||
const removeCustomTheme = useCustomThemeStore((state) => state.removeCustomTheme);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileActionRef = useRef<FileAction>({ kind: 'import' });
|
||||
const [fileBusy, setFileBusy] = useState(false);
|
||||
const [editingThemeId, setEditingThemeId] = useState<string | null>(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const activeTheme = useMemo(
|
||||
() => resolveAvailableCustomTheme(themes, activeThemeId),
|
||||
[activeThemeId, themes],
|
||||
);
|
||||
const activePreset = useMemo(
|
||||
() => {
|
||||
const preset = resolveBuiltinCustomThemePreset(activeThemeId);
|
||||
return preset === activeTheme ? preset : null;
|
||||
},
|
||||
[activeTheme, activeThemeId],
|
||||
);
|
||||
const activeThemeDisplayName = activePreset
|
||||
? t(activePreset.nameKey)
|
||||
: activeTheme?.name ?? '';
|
||||
const numberFormatter = useMemo(() => new Intl.NumberFormat(language), [language]);
|
||||
const recoveryShortcut = useMemo(
|
||||
() => isMacLikePlatform() ? 'Cmd+Shift+D' : 'Ctrl+Shift+D',
|
||||
[],
|
||||
);
|
||||
|
||||
const showStoreError = (result: CustomThemeStoreResult) => {
|
||||
if (result.ok) return;
|
||||
const key = STORE_ERROR_I18N_KEYS[result.reason] || 'app.theme.custom.error.operation_failed';
|
||||
message.error(t(key, {
|
||||
count: CUSTOM_THEME_MAX_COUNT,
|
||||
size: Math.round(CUSTOM_THEME_MAX_BYTES / 1024),
|
||||
}));
|
||||
};
|
||||
|
||||
const requestFile = (action: FileAction) => {
|
||||
if (fileBusy) return;
|
||||
fileActionRef.current = action;
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.css')) {
|
||||
message.error(t('app.theme.custom.error.invalid_extension'));
|
||||
return;
|
||||
}
|
||||
if (file.size > CUSTOM_THEME_MAX_BYTES) {
|
||||
message.error(t('app.theme.custom.error.too_large', {
|
||||
size: Math.round(CUSTOM_THEME_MAX_BYTES / 1024),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setFileBusy(true);
|
||||
try {
|
||||
const css = await readFileAsText(file);
|
||||
const action = fileActionRef.current;
|
||||
if (action.kind === 'replace') {
|
||||
const result = updateCustomTheme(action.themeId, {
|
||||
css,
|
||||
sourceFileName: file.name,
|
||||
});
|
||||
if (!result.ok) {
|
||||
showStoreError(result);
|
||||
return;
|
||||
}
|
||||
message.success(t('app.theme.custom.message.css_replaced'));
|
||||
return;
|
||||
}
|
||||
const result = importCustomTheme({
|
||||
name: deriveCustomThemeName(file.name),
|
||||
sourceFileName: file.name,
|
||||
baseMode: 'system',
|
||||
css,
|
||||
});
|
||||
if (!result.ok) {
|
||||
showStoreError(result);
|
||||
return;
|
||||
}
|
||||
message.success(t('app.theme.custom.message.imported', { name: result.theme?.name || file.name }));
|
||||
} catch {
|
||||
message.error(t('app.theme.custom.error.read_failed'));
|
||||
} finally {
|
||||
setFileBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (theme: CustomThemeDefinition, displayName = theme.name) => {
|
||||
const result = selectCustomTheme(theme.id);
|
||||
if (!result.ok) {
|
||||
showStoreError(result);
|
||||
return;
|
||||
}
|
||||
message.success(t('app.theme.custom.message.selected', { name: displayName }));
|
||||
};
|
||||
|
||||
const handleDeactivate = () => {
|
||||
const result = selectCustomTheme(null);
|
||||
if (!result.ok) {
|
||||
showStoreError(result);
|
||||
return;
|
||||
}
|
||||
message.success(t('app.theme.custom.message.deactivated'));
|
||||
};
|
||||
|
||||
const handleBaseModeChange = (theme: CustomThemeDefinition, baseMode: CustomThemeBaseMode) => {
|
||||
const result = updateCustomTheme(theme.id, { baseMode });
|
||||
if (!result.ok) showStoreError(result);
|
||||
};
|
||||
|
||||
const beginRename = (theme: CustomThemeDefinition) => {
|
||||
setEditingThemeId(theme.id);
|
||||
setDraftName(theme.name);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingThemeId(null);
|
||||
setDraftName('');
|
||||
};
|
||||
|
||||
const submitRename = (event: FormEvent, theme: CustomThemeDefinition) => {
|
||||
event.preventDefault();
|
||||
const nextName = draftName.trim();
|
||||
if (!nextName) {
|
||||
message.error(t('app.theme.custom.error.name_required'));
|
||||
return;
|
||||
}
|
||||
const result = updateCustomTheme(theme.id, { name: nextName });
|
||||
if (!result.ok) {
|
||||
showStoreError(result);
|
||||
return;
|
||||
}
|
||||
cancelRename();
|
||||
message.success(t('app.theme.custom.message.renamed'));
|
||||
};
|
||||
|
||||
const handleRemove = (theme: CustomThemeDefinition) => {
|
||||
const result = removeCustomTheme(theme.id);
|
||||
if (!result.ok) {
|
||||
showStoreError(result);
|
||||
return;
|
||||
}
|
||||
if (editingThemeId === theme.id) cancelRename();
|
||||
message.success(t('app.theme.custom.message.deleted', { name: theme.name }));
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
if (!triggerTextDownload('gonavi-custom-theme-template.css', CUSTOM_THEME_TEMPLATE)) {
|
||||
message.error(t('app.theme.custom.error.download_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`gonavi-custom-theme-manager${legacyMode ? ' is-legacy' : ''}`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".css,text/css"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="gonavi-custom-theme-file-input"
|
||||
onChange={(event) => { void handleFileChange(event); }}
|
||||
/>
|
||||
|
||||
<fieldset className="gonavi-custom-theme-library">
|
||||
<legend className="gonavi-custom-theme-sr-only">
|
||||
{t('app.theme.custom.list_label')}
|
||||
</legend>
|
||||
|
||||
<section className="gonavi-custom-theme-preset-section" aria-labelledby={builtinThemeTitleId}>
|
||||
<div className="gonavi-custom-theme-section-heading">
|
||||
<div>
|
||||
<strong id={builtinThemeTitleId}>{t('app.theme.custom.preset.title')}</strong>
|
||||
<span>{t('app.theme.custom.preset.description')}</span>
|
||||
</div>
|
||||
<Tag>{t('app.theme.custom.preset.count', { count: BUILTIN_CUSTOM_THEME_PRESETS.length })}</Tag>
|
||||
</div>
|
||||
<div className="gonavi-custom-theme-preset-grid">
|
||||
{BUILTIN_CUSTOM_THEME_PRESETS.map((preset) => {
|
||||
const displayName = t(preset.nameKey);
|
||||
const active = preset === activePreset;
|
||||
const descriptionId = `${builtinThemeTitleId}-${preset.id}-description`;
|
||||
const modeId = `${builtinThemeTitleId}-${preset.id}-mode`;
|
||||
const previewStyle = {
|
||||
'--gonavi-preset-app': preset.preview.app,
|
||||
'--gonavi-preset-chrome': preset.preview.chrome,
|
||||
'--gonavi-preset-panel': preset.preview.panel,
|
||||
'--gonavi-preset-text': preset.preview.text,
|
||||
'--gonavi-preset-muted': preset.preview.muted,
|
||||
'--gonavi-preset-accent': preset.preview.accent,
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<label
|
||||
key={preset.id}
|
||||
className={`gonavi-custom-theme-preset-card${active ? ' is-active' : ''}`}
|
||||
style={previewStyle}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="gonavi-custom-theme-selection"
|
||||
value={preset.id}
|
||||
checked={active}
|
||||
aria-label={t('app.theme.custom.action.use_named', { name: displayName })}
|
||||
aria-describedby={`${descriptionId} ${modeId}`}
|
||||
className="gonavi-custom-theme-radio"
|
||||
onChange={() => handleSelect(preset, displayName)}
|
||||
/>
|
||||
<span className="gonavi-custom-theme-preset-preview" aria-hidden="true">
|
||||
<span className="gonavi-custom-theme-preset-preview-chrome" />
|
||||
<span className="gonavi-custom-theme-preset-preview-sidebar" />
|
||||
<span className="gonavi-custom-theme-preset-preview-content">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</span>
|
||||
<span className="gonavi-custom-theme-preset-copy">
|
||||
<span className="gonavi-custom-theme-preset-name-row">
|
||||
<strong>{displayName}</strong>
|
||||
{preset.badgeKey ? (
|
||||
<Tag className="gonavi-custom-theme-preset-badge is-recommended">
|
||||
{t(preset.badgeKey)}
|
||||
</Tag>
|
||||
) : null}
|
||||
{active ? (
|
||||
<Tag
|
||||
className="gonavi-custom-theme-preset-badge is-active"
|
||||
icon={<CheckOutlined aria-hidden="true" />}
|
||||
>
|
||||
{t('app.theme.custom.badge.active')}
|
||||
</Tag>
|
||||
) : null}
|
||||
</span>
|
||||
<span id={descriptionId} className="gonavi-custom-theme-preset-description">
|
||||
{t(preset.descriptionKey)}
|
||||
</span>
|
||||
<span id={modeId} className="gonavi-custom-theme-preset-mode">
|
||||
{t(`app.theme.mode.${preset.baseMode}.label`)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="gonavi-custom-theme-toolbar">
|
||||
<div className="gonavi-custom-theme-intro">
|
||||
<strong className="gonavi-custom-theme-my-title">
|
||||
{t('app.theme.custom.my_themes_title')}
|
||||
</strong>
|
||||
<div className="gonavi-custom-theme-description">
|
||||
{t('app.theme.custom.description')}
|
||||
</div>
|
||||
<div className="gonavi-custom-theme-count">
|
||||
{t('app.theme.custom.count', {
|
||||
count: numberFormatter.format(themes.length),
|
||||
max: numberFormatter.format(CUSTOM_THEME_MAX_COUNT),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="gonavi-custom-theme-toolbar-actions">
|
||||
<Button
|
||||
icon={<DownloadOutlined aria-hidden="true" />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
{t('app.theme.custom.action.download_template')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
title={themes.length >= CUSTOM_THEME_MAX_COUNT ? t('app.theme.custom.error.max_count', { count: CUSTOM_THEME_MAX_COUNT }) : undefined}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined aria-hidden="true" />}
|
||||
loading={fileBusy}
|
||||
disabled={themes.length >= CUSTOM_THEME_MAX_COUNT}
|
||||
onClick={() => requestFile({ kind: 'import' })}
|
||||
>
|
||||
{t('app.theme.custom.action.upload')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{legacyMode ? (
|
||||
<div className="gonavi-custom-theme-legacy-note" role="note">
|
||||
{t('app.theme.custom.legacy_compatibility_hint')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="gonavi-custom-theme-safety-note" role="note">
|
||||
{t('app.theme.custom.safety_hint', { shortcut: recoveryShortcut })}
|
||||
</div>
|
||||
|
||||
<div className="gonavi-custom-theme-active" role="status" aria-live="polite">
|
||||
<span className="gonavi-custom-theme-active-icon" aria-hidden="true">
|
||||
{activeTheme ? <CheckOutlined /> : <StopOutlined />}
|
||||
</span>
|
||||
<span className="gonavi-custom-theme-active-copy">
|
||||
<strong>
|
||||
{activeTheme
|
||||
? t('app.theme.custom.active_theme', { name: activeThemeDisplayName })
|
||||
: t('app.theme.custom.inactive')}
|
||||
</strong>
|
||||
<span>
|
||||
{activeTheme
|
||||
? t('app.theme.custom.active_hint')
|
||||
: t('app.theme.custom.inactive_hint')}
|
||||
</span>
|
||||
</span>
|
||||
{activeTheme ? (
|
||||
<Button icon={<StopOutlined aria-hidden="true" />} onClick={handleDeactivate}>
|
||||
{t('app.theme.custom.action.deactivate')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{themes.length === 0 ? (
|
||||
<div className="gonavi-custom-theme-empty">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('app.theme.custom.empty')}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined aria-hidden="true" />}
|
||||
loading={fileBusy}
|
||||
onClick={() => requestFile({ kind: 'import' })}
|
||||
>
|
||||
{t('app.theme.custom.action.upload_first')}
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="gonavi-custom-theme-grid"
|
||||
>
|
||||
{themes.map((theme) => {
|
||||
const active = theme === activeTheme;
|
||||
const editing = theme.id === editingThemeId;
|
||||
const accent = resolveThemeAccent(theme);
|
||||
const previewStyle = { '--gonavi-custom-theme-preview-accent': accent } as CSSProperties;
|
||||
return (
|
||||
<article
|
||||
key={theme.id}
|
||||
className={`gonavi-custom-theme-card${active ? ' is-active' : ''}`}
|
||||
style={previewStyle}
|
||||
>
|
||||
<label className="gonavi-custom-theme-select">
|
||||
<input
|
||||
type="radio"
|
||||
name="gonavi-custom-theme-selection"
|
||||
value={theme.id}
|
||||
checked={active}
|
||||
aria-label={t('app.theme.custom.action.use_named', { name: theme.name })}
|
||||
className="gonavi-custom-theme-radio"
|
||||
onChange={() => handleSelect(theme)}
|
||||
/>
|
||||
<span className="gonavi-custom-theme-preview" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span className="gonavi-custom-theme-card-heading">
|
||||
<span className="gonavi-custom-theme-name" title={theme.name}>{theme.name}</span>
|
||||
{active ? (
|
||||
<Tag color="success" icon={<CheckOutlined aria-hidden="true" />}>
|
||||
{t('app.theme.custom.badge.active')}
|
||||
</Tag>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="gonavi-custom-theme-file-name" title={theme.sourceFileName}>
|
||||
{theme.sourceFileName || t('app.theme.custom.unknown_file')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{editing ? (
|
||||
<form className="gonavi-custom-theme-rename" onSubmit={(event) => submitRename(event, theme)}>
|
||||
<Input
|
||||
value={draftName}
|
||||
maxLength={80}
|
||||
name={`custom-theme-name-${theme.id}`}
|
||||
autoComplete="off"
|
||||
aria-label={t('app.theme.custom.rename_input_label', { name: theme.name })}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip title={t('common.save')}>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SaveOutlined aria-hidden="true" />}
|
||||
aria-label={t('common.save')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.cancel')}>
|
||||
<Button
|
||||
htmlType="button"
|
||||
size="small"
|
||||
icon={<CloseOutlined aria-hidden="true" />}
|
||||
aria-label={t('common.cancel')}
|
||||
onClick={cancelRename}
|
||||
/>
|
||||
</Tooltip>
|
||||
</form>
|
||||
) : (
|
||||
<div className="gonavi-custom-theme-card-controls">
|
||||
<label className="gonavi-custom-theme-base-mode-label" htmlFor={`custom-theme-base-mode-${theme.id}`}>
|
||||
{t('app.theme.custom.base_mode')}
|
||||
</label>
|
||||
<Select<CustomThemeBaseMode>
|
||||
id={`custom-theme-base-mode-${theme.id}`}
|
||||
value={theme.baseMode}
|
||||
className="gonavi-custom-theme-base-mode"
|
||||
aria-label={t('app.theme.custom.base_mode_for', { name: theme.name })}
|
||||
options={[
|
||||
{ value: 'system', label: t('app.theme.mode.system.label') },
|
||||
{ value: 'light', label: t('app.theme.mode.light.label') },
|
||||
{ value: 'dark', label: t('app.theme.mode.dark.label') },
|
||||
]}
|
||||
onChange={(value) => handleBaseModeChange(theme, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gonavi-custom-theme-card-actions">
|
||||
<Tooltip title={t('app.theme.custom.action.rename')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined aria-hidden="true" />}
|
||||
aria-label={t('app.theme.custom.action.rename_named', { name: theme.name })}
|
||||
disabled={editing}
|
||||
onClick={() => beginRename(theme)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('app.theme.custom.action.replace_css')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FileSyncOutlined aria-hidden="true" />}
|
||||
aria-label={t('app.theme.custom.action.replace_named', { name: theme.name })}
|
||||
loading={fileBusy && fileActionRef.current.kind === 'replace' && fileActionRef.current.themeId === theme.id}
|
||||
disabled={fileBusy}
|
||||
onClick={() => requestFile({ kind: 'replace', themeId: theme.id })}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={t('app.theme.custom.delete_confirm.title')}
|
||||
description={t('app.theme.custom.delete_confirm.description', { name: theme.name })}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleRemove(theme)}
|
||||
>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined aria-hidden="true" />}
|
||||
aria-label={t('app.theme.custom.action.delete_named', { name: theme.name })}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/theme/CustomThemeStyleHost.test.ts
Normal file
141
frontend/src/components/theme/CustomThemeStyleHost.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CUSTOM_THEME_STYLE_ID, type CustomThemeDefinition } from '../../utils/customTheme';
|
||||
import {
|
||||
installCustomThemeRecoveryShortcut,
|
||||
isCustomThemeRecoveryShortcut,
|
||||
shouldReloadCustomThemesForStorageEvent,
|
||||
syncCustomThemeStyle,
|
||||
} from './CustomThemeStyleHost';
|
||||
|
||||
const buildTheme = (id: string, css: string): CustomThemeDefinition => ({
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name: id,
|
||||
sourceFileName: `${id}.css`,
|
||||
baseMode: 'dark',
|
||||
css,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
});
|
||||
|
||||
const createFakeDocument = () => {
|
||||
const attributes = new Map<string, string>();
|
||||
let style: any = null;
|
||||
let appendCount = 0;
|
||||
const documentRef = {
|
||||
body: {
|
||||
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
||||
removeAttribute: (name: string) => attributes.delete(name),
|
||||
},
|
||||
head: {
|
||||
appendChild: (node: any) => {
|
||||
style = node;
|
||||
node.isConnected = true;
|
||||
appendCount += 1;
|
||||
},
|
||||
},
|
||||
getElementById: (id: string) => id === CUSTOM_THEME_STYLE_ID ? style : null,
|
||||
createElement: () => {
|
||||
const node: any = {
|
||||
tagName: 'STYLE',
|
||||
isConnected: false,
|
||||
attributes: new Map<string, string>(),
|
||||
setAttribute(name: string, value: string) { this.attributes.set(name, value); },
|
||||
remove() {
|
||||
this.isConnected = false;
|
||||
style = null;
|
||||
},
|
||||
};
|
||||
return node;
|
||||
},
|
||||
};
|
||||
return {
|
||||
documentRef: documentRef as unknown as Document,
|
||||
attributes,
|
||||
getStyle: () => style,
|
||||
getAppendCount: () => appendCount,
|
||||
};
|
||||
};
|
||||
|
||||
describe('CustomThemeStyleHost runtime', () => {
|
||||
it('uses one style element, updates textContent, and cleans body state', () => {
|
||||
const fake = createFakeDocument();
|
||||
syncCustomThemeStyle(buildTheme('theme-one', 'body { color: red; }'), fake.documentRef);
|
||||
expect(fake.getStyle().textContent).toBe('body { color: red; }');
|
||||
expect(fake.attributes.get('data-custom-theme')).toBe('active');
|
||||
expect(fake.attributes.get('data-custom-theme-id')).toBe('theme-one');
|
||||
expect(fake.getAppendCount()).toBe(1);
|
||||
|
||||
syncCustomThemeStyle(buildTheme('theme-two', 'body { color: blue; }'), fake.documentRef);
|
||||
expect(fake.getStyle().textContent).toBe('body { color: blue; }');
|
||||
expect(fake.attributes.get('data-custom-theme-id')).toBe('theme-two');
|
||||
expect(fake.getAppendCount()).toBe(1);
|
||||
|
||||
syncCustomThemeStyle(null, fake.documentRef);
|
||||
expect(fake.getStyle()).toBeNull();
|
||||
expect(fake.attributes.has('data-custom-theme')).toBe(false);
|
||||
expect(fake.attributes.has('data-custom-theme-id')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a fixed recovery shortcut independent of editable targets and custom bindings', () => {
|
||||
const event = {
|
||||
altKey: false,
|
||||
code: 'KeyD',
|
||||
ctrlKey: true,
|
||||
isComposing: false,
|
||||
key: 'd',
|
||||
metaKey: false,
|
||||
shiftKey: true,
|
||||
};
|
||||
expect(isCustomThemeRecoveryShortcut(event)).toBe(true);
|
||||
expect(isCustomThemeRecoveryShortcut({ ...event, ctrlKey: false, metaKey: true })).toBe(true);
|
||||
expect(isCustomThemeRecoveryShortcut({ ...event, shiftKey: false })).toBe(false);
|
||||
expect(isCustomThemeRecoveryShortcut({ ...event, altKey: true })).toBe(false);
|
||||
expect(isCustomThemeRecoveryShortcut({ ...event, isComposing: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('installs the recovery shortcut in capture phase and stops other handlers', () => {
|
||||
let listener: EventListener | null = null;
|
||||
let capture: boolean | AddEventListenerOptions | undefined;
|
||||
const removeEventListener = vi.fn();
|
||||
const target = {
|
||||
addEventListener: (_type: string, nextListener: EventListener, options?: boolean | AddEventListenerOptions) => {
|
||||
listener = nextListener;
|
||||
capture = options;
|
||||
},
|
||||
removeEventListener,
|
||||
} as unknown as Pick<Window, 'addEventListener' | 'removeEventListener'>;
|
||||
const deactivate = vi.fn();
|
||||
const cleanup = installCustomThemeRecoveryShortcut(deactivate, target);
|
||||
const keyboardEvent = {
|
||||
altKey: false,
|
||||
code: 'KeyD',
|
||||
ctrlKey: true,
|
||||
isComposing: false,
|
||||
key: 'd',
|
||||
metaKey: false,
|
||||
shiftKey: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
expect(capture).toBe(true);
|
||||
const registeredListener = listener as EventListener | null;
|
||||
expect(registeredListener).not.toBeNull();
|
||||
if (!registeredListener) throw new Error('Recovery listener was not registered');
|
||||
registeredListener(keyboardEvent);
|
||||
expect(deactivate).toHaveBeenCalledOnce();
|
||||
expect(keyboardEvent.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(keyboardEvent.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
|
||||
cleanup();
|
||||
expect(removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
});
|
||||
|
||||
it('reloads themes for the custom key and localStorage.clear events', () => {
|
||||
expect(shouldReloadCustomThemesForStorageEvent('gonavi-custom-themes-v1')).toBe(true);
|
||||
expect(shouldReloadCustomThemesForStorageEvent(null)).toBe(true);
|
||||
expect(shouldReloadCustomThemesForStorageEvent('unrelated-key')).toBe(false);
|
||||
});
|
||||
});
|
||||
155
frontend/src/components/theme/CustomThemeStyleHost.tsx
Normal file
155
frontend/src/components/theme/CustomThemeStyleHost.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useLayoutEffect, useMemo } from 'react';
|
||||
import { CUSTOM_THEME_STORAGE_KEY, useCustomThemeStore } from '../../customThemeStore';
|
||||
import {
|
||||
CUSTOM_THEME_STYLE_ID,
|
||||
extractComputedCustomThemeAntTokens,
|
||||
extractCustomThemeAntTokens,
|
||||
type CustomThemeAntTokens,
|
||||
type CustomThemeDefinition,
|
||||
} from '../../utils/customTheme';
|
||||
import { resolveAvailableCustomTheme } from '../../utils/customThemePresets';
|
||||
|
||||
export type CustomThemeAntTokenSnapshot = {
|
||||
themeId: string;
|
||||
themeRevision: number;
|
||||
contextKey: string;
|
||||
tokens: CustomThemeAntTokens;
|
||||
};
|
||||
|
||||
type CustomThemeStyleHostProps = {
|
||||
contextKey: string;
|
||||
onAntTokensChange: (snapshot: CustomThemeAntTokenSnapshot | null) => void;
|
||||
};
|
||||
|
||||
type CustomThemeRecoveryKeyboardEvent = Pick<
|
||||
KeyboardEvent,
|
||||
'altKey' | 'code' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey' | 'shiftKey'
|
||||
>;
|
||||
|
||||
export const isCustomThemeRecoveryShortcut = (event: CustomThemeRecoveryKeyboardEvent): boolean => (
|
||||
!event.altKey
|
||||
&& !event.isComposing
|
||||
&& event.shiftKey
|
||||
&& (event.ctrlKey || event.metaKey)
|
||||
&& (event.code === 'KeyD' || event.key.toLowerCase() === 'd')
|
||||
);
|
||||
|
||||
export const shouldReloadCustomThemesForStorageEvent = (key: string | null): boolean => (
|
||||
key === null || key === CUSTOM_THEME_STORAGE_KEY
|
||||
);
|
||||
|
||||
export const installCustomThemeRecoveryShortcut = (
|
||||
deactivate: () => void,
|
||||
target: Pick<Window, 'addEventListener' | 'removeEventListener'> | null = (
|
||||
typeof window === 'undefined' ? null : window
|
||||
),
|
||||
): (() => void) => {
|
||||
if (!target) return () => undefined;
|
||||
const handleRecoveryShortcut = (event: KeyboardEvent) => {
|
||||
if (!isCustomThemeRecoveryShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
// This fixed capture-phase escape hatch deliberately ignores editable
|
||||
// targets and user-remappable shortcuts so destructive CSS is reversible.
|
||||
deactivate();
|
||||
};
|
||||
target.addEventListener('keydown', handleRecoveryShortcut as EventListener, true);
|
||||
return () => target.removeEventListener('keydown', handleRecoveryShortcut as EventListener, true);
|
||||
};
|
||||
|
||||
export const syncCustomThemeStyle = (
|
||||
theme: CustomThemeDefinition | null,
|
||||
documentRef: Document | null = typeof document === 'undefined' ? null : document,
|
||||
): void => {
|
||||
if (!documentRef) return;
|
||||
const existing = documentRef.getElementById(CUSTOM_THEME_STYLE_ID);
|
||||
if (!theme) {
|
||||
existing?.remove();
|
||||
documentRef.body.removeAttribute('data-custom-theme');
|
||||
documentRef.body.removeAttribute('data-custom-theme-id');
|
||||
return;
|
||||
}
|
||||
if (existing && existing.tagName.toLowerCase() !== 'style') existing.remove();
|
||||
const style = existing?.tagName.toLowerCase() === 'style'
|
||||
? existing as HTMLStyleElement
|
||||
: documentRef.createElement('style');
|
||||
style.id = CUSTOM_THEME_STYLE_ID;
|
||||
style.setAttribute('data-gonavi-custom-theme', theme.id);
|
||||
// textContent is intentional: imported CSS must never pass through HTML.
|
||||
style.textContent = theme.css;
|
||||
if (!style.isConnected) documentRef.head.appendChild(style);
|
||||
documentRef.body.setAttribute('data-custom-theme', 'active');
|
||||
documentRef.body.setAttribute('data-custom-theme-id', theme.id);
|
||||
};
|
||||
|
||||
export default function CustomThemeStyleHost({
|
||||
contextKey,
|
||||
onAntTokensChange,
|
||||
}: CustomThemeStyleHostProps) {
|
||||
const themes = useCustomThemeStore((state) => state.themes);
|
||||
const activeThemeId = useCustomThemeStore((state) => state.activeThemeId);
|
||||
const reloadCustomThemes = useCustomThemeStore((state) => state.reloadCustomThemes);
|
||||
const selectCustomTheme = useCustomThemeStore((state) => state.selectCustomTheme);
|
||||
const activeTheme = useMemo(
|
||||
() => resolveAvailableCustomTheme(themes, activeThemeId),
|
||||
[activeThemeId, themes],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncCustomThemeStyle(activeTheme);
|
||||
return () => syncCustomThemeStyle(null);
|
||||
}, [activeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (shouldReloadCustomThemesForStorageEvent(event.key)) reloadCustomThemes();
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, [reloadCustomThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTheme) return undefined;
|
||||
return installCustomThemeRecoveryShortcut(() => { selectCustomTheme(null); });
|
||||
}, [activeTheme, selectCustomTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTheme || typeof window === 'undefined') {
|
||||
onAntTokensChange(null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let animationFrame = 0;
|
||||
const updateTokens = () => {
|
||||
animationFrame = 0;
|
||||
let tokens = extractCustomThemeAntTokens(activeTheme.css);
|
||||
try {
|
||||
tokens = extractComputedCustomThemeAntTokens(window.getComputedStyle(document.body));
|
||||
} catch {
|
||||
// Source-level tokens are a conservative fallback for non-DOM test or
|
||||
// teardown states. Normal app windows always use the computed cascade.
|
||||
}
|
||||
onAntTokensChange({
|
||||
themeId: activeTheme.id,
|
||||
themeRevision: activeTheme.updatedAt,
|
||||
contextKey,
|
||||
tokens,
|
||||
});
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (animationFrame) window.cancelAnimationFrame(animationFrame);
|
||||
animationFrame = window.requestAnimationFrame(updateTokens);
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
return () => {
|
||||
if (animationFrame) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [activeTheme, contextKey, onAntTokensChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
208
frontend/src/customThemeStore.test.ts
Normal file
208
frontend/src/customThemeStore.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
data = new Map<string, string>();
|
||||
failWrites = false;
|
||||
get length() { return this.data.size; }
|
||||
clear() { this.data.clear(); }
|
||||
getItem(key: string) { return this.data.get(key) ?? null; }
|
||||
key(index: number) { return Array.from(this.data.keys())[index] ?? null; }
|
||||
removeItem(key: string) { this.data.delete(key); }
|
||||
setItem(key: string, value: string) {
|
||||
if (this.failWrites) throw new DOMException('Quota exceeded', 'QuotaExceededError');
|
||||
this.data.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const importThemeStore = async () => import('./customThemeStore');
|
||||
|
||||
describe('custom theme store', () => {
|
||||
let storage: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('imports, selects, updates, persists and removes custom themes independently', async () => {
|
||||
let module = await importThemeStore();
|
||||
const imported = module.useCustomThemeStore.getState().importCustomTheme({
|
||||
name: 'Purple Night',
|
||||
sourceFileName: 'purple.css',
|
||||
baseMode: 'dark',
|
||||
css: 'body[data-custom-theme] { --gn-accent: #8b5cf6; }',
|
||||
});
|
||||
expect(imported.ok).toBe(true);
|
||||
const themeId = imported.ok ? imported.theme?.id : undefined;
|
||||
expect(themeId).toBeTruthy();
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBeNull();
|
||||
|
||||
expect(module.useCustomThemeStore.getState().selectCustomTheme(themeId!)).toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
expect(module.useCustomThemeStore.getState().updateCustomTheme(themeId!, { baseMode: 'system' })).toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
module = await importThemeStore();
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBe(themeId);
|
||||
expect(module.useCustomThemeStore.getState().themes[0].baseMode).toBe('system');
|
||||
|
||||
expect(module.useCustomThemeStore.getState().removeCustomTheme(themeId!)).toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
expect(module.useCustomThemeStore.getState().themes).toEqual([]);
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBeNull();
|
||||
});
|
||||
|
||||
it('selects and restores built-in themes without consuming the custom theme quota', async () => {
|
||||
let module = await importThemeStore();
|
||||
const selected = module.useCustomThemeStore.getState().selectCustomTheme('builtin-comfort-dark');
|
||||
expect(selected).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
theme: expect.objectContaining({
|
||||
id: 'builtin-comfort-dark',
|
||||
baseMode: 'dark',
|
||||
}),
|
||||
}));
|
||||
expect(module.useCustomThemeStore.getState().themes).toEqual([]);
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
|
||||
|
||||
const imported = module.useCustomThemeStore.getState().importCustomTheme({
|
||||
name: 'User while built-in is active',
|
||||
sourceFileName: 'user.css',
|
||||
css: 'body { color: red; }',
|
||||
});
|
||||
expect(imported.ok).toBe(true);
|
||||
const userThemeId = imported.ok ? imported.theme?.id : '';
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
|
||||
expect(module.useCustomThemeStore.getState().removeCustomTheme(userThemeId!)).toEqual({ ok: true });
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
|
||||
|
||||
vi.resetModules();
|
||||
module = await importThemeStore();
|
||||
expect(module.useCustomThemeStore.getState().themes).toEqual([]);
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
|
||||
|
||||
storage.clear();
|
||||
module.useCustomThemeStore.getState().reloadCustomThemes();
|
||||
expect(module.useCustomThemeStore.getState().activeThemeId).toBeNull();
|
||||
});
|
||||
|
||||
it('reserves built-in IDs when hydrating untrusted user theme data', async () => {
|
||||
storage.setItem('gonavi-custom-themes-v1', JSON.stringify({
|
||||
version: 1,
|
||||
activeThemeId: 'builtin-warm-paper',
|
||||
themes: [{
|
||||
schemaVersion: 1,
|
||||
id: 'builtin-warm-paper',
|
||||
name: 'Shadow copy',
|
||||
sourceFileName: 'shadow.css',
|
||||
baseMode: 'dark',
|
||||
css: 'body { color: red; }',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}));
|
||||
const { useCustomThemeStore } = await importThemeStore();
|
||||
expect(useCustomThemeStore.getState().themes).toEqual([]);
|
||||
expect(useCustomThemeStore.getState().activeThemeId).toBe('builtin-warm-paper');
|
||||
expect(useCustomThemeStore.getState().selectCustomTheme('builtin-warm-paper')).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
theme: expect.objectContaining({ name: 'Warm Paper', baseMode: 'light' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops an invalid persisted active id and unsafe persisted CSS', async () => {
|
||||
storage.setItem('gonavi-custom-themes-v1', JSON.stringify({
|
||||
version: 1,
|
||||
activeThemeId: 'missing',
|
||||
themes: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
id: 'unsafe-theme',
|
||||
name: 'Unsafe',
|
||||
sourceFileName: 'unsafe.css',
|
||||
baseMode: 'dark',
|
||||
css: 'body { background: url(https://example.com/pixel); }',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
const { useCustomThemeStore } = await importThemeStore();
|
||||
expect(useCustomThemeStore.getState().themes).toEqual([]);
|
||||
expect(useCustomThemeStore.getState().activeThemeId).toBeNull();
|
||||
});
|
||||
|
||||
it('does not interpret a future persisted schema as the current format', async () => {
|
||||
storage.setItem('gonavi-custom-themes-v1', JSON.stringify({
|
||||
version: 2,
|
||||
activeThemeId: 'theme-future',
|
||||
themes: [{
|
||||
schemaVersion: 2,
|
||||
id: 'theme-future',
|
||||
name: 'Future',
|
||||
sourceFileName: 'future.css',
|
||||
baseMode: 'dark',
|
||||
css: 'body { color: red; }',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}));
|
||||
const { useCustomThemeStore } = await importThemeStore();
|
||||
expect(useCustomThemeStore.getState().themes).toEqual([]);
|
||||
expect(useCustomThemeStore.getState().activeThemeId).toBeNull();
|
||||
});
|
||||
|
||||
it('reports storage failures without claiming that an import succeeded', async () => {
|
||||
storage.failWrites = true;
|
||||
const { useCustomThemeStore } = await importThemeStore();
|
||||
const result = useCustomThemeStore.getState().importCustomTheme({
|
||||
name: 'No space',
|
||||
sourceFileName: 'no-space.css',
|
||||
css: 'body { color: red; }',
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: 'storage-failed' });
|
||||
expect(useCustomThemeStore.getState().themes).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports unavailable browser storage instead of creating a temporary theme', async () => {
|
||||
vi.stubGlobal('window', {});
|
||||
vi.stubGlobal('localStorage', undefined);
|
||||
vi.resetModules();
|
||||
const { useCustomThemeStore } = await importThemeStore();
|
||||
const result = useCustomThemeStore.getState().importCustomTheme({
|
||||
name: 'Unavailable storage',
|
||||
sourceFileName: 'unavailable.css',
|
||||
css: 'body { color: red; }',
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: 'storage-failed' });
|
||||
expect(useCustomThemeStore.getState().themes).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the in-memory escape hatch when persistence is unavailable', async () => {
|
||||
const { useCustomThemeStore } = await importThemeStore();
|
||||
const imported = useCustomThemeStore.getState().importCustomTheme({
|
||||
name: 'Escape hatch',
|
||||
sourceFileName: 'escape.css',
|
||||
css: 'body { color: red; }',
|
||||
});
|
||||
expect(imported.ok).toBe(true);
|
||||
const themeId = imported.ok ? imported.theme?.id : '';
|
||||
useCustomThemeStore.getState().selectCustomTheme(themeId!);
|
||||
storage.failWrites = true;
|
||||
const result = useCustomThemeStore.getState().selectCustomTheme(null);
|
||||
expect(result).toEqual({ ok: false, reason: 'storage-failed' });
|
||||
expect(useCustomThemeStore.getState().activeThemeId).toBeNull();
|
||||
});
|
||||
});
|
||||
233
frontend/src/customThemeStore.ts
Normal file
233
frontend/src/customThemeStore.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
CUSTOM_THEME_MAX_COUNT,
|
||||
CUSTOM_THEME_MAX_TOTAL_BYTES,
|
||||
CUSTOM_THEME_SCHEMA_VERSION,
|
||||
createCustomThemeId,
|
||||
getCustomThemeByteLength,
|
||||
sanitizeCustomThemeBaseMode,
|
||||
sanitizeCustomThemeDefinition,
|
||||
sanitizeCustomThemeFileName,
|
||||
sanitizeCustomThemeList,
|
||||
sanitizeCustomThemeName,
|
||||
validateCustomThemeCss,
|
||||
type CustomThemeBaseMode,
|
||||
type CustomThemeDefinition,
|
||||
type CustomThemeValidationReason,
|
||||
} from './utils/customTheme';
|
||||
import {
|
||||
resolveAvailableCustomTheme,
|
||||
resolveBuiltinCustomThemePreset,
|
||||
} from './utils/customThemePresets';
|
||||
|
||||
export const CUSTOM_THEME_STORAGE_KEY = 'gonavi-custom-themes-v1';
|
||||
|
||||
type CustomThemeStoreError =
|
||||
| CustomThemeValidationReason
|
||||
| 'max-count'
|
||||
| 'max-total-size'
|
||||
| 'not-found'
|
||||
| 'storage-failed';
|
||||
|
||||
export type CustomThemeStoreResult =
|
||||
| { ok: true; theme?: CustomThemeDefinition }
|
||||
| { ok: false; reason: CustomThemeStoreError };
|
||||
|
||||
type CustomThemeSnapshot = {
|
||||
version: 1;
|
||||
themes: CustomThemeDefinition[];
|
||||
activeThemeId: string | null;
|
||||
};
|
||||
|
||||
type CustomThemeStorage = Pick<Storage, 'getItem' | 'setItem'>;
|
||||
|
||||
type ImportCustomThemeInput = {
|
||||
name: string;
|
||||
sourceFileName: string;
|
||||
baseMode?: CustomThemeBaseMode;
|
||||
css: string;
|
||||
};
|
||||
|
||||
type UpdateCustomThemeInput = Partial<Pick<
|
||||
CustomThemeDefinition,
|
||||
'name' | 'sourceFileName' | 'baseMode' | 'css'
|
||||
>>;
|
||||
|
||||
interface CustomThemeState extends CustomThemeSnapshot {
|
||||
importCustomTheme: (input: ImportCustomThemeInput) => CustomThemeStoreResult;
|
||||
updateCustomTheme: (id: string, patch: UpdateCustomThemeInput) => CustomThemeStoreResult;
|
||||
selectCustomTheme: (id: string | null) => CustomThemeStoreResult;
|
||||
removeCustomTheme: (id: string) => CustomThemeStoreResult;
|
||||
reloadCustomThemes: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_CUSTOM_THEME_SNAPSHOT: CustomThemeSnapshot = {
|
||||
version: 1,
|
||||
themes: [],
|
||||
activeThemeId: null,
|
||||
};
|
||||
|
||||
const getBrowserStorage = (): CustomThemeStorage | null => {
|
||||
try {
|
||||
return typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const sanitizeCustomThemeSnapshot = (value: unknown): CustomThemeSnapshot => {
|
||||
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
if (raw.version !== CUSTOM_THEME_SCHEMA_VERSION) return { ...EMPTY_CUSTOM_THEME_SNAPSHOT };
|
||||
// builtin-* IDs are a reserved catalog namespace. Persisted user data is an
|
||||
// untrusted boundary and must not shadow an immutable built-in theme.
|
||||
const themes = sanitizeCustomThemeList(raw.themes).filter(
|
||||
(theme) => !resolveBuiltinCustomThemePreset(theme.id),
|
||||
);
|
||||
const activeTheme = resolveAvailableCustomTheme(themes, raw.activeThemeId);
|
||||
return {
|
||||
version: 1,
|
||||
themes,
|
||||
activeThemeId: activeTheme?.id ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadCustomThemeSnapshot = (
|
||||
storage: CustomThemeStorage | null = getBrowserStorage(),
|
||||
): CustomThemeSnapshot => {
|
||||
if (!storage) return { ...EMPTY_CUSTOM_THEME_SNAPSHOT };
|
||||
try {
|
||||
const raw = storage.getItem(CUSTOM_THEME_STORAGE_KEY);
|
||||
if (!raw) return { ...EMPTY_CUSTOM_THEME_SNAPSHOT };
|
||||
return sanitizeCustomThemeSnapshot(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...EMPTY_CUSTOM_THEME_SNAPSHOT };
|
||||
}
|
||||
};
|
||||
|
||||
const persistCustomThemeSnapshot = (snapshot: CustomThemeSnapshot): boolean => {
|
||||
const storage = getBrowserStorage();
|
||||
// Server-side/test imports may intentionally run without a browser. In an
|
||||
// actual app window, unavailable storage must not be reported as persisted.
|
||||
if (!storage) return typeof window === 'undefined';
|
||||
try {
|
||||
storage.setItem(CUSTOM_THEME_STORAGE_KEY, JSON.stringify(snapshot));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalThemeBytes = (themes: CustomThemeDefinition[]): number => themes.reduce(
|
||||
(total, theme) => total + getCustomThemeByteLength(theme.css),
|
||||
0,
|
||||
);
|
||||
|
||||
const createSnapshot = (
|
||||
themes: CustomThemeDefinition[],
|
||||
activeThemeId: string | null,
|
||||
): CustomThemeSnapshot => ({ version: 1, themes, activeThemeId });
|
||||
|
||||
const initialSnapshot = loadCustomThemeSnapshot();
|
||||
|
||||
export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
||||
...initialSnapshot,
|
||||
|
||||
importCustomTheme: (input) => {
|
||||
const validation = validateCustomThemeCss(input.css);
|
||||
if (!validation.ok) return { ok: false, reason: validation.reason };
|
||||
const state = get();
|
||||
if (state.themes.length >= CUSTOM_THEME_MAX_COUNT) {
|
||||
return { ok: false, reason: 'max-count' };
|
||||
}
|
||||
if (getTotalThemeBytes(state.themes) + validation.byteLength > CUSTOM_THEME_MAX_TOTAL_BYTES) {
|
||||
return { ok: false, reason: 'max-total-size' };
|
||||
}
|
||||
let id = createCustomThemeId();
|
||||
const existingIds = new Set(state.themes.map((theme) => theme.id));
|
||||
while (existingIds.has(id)) id = createCustomThemeId();
|
||||
const now = Date.now();
|
||||
const theme = sanitizeCustomThemeDefinition({
|
||||
schemaVersion: CUSTOM_THEME_SCHEMA_VERSION,
|
||||
id,
|
||||
name: sanitizeCustomThemeName(input.name),
|
||||
sourceFileName: sanitizeCustomThemeFileName(input.sourceFileName),
|
||||
baseMode: sanitizeCustomThemeBaseMode(input.baseMode),
|
||||
css: validation.css,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (!theme) return { ok: false, reason: 'invalid-syntax' };
|
||||
const nextSnapshot = createSnapshot([theme, ...state.themes], state.activeThemeId);
|
||||
if (!persistCustomThemeSnapshot(nextSnapshot)) {
|
||||
return { ok: false, reason: 'storage-failed' };
|
||||
}
|
||||
set(nextSnapshot);
|
||||
return { ok: true, theme };
|
||||
},
|
||||
|
||||
updateCustomTheme: (id, patch) => {
|
||||
const state = get();
|
||||
const currentIndex = state.themes.findIndex((theme) => theme.id === id);
|
||||
if (currentIndex < 0) return { ok: false, reason: 'not-found' };
|
||||
const current = state.themes[currentIndex];
|
||||
const nextCss = patch.css ?? current.css;
|
||||
const validation = validateCustomThemeCss(nextCss);
|
||||
if (!validation.ok) return { ok: false, reason: validation.reason };
|
||||
const otherBytes = getTotalThemeBytes(state.themes) - getCustomThemeByteLength(current.css);
|
||||
if (otherBytes + validation.byteLength > CUSTOM_THEME_MAX_TOTAL_BYTES) {
|
||||
return { ok: false, reason: 'max-total-size' };
|
||||
}
|
||||
const nextTheme = sanitizeCustomThemeDefinition({
|
||||
...current,
|
||||
...patch,
|
||||
schemaVersion: CUSTOM_THEME_SCHEMA_VERSION,
|
||||
name: sanitizeCustomThemeName(patch.name ?? current.name, current.name),
|
||||
sourceFileName: sanitizeCustomThemeFileName(patch.sourceFileName ?? current.sourceFileName),
|
||||
baseMode: sanitizeCustomThemeBaseMode(patch.baseMode ?? current.baseMode),
|
||||
css: validation.css,
|
||||
createdAt: current.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (!nextTheme) return { ok: false, reason: 'invalid-syntax' };
|
||||
const themes = state.themes.map((theme, index) => index === currentIndex ? nextTheme : theme);
|
||||
const nextSnapshot = createSnapshot(themes, state.activeThemeId);
|
||||
if (!persistCustomThemeSnapshot(nextSnapshot)) {
|
||||
return { ok: false, reason: 'storage-failed' };
|
||||
}
|
||||
set(nextSnapshot);
|
||||
return { ok: true, theme: nextTheme };
|
||||
},
|
||||
|
||||
selectCustomTheme: (id) => {
|
||||
const state = get();
|
||||
if (id !== null && !resolveAvailableCustomTheme(state.themes, id)) {
|
||||
return { ok: false, reason: 'not-found' };
|
||||
}
|
||||
const nextSnapshot = createSnapshot(state.themes, id);
|
||||
if (!persistCustomThemeSnapshot(nextSnapshot)) {
|
||||
// Deactivation is also the recovery path for a malformed theme. It must
|
||||
// remain available in-memory even when localStorage is blocked or full.
|
||||
if (id === null) set(nextSnapshot);
|
||||
return { ok: false, reason: 'storage-failed' };
|
||||
}
|
||||
set(nextSnapshot);
|
||||
return { ok: true, theme: resolveAvailableCustomTheme(state.themes, id) ?? undefined };
|
||||
},
|
||||
|
||||
removeCustomTheme: (id) => {
|
||||
const state = get();
|
||||
if (!state.themes.some((theme) => theme.id === id)) {
|
||||
return { ok: false, reason: 'not-found' };
|
||||
}
|
||||
const themes = state.themes.filter((theme) => theme.id !== id);
|
||||
const activeThemeId = state.activeThemeId === id ? null : state.activeThemeId;
|
||||
const nextSnapshot = createSnapshot(themes, activeThemeId);
|
||||
if (!persistCustomThemeSnapshot(nextSnapshot)) {
|
||||
return { ok: false, reason: 'storage-failed' };
|
||||
}
|
||||
set(nextSnapshot);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
reloadCustomThemes: () => set(loadCustomThemeSnapshot()),
|
||||
}));
|
||||
@@ -790,7 +790,7 @@ body[data-ui-version="v2"] .gn-v2-table-overview-icon {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
color: #fff;
|
||||
color: var(--gn-on-accent, #fff);
|
||||
background: linear-gradient(135deg, var(--gn-accent) 0%, var(--gn-accent-2) 100%);
|
||||
box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.3);
|
||||
}
|
||||
@@ -1371,7 +1371,7 @@ body[data-ui-version="v2"] .gn-v2-ai-context-toggle strong {
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--gn-info);
|
||||
color: #fff;
|
||||
color: var(--gn-on-info, #fff);
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -1785,7 +1785,7 @@ body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn {
|
||||
border: 0.5px solid var(--gn-info) !important;
|
||||
background: var(--gn-info) !important;
|
||||
background-color: var(--gn-info) !important;
|
||||
color: #fff !important;
|
||||
color: var(--gn-on-info, #fff) !important;
|
||||
opacity: 1 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
135
frontend/src/utils/customTheme.test.ts
Normal file
135
frontend/src/utils/customTheme.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CUSTOM_THEME_MAX_BYTES,
|
||||
extractComputedCustomThemeAntTokens,
|
||||
extractCustomThemeAntTokens,
|
||||
sanitizeCustomThemeList,
|
||||
validateCustomThemeCss,
|
||||
} from './customTheme';
|
||||
|
||||
describe('custom theme CSS validation', () => {
|
||||
it('accepts local CSS and normalizes line endings', () => {
|
||||
const result = validateCustomThemeCss(
|
||||
'body[data-custom-theme] {\r\n --gn-accent: #8b5cf6;\r\n}',
|
||||
);
|
||||
expect(result).toEqual(expect.objectContaining({ ok: true }));
|
||||
if (result.ok) expect(result.css).not.toContain('\r');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unsafe-import', '@import "https://example.com/theme.css"; body { color: red; }'],
|
||||
['unsafe-import', '@\\69mport "theme.css"; body { color: red; }'],
|
||||
['unsafe-import', '@im/**/port "theme.css"; body { color: red; }'],
|
||||
['unsafe-url', 'body { background: u\\72l(https://example.com/pixel); }'],
|
||||
['unsafe-url', 'body { background: u/**/rl(/sensitive-endpoint); }'],
|
||||
['unsafe-url', 'body { background: image-set("https://example.com/pixel.png" 1x); }'],
|
||||
['unsafe-url', 'body { background: image-set("/sensitive-endpoint" 1x); }'],
|
||||
['unsafe-url', 'body { background: image-set("\\\\\\\\evil.example/pixel" 1x); }'],
|
||||
['unsafe-url', 'body { content: "/*"; background: url(https://evil.example/pixel); --x: "*/"; }'],
|
||||
['unsafe-url', 'body { background: src("relative-image.png"); }'],
|
||||
['unsafe-font-face', '@font-face { font-family: demo; src: local(demo); }'],
|
||||
['unsafe-legacy-script', 'body { behavior: expression(alert(1)); }'],
|
||||
] as const)('rejects %s resources or legacy execution hooks', (reason, css) => {
|
||||
expect(validateCustomThemeCss(css)).toEqual(expect.objectContaining({ ok: false, reason }));
|
||||
});
|
||||
|
||||
it('rejects empty, malformed and oversized stylesheets', () => {
|
||||
expect(validateCustomThemeCss('')).toEqual(expect.objectContaining({ ok: false, reason: 'empty' }));
|
||||
expect(validateCustomThemeCss('body { color: red;')).toEqual(
|
||||
expect.objectContaining({ ok: false, reason: 'invalid-syntax' }),
|
||||
);
|
||||
expect(validateCustomThemeCss('body { color: red; ) }')).toEqual(
|
||||
expect.objectContaining({ ok: false, reason: 'invalid-syntax' }),
|
||||
);
|
||||
expect(validateCustomThemeCss('body { width: calc(1px; }')).toEqual(
|
||||
expect.objectContaining({ ok: false, reason: 'invalid-syntax' }),
|
||||
);
|
||||
expect(validateCustomThemeCss(`body { --theme: "${'x'.repeat(CUSTOM_THEME_MAX_BYTES)}"; }`)).toEqual(
|
||||
expect.objectContaining({ ok: false, reason: 'too-large' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts safe CSS color tokens for Ant Design without accepting arbitrary values', () => {
|
||||
const tokens = extractCustomThemeAntTokens(`
|
||||
body[data-custom-theme] {
|
||||
--gn-accent: #8b5cf6;
|
||||
--gn-on-accent: #111827;
|
||||
--gn-accent-2: #7c3aed;
|
||||
--gn-accent-soft: rgba(139, 92, 246, 0.18);
|
||||
--gn-bg-panel: #1d202b;
|
||||
--gn-bg-panel-2: #232733;
|
||||
--gn-bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--gn-fg-1: #f5f3ff;
|
||||
--gn-fg-3: #c4b5fd;
|
||||
--gn-br-2: rgba(196, 181, 253, 0.18);
|
||||
--gn-info: #38bdf8;
|
||||
--gn-on-info: #08131a;
|
||||
--gn-on-danger: #ffffff;
|
||||
--gn-ant-primary-hover: hsl(258, 90%, 72%);
|
||||
--gn-ant-primary-active: var(--not-supported-by-ant-parser);
|
||||
}
|
||||
`);
|
||||
expect(tokens.primary).toBe('#8b5cf6');
|
||||
expect(tokens.primaryContrast).toBe('#111827');
|
||||
expect(tokens.primaryHover).toBe('hsl(258, 90%, 72%)');
|
||||
expect(tokens.primaryActive).toBe('#7c3aed');
|
||||
expect(tokens.primaryBg).toBe('rgba(139, 92, 246, 0.18)');
|
||||
expect(tokens).toEqual(expect.objectContaining({
|
||||
bgContainer: '#1d202b',
|
||||
bgElevated: '#1d202b',
|
||||
fillAlter: '#232733',
|
||||
rowHoverBg: 'rgba(255, 255, 255, 0.06)',
|
||||
textPrimary: '#f5f3ff',
|
||||
textSecondary: '#c4b5fd',
|
||||
border: 'rgba(196, 181, 253, 0.18)',
|
||||
info: '#38bdf8',
|
||||
infoContrast: '#08131a',
|
||||
dangerContrast: '#ffffff',
|
||||
}));
|
||||
|
||||
const invalidTokens = extractCustomThemeAntTokens(`
|
||||
body[data-custom-theme] {
|
||||
--gn-accent: #12345;
|
||||
--gn-ant-primary: rgba(not-a-color);
|
||||
}
|
||||
`);
|
||||
expect(invalidTokens.primary).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads Ant tokens from the browser-computed cascade', () => {
|
||||
const values: Record<string, string> = {
|
||||
'--gn-accent': '#0f766e',
|
||||
'--gn-ant-primary': '#7c3aed',
|
||||
'--gn-ant-primary-hover': 'rgba(124, 58, 237, 0.8)',
|
||||
'--gn-ant-primary-active': 'rgba(not-a-color)',
|
||||
};
|
||||
const tokens = extractComputedCustomThemeAntTokens({
|
||||
getPropertyValue: (property: string) => values[property] || '',
|
||||
});
|
||||
expect(tokens.primary).toBe('#7c3aed');
|
||||
expect(tokens.primaryHover).toBe('rgba(124, 58, 237, 0.8)');
|
||||
expect(tokens.primaryActive).toBe('#0f766e');
|
||||
});
|
||||
|
||||
it('sanitizes malformed and duplicate persisted theme entries', () => {
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
id: 'theme-one',
|
||||
name: 'One',
|
||||
sourceFileName: 'one.css',
|
||||
baseMode: 'dark',
|
||||
css: 'body { color: #fff; }',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const themes = sanitizeCustomThemeList([
|
||||
base,
|
||||
{ ...base, name: 'Duplicate' },
|
||||
{ ...base, id: '../unsafe' },
|
||||
{ ...base, id: 'theme-future', schemaVersion: 2 },
|
||||
{ ...base, id: 'theme-network', css: 'body { background: url(https://example.com); }' },
|
||||
]);
|
||||
expect(themes).toHaveLength(1);
|
||||
expect(themes[0].id).toBe('theme-one');
|
||||
});
|
||||
});
|
||||
416
frontend/src/utils/customTheme.ts
Normal file
416
frontend/src/utils/customTheme.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
export const CUSTOM_THEME_SCHEMA_VERSION = 1;
|
||||
export const CUSTOM_THEME_MAX_COUNT = 12;
|
||||
export const CUSTOM_THEME_MAX_BYTES = 256 * 1024;
|
||||
export const CUSTOM_THEME_MAX_TOTAL_BYTES = 1024 * 1024;
|
||||
export const CUSTOM_THEME_STYLE_ID = 'gonavi-custom-theme-style';
|
||||
|
||||
export type CustomThemeBaseMode = 'system' | 'light' | 'dark';
|
||||
|
||||
export interface CustomThemeDefinition {
|
||||
schemaVersion: typeof CUSTOM_THEME_SCHEMA_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
sourceFileName: string;
|
||||
baseMode: CustomThemeBaseMode;
|
||||
css: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type CustomThemeValidationReason =
|
||||
| 'empty'
|
||||
| 'too-large'
|
||||
| 'invalid-syntax'
|
||||
| 'unsafe-import'
|
||||
| 'unsafe-url'
|
||||
| 'unsafe-font-face'
|
||||
| 'unsafe-legacy-script';
|
||||
|
||||
export type CustomThemeValidationResult =
|
||||
| { ok: true; css: string; byteLength: number }
|
||||
| { ok: false; reason: CustomThemeValidationReason; byteLength: number };
|
||||
|
||||
export type CustomThemeAntTokens = Partial<{
|
||||
primary: string;
|
||||
primaryContrast: string;
|
||||
primaryHover: string;
|
||||
primaryActive: string;
|
||||
primaryBg: string;
|
||||
primaryBgHover: string;
|
||||
primaryBorder: string;
|
||||
primaryBorderHover: string;
|
||||
controlActiveBg: string;
|
||||
controlActiveHoverBg: string;
|
||||
controlOutline: string;
|
||||
bgContainer: string;
|
||||
bgElevated: string;
|
||||
fillAlter: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
rowHoverBg: string;
|
||||
info: string;
|
||||
infoContrast: string;
|
||||
dangerContrast: string;
|
||||
}>;
|
||||
|
||||
const textEncoder = typeof TextEncoder === 'undefined' ? null : new TextEncoder();
|
||||
|
||||
export const getCustomThemeByteLength = (value: string): number => {
|
||||
if (textEncoder) return textEncoder.encode(value).byteLength;
|
||||
return unescape(encodeURIComponent(value)).length;
|
||||
};
|
||||
|
||||
const stripCssComments = (value: string): string => {
|
||||
let result = '';
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const current = value[index];
|
||||
const next = value[index + 1];
|
||||
if (quote) {
|
||||
result += current;
|
||||
if (current === '\\' && next !== undefined) {
|
||||
result += next;
|
||||
index += 1;
|
||||
} else if (current === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current === '\\' && next !== undefined) {
|
||||
result += current + next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (current === '"' || current === "'") {
|
||||
quote = current;
|
||||
result += current;
|
||||
continue;
|
||||
}
|
||||
if (current === '/' && next === '*') {
|
||||
const end = value.indexOf('*/', index + 2);
|
||||
if (end < 0) return result;
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
result += current;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const decodeCssEscapes = (value: string): string => value
|
||||
.replace(/\\\r?\n/g, '')
|
||||
.replace(/\\([0-9a-f]{1,6})(?:\s)?/gi, (_match, codePoint: string) => {
|
||||
const valueNumber = Number.parseInt(codePoint, 16);
|
||||
if (!Number.isFinite(valueNumber) || valueNumber <= 0 || valueNumber > 0x10ffff) return '';
|
||||
return String.fromCodePoint(valueNumber);
|
||||
})
|
||||
.replace(/\\(.)/gs, '$1');
|
||||
|
||||
const hasBalancedCssBlocks = (value: string): boolean => {
|
||||
const stack: string[] = [];
|
||||
let sawBlock = false;
|
||||
let quote: '"' | "'" | null = null;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const current = value[index];
|
||||
const next = value[index + 1];
|
||||
if (!quote && current === '/' && next === '*') {
|
||||
const end = value.indexOf('*/', index + 2);
|
||||
if (end < 0) return false;
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (current === '\\') {
|
||||
escaped = true;
|
||||
} else if (current === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current === '\\') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (current === '"' || current === "'") {
|
||||
quote = current;
|
||||
continue;
|
||||
}
|
||||
if (current === '{' || current === '(' || current === '[') {
|
||||
if (current === '{') sawBlock = true;
|
||||
stack.push(current);
|
||||
} else if (current === '}' || current === ')' || current === ']') {
|
||||
const expected = current === '}' ? '{' : current === ')' ? '(' : '[';
|
||||
if (stack.pop() !== expected) return false;
|
||||
}
|
||||
}
|
||||
return sawBlock && stack.length === 0 && quote === null;
|
||||
};
|
||||
|
||||
export const validateCustomThemeCss = (value: unknown): CustomThemeValidationResult => {
|
||||
const css = typeof value === 'string' ? value.replace(/\r\n?/g, '\n').trim() : '';
|
||||
const byteLength = getCustomThemeByteLength(css);
|
||||
if (!css) return { ok: false, reason: 'empty', byteLength };
|
||||
if (byteLength > CUSTOM_THEME_MAX_BYTES) {
|
||||
return { ok: false, reason: 'too-large', byteLength };
|
||||
}
|
||||
if (css.includes('\0') || !hasBalancedCssBlocks(css)) {
|
||||
return { ok: false, reason: 'invalid-syntax', byteLength };
|
||||
}
|
||||
|
||||
// Decode CSS escapes before scanning so constructs such as @\69mport and
|
||||
// u\72l(...) cannot bypass the external-resource boundary.
|
||||
const scanText = decodeCssEscapes(stripCssComments(css)).toLowerCase().replace(/\\/g, '/');
|
||||
if (/@\s*import\b/.test(scanText)) {
|
||||
return { ok: false, reason: 'unsafe-import', byteLength };
|
||||
}
|
||||
if (
|
||||
/\burl\s*\(/.test(scanText)
|
||||
|| /(?:^|[^a-z0-9_-])(?:-webkit-)?(?:image-set|image|cross-fade|src)\s*\(/.test(scanText)
|
||||
|| /\b(?:https?|ftp|file|data|blob)\s*:/.test(scanText)
|
||||
|| /(?:^|[\s('"=])\/\/[a-z0-9]/i.test(scanText)
|
||||
) {
|
||||
return { ok: false, reason: 'unsafe-url', byteLength };
|
||||
}
|
||||
if (/@\s*font-face\b/.test(scanText)) {
|
||||
return { ok: false, reason: 'unsafe-font-face', byteLength };
|
||||
}
|
||||
if (
|
||||
/\bexpression\s*\(/.test(scanText)
|
||||
|| /(?:^|[;{])\s*behavior\s*:/.test(scanText)
|
||||
|| /-moz-binding\s*:/.test(scanText)
|
||||
|| /(?:java|vb)script\s*:/.test(scanText)
|
||||
|| /<\s*\/\s*style\b/.test(scanText)
|
||||
) {
|
||||
return { ok: false, reason: 'unsafe-legacy-script', byteLength };
|
||||
}
|
||||
return { ok: true, css, byteLength };
|
||||
};
|
||||
|
||||
export const sanitizeCustomThemeName = (value: unknown, fallback = 'Custom theme'): string => {
|
||||
const name = typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() : '';
|
||||
return (name || fallback).slice(0, 80);
|
||||
};
|
||||
|
||||
export const sanitizeCustomThemeFileName = (value: unknown): string => {
|
||||
if (typeof value !== 'string') return '';
|
||||
const fileName = value.split(/[\\/]/).pop()?.replace(/[\u0000-\u001f\u007f]/g, '').trim() || '';
|
||||
return fileName.slice(0, 160);
|
||||
};
|
||||
|
||||
export const deriveCustomThemeName = (fileName: string): string => {
|
||||
const safeFileName = sanitizeCustomThemeFileName(fileName);
|
||||
return sanitizeCustomThemeName(safeFileName.replace(/\.css$/i, ''), 'Custom theme');
|
||||
};
|
||||
|
||||
export const sanitizeCustomThemeBaseMode = (value: unknown): CustomThemeBaseMode => {
|
||||
if (value === 'light' || value === 'dark') return value;
|
||||
return 'system';
|
||||
};
|
||||
|
||||
export const createCustomThemeId = (): string => {
|
||||
const randomId = typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return `theme-${randomId.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`.slice(0, 80);
|
||||
};
|
||||
|
||||
export const sanitizeCustomThemeDefinition = (value: unknown): CustomThemeDefinition | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (raw.schemaVersion !== CUSTOM_THEME_SCHEMA_VERSION) return null;
|
||||
const id = typeof raw.id === 'string' && /^[a-z0-9][a-z0-9_-]{0,79}$/i.test(raw.id)
|
||||
? raw.id
|
||||
: '';
|
||||
if (!id) return null;
|
||||
const validation = validateCustomThemeCss(raw.css);
|
||||
if (!validation.ok) return null;
|
||||
const now = Date.now();
|
||||
const createdAt = Number.isFinite(Number(raw.createdAt)) && Number(raw.createdAt) > 0
|
||||
? Number(raw.createdAt)
|
||||
: now;
|
||||
const updatedAt = Number.isFinite(Number(raw.updatedAt)) && Number(raw.updatedAt) > 0
|
||||
? Number(raw.updatedAt)
|
||||
: createdAt;
|
||||
const sourceFileName = sanitizeCustomThemeFileName(raw.sourceFileName);
|
||||
return {
|
||||
schemaVersion: CUSTOM_THEME_SCHEMA_VERSION,
|
||||
id,
|
||||
name: sanitizeCustomThemeName(raw.name, deriveCustomThemeName(sourceFileName)),
|
||||
sourceFileName,
|
||||
baseMode: sanitizeCustomThemeBaseMode(raw.baseMode),
|
||||
css: validation.css,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const sanitizeCustomThemeList = (value: unknown): CustomThemeDefinition[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const themes: CustomThemeDefinition[] = [];
|
||||
const ids = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
for (const item of value) {
|
||||
if (themes.length >= CUSTOM_THEME_MAX_COUNT) break;
|
||||
const theme = sanitizeCustomThemeDefinition(item);
|
||||
if (!theme || ids.has(theme.id)) continue;
|
||||
const nextBytes = getCustomThemeByteLength(theme.css);
|
||||
if (totalBytes + nextBytes > CUSTOM_THEME_MAX_TOTAL_BYTES) continue;
|
||||
ids.add(theme.id);
|
||||
totalBytes += nextBytes;
|
||||
themes.push(theme);
|
||||
}
|
||||
return themes;
|
||||
};
|
||||
|
||||
export const resolveActiveCustomTheme = (
|
||||
themes: CustomThemeDefinition[],
|
||||
activeThemeId: unknown,
|
||||
): CustomThemeDefinition | null => {
|
||||
if (typeof activeThemeId !== 'string' || !activeThemeId) return null;
|
||||
return themes.find((theme) => theme.id === activeThemeId) ?? null;
|
||||
};
|
||||
|
||||
const CSS_COLOR_VALUE = /^(?:#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([^;{}]+\)|hsla?\([^;{}]+\))$/i;
|
||||
const CSS_COLOR_COMPONENT = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:%|deg|grad|rad|turn)?$/i;
|
||||
|
||||
const isSyntacticallyValidFunctionalColor = (candidate: string): boolean => {
|
||||
const body = candidate.slice(candidate.indexOf('(') + 1, -1).trim();
|
||||
if (!body || !/^[\d\s.,%+\-/a-z]*$/i.test(body)) return false;
|
||||
if (body.includes(',')) {
|
||||
const components = body.split(',').map((part) => part.trim());
|
||||
return (components.length === 3 || components.length === 4)
|
||||
&& components.every((part) => CSS_COLOR_COMPONENT.test(part));
|
||||
}
|
||||
const slashParts = body.split('/').map((part) => part.trim());
|
||||
if (slashParts.length > 2 || (slashParts.length === 2 && !CSS_COLOR_COMPONENT.test(slashParts[1]))) {
|
||||
return false;
|
||||
}
|
||||
const components = slashParts[0].split(/\s+/).filter(Boolean);
|
||||
return components.length === 3 && components.every((part) => CSS_COLOR_COMPONENT.test(part));
|
||||
};
|
||||
|
||||
const normalizeCustomThemeColor = (value: string | undefined): string | undefined => {
|
||||
const candidate = value?.trim();
|
||||
if (!candidate || !CSS_COLOR_VALUE.test(candidate)) return undefined;
|
||||
const cssApi = typeof globalThis.CSS === 'undefined' ? null : globalThis.CSS;
|
||||
if (cssApi && typeof cssApi.supports === 'function' && !cssApi.supports('color', candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^(?:rgb|hsl)a?\(/i.test(candidate)) {
|
||||
if (!isSyntacticallyValidFunctionalColor(candidate)) return undefined;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const readCustomProperty = (css: string, property: string): string | undefined => {
|
||||
const source = stripCssComments(css);
|
||||
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const matcher = new RegExp(`(?:^|[;{])\\s*${escapedProperty}\\s*:\\s*([^;}]+)`, 'gi');
|
||||
let value: string | undefined;
|
||||
for (const match of source.matchAll(matcher)) {
|
||||
const candidate = match[1]?.trim();
|
||||
const color = normalizeCustomThemeColor(candidate);
|
||||
if (color) value = color;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const extractCustomThemeAntTokensWith = (
|
||||
readProperty: (property: string) => string | undefined,
|
||||
): CustomThemeAntTokens => {
|
||||
const accent = normalizeCustomThemeColor(readProperty('--gn-accent'));
|
||||
const onAccent = normalizeCustomThemeColor(readProperty('--gn-on-accent'));
|
||||
const accent2 = normalizeCustomThemeColor(readProperty('--gn-accent-2'));
|
||||
const accentSoft = normalizeCustomThemeColor(readProperty('--gn-accent-soft'));
|
||||
const read = (property: string, fallback?: string) => (
|
||||
normalizeCustomThemeColor(readProperty(property)) ?? fallback
|
||||
);
|
||||
return {
|
||||
primary: read('--gn-ant-primary', accent),
|
||||
primaryContrast: read('--gn-ant-on-primary', onAccent),
|
||||
primaryHover: read('--gn-ant-primary-hover', accent2 ?? accent),
|
||||
primaryActive: read('--gn-ant-primary-active', accent2 ?? accent),
|
||||
primaryBg: read('--gn-ant-primary-bg', accentSoft),
|
||||
primaryBgHover: read('--gn-ant-primary-bg-hover', accentSoft),
|
||||
primaryBorder: read('--gn-ant-primary-border', accent),
|
||||
primaryBorderHover: read('--gn-ant-primary-border-hover', accent2 ?? accent),
|
||||
controlActiveBg: read('--gn-ant-control-active-bg', accentSoft),
|
||||
controlActiveHoverBg: read('--gn-ant-control-active-hover-bg', accentSoft),
|
||||
controlOutline: read('--gn-ant-control-outline', accent),
|
||||
bgContainer: read('--gn-bg-panel'),
|
||||
bgElevated: read('--gn-bg-panel'),
|
||||
fillAlter: read('--gn-bg-panel-2'),
|
||||
textPrimary: read('--gn-fg-1'),
|
||||
textSecondary: read('--gn-fg-3'),
|
||||
border: read('--gn-br-2'),
|
||||
rowHoverBg: read('--gn-bg-hover'),
|
||||
info: read('--gn-info'),
|
||||
infoContrast: read('--gn-on-info'),
|
||||
dangerContrast: read('--gn-on-danger'),
|
||||
};
|
||||
};
|
||||
|
||||
export const extractCustomThemeAntTokens = (css: string): CustomThemeAntTokens => (
|
||||
extractCustomThemeAntTokensWith((property) => readCustomProperty(css, property))
|
||||
);
|
||||
|
||||
export const extractComputedCustomThemeAntTokens = (
|
||||
computedStyle: Pick<CSSStyleDeclaration, 'getPropertyValue'> | null,
|
||||
): CustomThemeAntTokens => {
|
||||
if (!computedStyle) return {};
|
||||
return extractCustomThemeAntTokensWith((property) => computedStyle.getPropertyValue(property));
|
||||
};
|
||||
|
||||
export const CUSTOM_THEME_TEMPLATE = `/*
|
||||
* GoNavi custom theme template
|
||||
* Choose the light/dark/system base mode in Theme Settings.
|
||||
* External @import, url() and @font-face resources are intentionally blocked.
|
||||
*/
|
||||
body[data-custom-theme][data-ui-version="v2"] {
|
||||
--gn-bg-app: #11131a;
|
||||
--gn-bg-chrome: #171a23;
|
||||
--gn-bg-panel: #1d202b;
|
||||
--gn-bg-panel-2: #232733;
|
||||
--gn-bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--gn-bg-active: rgba(255, 255, 255, 0.10);
|
||||
--gn-bg-selected: rgba(139, 92, 246, 0.18);
|
||||
--gn-bg-input: #141720;
|
||||
|
||||
--gn-fg-1: #f5f3ff;
|
||||
--gn-fg-2: #e9e5ff;
|
||||
--gn-fg-3: #c4b5fd;
|
||||
--gn-fg-4: #a78bfa;
|
||||
--gn-fg-5: #7c6aa8;
|
||||
|
||||
--gn-br-1: rgba(196, 181, 253, 0.10);
|
||||
--gn-br-2: rgba(196, 181, 253, 0.18);
|
||||
--gn-br-3: rgba(196, 181, 253, 0.28);
|
||||
|
||||
--gn-accent: #8b5cf6;
|
||||
--gn-accent-2: #7c3aed;
|
||||
--gn-accent-soft: rgba(139, 92, 246, 0.18);
|
||||
--gn-on-accent: #ffffff;
|
||||
--gn-info: #38bdf8;
|
||||
--gn-on-info: #08131a;
|
||||
--gn-danger: #ef4444;
|
||||
--gn-danger-strong: #dc2626;
|
||||
--gn-on-danger: #ffffff;
|
||||
|
||||
/* Optional Ant Design token bridge. */
|
||||
--gn-ant-primary: #8b5cf6;
|
||||
--gn-ant-on-primary: #ffffff;
|
||||
--gn-ant-primary-hover: #a78bfa;
|
||||
--gn-ant-primary-active: #7c3aed;
|
||||
--gn-ant-primary-bg: rgba(139, 92, 246, 0.18);
|
||||
--gn-ant-primary-bg-hover: rgba(139, 92, 246, 0.26);
|
||||
--gn-ant-primary-border: #8b5cf6;
|
||||
--gn-ant-primary-border-hover: #a78bfa;
|
||||
--gn-ant-control-active-bg: rgba(139, 92, 246, 0.16);
|
||||
--gn-ant-control-active-hover-bg: rgba(139, 92, 246, 0.24);
|
||||
--gn-ant-control-outline: rgba(139, 92, 246, 0.38);
|
||||
}
|
||||
`;
|
||||
137
frontend/src/utils/customThemePresets.test.ts
Normal file
137
frontend/src/utils/customThemePresets.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CUSTOM_THEME_MAX_BYTES,
|
||||
getCustomThemeByteLength,
|
||||
sanitizeCustomThemeDefinition,
|
||||
validateCustomThemeCss,
|
||||
type CustomThemeDefinition,
|
||||
} from './customTheme';
|
||||
import {
|
||||
BUILTIN_CUSTOM_THEME_PRESETS,
|
||||
resolveAvailableCustomTheme,
|
||||
resolveBuiltinCustomThemePreset,
|
||||
} from './customThemePresets';
|
||||
|
||||
const readHexProperty = (css: string, property: string): string => {
|
||||
const match = css.match(new RegExp(`${property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*(#[0-9a-f]{6})`, 'i'));
|
||||
if (!match?.[1]) throw new Error(`Missing hexadecimal custom property: ${property}`);
|
||||
return match[1];
|
||||
};
|
||||
|
||||
const relativeLuminance = (hex: string): number => {
|
||||
const channels = [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
|
||||
const [red, green, blue] = channels.map((channel) => (
|
||||
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
));
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
};
|
||||
|
||||
const contrastRatio = (foreground: string, background: string): number => {
|
||||
const light = Math.max(relativeLuminance(foreground), relativeLuminance(background));
|
||||
const dark = Math.min(relativeLuminance(foreground), relativeLuminance(background));
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
};
|
||||
|
||||
describe('built-in custom theme presets', () => {
|
||||
it('ships six unique, safe, size-bounded presets', () => {
|
||||
expect(BUILTIN_CUSTOM_THEME_PRESETS).toHaveLength(6);
|
||||
const ids = new Set<string>();
|
||||
const nameKeys = new Set<string>();
|
||||
for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) {
|
||||
expect(preset.id).toMatch(/^builtin-[a-z0-9-]+$/);
|
||||
expect(ids.has(preset.id)).toBe(false);
|
||||
expect(nameKeys.has(preset.nameKey)).toBe(false);
|
||||
ids.add(preset.id);
|
||||
nameKeys.add(preset.nameKey);
|
||||
expect(validateCustomThemeCss(preset.css)).toEqual(expect.objectContaining({ ok: true }));
|
||||
expect(getCustomThemeByteLength(preset.css)).toBeLessThan(CUSTOM_THEME_MAX_BYTES);
|
||||
expect(sanitizeCustomThemeDefinition(preset)).toEqual(expect.objectContaining({ id: preset.id }));
|
||||
expect(preset.css).toContain('--gn-ant-primary:');
|
||||
expect(preset.css).toContain('--gn-ant-on-primary:');
|
||||
expect(preset.css).toContain('--gn-settings-card-bg:');
|
||||
expect(preset.css).toContain('--gn-explain-critical:');
|
||||
}
|
||||
expect(BUILTIN_CUSTOM_THEME_PRESETS.filter((preset) => preset.baseMode === 'dark')).toHaveLength(4);
|
||||
expect(BUILTIN_CUSTOM_THEME_PRESETS.filter((preset) => preset.baseMode === 'light')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps Comfort Dark first and covers low-glare surfaces plus hard-coded hotspots', () => {
|
||||
const comfortDark = BUILTIN_CUSTOM_THEME_PRESETS[0];
|
||||
expect(comfortDark.id).toBe('builtin-comfort-dark');
|
||||
expect(comfortDark.baseMode).toBe('dark');
|
||||
expect(comfortDark.badgeKey).toBe('app.theme.custom.preset.badge.recommended');
|
||||
expect(comfortDark.css).toContain('--gn-bg-app: #1b1d21');
|
||||
expect(comfortDark.css).toContain('--gn-bg-panel: #24272d');
|
||||
expect(comfortDark.css).toContain('--gn-fg-5: #878e98');
|
||||
expect(comfortDark.css).toContain('--gn-on-accent: #142019');
|
||||
expect(comfortDark.css).toContain('.gn-v2-query-toolbar-save-action');
|
||||
expect(comfortDark.css).toContain('.gn-v2-ai-panel .ai-logo');
|
||||
expect(comfortDark.css).toContain('.monaco-editor-background');
|
||||
});
|
||||
|
||||
it('keeps preset text and solid-button colors at WCAG AA contrast', () => {
|
||||
for (const preset of BUILTIN_CUSTOM_THEME_PRESETS) {
|
||||
const panel = readHexProperty(preset.css, '--gn-bg-panel');
|
||||
for (const property of [
|
||||
'--gn-fg-1',
|
||||
'--gn-fg-2',
|
||||
'--gn-fg-3',
|
||||
'--gn-fg-4',
|
||||
'--gn-fg-5',
|
||||
'--gn-accent',
|
||||
'--gn-accent-2',
|
||||
'--gn-info',
|
||||
'--gn-warn',
|
||||
'--gn-danger',
|
||||
'--gn-purple',
|
||||
]) {
|
||||
const color = readHexProperty(preset.css, property);
|
||||
expect(
|
||||
contrastRatio(color, panel),
|
||||
`${preset.id} ${property} must contrast with --gn-bg-panel`,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
|
||||
const onAccent = readHexProperty(preset.css, '--gn-on-accent');
|
||||
for (const property of ['--gn-accent', '--gn-accent-2']) {
|
||||
expect(
|
||||
contrastRatio(onAccent, readHexProperty(preset.css, property)),
|
||||
`${preset.id} --gn-on-accent must contrast with ${property}`,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
expect(
|
||||
contrastRatio(readHexProperty(preset.css, '--gn-on-info'), readHexProperty(preset.css, '--gn-info')),
|
||||
`${preset.id} --gn-on-info must contrast with --gn-info`,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
for (const property of ['--gn-danger-strong', '--gn-danger-strong-hover']) {
|
||||
expect(
|
||||
contrastRatio(readHexProperty(preset.css, '--gn-on-danger'), readHexProperty(preset.css, property)),
|
||||
`${preset.id} --gn-on-danger must contrast with ${property}`,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves built-in and user themes through one active-theme boundary', () => {
|
||||
expect(resolveBuiltinCustomThemePreset('builtin-warm-paper')).toEqual(
|
||||
expect.objectContaining({ id: 'builtin-warm-paper', baseMode: 'light' }),
|
||||
);
|
||||
expect(resolveBuiltinCustomThemePreset('missing')).toBeNull();
|
||||
|
||||
const userTheme: CustomThemeDefinition = {
|
||||
schemaVersion: 1,
|
||||
id: 'theme-user',
|
||||
name: 'User',
|
||||
sourceFileName: 'user.css',
|
||||
baseMode: 'system',
|
||||
css: 'body { color: red; }',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
expect(resolveAvailableCustomTheme([userTheme], userTheme.id)).toBe(userTheme);
|
||||
expect(resolveAvailableCustomTheme([userTheme], 'builtin-midnight-navy')).toEqual(
|
||||
expect.objectContaining({ id: 'builtin-midnight-navy' }),
|
||||
);
|
||||
expect(resolveAvailableCustomTheme([userTheme], 'missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
381
frontend/src/utils/customThemePresets.ts
Normal file
381
frontend/src/utils/customThemePresets.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
CUSTOM_THEME_SCHEMA_VERSION,
|
||||
resolveActiveCustomTheme,
|
||||
type CustomThemeDefinition,
|
||||
} from './customTheme';
|
||||
|
||||
type BuiltinThemePalette = {
|
||||
mode: 'light' | 'dark';
|
||||
app: string;
|
||||
chrome: string;
|
||||
panel: string;
|
||||
panel2: string;
|
||||
input: string;
|
||||
hover: string;
|
||||
active: string;
|
||||
selected: string;
|
||||
fg1: string;
|
||||
fg2: string;
|
||||
fg3: string;
|
||||
fg4: string;
|
||||
fg5: string;
|
||||
border1: string;
|
||||
border2: string;
|
||||
border3: string;
|
||||
accent: string;
|
||||
accent2: string;
|
||||
accentSoft: string;
|
||||
accentSoftHover: string;
|
||||
accentOutline: string;
|
||||
onAccent: string;
|
||||
info: string;
|
||||
infoSoft: string;
|
||||
onInfo: string;
|
||||
warn: string;
|
||||
warnSoft: string;
|
||||
danger: string;
|
||||
dangerStrong: string;
|
||||
dangerHover: string;
|
||||
onDanger: string;
|
||||
purple: string;
|
||||
purpleSoft: string;
|
||||
shadowSm: string;
|
||||
shadowMd: string;
|
||||
shadowLg: string;
|
||||
shadowCard: string;
|
||||
kbdBg: string;
|
||||
kbdFg: string;
|
||||
};
|
||||
|
||||
export type BuiltinCustomThemePreset = CustomThemeDefinition & {
|
||||
kind: 'builtin';
|
||||
nameKey: string;
|
||||
descriptionKey: string;
|
||||
badgeKey?: string;
|
||||
preview: {
|
||||
app: string;
|
||||
chrome: string;
|
||||
panel: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
accent: string;
|
||||
};
|
||||
};
|
||||
|
||||
const BUILTIN_THEME_REVISION = 2026071101;
|
||||
|
||||
const createBuiltinThemeCss = (id: string, palette: BuiltinThemePalette): string => `/* GoNavi built-in theme: ${id} */
|
||||
body[data-custom-theme],
|
||||
body[data-custom-theme][data-ui-version="v2"] {
|
||||
color-scheme: ${palette.mode};
|
||||
--gn-bg-app: ${palette.app};
|
||||
--gn-bg-chrome: ${palette.chrome};
|
||||
--gn-bg-panel: ${palette.panel};
|
||||
--gn-bg-panel-2: ${palette.panel2};
|
||||
--gn-bg-input: ${palette.input};
|
||||
--gn-bg-subtle: ${palette.panel2};
|
||||
--gn-bg-hover: ${palette.hover};
|
||||
--gn-bg-active: ${palette.active};
|
||||
--gn-bg-selected: ${palette.selected};
|
||||
|
||||
--gn-fg-1: ${palette.fg1};
|
||||
--gn-fg-2: ${palette.fg2};
|
||||
--gn-fg-3: ${palette.fg3};
|
||||
--gn-fg-4: ${palette.fg4};
|
||||
--gn-fg-5: ${palette.fg5};
|
||||
--gn-text-muted: ${palette.fg4};
|
||||
|
||||
--gn-br-1: ${palette.border1};
|
||||
--gn-br-2: ${palette.border2};
|
||||
--gn-br-3: ${palette.border3};
|
||||
|
||||
--gn-accent: ${palette.accent};
|
||||
--gn-accent-2: ${palette.accent2};
|
||||
--gn-accent-soft: ${palette.accentSoft};
|
||||
--gn-accent-text: ${palette.accent};
|
||||
--gn-accent-fill: ${palette.accent};
|
||||
--gn-accent-fill-hover: ${palette.accent2};
|
||||
--gn-on-accent: ${palette.onAccent};
|
||||
--gn-focus-ring: ${palette.accentOutline};
|
||||
|
||||
--gn-info: ${palette.info};
|
||||
--gn-info-soft: ${palette.infoSoft};
|
||||
--gn-on-info: ${palette.onInfo};
|
||||
--gn-warn: ${palette.warn};
|
||||
--gn-warn-soft: ${palette.warnSoft};
|
||||
--gn-danger: ${palette.danger};
|
||||
--gn-danger-strong: ${palette.dangerStrong};
|
||||
--gn-danger-strong-hover: ${palette.dangerHover};
|
||||
--gn-on-danger: ${palette.onDanger};
|
||||
--gn-purple: ${palette.purple};
|
||||
--gn-purple-soft: ${palette.purpleSoft};
|
||||
|
||||
--gn-shadow-sm: ${palette.shadowSm};
|
||||
--gn-shadow-md: ${palette.shadowMd};
|
||||
--gn-shadow-lg: ${palette.shadowLg};
|
||||
--gn-shadow-card: ${palette.shadowCard};
|
||||
--gn-kbd-bg: ${palette.kbdBg};
|
||||
--gn-kbd-fg: ${palette.kbdFg};
|
||||
|
||||
--gn-ant-primary: ${palette.accent};
|
||||
--gn-ant-primary-hover: ${palette.accent2};
|
||||
--gn-ant-primary-active: ${palette.accent2};
|
||||
--gn-ant-primary-bg: ${palette.accentSoft};
|
||||
--gn-ant-primary-bg-hover: ${palette.accentSoftHover};
|
||||
--gn-ant-primary-border: ${palette.accent};
|
||||
--gn-ant-primary-border-hover: ${palette.accent2};
|
||||
--gn-ant-control-active-bg: ${palette.accentSoft};
|
||||
--gn-ant-control-active-hover-bg: ${palette.accentSoftHover};
|
||||
--gn-ant-control-outline: ${palette.accentOutline};
|
||||
--gn-ant-on-primary: ${palette.onAccent};
|
||||
|
||||
--gn-explain-scan: ${palette.danger};
|
||||
--gn-explain-index-scan: ${palette.info};
|
||||
--gn-explain-index-only: ${palette.accent};
|
||||
--gn-explain-join: ${palette.info};
|
||||
--gn-explain-aggregate: ${palette.purple};
|
||||
--gn-explain-sort: ${palette.warn};
|
||||
--gn-explain-limit: ${palette.fg3};
|
||||
--gn-explain-filter: ${palette.fg3};
|
||||
--gn-explain-subquery: ${palette.purple};
|
||||
--gn-explain-materialize: ${palette.warn};
|
||||
--gn-explain-other: ${palette.fg4};
|
||||
--gn-explain-critical: ${palette.dangerStrong};
|
||||
--gn-explain-warning: ${palette.warn};
|
||||
--gn-explain-info: ${palette.info};
|
||||
}
|
||||
|
||||
body[data-custom-theme] .gonavi-theme-settings,
|
||||
body[data-custom-theme] .gonavi-custom-theme-manager.is-legacy {
|
||||
--gn-settings-fg: var(--gn-fg-1);
|
||||
--gn-settings-muted: var(--gn-fg-4);
|
||||
--gn-settings-line: var(--gn-br-2);
|
||||
--gn-settings-track: var(--gn-br-3);
|
||||
--gn-settings-chip-bg: var(--gn-bg-panel-2);
|
||||
--gn-settings-chip-fg: var(--gn-fg-2);
|
||||
--gn-settings-seg-bg: var(--gn-bg-panel-2);
|
||||
--gn-settings-seg-border: var(--gn-br-2);
|
||||
--gn-settings-seg-item: var(--gn-fg-4);
|
||||
--gn-settings-seg-selected-bg: var(--gn-bg-panel);
|
||||
--gn-settings-seg-selected-fg: var(--gn-fg-1);
|
||||
--gn-settings-seg-shadow: var(--gn-shadow-sm);
|
||||
--gn-settings-accent: var(--gn-accent-text, var(--gn-accent));
|
||||
--gn-settings-accent-soft: var(--gn-accent-soft);
|
||||
--gn-settings-accent-border: var(--gn-ant-primary-border);
|
||||
--gn-settings-card-bg: var(--gn-bg-panel);
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .ant-btn-primary:not(.ant-btn-dangerous):not(:disabled):not(.ant-btn-disabled) {
|
||||
color: var(--gn-on-accent, #fff) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .ant-btn-primary.ant-btn-dangerous:not(:disabled):not(.ant-btn-disabled) {
|
||||
color: var(--gn-on-danger, #fff) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:hover,
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus,
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus-visible,
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button:active {
|
||||
border-color: var(--gn-ant-primary-border) !important;
|
||||
background: var(--gn-ant-primary-bg-hover) !important;
|
||||
box-shadow: 0 0 0 1px var(--gn-ant-control-outline), var(--gn-shadow-sm) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-transaction-commit-button .gn-v2-toolbar-kbd {
|
||||
background: var(--gn-ant-primary-bg) !important;
|
||||
color: var(--gn-accent-text, var(--gn-accent)) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled) {
|
||||
background: var(--gn-info) !important;
|
||||
border-color: var(--gn-info) !important;
|
||||
color: var(--gn-on-info, #fff) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled):hover,
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled):focus-visible {
|
||||
background: color-mix(in srgb, var(--gn-info) 86%, var(--gn-bg-panel)) !important;
|
||||
border-color: color-mix(in srgb, var(--gn-info) 86%, var(--gn-bg-panel)) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-query-toolbar-save-action.ant-btn-primary:not(:disabled):active {
|
||||
background: color-mix(in srgb, var(--gn-info) 74%, var(--gn-bg-panel)) !important;
|
||||
border-color: color-mix(in srgb, var(--gn-info) 74%, var(--gn-bg-panel)) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-ai-panel .ai-logo {
|
||||
background: var(--gn-info) !important;
|
||||
color: var(--gn-on-info, #fff) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-ai-quick-card.tone-purple .gn-v2-ai-quick-icon {
|
||||
background: var(--gn-purple-soft) !important;
|
||||
color: var(--gn-purple) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-live-dot.is-live,
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-rail-status.is-live,
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-tree-status.is-success::before {
|
||||
background: var(--gn-accent-text, var(--gn-accent)) !important;
|
||||
box-shadow: 0 0 0 3px var(--gn-accent-soft) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-live-dot.is-loading,
|
||||
body[data-custom-theme][data-ui-version="v2"] .gn-v2-tree-status.is-loading::before {
|
||||
border-color: var(--gn-info-soft) !important;
|
||||
border-top-color: var(--gn-info) !important;
|
||||
}
|
||||
|
||||
body[data-custom-theme][data-ui-version="v2"] .monaco-editor,
|
||||
body[data-custom-theme][data-ui-version="v2"] .monaco-editor-background,
|
||||
body[data-custom-theme][data-ui-version="v2"] .monaco-editor .margin,
|
||||
body[data-custom-theme][data-ui-version="v2"] .monaco-editor .sticky-widget {
|
||||
background-color: var(--gn-bg-input) !important;
|
||||
}`;
|
||||
|
||||
const createPreset = (
|
||||
id: string,
|
||||
name: string,
|
||||
nameKey: string,
|
||||
descriptionKey: string,
|
||||
palette: BuiltinThemePalette,
|
||||
badgeKey?: string,
|
||||
): BuiltinCustomThemePreset => ({
|
||||
kind: 'builtin',
|
||||
schemaVersion: CUSTOM_THEME_SCHEMA_VERSION,
|
||||
id,
|
||||
name,
|
||||
nameKey,
|
||||
descriptionKey,
|
||||
badgeKey,
|
||||
sourceFileName: `${id}.css`,
|
||||
baseMode: palette.mode,
|
||||
css: createBuiltinThemeCss(id, palette),
|
||||
createdAt: BUILTIN_THEME_REVISION,
|
||||
updatedAt: BUILTIN_THEME_REVISION,
|
||||
preview: {
|
||||
app: palette.app,
|
||||
chrome: palette.chrome,
|
||||
panel: palette.panel,
|
||||
text: palette.fg1,
|
||||
muted: palette.fg4,
|
||||
accent: palette.accent,
|
||||
},
|
||||
});
|
||||
|
||||
export const BUILTIN_CUSTOM_THEME_PRESETS: readonly BuiltinCustomThemePreset[] = [
|
||||
createPreset(
|
||||
'builtin-comfort-dark',
|
||||
'Comfort Dark',
|
||||
'app.theme.custom.preset.comfort_dark.name',
|
||||
'app.theme.custom.preset.comfort_dark.description',
|
||||
{
|
||||
mode: 'dark', app: '#1b1d21', chrome: '#1f2227', panel: '#24272d', panel2: '#292d34', input: '#202329',
|
||||
hover: 'rgba(226, 232, 240, 0.045)', active: 'rgba(226, 232, 240, 0.075)', selected: 'rgba(121, 166, 147, 0.14)',
|
||||
fg1: '#d2d5da', fg2: '#bbc0c7', fg3: '#9ea5ae', fg4: '#9299a3', fg5: '#878e98',
|
||||
border1: 'rgba(226, 232, 240, 0.06)', border2: 'rgba(226, 232, 240, 0.10)', border3: 'rgba(226, 232, 240, 0.16)',
|
||||
accent: '#79a693', accent2: '#6b9887', accentSoft: 'rgba(121, 166, 147, 0.16)', accentSoftHover: 'rgba(121, 166, 147, 0.24)', accentOutline: 'rgba(141, 183, 166, 0.38)', onAccent: '#142019',
|
||||
info: '#79a6c9', infoSoft: 'rgba(121, 166, 201, 0.14)', onInfo: '#10202d', warn: '#c6a15b', warnSoft: 'rgba(198, 161, 91, 0.15)', danger: '#d47777', dangerStrong: '#b3575d', dangerHover: '#a74f56', onDanger: '#ffffff', purple: '#9b8ab5', purpleSoft: 'rgba(155, 138, 181, 0.16)',
|
||||
shadowSm: '0 1px 2px rgba(0, 0, 0, 0.16)', shadowMd: '0 4px 14px rgba(0, 0, 0, 0.24)', shadowLg: '0 12px 36px rgba(0, 0, 0, 0.32)', shadowCard: '0 0 0 0.5px rgba(255, 255, 255, 0.07), 0 1px 3px rgba(0, 0, 0, 0.18)',
|
||||
kbdBg: '#2d3138', kbdFg: '#bbc0c7',
|
||||
},
|
||||
'app.theme.custom.preset.badge.recommended',
|
||||
),
|
||||
createPreset(
|
||||
'builtin-midnight-navy',
|
||||
'Midnight Navy',
|
||||
'app.theme.custom.preset.midnight_navy.name',
|
||||
'app.theme.custom.preset.midnight_navy.description',
|
||||
{
|
||||
mode: 'dark', app: '#111821', chrome: '#15202c', panel: '#192533', panel2: '#1e2b3a', input: '#141e2a',
|
||||
hover: 'rgba(199, 210, 223, 0.05)', active: 'rgba(199, 210, 223, 0.08)', selected: 'rgba(104, 160, 200, 0.16)',
|
||||
fg1: '#e1e8f0', fg2: '#c7d2df', fg3: '#a8b7c7', fg4: '#8497aa', fg5: '#7a8e9f',
|
||||
border1: 'rgba(199, 210, 223, 0.06)', border2: 'rgba(199, 210, 223, 0.11)', border3: 'rgba(199, 210, 223, 0.18)',
|
||||
accent: '#68a0c8', accent2: '#5c91b8', accentSoft: 'rgba(104, 160, 200, 0.16)', accentSoftHover: 'rgba(104, 160, 200, 0.24)', accentOutline: 'rgba(104, 160, 200, 0.40)', onAccent: '#10202d',
|
||||
info: '#6cb6d9', infoSoft: 'rgba(108, 182, 217, 0.15)', onInfo: '#10202d', warn: '#d0a85c', warnSoft: 'rgba(208, 168, 92, 0.16)', danger: '#df7d84', dangerStrong: '#b15860', dangerHover: '#a04d55', onDanger: '#ffffff', purple: '#9c8bc4', purpleSoft: 'rgba(156, 139, 196, 0.16)',
|
||||
shadowSm: '0 1px 2px rgba(0, 0, 0, 0.20)', shadowMd: '0 4px 14px rgba(0, 0, 0, 0.28)', shadowLg: '0 12px 38px rgba(0, 0, 0, 0.38)', shadowCard: '0 0 0 0.5px rgba(199, 210, 223, 0.07), 0 1px 3px rgba(0, 0, 0, 0.22)',
|
||||
kbdBg: '#223142', kbdFg: '#c7d2df',
|
||||
},
|
||||
),
|
||||
createPreset(
|
||||
'builtin-nord-slate',
|
||||
'Nord Slate',
|
||||
'app.theme.custom.preset.nord_slate.name',
|
||||
'app.theme.custom.preset.nord_slate.description',
|
||||
{
|
||||
mode: 'dark', app: '#282e38', chrome: '#2b313c', panel: '#2e3440', panel2: '#353c49', input: '#292f3a',
|
||||
hover: 'rgba(216, 222, 233, 0.055)', active: 'rgba(216, 222, 233, 0.09)', selected: 'rgba(136, 192, 208, 0.16)',
|
||||
fg1: '#eceff4', fg2: '#d8dee9', fg3: '#c1cad8', fg4: '#9aa7b8', fg5: '#929ead',
|
||||
border1: 'rgba(216, 222, 233, 0.07)', border2: 'rgba(216, 222, 233, 0.12)', border3: 'rgba(216, 222, 233, 0.20)',
|
||||
accent: '#88c0d0', accent2: '#6faabb', accentSoft: 'rgba(136, 192, 208, 0.16)', accentSoftHover: 'rgba(136, 192, 208, 0.24)', accentOutline: 'rgba(136, 192, 208, 0.42)', onAccent: '#17252a',
|
||||
info: '#81a1c1', infoSoft: 'rgba(129, 161, 193, 0.17)', onInfo: '#17212b', warn: '#ebcb8b', warnSoft: 'rgba(235, 203, 139, 0.16)', danger: '#df858d', dangerStrong: '#a94f59', dangerHover: '#943f49', onDanger: '#ffffff', purple: '#b993b2', purpleSoft: 'rgba(185, 147, 178, 0.17)',
|
||||
shadowSm: '0 1px 2px rgba(20, 24, 31, 0.24)', shadowMd: '0 4px 14px rgba(20, 24, 31, 0.32)', shadowLg: '0 12px 38px rgba(20, 24, 31, 0.42)', shadowCard: '0 0 0 0.5px rgba(216, 222, 233, 0.08), 0 1px 3px rgba(20, 24, 31, 0.25)',
|
||||
kbdBg: '#3b4351', kbdFg: '#d8dee9',
|
||||
},
|
||||
),
|
||||
createPreset(
|
||||
'builtin-deep-ocean',
|
||||
'Deep Ocean',
|
||||
'app.theme.custom.preset.deep_ocean.name',
|
||||
'app.theme.custom.preset.deep_ocean.description',
|
||||
{
|
||||
mode: 'dark', app: '#0f181c', chrome: '#142126', panel: '#18282e', panel2: '#1c2e35', input: '#111e23',
|
||||
hover: 'rgba(184, 214, 214, 0.05)', active: 'rgba(184, 214, 214, 0.08)', selected: 'rgba(98, 166, 163, 0.17)',
|
||||
fg1: '#deebea', fg2: '#c5d7d6', fg3: '#a6bcbb', fg4: '#879f9e', fg5: '#7b9392',
|
||||
border1: 'rgba(197, 215, 214, 0.06)', border2: 'rgba(197, 215, 214, 0.11)', border3: 'rgba(197, 215, 214, 0.18)',
|
||||
accent: '#62a6a3', accent2: '#66aaa6', accentSoft: 'rgba(98, 166, 163, 0.17)', accentSoftHover: 'rgba(98, 166, 163, 0.25)', accentOutline: 'rgba(117, 181, 178, 0.40)', onAccent: '#102321',
|
||||
info: '#69a9bd', infoSoft: 'rgba(105, 169, 189, 0.15)', onInfo: '#0d2026', warn: '#c5a36d', warnSoft: 'rgba(197, 163, 109, 0.16)', danger: '#cd787b', dangerStrong: '#b05a5f', dangerHover: '#9f4d52', onDanger: '#ffffff', purple: '#9b8bb3', purpleSoft: 'rgba(155, 139, 179, 0.16)',
|
||||
shadowSm: '0 1px 2px rgba(0, 0, 0, 0.22)', shadowMd: '0 4px 14px rgba(0, 0, 0, 0.30)', shadowLg: '0 12px 38px rgba(0, 0, 0, 0.40)', shadowCard: '0 0 0 0.5px rgba(197, 215, 214, 0.07), 0 1px 3px rgba(0, 0, 0, 0.24)',
|
||||
kbdBg: '#21363d', kbdFg: '#c5d7d6',
|
||||
},
|
||||
),
|
||||
createPreset(
|
||||
'builtin-warm-paper',
|
||||
'Warm Paper',
|
||||
'app.theme.custom.preset.warm_paper.name',
|
||||
'app.theme.custom.preset.warm_paper.description',
|
||||
{
|
||||
mode: 'light', app: '#f4f0e7', chrome: '#eae4d8', panel: '#fffcf5', panel2: '#f8f3e9', input: '#fffdf8',
|
||||
hover: 'rgba(67, 59, 49, 0.05)', active: 'rgba(67, 59, 49, 0.09)', selected: 'rgba(47, 125, 104, 0.14)',
|
||||
fg1: '#292722', fg2: '#45413a', fg3: '#655f55', fg4: '#766e63', fg5: '#7a7267',
|
||||
border1: 'rgba(67, 59, 49, 0.08)', border2: 'rgba(67, 59, 49, 0.13)', border3: 'rgba(67, 59, 49, 0.20)',
|
||||
accent: '#2f7d68', accent2: '#236553', accentSoft: '#dcede6', accentSoftHover: '#cce4da', accentOutline: 'rgba(47, 125, 104, 0.30)', onAccent: '#ffffff',
|
||||
info: '#3976a8', infoSoft: '#ddeaf4', onInfo: '#ffffff', warn: '#9f5f1d', warnSoft: '#f2e4cf', danger: '#b94a4a', dangerStrong: '#a83c3c', dangerHover: '#923333', onDanger: '#ffffff', purple: '#765b93', purpleSoft: '#eae1f2',
|
||||
shadowSm: '0 1px 2px rgba(67, 59, 49, 0.07)', shadowMd: '0 4px 14px rgba(67, 59, 49, 0.10)', shadowLg: '0 12px 36px rgba(67, 59, 49, 0.16)', shadowCard: '0 0 0 0.5px rgba(67, 59, 49, 0.10), 0 1px 3px rgba(67, 59, 49, 0.07)',
|
||||
kbdBg: '#ece5d9', kbdFg: '#45413a',
|
||||
},
|
||||
),
|
||||
createPreset(
|
||||
'builtin-mist-jade',
|
||||
'Mist Jade',
|
||||
'app.theme.custom.preset.mist_jade.name',
|
||||
'app.theme.custom.preset.mist_jade.description',
|
||||
{
|
||||
mode: 'light', app: '#eef4f0', chrome: '#e4eee8', panel: '#f7faf8', panel2: '#f1f6f3', input: '#ffffff',
|
||||
hover: 'rgba(38, 72, 60, 0.05)', active: 'rgba(38, 72, 60, 0.09)', selected: 'rgba(50, 122, 103, 0.14)',
|
||||
fg1: '#18231f', fg2: '#2d3d37', fg3: '#4d625a', fg4: '#5e7169', fg5: '#64776f',
|
||||
border1: 'rgba(38, 72, 60, 0.08)', border2: 'rgba(38, 72, 60, 0.13)', border3: 'rgba(38, 72, 60, 0.20)',
|
||||
accent: '#327a67', accent2: '#286452', accentSoft: '#d9ece5', accentSoftHover: '#c8e3d9', accentOutline: 'rgba(50, 122, 103, 0.30)', onAccent: '#ffffff',
|
||||
info: '#327386', infoSoft: '#dcebef', onInfo: '#ffffff', warn: '#946329', warnSoft: '#efe4d3', danger: '#b54f5e', dangerStrong: '#a43e4e', dangerHover: '#903442', onDanger: '#ffffff', purple: '#6f6590', purpleSoft: '#e5e1ee',
|
||||
shadowSm: '0 1px 2px rgba(38, 72, 60, 0.06)', shadowMd: '0 4px 14px rgba(38, 72, 60, 0.09)', shadowLg: '0 12px 36px rgba(38, 72, 60, 0.14)', shadowCard: '0 0 0 0.5px rgba(38, 72, 60, 0.10), 0 1px 3px rgba(38, 72, 60, 0.06)',
|
||||
kbdBg: '#e3ece7', kbdFg: '#2d3d37',
|
||||
},
|
||||
),
|
||||
] as const;
|
||||
|
||||
const BUILTIN_CUSTOM_THEME_PRESET_MAP = new Map(
|
||||
BUILTIN_CUSTOM_THEME_PRESETS.map((preset) => [preset.id, preset]),
|
||||
);
|
||||
|
||||
export const resolveBuiltinCustomThemePreset = (id: unknown): BuiltinCustomThemePreset | null => (
|
||||
typeof id === 'string' ? BUILTIN_CUSTOM_THEME_PRESET_MAP.get(id) ?? null : null
|
||||
);
|
||||
|
||||
export const resolveAvailableCustomTheme = (
|
||||
themes: CustomThemeDefinition[],
|
||||
activeThemeId: unknown,
|
||||
): CustomThemeDefinition | null => (
|
||||
resolveBuiltinCustomThemePreset(activeThemeId)
|
||||
?? resolveActiveCustomTheme(themes, activeThemeId)
|
||||
);
|
||||
@@ -1754,7 +1754,7 @@ body[data-ui-version="v2"] .gonavi-v2-brand-chip {
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, var(--gn-accent) 0%, var(--gn-accent-2) 100%);
|
||||
color: #fff;
|
||||
color: var(--gn-on-accent, #fff);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1916,7 +1916,7 @@ body[data-ui-version="v2"] .gn-v2-rail-group-count {
|
||||
padding: 0 calc(4px * var(--gn-v2-rail-scale));
|
||||
border: 2px solid var(--gn-bg-panel-2);
|
||||
background: var(--gn-accent);
|
||||
color: #fff;
|
||||
color: var(--gn-on-accent, #fff);
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: calc(9px * var(--gn-v2-rail-scale));
|
||||
font-weight: 800;
|
||||
@@ -2073,7 +2073,7 @@ body[data-ui-version="v2"] .gn-v2-rail-status {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-rail-status.is-live {
|
||||
background: #22c55e;
|
||||
background: var(--gn-accent);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-rail-status.is-error {
|
||||
@@ -3047,7 +3047,7 @@ body[data-ui-version="v2"] .gn-v2-table-pin-action:hover {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-table-pin-action.is-pinned {
|
||||
color: #f59e0b;
|
||||
color: var(--gn-warn);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -3353,7 +3353,7 @@ body[data-ui-version="v2"] .gn-v2-titlebar-brand {
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(135deg, var(--gn-accent) 0%, var(--gn-accent-2) 100%);
|
||||
color: #fff;
|
||||
color: var(--gn-on-accent, #fff);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user