diff --git a/frontend/src/App.settings-center.test.ts b/frontend/src/App.settings-center.test.ts index e9228eee..07743541 100644 --- a/frontend/src/App.settings-center.test.ts +++ b/frontend/src/App.settings-center.test.ts @@ -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(''); + expect(appSource).toContain(''); 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')"); diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index db9feed6..3010d2ce 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -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();'], diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0bd66ea3..c5d36fda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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) => { 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 ( + = CUSTOM_THEME_MAX_COUNT ? t('app.theme.custom.error.max_count', { count: CUSTOM_THEME_MAX_COUNT }) : undefined} + > + + + + + + {legacyMode ? ( +
+ {t('app.theme.custom.legacy_compatibility_hint')} +
+ ) : null} + +
+ {t('app.theme.custom.safety_hint', { shortcut: recoveryShortcut })} +
+ +
+ + + + {activeTheme + ? t('app.theme.custom.active_theme', { name: activeThemeDisplayName }) + : t('app.theme.custom.inactive')} + + + {activeTheme + ? t('app.theme.custom.active_hint') + : t('app.theme.custom.inactive_hint')} + + + {activeTheme ? ( + + ) : null} +
+ + {themes.length === 0 ? ( +
+ + + +
+ ) : ( +
+ {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 ( +
+ + + {editing ? ( +
submitRename(event, theme)}> + setDraftName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + cancelRename(); + } + }} + /> + +
+ + ); + })} + + )} + + + ); +} diff --git a/frontend/src/components/theme/CustomThemeStyleHost.test.ts b/frontend/src/components/theme/CustomThemeStyleHost.test.ts new file mode 100644 index 00000000..ea8f404c --- /dev/null +++ b/frontend/src/components/theme/CustomThemeStyleHost.test.ts @@ -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(); + 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(), + 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; + 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); + }); +}); diff --git a/frontend/src/components/theme/CustomThemeStyleHost.tsx b/frontend/src/components/theme/CustomThemeStyleHost.tsx new file mode 100644 index 00000000..e0fbda82 --- /dev/null +++ b/frontend/src/components/theme/CustomThemeStyleHost.tsx @@ -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 | 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; +} diff --git a/frontend/src/customThemeStore.test.ts b/frontend/src/customThemeStore.test.ts new file mode 100644 index 00000000..4cec623f --- /dev/null +++ b/frontend/src/customThemeStore.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +class MemoryStorage implements Storage { + data = new Map(); + 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(); + }); +}); diff --git a/frontend/src/customThemeStore.ts b/frontend/src/customThemeStore.ts new file mode 100644 index 00000000..57121ed8 --- /dev/null +++ b/frontend/src/customThemeStore.ts @@ -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; + +type ImportCustomThemeInput = { + name: string; + sourceFileName: string; + baseMode?: CustomThemeBaseMode; + css: string; +}; + +type UpdateCustomThemeInput = Partial>; + +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 : {}; + 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((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()), +})); diff --git a/frontend/src/styles/v2-theme-workbench.css b/frontend/src/styles/v2-theme-workbench.css index e60a8286..5863025b 100644 --- a/frontend/src/styles/v2-theme-workbench.css +++ b/frontend/src/styles/v2-theme-workbench.css @@ -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; } diff --git a/frontend/src/utils/customTheme.test.ts b/frontend/src/utils/customTheme.test.ts new file mode 100644 index 00000000..bb99c9f7 --- /dev/null +++ b/frontend/src/utils/customTheme.test.ts @@ -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 = { + '--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'); + }); +}); diff --git a/frontend/src/utils/customTheme.ts b/frontend/src/utils/customTheme.ts new file mode 100644 index 00000000..d148d2a5 --- /dev/null +++ b/frontend/src/utils/customTheme.ts @@ -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; + 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(); + 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 | 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); +} +`; diff --git a/frontend/src/utils/customThemePresets.test.ts b/frontend/src/utils/customThemePresets.test.ts new file mode 100644 index 00000000..4ceca9cd --- /dev/null +++ b/frontend/src/utils/customThemePresets.test.ts @@ -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(); + const nameKeys = new Set(); + 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(); + }); +}); diff --git a/frontend/src/utils/customThemePresets.ts b/frontend/src/utils/customThemePresets.ts new file mode 100644 index 00000000..8714dd1e --- /dev/null +++ b/frontend/src/utils/customThemePresets.ts @@ -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) +); diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index f7508060..726b7947 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -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; diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 0c730cb6..3a216eee 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2800,6 +2800,73 @@ "app.theme.appearance.windows_acrylic_hint": "Windows-Leistungsmodus: Systemhintergrundunschärfe ist deaktiviert.", "app.theme.appearance_settings_description": "Skalierung, Schriftgröße, Transparenz und Weichzeichnung zentral anpassen.", "app.theme.appearance_settings_title": "Darstellungseinstellungen", + "app.theme.custom.action.deactivate": "Zum Basistheme zurückkehren", + "app.theme.custom.action.delete_named": "Theme „{{name}}“ löschen", + "app.theme.custom.action.download_template": "Vorlage herunterladen", + "app.theme.custom.action.rename": "Umbenennen", + "app.theme.custom.action.rename_named": "Theme „{{name}}“ umbenennen", + "app.theme.custom.action.replace_css": "CSS ersetzen", + "app.theme.custom.action.replace_named": "CSS von Theme „{{name}}“ ersetzen", + "app.theme.custom.action.upload": "CSS hochladen", + "app.theme.custom.action.upload_first": "Erstes Theme hochladen", + "app.theme.custom.action.use_named": "Theme „{{name}}“ verwenden", + "app.theme.custom.active_hint": "Das GoNavi-Theme liegt über dem gewählten Basismodus.", + "app.theme.custom.active_theme": "Aktiv: {{name}}", + "app.theme.custom.badge.active": "Aktiv", + "app.theme.custom.base_mode": "Basismodus", + "app.theme.custom.base_mode_for": "Basismodus für Theme „{{name}}“", + "app.theme.custom.count": "{{count}} / {{max}} Themes gespeichert", + "app.theme.custom.delete_confirm.description": "„{{name}}“ kann nach dem Löschen nicht wiederhergestellt werden. Ist es aktiv, wechselt GoNavi sofort zum voreingestellten Theme.", + "app.theme.custom.delete_confirm.title": "Benutzerdefiniertes Theme löschen?", + "app.theme.custom.description": "Laden Sie Ihr eigenes CSS-Theme hoch und wählen Sie System, Hell oder Dunkel als Basis für Komponenten und Editor.", + "app.theme.custom.empty": "Noch keine benutzerdefinierten Themes. Laden Sie die Vorlage herunter, um zu beginnen.", + "app.theme.custom.error.download_failed": "Theme-Vorlage konnte nicht heruntergeladen werden", + "app.theme.custom.error.empty": "Die CSS-Datei ist leer", + "app.theme.custom.error.invalid_extension": "Bitte eine .css-Datei auswählen", + "app.theme.custom.error.invalid_syntax": "Die CSS-Struktur ist unvollständig. Prüfen Sie Klammern und Anführungszeichen.", + "app.theme.custom.error.max_count": "Es können höchstens {{count}} benutzerdefinierte Themes gespeichert werden", + "app.theme.custom.error.max_total_size": "Die gesamte CSS-Größe der benutzerdefinierten Themes überschreitet 1 MB", + "app.theme.custom.error.name_required": "Ein Theme-Name ist erforderlich", + "app.theme.custom.error.not_found": "Benutzerdefiniertes Theme nicht gefunden", + "app.theme.custom.error.operation_failed": "Theme-Aktion fehlgeschlagen", + "app.theme.custom.error.read_failed": "CSS-Datei konnte nicht gelesen werden", + "app.theme.custom.error.storage_failed": "Theme konnte nicht gespeichert werden. Prüfen Sie Speicherplatz und Berechtigungen des Browsers.", + "app.theme.custom.error.too_large": "Eine CSS-Datei darf höchstens {{size}} KB groß sein", + "app.theme.custom.error.unsafe_font_face": "@font-face ist gesperrt, um externe Schriftanfragen zu verhindern", + "app.theme.custom.error.unsafe_import": "@import ist gesperrt, um externe Ressourcenanfragen zu verhindern", + "app.theme.custom.error.unsafe_script": "Das CSS enthält unsichere veraltete Skript- oder Behavior-Syntax", + "app.theme.custom.error.unsafe_url": "url() ist gesperrt, um externe Ressourcenanfragen zu verhindern", + "app.theme.custom.inactive": "Basistheme aktiv", + "app.theme.custom.inactive_hint": "Wählen Sie ein integriertes Theme oder eines Ihrer CSS-Themes aus.", + "app.theme.custom.legacy_compatibility_hint": "Benutzerdefiniertes CSS funktioniert in der alten UI; der vollständige --gn-*-Tokenvertrag richtet sich jedoch an die V2-UI.", + "app.theme.custom.list_label": "Liste der GoNavi-Themes", + "app.theme.custom.message.css_replaced": "Theme-CSS ersetzt", + "app.theme.custom.message.deactivated": "Benutzerdefiniertes Theme deaktiviert", + "app.theme.custom.message.deleted": "Theme „{{name}}“ gelöscht", + "app.theme.custom.message.imported": "Theme „{{name}}“ importiert", + "app.theme.custom.message.renamed": "Theme-Name aktualisiert", + "app.theme.custom.message.selected": "Theme „{{name}}“ aktiviert", + "app.theme.custom.my_themes_title": "Meine CSS-Themes", + "app.theme.custom.preset.badge.recommended": "Empfohlen", + "app.theme.custom.preset.comfort_dark.description": "Kontrastarmes Graphit und gedämpftes Jade reduzieren die Belastung durch reines Schwarz, Weiß und gesättigte Farben.", + "app.theme.custom.preset.comfort_dark.name": "Komfort-Dunkel", + "app.theme.custom.preset.count": "{{count}} integrierte Themes", + "app.theme.custom.preset.deep_ocean.description": "Ruhige tiefblaue Flächen mit gedämpften Aqua-Akzenten für lange Sitzungen bei Nacht.", + "app.theme.custom.preset.deep_ocean.name": "Tiefer Ozean", + "app.theme.custom.preset.description": "Integrierte Themes belegen kein CSS-Kontingent. Zum Anwenden eine Karte auswählen.", + "app.theme.custom.preset.midnight_navy.description": "Ein weicher blau-schwarzer Arbeitsbereich für lange SQL-Sitzungen.", + "app.theme.custom.preset.midnight_navy.name": "Mitternachtsblau", + "app.theme.custom.preset.mist_jade.description": "Ein entsättigtes helles Theme mit dem grünen Charakter von GoNavi.", + "app.theme.custom.preset.mist_jade.name": "Jadenebel", + "app.theme.custom.preset.nord_slate.description": "Ein klar geschichtetes, kühles IDE-Theme ohne rein schwarzen Hintergrund.", + "app.theme.custom.preset.nord_slate.name": "Nordischer Schiefer", + "app.theme.custom.preset.title": "Integrierte Themes", + "app.theme.custom.preset.warm_paper.description": "Warme Papierflächen reduzieren reines Weiß und eignen sich für den Tag.", + "app.theme.custom.preset.warm_paper.name": "Warmes Papier", + "app.theme.custom.rename_input_label": "Theme „{{name}}“ umbenennen", + "app.theme.custom.safety_hint": "Importieren Sie nur vertrauenswürdiges CSS; externe Ressourcen werden blockiert. Bei Darstellungsfehlern beendet {{shortcut}} das benutzerdefinierte Theme.", + "app.theme.custom.title": "GoNavi-Themes", + "app.theme.custom.unknown_file": "Unbekannte CSS-Datei", "app.theme.data_table.column_width_hint": "Der Standardmodus nutzt 200px, der kompakte Modus 140px als Standardspaltenbreite. Manuell angepasste Spaltenbreiten bleiben vorrangig erhalten.", "app.theme.data_table.column_width_mode": "Spaltenbreitenmodus der Datentabelle", "app.theme.data_table.column_width_mode.compact": "Kompakt 140px", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index c608c701..6129bd9b 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2800,6 +2800,73 @@ "app.theme.appearance.windows_acrylic_hint": "Windows performance mode: system background blur is off.", "app.theme.appearance_settings_description": "Adjust scale, font size, transparency, and blur effects in one place.", "app.theme.appearance_settings_title": "Appearance Settings", + "app.theme.custom.action.deactivate": "Return to base theme", + "app.theme.custom.action.delete_named": "Delete theme “{{name}}”", + "app.theme.custom.action.download_template": "Download template", + "app.theme.custom.action.rename": "Rename", + "app.theme.custom.action.rename_named": "Rename theme “{{name}}”", + "app.theme.custom.action.replace_css": "Replace CSS", + "app.theme.custom.action.replace_named": "Replace CSS for theme “{{name}}”", + "app.theme.custom.action.upload": "Upload CSS", + "app.theme.custom.action.upload_first": "Upload your first theme", + "app.theme.custom.action.use_named": "Use theme “{{name}}”", + "app.theme.custom.active_hint": "The GoNavi theme is layered over the selected base mode.", + "app.theme.custom.active_theme": "In use: {{name}}", + "app.theme.custom.badge.active": "Active", + "app.theme.custom.base_mode": "Base mode", + "app.theme.custom.base_mode_for": "Base mode for theme “{{name}}”", + "app.theme.custom.count": "{{count}} / {{max}} themes saved", + "app.theme.custom.delete_confirm.description": "Deleting “{{name}}” cannot be undone. If active, GoNavi will immediately return to the preset theme.", + "app.theme.custom.delete_confirm.title": "Delete custom theme?", + "app.theme.custom.description": "Upload your own CSS theme and choose System, Light, or Dark as the base for components and the editor.", + "app.theme.custom.empty": "No custom themes yet. Download the template to get started.", + "app.theme.custom.error.download_failed": "Could not download the theme template", + "app.theme.custom.error.empty": "The CSS file is empty", + "app.theme.custom.error.invalid_extension": "Choose a .css file", + "app.theme.custom.error.invalid_syntax": "The CSS structure is incomplete. Check braces and quotes.", + "app.theme.custom.error.max_count": "You can save up to {{count}} custom themes", + "app.theme.custom.error.max_total_size": "The total custom-theme CSS size exceeds 1 MB", + "app.theme.custom.error.name_required": "Theme name is required", + "app.theme.custom.error.not_found": "Custom theme not found", + "app.theme.custom.error.operation_failed": "Custom theme operation failed", + "app.theme.custom.error.read_failed": "Could not read the CSS file", + "app.theme.custom.error.storage_failed": "Could not save the theme. Check browser storage space or permissions.", + "app.theme.custom.error.too_large": "A CSS file cannot exceed {{size}} KB", + "app.theme.custom.error.unsafe_font_face": "@font-face is blocked to prevent external font requests", + "app.theme.custom.error.unsafe_import": "@import is blocked to prevent external resource requests", + "app.theme.custom.error.unsafe_script": "The CSS contains an unsafe legacy script or behavior construct", + "app.theme.custom.error.unsafe_url": "url() is blocked to prevent external resource requests", + "app.theme.custom.inactive": "Using the base theme", + "app.theme.custom.inactive_hint": "Select a built-in theme or one of your CSS themes to apply it immediately.", + "app.theme.custom.legacy_compatibility_hint": "Custom CSS works in the legacy UI, but the complete --gn-* theme-token contract targets the V2 UI.", + "app.theme.custom.list_label": "GoNavi theme list", + "app.theme.custom.message.css_replaced": "Theme CSS replaced", + "app.theme.custom.message.deactivated": "Custom theme disabled", + "app.theme.custom.message.deleted": "Theme “{{name}}” deleted", + "app.theme.custom.message.imported": "Theme “{{name}}” imported", + "app.theme.custom.message.renamed": "Theme name updated", + "app.theme.custom.message.selected": "Theme “{{name}}” enabled", + "app.theme.custom.my_themes_title": "My CSS themes", + "app.theme.custom.preset.badge.recommended": "Recommended", + "app.theme.custom.preset.comfort_dark.description": "Low-contrast graphite and muted jade reduce pure-black, pure-white, and saturated glare.", + "app.theme.custom.preset.comfort_dark.name": "Comfort Dark", + "app.theme.custom.preset.count": "{{count}} built-in themes", + "app.theme.custom.preset.deep_ocean.description": "A calm deep-teal workspace with muted aqua accents for long nighttime sessions.", + "app.theme.custom.preset.deep_ocean.name": "Deep Ocean", + "app.theme.custom.preset.description": "Built-in themes do not use your CSS quota. Select a card to apply it.", + "app.theme.custom.preset.midnight_navy.description": "A softened blue-black workspace suited to long SQL editing sessions.", + "app.theme.custom.preset.midnight_navy.name": "Midnight Navy", + "app.theme.custom.preset.mist_jade.description": "A low-saturation light theme that keeps GoNavi's green character.", + "app.theme.custom.preset.mist_jade.name": "Mist Jade", + "app.theme.custom.preset.nord_slate.description": "A layered cool-gray IDE theme without a pure-black canvas.", + "app.theme.custom.preset.nord_slate.name": "Nord Slate", + "app.theme.custom.preset.title": "Built-in themes", + "app.theme.custom.preset.warm_paper.description": "Warm paper-like surfaces reduce pure-white glare during daytime use.", + "app.theme.custom.preset.warm_paper.name": "Warm Paper", + "app.theme.custom.rename_input_label": "Rename theme “{{name}}”", + "app.theme.custom.safety_hint": "Only import trusted CSS; external resources are blocked. If the layout breaks, press {{shortcut}} to exit the custom theme.", + "app.theme.custom.title": "GoNavi Themes", + "app.theme.custom.unknown_file": "Unknown CSS file", "app.theme.data_table.column_width_hint": "Standard mode defaults to 200px; compact mode defaults to 140px. Manually adjusted column widths are preserved first.", "app.theme.data_table.column_width_mode": "Data Table Column Width Mode", "app.theme.data_table.column_width_mode.compact": "Compact 140px", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index c5730656..b93fd113 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2800,6 +2800,73 @@ "app.theme.appearance.windows_acrylic_hint": "Windows パフォーマンスモード:システム背景のぼかしは無効です。", "app.theme.appearance_settings_description": "スケール、フォントサイズ、透明度、ぼかし効果をまとめて調整します。", "app.theme.appearance_settings_title": "外観設定", + "app.theme.custom.action.deactivate": "ベーステーマに戻す", + "app.theme.custom.action.delete_named": "テーマ「{{name}}」を削除", + "app.theme.custom.action.download_template": "テンプレートをダウンロード", + "app.theme.custom.action.rename": "名前を変更", + "app.theme.custom.action.rename_named": "テーマ「{{name}}」の名前を変更", + "app.theme.custom.action.replace_css": "CSS を置換", + "app.theme.custom.action.replace_named": "テーマ「{{name}}」の CSS を置換", + "app.theme.custom.action.upload": "CSS をアップロード", + "app.theme.custom.action.upload_first": "最初のテーマをアップロード", + "app.theme.custom.action.use_named": "テーマ「{{name}}」を使用", + "app.theme.custom.active_hint": "GoNavi テーマは選択したベースモードの上に適用されます。", + "app.theme.custom.active_theme": "使用中:{{name}}", + "app.theme.custom.badge.active": "使用中", + "app.theme.custom.base_mode": "ベースモード", + "app.theme.custom.base_mode_for": "テーマ「{{name}}」のベースモード", + "app.theme.custom.count": "保存済みテーマ {{count}} / {{max}}", + "app.theme.custom.delete_confirm.description": "「{{name}}」を削除すると元に戻せません。使用中の場合は、すぐにプリセットテーマへ戻ります。", + "app.theme.custom.delete_confirm.title": "カスタムテーマを削除しますか?", + "app.theme.custom.description": "自作の CSS テーマをアップロードし、コンポーネントとエディターのベースとしてシステム、ライト、ダークを選択します。", + "app.theme.custom.empty": "カスタムテーマはまだありません。テンプレートをダウンロードして作成できます。", + "app.theme.custom.error.download_failed": "テーマテンプレートをダウンロードできませんでした", + "app.theme.custom.error.empty": "CSS ファイルが空です", + "app.theme.custom.error.invalid_extension": ".css ファイルを選択してください", + "app.theme.custom.error.invalid_syntax": "CSS の構造が不完全です。中括弧と引用符を確認してください。", + "app.theme.custom.error.max_count": "カスタムテーマは最大 {{count}} 件まで保存できます", + "app.theme.custom.error.max_total_size": "カスタムテーマ CSS の合計サイズが 1 MB を超えています", + "app.theme.custom.error.name_required": "テーマ名を入力してください", + "app.theme.custom.error.not_found": "カスタムテーマが見つかりません", + "app.theme.custom.error.operation_failed": "カスタムテーマの操作に失敗しました", + "app.theme.custom.error.read_failed": "CSS ファイルを読み取れませんでした", + "app.theme.custom.error.storage_failed": "テーマを保存できませんでした。ブラウザの保存容量または権限を確認してください。", + "app.theme.custom.error.too_large": "CSS ファイルは {{size}} KB 以下にしてください", + "app.theme.custom.error.unsafe_font_face": "外部フォント要求を防ぐため @font-face は使用できません", + "app.theme.custom.error.unsafe_import": "外部リソース要求を防ぐため @import は使用できません", + "app.theme.custom.error.unsafe_script": "CSS に安全でない旧式スクリプトまたは behavior 構文が含まれています", + "app.theme.custom.error.unsafe_url": "外部リソース要求を防ぐため url() は使用できません", + "app.theme.custom.inactive": "ベーステーマを使用中", + "app.theme.custom.inactive_hint": "組み込みテーマまたは自分の CSS テーマを選択するとすぐに適用されます。", + "app.theme.custom.legacy_compatibility_hint": "旧 UI でもカスタム CSS は使用できますが、完全な --gn-* テーマトークンは主に V2 UI 向けです。", + "app.theme.custom.list_label": "GoNavi テーマ一覧", + "app.theme.custom.message.css_replaced": "テーマ CSS を置換しました", + "app.theme.custom.message.deactivated": "カスタムテーマを無効にしました", + "app.theme.custom.message.deleted": "テーマ「{{name}}」を削除しました", + "app.theme.custom.message.imported": "テーマ「{{name}}」をインポートしました", + "app.theme.custom.message.renamed": "テーマ名を更新しました", + "app.theme.custom.message.selected": "テーマ「{{name}}」を有効にしました", + "app.theme.custom.my_themes_title": "自分の CSS テーマ", + "app.theme.custom.preset.badge.recommended": "おすすめ", + "app.theme.custom.preset.comfort_dark.description": "低コントラストのグラファイトと落ち着いた翡翠色で、純黒・純白・高彩度の刺激を抑えます。", + "app.theme.custom.preset.comfort_dark.name": "コンフォートダーク", + "app.theme.custom.preset.count": "組み込みテーマ {{count}} 件", + "app.theme.custom.preset.deep_ocean.description": "深い青緑の背景と穏やかなアクア色で、夜間の長時間利用に適しています。", + "app.theme.custom.preset.deep_ocean.name": "ディープオーシャン", + "app.theme.custom.preset.description": "組み込みテーマは CSS の保存上限を消費しません。カードを選ぶだけで適用できます。", + "app.theme.custom.preset.midnight_navy.description": "長時間の SQL 編集に向く、柔らかな青黒のワークスペースです。", + "app.theme.custom.preset.midnight_navy.name": "ミッドナイトネイビー", + "app.theme.custom.preset.mist_jade.description": "GoNavi の緑を残した低彩度のライトテーマです。", + "app.theme.custom.preset.mist_jade.name": "ミストジェイド", + "app.theme.custom.preset.nord_slate.description": "純黒を避けた、階層の見やすいクールグレーの IDE テーマです。", + "app.theme.custom.preset.nord_slate.name": "ノルドスレート", + "app.theme.custom.preset.title": "組み込みテーマ", + "app.theme.custom.preset.warm_paper.description": "純白のまぶしさを抑える、昼間向けの暖かな紙色テーマです。", + "app.theme.custom.preset.warm_paper.name": "ウォームペーパー", + "app.theme.custom.rename_input_label": "テーマ「{{name}}」の名前を変更", + "app.theme.custom.safety_hint": "信頼できる CSS のみを読み込んでください。外部リソースは拒否されます。表示が崩れた場合は {{shortcut}} でカスタムテーマを終了できます。", + "app.theme.custom.title": "GoNavi テーマ", + "app.theme.custom.unknown_file": "不明な CSS ファイル", "app.theme.data_table.column_width_hint": "標準モードの既定列幅は 200px、コンパクトモードの既定列幅は 140px です。手動で調整した列幅は優先して保持されます。", "app.theme.data_table.column_width_mode": "データテーブル列幅モード", "app.theme.data_table.column_width_mode.compact": "コンパクト 140px", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index e81645d7..04b094e4 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2800,6 +2800,73 @@ "app.theme.appearance.windows_acrylic_hint": "Режим производительности Windows: системное размытие фона отключено.", "app.theme.appearance_settings_description": "Единая настройка масштаба, размера шрифта, прозрачности и размытия.", "app.theme.appearance_settings_title": "Настройки внешнего вида", + "app.theme.custom.action.deactivate": "Вернуться к базовой теме", + "app.theme.custom.action.delete_named": "Удалить тему «{{name}}»", + "app.theme.custom.action.download_template": "Скачать шаблон", + "app.theme.custom.action.rename": "Переименовать", + "app.theme.custom.action.rename_named": "Переименовать тему «{{name}}»", + "app.theme.custom.action.replace_css": "Заменить CSS", + "app.theme.custom.action.replace_named": "Заменить CSS темы «{{name}}»", + "app.theme.custom.action.upload": "Загрузить CSS", + "app.theme.custom.action.upload_first": "Загрузить первую тему", + "app.theme.custom.action.use_named": "Использовать тему «{{name}}»", + "app.theme.custom.active_hint": "Тема GoNavi применяется поверх выбранного базового режима.", + "app.theme.custom.active_theme": "Используется: {{name}}", + "app.theme.custom.badge.active": "Активна", + "app.theme.custom.base_mode": "Базовый режим", + "app.theme.custom.base_mode_for": "Базовый режим темы «{{name}}»", + "app.theme.custom.count": "Сохранено тем: {{count}} / {{max}}", + "app.theme.custom.delete_confirm.description": "Удаление «{{name}}» нельзя отменить. Если тема активна, GoNavi сразу вернётся к предустановленной теме.", + "app.theme.custom.delete_confirm.title": "Удалить пользовательскую тему?", + "app.theme.custom.description": "Загрузите собственную CSS-тему и выберите системный, светлый или тёмный базовый режим для компонентов и редактора.", + "app.theme.custom.empty": "Пользовательских тем пока нет. Скачайте шаблон, чтобы начать.", + "app.theme.custom.error.download_failed": "Не удалось скачать шаблон темы", + "app.theme.custom.error.empty": "CSS-файл пуст", + "app.theme.custom.error.invalid_extension": "Выберите файл .css", + "app.theme.custom.error.invalid_syntax": "Структура CSS неполна. Проверьте скобки и кавычки.", + "app.theme.custom.error.max_count": "Можно сохранить не более {{count}} пользовательских тем", + "app.theme.custom.error.max_total_size": "Общий размер CSS пользовательских тем превышает 1 МБ", + "app.theme.custom.error.name_required": "Укажите название темы", + "app.theme.custom.error.not_found": "Пользовательская тема не найдена", + "app.theme.custom.error.operation_failed": "Не удалось выполнить операцию с темой", + "app.theme.custom.error.read_failed": "Не удалось прочитать CSS-файл", + "app.theme.custom.error.storage_failed": "Не удалось сохранить тему. Проверьте место и разрешения хранилища браузера.", + "app.theme.custom.error.too_large": "Размер CSS-файла не должен превышать {{size}} КБ", + "app.theme.custom.error.unsafe_font_face": "@font-face запрещён для предотвращения внешних запросов шрифтов", + "app.theme.custom.error.unsafe_import": "@import запрещён для предотвращения внешних запросов ресурсов", + "app.theme.custom.error.unsafe_script": "CSS содержит небезопасную устаревшую конструкцию script или behavior", + "app.theme.custom.error.unsafe_url": "url() запрещён для предотвращения внешних запросов ресурсов", + "app.theme.custom.inactive": "Используется базовая тема", + "app.theme.custom.inactive_hint": "Выберите встроенную тему или одну из своих CSS-тем.", + "app.theme.custom.legacy_compatibility_hint": "Пользовательский CSS работает в старом интерфейсе, но полный контракт токенов --gn-* рассчитан на V2.", + "app.theme.custom.list_label": "Список тем GoNavi", + "app.theme.custom.message.css_replaced": "CSS темы заменён", + "app.theme.custom.message.deactivated": "Пользовательская тема отключена", + "app.theme.custom.message.deleted": "Тема «{{name}}» удалена", + "app.theme.custom.message.imported": "Тема «{{name}}» импортирована", + "app.theme.custom.message.renamed": "Название темы обновлено", + "app.theme.custom.message.selected": "Тема «{{name}}» включена", + "app.theme.custom.my_themes_title": "Мои CSS-темы", + "app.theme.custom.preset.badge.recommended": "Рекомендуется", + "app.theme.custom.preset.comfort_dark.description": "Низкоконтрастный графит и приглушённый нефрит уменьшают нагрузку от чистого чёрного, белого и насыщенных цветов.", + "app.theme.custom.preset.comfort_dark.name": "Комфортная тёмная", + "app.theme.custom.preset.count": "Встроенных тем: {{count}}", + "app.theme.custom.preset.deep_ocean.description": "Спокойный тёмно-бирюзовый фон и мягкие акценты для долгой работы ночью.", + "app.theme.custom.preset.deep_ocean.name": "Глубокий океан", + "app.theme.custom.preset.description": "Встроенные темы не расходуют квоту CSS. Выберите карточку, чтобы применить тему.", + "app.theme.custom.preset.midnight_navy.description": "Мягкое сине-чёрное рабочее пространство для долгого редактирования SQL.", + "app.theme.custom.preset.midnight_navy.name": "Полуночный синий", + "app.theme.custom.preset.mist_jade.description": "Малонасыщенная светлая тема, сохраняющая зелёный характер GoNavi.", + "app.theme.custom.preset.mist_jade.name": "Нефритовый туман", + "app.theme.custom.preset.nord_slate.description": "Многослойная холодно-серая IDE-тема без чисто чёрного фона.", + "app.theme.custom.preset.nord_slate.name": "Северный сланец", + "app.theme.custom.preset.title": "Встроенные темы", + "app.theme.custom.preset.warm_paper.description": "Тёплые бумажные поверхности снижают блики чистого белого днём.", + "app.theme.custom.preset.warm_paper.name": "Тёплая бумага", + "app.theme.custom.rename_input_label": "Переименовать тему «{{name}}»", + "app.theme.custom.safety_hint": "Импортируйте только доверенный CSS; внешние ресурсы блокируются. Если интерфейс сломан, нажмите {{shortcut}}, чтобы выйти из пользовательской темы.", + "app.theme.custom.title": "Темы GoNavi", + "app.theme.custom.unknown_file": "Неизвестный CSS-файл", "app.theme.data_table.column_width_hint": "В стандартном режиме ширина столбца по умолчанию 200px; в компактном режиме 140px. Вручную измененные ширины столбцов сохраняются в приоритете.", "app.theme.data_table.column_width_mode": "Режим ширины столбцов таблицы данных", "app.theme.data_table.column_width_mode.compact": "Компактный 140px", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 8316a080..952b50ad 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2800,6 +2800,73 @@ "app.theme.appearance.windows_acrylic_hint": "Windows 性能模式:已关闭系统背景模糊", "app.theme.appearance_settings_description": "统一调整缩放、字体、透明度与模糊效果。", "app.theme.appearance_settings_title": "外观设置", + "app.theme.custom.action.deactivate": "回到基础主题", + "app.theme.custom.action.delete_named": "删除主题“{{name}}”", + "app.theme.custom.action.download_template": "下载模板", + "app.theme.custom.action.rename": "重命名", + "app.theme.custom.action.rename_named": "重命名主题“{{name}}”", + "app.theme.custom.action.replace_css": "替换 CSS", + "app.theme.custom.action.replace_named": "替换主题“{{name}}”的 CSS", + "app.theme.custom.action.upload": "上传 CSS", + "app.theme.custom.action.upload_first": "上传第一个主题", + "app.theme.custom.action.use_named": "使用主题“{{name}}”", + "app.theme.custom.active_hint": "GoNavi 主题样式已覆盖在所选基础模式之上。", + "app.theme.custom.active_theme": "正在使用:{{name}}", + "app.theme.custom.badge.active": "使用中", + "app.theme.custom.base_mode": "基础模式", + "app.theme.custom.base_mode_for": "主题“{{name}}”的基础模式", + "app.theme.custom.count": "已保存 {{count}} / {{max}} 个主题", + "app.theme.custom.delete_confirm.description": "删除“{{name}}”后无法恢复;若正在使用,将立即回退到预设主题。", + "app.theme.custom.delete_confirm.title": "删除自定义主题?", + "app.theme.custom.description": "上传自己制作的 CSS 主题,并选择系统、亮色或暗色作为组件与编辑器的基础模式。", + "app.theme.custom.empty": "尚未上传自定义主题。可先下载模板进行制作。", + "app.theme.custom.error.download_failed": "无法下载主题模板", + "app.theme.custom.error.empty": "CSS 文件内容为空", + "app.theme.custom.error.invalid_extension": "请选择 .css 文件", + "app.theme.custom.error.invalid_syntax": "CSS 结构不完整,请检查括号和引号", + "app.theme.custom.error.max_count": "最多保存 {{count}} 个自定义主题", + "app.theme.custom.error.max_total_size": "自定义主题 CSS 总大小已超过 1 MB", + "app.theme.custom.error.name_required": "主题名称不能为空", + "app.theme.custom.error.not_found": "找不到该自定义主题", + "app.theme.custom.error.operation_failed": "自定义主题操作失败", + "app.theme.custom.error.read_failed": "读取 CSS 文件失败", + "app.theme.custom.error.storage_failed": "保存主题失败,请检查浏览器存储空间或权限", + "app.theme.custom.error.too_large": "单个 CSS 文件不能超过 {{size}} KB", + "app.theme.custom.error.unsafe_font_face": "为避免外部字体请求,自定义主题不允许 @font-face", + "app.theme.custom.error.unsafe_import": "为避免外部资源请求,自定义主题不允许 @import", + "app.theme.custom.error.unsafe_script": "CSS 包含不安全的旧式脚本或行为语法", + "app.theme.custom.error.unsafe_url": "为避免外部资源请求,自定义主题不允许 url()", + "app.theme.custom.inactive": "当前使用基础主题", + "app.theme.custom.inactive_hint": "选择内置主题或我的 CSS 后会立即生效。", + "app.theme.custom.legacy_compatibility_hint": "旧版 UI 可使用自定义 CSS,但完整的 --gn-* 主题令牌主要面向新版 UI。", + "app.theme.custom.list_label": "GoNavi 主题列表", + "app.theme.custom.message.css_replaced": "主题 CSS 已替换", + "app.theme.custom.message.deactivated": "已停用自定义主题", + "app.theme.custom.message.deleted": "已删除主题“{{name}}”", + "app.theme.custom.message.imported": "已导入主题“{{name}}”", + "app.theme.custom.message.renamed": "主题名称已更新", + "app.theme.custom.message.selected": "已启用主题“{{name}}”", + "app.theme.custom.my_themes_title": "我的 CSS 主题", + "app.theme.custom.preset.badge.recommended": "推荐", + "app.theme.custom.preset.comfort_dark.description": "低对比石墨灰与柔和雾绿色,减少纯黑、纯白和高饱和颜色刺激。", + "app.theme.custom.preset.comfort_dark.name": "舒适暗色", + "app.theme.custom.preset.count": "{{count}} 套内置主题", + "app.theme.custom.preset.deep_ocean.description": "沉静深青背景与柔和青绿色强调色,适合夜间长时间查看。", + "app.theme.custom.preset.deep_ocean.name": "深海", + "app.theme.custom.preset.description": "内置主题不占我的 CSS 配额,点击卡片即可使用。", + "app.theme.custom.preset.midnight_navy.description": "柔和的蓝黑工作区,适合长时间编写和查看 SQL。", + "app.theme.custom.preset.midnight_navy.name": "午夜蓝", + "app.theme.custom.preset.mist_jade.description": "延续 GoNavi 绿色风格的低饱和亮色主题。", + "app.theme.custom.preset.mist_jade.name": "雾青", + "app.theme.custom.preset.nord_slate.description": "层次清晰的冷灰 IDE 风格,弱化纯黑背景。", + "app.theme.custom.preset.nord_slate.name": "北境灰", + "app.theme.custom.preset.title": "内置主题", + "app.theme.custom.preset.warm_paper.description": "避免纯白眩光的暖色纸张质感,适合白天使用。", + "app.theme.custom.preset.warm_paper.name": "暖纸", + "app.theme.custom.rename_input_label": "重命名主题“{{name}}”", + "app.theme.custom.safety_hint": "仅导入可信 CSS;外部资源会被拒绝。如样式异常,可按 {{shortcut}} 退出自定义主题。", + "app.theme.custom.title": "GoNavi 主题", + "app.theme.custom.unknown_file": "未知 CSS 文件", "app.theme.data_table.column_width_hint": "标准模式默认列宽 200px;紧凑模式默认列宽 140px。已手动拖拽调整的列宽优先保留。", "app.theme.data_table.column_width_mode": "数据表列宽模式", "app.theme.data_table.column_width_mode.compact": "紧凑 140px", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 59a28aa1..166ef8aa 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2800,6 +2800,73 @@ "app.theme.appearance.windows_acrylic_hint": "Windows 效能模式:已停用系統背景模糊", "app.theme.appearance_settings_description": "集中調整縮放、字型、透明度與模糊效果。", "app.theme.appearance_settings_title": "外觀設定", + "app.theme.custom.action.deactivate": "返回基礎主題", + "app.theme.custom.action.delete_named": "刪除主題「{{name}}」", + "app.theme.custom.action.download_template": "下載範本", + "app.theme.custom.action.rename": "重新命名", + "app.theme.custom.action.rename_named": "重新命名主題「{{name}}」", + "app.theme.custom.action.replace_css": "替換 CSS", + "app.theme.custom.action.replace_named": "替換主題「{{name}}」的 CSS", + "app.theme.custom.action.upload": "上傳 CSS", + "app.theme.custom.action.upload_first": "上傳第一個主題", + "app.theme.custom.action.use_named": "使用主題「{{name}}」", + "app.theme.custom.active_hint": "GoNavi 主題樣式已疊加在所選基礎模式之上。", + "app.theme.custom.active_theme": "正在使用:{{name}}", + "app.theme.custom.badge.active": "使用中", + "app.theme.custom.base_mode": "基礎模式", + "app.theme.custom.base_mode_for": "主題「{{name}}」的基礎模式", + "app.theme.custom.count": "已儲存 {{count}} / {{max}} 個主題", + "app.theme.custom.delete_confirm.description": "刪除「{{name}}」後無法復原;若正在使用,將立即回到預設主題。", + "app.theme.custom.delete_confirm.title": "刪除自訂主題?", + "app.theme.custom.description": "上傳自行製作的 CSS 主題,並選擇系統、亮色或暗色作為元件與編輯器的基礎模式。", + "app.theme.custom.empty": "尚未上傳自訂主題。可先下載範本開始製作。", + "app.theme.custom.error.download_failed": "無法下載主題範本", + "app.theme.custom.error.empty": "CSS 檔案內容為空", + "app.theme.custom.error.invalid_extension": "請選擇 .css 檔案", + "app.theme.custom.error.invalid_syntax": "CSS 結構不完整,請檢查括號與引號", + "app.theme.custom.error.max_count": "最多可儲存 {{count}} 個自訂主題", + "app.theme.custom.error.max_total_size": "自訂主題 CSS 總大小已超過 1 MB", + "app.theme.custom.error.name_required": "主題名稱不可為空", + "app.theme.custom.error.not_found": "找不到該自訂主題", + "app.theme.custom.error.operation_failed": "自訂主題操作失敗", + "app.theme.custom.error.read_failed": "讀取 CSS 檔案失敗", + "app.theme.custom.error.storage_failed": "儲存主題失敗,請檢查瀏覽器儲存空間或權限", + "app.theme.custom.error.too_large": "單一 CSS 檔案不可超過 {{size}} KB", + "app.theme.custom.error.unsafe_font_face": "為避免外部字型請求,自訂主題不允許 @font-face", + "app.theme.custom.error.unsafe_import": "為避免外部資源請求,自訂主題不允許 @import", + "app.theme.custom.error.unsafe_script": "CSS 包含不安全的舊式指令碼或行為語法", + "app.theme.custom.error.unsafe_url": "為避免外部資源請求,自訂主題不允許 url()", + "app.theme.custom.inactive": "目前使用基礎主題", + "app.theme.custom.inactive_hint": "選擇內建主題或我的 CSS 後會立即生效。", + "app.theme.custom.legacy_compatibility_hint": "舊版 UI 可使用自訂 CSS,但完整的 --gn-* 主題權杖主要面向新版 UI。", + "app.theme.custom.list_label": "GoNavi 主題清單", + "app.theme.custom.message.css_replaced": "主題 CSS 已替換", + "app.theme.custom.message.deactivated": "已停用自訂主題", + "app.theme.custom.message.deleted": "已刪除主題「{{name}}」", + "app.theme.custom.message.imported": "已匯入主題「{{name}}」", + "app.theme.custom.message.renamed": "主題名稱已更新", + "app.theme.custom.message.selected": "已啟用主題「{{name}}」", + "app.theme.custom.my_themes_title": "我的 CSS 主題", + "app.theme.custom.preset.badge.recommended": "推薦", + "app.theme.custom.preset.comfort_dark.description": "低對比石墨灰與柔和霧綠色,減少純黑、純白和高飽和色彩刺激。", + "app.theme.custom.preset.comfort_dark.name": "舒適暗色", + "app.theme.custom.preset.count": "{{count}} 套內建主題", + "app.theme.custom.preset.deep_ocean.description": "沉靜深青背景與柔和青綠強調色,適合夜間長時間查看。", + "app.theme.custom.preset.deep_ocean.name": "深海", + "app.theme.custom.preset.description": "內建主題不占用我的 CSS 配額,點選卡片即可使用。", + "app.theme.custom.preset.midnight_navy.description": "柔和的藍黑工作區,適合長時間編寫和查看 SQL。", + "app.theme.custom.preset.midnight_navy.name": "午夜藍", + "app.theme.custom.preset.mist_jade.description": "延續 GoNavi 綠色風格的低飽和亮色主題。", + "app.theme.custom.preset.mist_jade.name": "霧青", + "app.theme.custom.preset.nord_slate.description": "層次清晰的冷灰 IDE 風格,弱化純黑背景。", + "app.theme.custom.preset.nord_slate.name": "北境灰", + "app.theme.custom.preset.title": "內建主題", + "app.theme.custom.preset.warm_paper.description": "避免純白眩光的暖色紙張質感,適合白天使用。", + "app.theme.custom.preset.warm_paper.name": "暖紙", + "app.theme.custom.rename_input_label": "重新命名主題「{{name}}」", + "app.theme.custom.safety_hint": "僅匯入可信任的 CSS;外部資源會被拒絕。若樣式異常,可按 {{shortcut}} 離開自訂主題。", + "app.theme.custom.title": "GoNavi 主題", + "app.theme.custom.unknown_file": "未知 CSS 檔案", "app.theme.data_table.column_width_hint": "标准模式預設列宽 200px;紧凑模式預設列宽 140px。已手动拖拽调整的列宽优先保留。", "app.theme.data_table.column_width_mode": "資料表列宽模式", "app.theme.data_table.column_width_mode.compact": "紧凑 140px",