From 6aeafbccad6604cf1040f142ea54af5c491d5735 Mon Sep 17 00:00:00 2001 From: kimilooo Date: Tue, 12 May 2026 17:59:03 +0330 Subject: [PATCH 1/2] feat(theme): Implement Dynamic Neon Glass Theme Builder --- package.json | 2 +- .../features/settings/cosmetics/Cosmetics.tsx | 2 + .../settings/cosmetics/NeonGlassBuilder.tsx | 504 ++++++++++++++++++ src/app/pages/ThemeManager.tsx | 35 ++ src/app/services/ThemeEngine.ts | 122 +++++ src/app/state/settings.ts | 20 + src/app/styles/NeonGlass.css | 144 +++++ src/index.tsx | 1 + 8 files changed, 829 insertions(+), 1 deletion(-) create mode 100644 src/app/features/settings/cosmetics/NeonGlassBuilder.tsx create mode 100644 src/app/services/ThemeEngine.ts create mode 100644 src/app/styles/NeonGlass.css diff --git a/package.json b/package.json index 2438fcddf..90b9048ba 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", "engines": { - "node": "24.x", + "node": ">=20.x", "pnpm": ">=10" }, "scripts": { diff --git a/src/app/features/settings/cosmetics/Cosmetics.tsx b/src/app/features/settings/cosmetics/Cosmetics.tsx index 49ab59374..f056675f2 100644 --- a/src/app/features/settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/settings/cosmetics/Cosmetics.tsx @@ -26,6 +26,7 @@ import { SequenceCardStyle } from '$features/settings/styles.css'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { Appearance } from './Themes'; import { LanguageSpecificPronouns } from './LanguageSpecificPronouns'; +import { NeonGlassBuilder } from './NeonGlassBuilder'; const emojiSizeItems = [ { id: 'none', name: 'None (Same size as text)' }, @@ -378,6 +379,7 @@ export function Cosmetics({ requestBack, requestClose }: CosmeticsProps) { {!themeBrowserOpen && ( <> + diff --git a/src/app/features/settings/cosmetics/NeonGlassBuilder.tsx b/src/app/features/settings/cosmetics/NeonGlassBuilder.tsx new file mode 100644 index 000000000..e21d68ed8 --- /dev/null +++ b/src/app/features/settings/cosmetics/NeonGlassBuilder.tsx @@ -0,0 +1,504 @@ +import { useEffect, useCallback, useId, useState } from 'react'; +import { Box, Switch, Text, Line, config, Button } from 'folds'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useDebounce } from '$hooks/useDebounce'; +import { ThemeEngine, NEON_GLASS_DEFAULTS, type NeonGlassPrefs } from '../../../services/ThemeEngine'; +import { SequenceCardStyle } from '$features/settings/styles.css'; + +// Color presets for quick access +const COLOR_PRESETS = [ + { name: 'Cyan', color: '#00f0ff' }, + { name: 'Magenta', color: '#ff006e' }, + { name: 'Purple', color: '#9d4edd' }, + { name: 'Lime', color: '#00ff41' }, + { name: 'Pink', color: '#ff10f0' }, + { name: 'Blue', color: '#0087ff' }, +] as const; + +/** + * NeonGlassBuilder + * + * Live theme customisation panel for the "Neon Glass" aesthetic. + * Integrates into the Cosmetics section of Appearance settings. + * + * Architecture: + * - State persisted via existing Sable jotai settingsAtom (localStorage-backed) + * - DOM mutation (CSS vars) delegated to ThemeEngine service (Clean Architecture) + * - Slider updates debounced at 50 ms to prevent layout thrashing + * - All changes optimized with smooth transitions and sensible defaults + */ +export function NeonGlassBuilder() { + const [enabled, setEnabled] = useSetting(settingsAtom, 'neonGlassEnabled'); + const [primaryColor, setPrimaryColor] = useSetting(settingsAtom, 'neonGlassPrimaryColor'); + const [blurRadius, setBlurRadius] = useSetting(settingsAtom, 'neonGlassBlur'); + const [bgOpacity, setBgOpacity] = useSetting(settingsAtom, 'neonGlassBgOpacity'); + const [glowRadius, setGlowRadius] = useSetting(settingsAtom, 'neonGlassGlow'); + + const [applySidebar, setApplySidebar] = useSetting(settingsAtom, 'neonGlassApplySidebar'); + const [applyChat, setApplyChat] = useSetting(settingsAtom, 'neonGlassApplyChat'); + const [applyModals, setApplyModals] = useSetting(settingsAtom, 'neonGlassApplyModals'); + + // Local slider state for immediate UI feedback; debounced before hitting ThemeEngine + const [localBlur, setLocalBlur] = useState(blurRadius ?? NEON_GLASS_DEFAULTS.blurRadius); + const [localOpacity, setLocalOpacity] = useState(bgOpacity ?? NEON_GLASS_DEFAULTS.bgOpacity); + const [localColor, setLocalColor] = useState(primaryColor ?? NEON_GLASS_DEFAULTS.primaryColor); + const [localGlow, setLocalGlow] = useState(glowRadius ?? NEON_GLASS_DEFAULTS.glowRadius); + const [colorError, setColorError] = useState(false); + + // Sable's useDebounce is callback-based; wrap to debounce the save + const debounceSaveBlur = useDebounce( + useCallback((v: number) => setBlurRadius(v), [setBlurRadius]), + { wait: 50 } + ); + const debounceSaveOpacity = useDebounce( + useCallback((v: number) => setBgOpacity(v), [setBgOpacity]), + { wait: 50 } + ); + const debounceSaveColor = useDebounce( + useCallback((v: string) => setPrimaryColor(v), [setPrimaryColor]), + { wait: 50 } + ); + const debounceSaveGlow = useDebounce( + useCallback((v: number) => setGlowRadius(v), [setGlowRadius]), + { wait: 50 } + ); + + // Validate hex color format + const isValidColor = (color: string): boolean => { + return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color); + }; + + // Check if values are extreme (performance warning) + const isSlow = localBlur > 20 || localGlow > 20; + + // Apply / reset CSS variables whenever relevant state changes + useEffect(() => { + if (!enabled) { + ThemeEngine.resetNeonGlass(); + return; + } + ThemeEngine.applyNeonGlass({ + primaryColor: localColor, + blurRadius: localBlur, + bgOpacity: localOpacity, + glowRadius: localGlow, + applySidebar, + applyChat, + applyModals, + enableTransition: true, + }); + }, [enabled, localColor, localBlur, localOpacity, localGlow, applySidebar, applyChat, applyModals]); + + const colorId = useId(); + const blurId = useId(); + const opacityId = useId(); + const glowId = useId(); + + const handleColorChange = useCallback( + (e: React.ChangeEvent) => { + const v = e.target.value; + setLocalColor(v); + setColorError(!isValidColor(v)); + debounceSaveColor(v); + }, + [debounceSaveColor] + ); + + const handleBlurChange = useCallback( + (e: React.ChangeEvent) => { + const v = Number(e.target.value); + setLocalBlur(v); + debounceSaveBlur(v); + }, + [debounceSaveBlur] + ); + + const handleOpacityChange = useCallback( + (e: React.ChangeEvent) => { + const v = Number(e.target.value); + setLocalOpacity(v); + debounceSaveOpacity(v); + }, + [debounceSaveOpacity] + ); + + const handleGlowChange = useCallback( + (e: React.ChangeEvent) => { + const v = Number(e.target.value); + setLocalGlow(v); + debounceSaveGlow(v); + }, + [debounceSaveGlow] + ); + + // Reset all to defaults + const handleResetToDefaults = useCallback(() => { + setLocalColor(NEON_GLASS_DEFAULTS.primaryColor); + setLocalBlur(NEON_GLASS_DEFAULTS.blurRadius); + setLocalOpacity(NEON_GLASS_DEFAULTS.bgOpacity); + setLocalGlow(NEON_GLASS_DEFAULTS.glowRadius); + setColorError(false); + + setPrimaryColor(NEON_GLASS_DEFAULTS.primaryColor); + setBlurRadius(NEON_GLASS_DEFAULTS.blurRadius); + setBgOpacity(NEON_GLASS_DEFAULTS.bgOpacity); + setGlowRadius(NEON_GLASS_DEFAULTS.glowRadius); + }, [setPrimaryColor, setBlurRadius, setBgOpacity, setGlowRadius]); + + // Apply a preset color + const handlePresetColor = useCallback( + (color: string) => { + setLocalColor(color); + setColorError(false); + debounceSaveColor(color); + }, + [debounceSaveColor] + ); + + // Export theme as JSON + const handleExportTheme = useCallback(() => { + const theme: NeonGlassPrefs = { + primaryColor: localColor, + blurRadius: localBlur, + bgOpacity: localOpacity, + glowRadius: localGlow, + applySidebar, + applyChat, + applyModals, + }; + + const json = JSON.stringify(theme, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `neon-glass-theme-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [localColor, localBlur, localOpacity, localGlow, applySidebar, applyChat, applyModals]); + + return ( + + ✨ Neon Glass Builder + + {/* Live Preview Section */} + + + Live Preview + + + {/* Mini Sidebar */} + + + + + + + {/* Mini Chat */} + + + + + + + + {/* Mini Modals Preview */} + + + + Modal + + + + + {/* Enable / disable toggle */} + + setEnabled(v)} />} + /> + + + {enabled && ( + <> + {/* Neon accent colour with presets */} + + + + + {localColor.toUpperCase()} + + + } + /> + + + {/* Color Presets */} + + + {COLOR_PRESETS.map((preset) => ( + handlePresetColor(preset.color)} + style={{ + width: 32, + height: 32, + borderRadius: 6, + backgroundColor: preset.color, + cursor: 'pointer', + border: localColor === preset.color ? `2px solid white` : '1px solid rgba(255,255,255,0.2)', + boxShadow: + localColor === preset.color + ? `0 0 8px ${preset.color}, inset 0 0 4px rgba(255,255,255,0.3)` + : 'none', + transition: 'all 0.2s ease', + }} + title={preset.name} + role="button" + aria-label={`Select ${preset.name} color`} + /> + ))} + + } + /> + + + {/* Blur radius */} + + + } + /> + + + {/* Background opacity */} + + + } + /> + + + {/* Glow intensity */} + + + } + /> + + + {/* Performance warning */} + {isSlow && ( + + + ⚠️ High values may impact performance on lower-end devices + + + )} + + + Apply effects to: + + + {/* Granular Toggles */} + + } + /> + + } + /> + + } + /> + + + {/* Action Buttons */} + + + + + + )} + + ); +} diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 9dae91949..6e35f35f7 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -12,6 +12,7 @@ import { import { ArboriumThemeBridge } from '$plugins/arborium'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { ThemeEngine } from '../services/ThemeEngine'; import { getCachedThemeCss, putCachedThemeCss } from '../theme/cache'; import { isLocalImportBundledUrl } from '../theme/localImportUrls'; @@ -63,6 +64,15 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion'); const [enabledTweakUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls'); + const [neonGlassEnabled] = useSetting(settingsAtom, 'neonGlassEnabled'); + const [neonGlassPrimaryColor] = useSetting(settingsAtom, 'neonGlassPrimaryColor'); + const [neonGlassBlur] = useSetting(settingsAtom, 'neonGlassBlur'); + const [neonGlassBgOpacity] = useSetting(settingsAtom, 'neonGlassBgOpacity'); + const [neonGlassGlow] = useSetting(settingsAtom, 'neonGlassGlow'); + const [neonGlassApplySidebar] = useSetting(settingsAtom, 'neonGlassApplySidebar'); + const [neonGlassApplyChat] = useSetting(settingsAtom, 'neonGlassApplyChat'); + const [neonGlassApplyModals] = useSetting(settingsAtom, 'neonGlassApplyModals'); + useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); @@ -89,6 +99,31 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } }, [activeTheme, saturation, underlineLinks, reducedMotion]); + useEffect(() => { + if (neonGlassEnabled) { + ThemeEngine.applyNeonGlass({ + primaryColor: neonGlassPrimaryColor, + blurRadius: neonGlassBlur, + bgOpacity: neonGlassBgOpacity, + glowRadius: neonGlassGlow, + applySidebar: neonGlassApplySidebar, + applyChat: neonGlassApplyChat, + applyModals: neonGlassApplyModals, + }); + } else { + ThemeEngine.resetNeonGlass(); + } + }, [ + neonGlassEnabled, + neonGlassPrimaryColor, + neonGlassBlur, + neonGlassBgOpacity, + neonGlassGlow, + neonGlassApplySidebar, + neonGlassApplyChat, + neonGlassApplyModals, + ]); + useEffect(() => { const url = activeTheme.remoteFullUrl?.trim(); let cancelled = false; diff --git a/src/app/services/ThemeEngine.ts b/src/app/services/ThemeEngine.ts new file mode 100644 index 000000000..7f604ea9d --- /dev/null +++ b/src/app/services/ThemeEngine.ts @@ -0,0 +1,122 @@ +/** + * ThemeEngine — Pure DOM service (Clean Architecture). + * + * Responsible ONLY for writing/removing CSS custom properties on :root. + */ + +export interface NeonGlassPrefs { + primaryColor: string; + blurRadius: number; + bgOpacity: number; + glowRadius: number; + applySidebar: boolean; + applyChat: boolean; + applyModals: boolean; + enableTransition?: boolean; +} + +export const NEON_GLASS_DEFAULTS: NeonGlassPrefs = { + primaryColor: '#00f0ff', + blurRadius: 14, + bgOpacity: 0.42, + glowRadius: 12, + applySidebar: true, + applyChat: true, + applyModals: true, + enableTransition: true, +}; + +class ThemeEngineService { + private transitionTimeout: NodeJS.Timeout | null = null; + + applyNeonGlass(prefs: Partial): void { + try { + const root = document.documentElement; + const primary = this.sanitizeHexColor(prefs.primaryColor) ?? NEON_GLASS_DEFAULTS.primaryColor; + const blur = this.sanitizeNumber(prefs.blurRadius, 0, 32) ?? NEON_GLASS_DEFAULTS.blurRadius; + const opacity = this.sanitizeNumber(prefs.bgOpacity, 0.05, 1.0) ?? NEON_GLASS_DEFAULTS.bgOpacity; + const glow = this.sanitizeNumber(prefs.glowRadius, 0, 30) ?? NEON_GLASS_DEFAULTS.glowRadius; + const shouldTransition = prefs.enableTransition ?? NEON_GLASS_DEFAULTS.enableTransition; + + // Enable transition for smooth activation + if (shouldTransition) { + document.body.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + } + + root.style.setProperty('--sable-primary-main', primary); + root.style.setProperty('--sable-primary-on-main', '#ffffff'); + + const rgb = this.hexToRgb(primary); + if (rgb) root.style.setProperty('--sable-primary-main-rgb', rgb); + + root.style.setProperty('--ng-blur', `${blur}px`); + root.style.setProperty('--ng-opacity', String(opacity)); + root.style.setProperty('--ng-glow', `0 0 ${glow}px ${primary}`); + + document.body.dataset.neonGlass = 'true'; + document.body.dataset.ngSidebar = String(prefs.applySidebar ?? NEON_GLASS_DEFAULTS.applySidebar); + document.body.dataset.ngChat = String(prefs.applyChat ?? NEON_GLASS_DEFAULTS.applyChat); + document.body.dataset.ngModals = String(prefs.applyModals ?? NEON_GLASS_DEFAULTS.applyModals); + + // Clean up transition after completion + if (this.transitionTimeout) clearTimeout(this.transitionTimeout); + this.transitionTimeout = setTimeout(() => { + document.body.style.transition = ''; + this.transitionTimeout = null; + }, 300); + } catch (e) { + console.error('[ThemeEngine] applyNeonGlass failed:', e); + } + } + + resetNeonGlass(): void { + try { + const root = document.documentElement; + + // Enable smooth transition for reset + document.body.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + + root.style.removeProperty('--sable-primary-main'); + root.style.removeProperty('--sable-primary-on-main'); + root.style.removeProperty('--sable-primary-main-rgb'); + root.style.removeProperty('--ng-blur'); + root.style.removeProperty('--ng-opacity'); + root.style.removeProperty('--ng-glow'); + + delete document.body.dataset.neonGlass; + delete document.body.dataset.ngSidebar; + delete document.body.dataset.ngChat; + delete document.body.dataset.ngModals; + + // Clean up transition + if (this.transitionTimeout) clearTimeout(this.transitionTimeout); + this.transitionTimeout = setTimeout(() => { + document.body.style.transition = ''; + this.transitionTimeout = null; + }, 300); + } catch (e) { + console.error('[ThemeEngine] resetNeonGlass failed:', e); + } + } + + private sanitizeHexColor(val: unknown): string | null { + if (typeof val !== 'string') return null; + const t = val.trim(); + return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(t) ? t : null; + } + + private sanitizeNumber(val: unknown, min: number, max: number): number | null { + if (typeof val !== 'number' || !Number.isFinite(val)) return null; + if (val < min || val > max) return null; + return val; + } + + private hexToRgb(hex: string): string | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? `${parseInt(result[1] as string, 16)}, ${parseInt(result[2] as string, 16)}, ${parseInt(result[3] as string, 16)}` + : null; + } +} + +export const ThemeEngine = new ThemeEngineService(); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 401963f49..b5a8b368b 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -123,6 +123,16 @@ export interface Settings { captionPosition: CaptionPosition; customDMCards: boolean; + // Neon Glass Builder + neonGlassEnabled: boolean; + neonGlassPrimaryColor?: string; + neonGlassBlur?: number; + neonGlassBgOpacity?: number; + neonGlassGlow?: number; + neonGlassApplySidebar: boolean; + neonGlassApplyChat: boolean; + neonGlassApplyModals: boolean; + // Sable features! sendPresence: boolean; mobileGestures: boolean; @@ -245,6 +255,16 @@ export const defaultSettings: Settings = { captionPosition: CaptionPosition.Below, customDMCards: true, + // Neon Glass Builder - Optimized Defaults + neonGlassEnabled: false, + neonGlassPrimaryColor: '#00f0ff', + neonGlassBlur: 14, + neonGlassBgOpacity: 0.42, + neonGlassGlow: 12, + neonGlassApplySidebar: true, + neonGlassApplyChat: true, + neonGlassApplyModals: true, + // Sable features! sendPresence: true, mobileGestures: true, diff --git a/src/app/styles/NeonGlass.css b/src/app/styles/NeonGlass.css new file mode 100644 index 000000000..9b74372c6 --- /dev/null +++ b/src/app/styles/NeonGlass.css @@ -0,0 +1,144 @@ +/* + * Neon Glass Theme — Professional Overlay for Sable + * Fixed: Light Theme "Blackout" in Dialogs & Menus. + */ + +:root { + /* Default variables (Dark Theme) */ + --ng-glass-color: 15, 15, 25; + --ng-border-color: rgba(255, 255, 255, 0.1); + --ng-text-contrast: #ffffff; +} + +/* --- Theme Awareness --- */ + +/* Light Theme Settings */ +body.light-theme[data-neon-glass="true"] { + --ng-glass-color: 255, 255, 255; + --ng-border-color: rgba(0, 0, 0, 0.12); + --ng-text-contrast: #18181b; + background-color: #f0f0f5 !important; +} + +/* Dark Theme Settings */ +body.dark-theme[data-neon-glass="true"] { + --ng-glass-color: 10, 10, 18; + --ng-border-color: rgba(255, 255, 255, 0.08); + --ng-text-contrast: #ffffff; + background-color: #0a0a12 !important; +} + +/* Combined Glass Variable */ +body[data-neon-glass="true"] { + --ng-glass-bg: rgba(var(--ng-glass-color), var(--ng-opacity)); + + background-image: + radial-gradient(circle at 0% 0%, rgba(var(--sable-primary-main-rgb), 0.12) 0%, transparent 45%), + radial-gradient(circle at 100% 100%, rgba(var(--sable-primary-main-rgb), 0.08) 0%, transparent 45%) !important; + background-attachment: fixed !important; +} + +/* --- Global Transparency Fix --- + * This ensures Sable's internal boxes don't block the glass effect. + */ +body[data-neon-glass="true"] [class*="ContainerColor_variant_Background"], +body[data-neon-glass="true"] [class*="ContainerColor_variant_Surface"], +body[data-neon-glass="true"] [class*="ContainerColor_variant_SurfaceVariant"], +body[data-neon-glass="true"] [class*="Sidebar_Sidebar"], +body[data-neon-glass="true"] [class*="RoomView_RoomView"], +body[data-neon-glass="true"] [class*="Page_Page"] { + background-color: transparent !important; + box-shadow: none !important; +} + +/* --- Sidebar Glass --- */ +body[data-ng-sidebar="true"] [class*="Sidebar_Sidebar"], +body[data-ng-sidebar="true"] [class*="SidebarNav_SidebarNav"], +body[data-ng-sidebar="true"] aside { + backdrop-filter: blur(var(--ng-blur)) saturate(160%) !important; + background-color: var(--ng-glass-bg) !important; + border-right: 1px solid var(--ng-border-color) !important; + overflow: hidden; +} + +/* Aesthetic Polish: Fully rounded sidebar avatars (Profile, Search, etc.) */ +body[data-ng-sidebar="true"] [class*="SidebarAvatar_SidebarAvatar"], +body[data-ng-sidebar="true"] [class*="SidebarFolder_SidebarFolder"] { + border-radius: 50% !important; + overflow: hidden !important; +} +body[data-ng-sidebar="true"] [class*="SidebarAvatar_SidebarAvatar"] *, +body[data-ng-sidebar="true"] [class*="SidebarFolder_SidebarFolder"] * { + border-radius: 50% !important; +} + +/* --- Chat & Main Content Area --- */ +body[data-ng-chat="true"] [class*="RoomView_RoomView"], +body[data-ng-chat="true"] [class*="Page_Page"], +body[data-ng-chat="true"] main { + backdrop-filter: blur(var(--ng-blur)) saturate(160%) !important; + background-color: var(--ng-glass-bg) !important; +} + +/* Aesthetic Polish: Rounded Glassy Chat Bubbles */ +body[data-ng-chat="true"] [class*="layout_BubbleContent"] { + backdrop-filter: blur(calc(var(--ng-blur) * 0.8)) saturate(160%) !important; + background-color: var(--ng-glass-bg) !important; + border: 1px solid var(--ng-border-color) !important; + border-radius: 18px !important; /* Generous roundness */ +} +/* Preserve bubble tails if present */ +body[data-ng-chat="true"] [class*="layout_BubbleContentArrowLeft"] { + border-top-left-radius: 4px !important; +} +body[data-ng-chat="true"] [class*="layout_BubbleContentArrowRight"] { + border-top-right-radius: 4px !important; +} +body[data-ng-chat="true"] [class*="layout_BubbleLeftArrow"] path, +body[data-ng-chat="true"] [class*="layout_BubbleRightArrow"] path { + fill: var(--ng-border-color) !important; /* Hide solid tail color, make it match border */ +} + +/* --- Modals, Menus, Dialogs & Popouts --- + * We target the Portal-rendered elements specifically. + */ +body[data-ng-modals="true"] [class*="Modal_Modal"], +body[data-ng-modals="true"] [class*="Dialog_Dialog"], +body[data-ng-modals="true"] [class*="PopOut_PopOut"], +body[data-ng-modals="true"] [class*="Menu_Menu"], +body[data-ng-modals="true"] [class*="Overlay"], +body[data-ng-modals="true"] [role="dialog"], +body[data-ng-modals="true"] [role="menu"] { + backdrop-filter: blur(var(--ng-blur)) saturate(180%) !important; + background-color: var(--ng-glass-bg) !important; + border: 1px solid var(--ng-border-color) !important; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35) !important; + /* Preserve original border radius but ensure blur doesn't leak */ + overflow: hidden !important; +} + +/* Glow effects for active elements */ +body[data-neon-glass="true"] [aria-selected="true"], +body[data-neon-glass="true"] [data-active="true"], +body[data-neon-glass="true"] [class*="active_true"], +body[data-neon-glass="true"] [class*="variant_Primary"] { + box-shadow: var(--ng-glow) !important; +} + +/* Fix for Light Theme Text Contrast */ +body.light-theme[data-neon-glass="true"] { + color: #18181b; +} + +/* Scrollbar Integration */ +body[data-neon-glass="true"] ::-webkit-scrollbar-thumb { + background: rgba(var(--sable-primary-main-rgb), 0.2) !important; + border-radius: 10px !important; +} + +/* Focused inputs ring */ +body[data-neon-glass="true"] input:focus, +body[data-neon-glass="true"] textarea:focus { + border-color: var(--sable-primary-main) !important; + box-shadow: 0 0 0 2px rgba(var(--sable-primary-main-rgb), 0.2) !important; +} diff --git a/src/index.tsx b/src/index.tsx index 1721755d5..d31f0407c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,7 @@ import './app/i18n'; import './index.css'; import './app/styles/themes.css'; +import './app/styles/NeonGlass.css'; import './app/styles/overrides/General.css'; import './app/styles/overrides/Privacy.css'; import { pushSessionToSW } from './sw-session'; From 20607d1f0aa295b993b9c5eff5bcc4119c3c56df Mon Sep 17 00:00:00 2001 From: kimilooo Date: Thu, 14 May 2026 16:12:34 +0330 Subject: [PATCH 2/2] feat(neon-glass): complete overhaul with granular opacity, bubble glow, theme metadata, and CSS rewrite - Add neonGlassChatOpacity, neonGlassBubbleGlow, neonGlassApplyReply - Add Import/Export theme functionality with metadata support - Redesign NeonGlassBuilder with HexColorPickerPopOut and live preview - Add ng_* field parsing to theme metadata (ng_color, ng_blur, etc.) - ThemeCatalog now auto-applies neon-glass defaults from themes - Rewrite NeonGlass.css with split chat/sidebar opacity and bubble glow - Add multi-layer glow effect with luminance-based text color - Fix double-glass issue on modals and popups Files changed: NeonGlassBuilder, ThemeCatalogSettings, ThemeManager, ThemeEngine, settings, NeonGlass.css, metadata.ts/test, .gitignore --- .gitignore | Bin 563 -> 659 bytes .../settings/cosmetics/NeonGlassBuilder.tsx | 826 ++++++++++-------- .../cosmetics/ThemeCatalogSettings.tsx | 130 ++- src/app/pages/ThemeManager.tsx | 9 + src/app/services/ThemeEngine.ts | 34 +- src/app/state/settings.ts | 7 +- src/app/styles/NeonGlass.css | 182 ++-- src/app/theme/metadata.test.ts | 36 + src/app/theme/metadata.ts | 65 ++ 9 files changed, 837 insertions(+), 452 deletions(-) diff --git a/.gitignore b/.gitignore index 76af755426101cefc8dcd3cf2c31f6b4df1a3880..991d3fe2601aeb3e4481a9e1ebf5a6ab1e5f1256 100644 GIT binary patch delta 104 zcmdnYGMROQF;jvbLmoo`LoP!RLox#|0~e50$q)}@XD}oJWzrc^8HyS77)lr_fEXlC Gs(Jt~Y!c-F delta 7 OcmbQtx|wBzF%tj^F# setPrimaryColor(v), [setPrimaryColor]), + { wait: 50 } + ); const debounceSaveBlur = useDebounce( useCallback((v: number) => setBlurRadius(v), [setBlurRadius]), { wait: 50 } @@ -57,24 +62,21 @@ export function NeonGlassBuilder() { useCallback((v: number) => setBgOpacity(v), [setBgOpacity]), { wait: 50 } ); - const debounceSaveColor = useDebounce( - useCallback((v: string) => setPrimaryColor(v), [setPrimaryColor]), + const debounceSaveChatOpacity = useDebounce( + useCallback((v: number) => setChatOpacity(v), [setChatOpacity]), { wait: 50 } ); const debounceSaveGlow = useDebounce( useCallback((v: number) => setGlowRadius(v), [setGlowRadius]), { wait: 50 } ); + const debounceSaveBubbleGlow = useDebounce( + useCallback((v: number) => setBubbleGlow(v), [setBubbleGlow]), + { wait: 50 } + ); - // Validate hex color format - const isValidColor = (color: string): boolean => { - return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color); - }; - - // Check if values are extreme (performance warning) const isSlow = localBlur > 20 || localGlow > 20; - // Apply / reset CSS variables whenever relevant state changes useEffect(() => { if (!enabled) { ThemeEngine.resetNeonGlass(); @@ -84,417 +86,519 @@ export function NeonGlassBuilder() { primaryColor: localColor, blurRadius: localBlur, bgOpacity: localOpacity, + chatOpacity: localChatOpacity, glowRadius: localGlow, + bubbleGlow: localBubbleGlow, applySidebar, applyChat, applyModals, + applyReply, enableTransition: true, }); - }, [enabled, localColor, localBlur, localOpacity, localGlow, applySidebar, applyChat, applyModals]); - - const colorId = useId(); - const blurId = useId(); - const opacityId = useId(); - const glowId = useId(); - - const handleColorChange = useCallback( - (e: React.ChangeEvent) => { - const v = e.target.value; - setLocalColor(v); - setColorError(!isValidColor(v)); - debounceSaveColor(v); + }, [enabled, localColor, localBlur, localOpacity, localChatOpacity, localGlow, localBubbleGlow, applySidebar, applyChat, applyModals, applyReply]); + + const handleColorUpdate = useCallback( + (newColor: string) => { + let sanitized = newColor.trim(); + sanitized = sanitized.startsWith('#') ? sanitized : `#${sanitized}`; + setLocalColor(sanitized); + if (/^#[0-9A-F]{6}$/i.test(sanitized)) { + debounceSaveColor(sanitized); + } }, [debounceSaveColor] ); - const handleBlurChange = useCallback( - (e: React.ChangeEvent) => { - const v = Number(e.target.value); - setLocalBlur(v); - debounceSaveBlur(v); - }, - [debounceSaveBlur] - ); - - const handleOpacityChange = useCallback( - (e: React.ChangeEvent) => { - const v = Number(e.target.value); - setLocalOpacity(v); - debounceSaveOpacity(v); - }, - [debounceSaveOpacity] - ); - - const handleGlowChange = useCallback( - (e: React.ChangeEvent) => { - const v = Number(e.target.value); - setLocalGlow(v); - debounceSaveGlow(v); + const handlePresetColor = useCallback( + (color: string) => { + setLocalColor(color); + debounceSaveColor(color); }, - [debounceSaveGlow] + [debounceSaveColor] ); - // Reset all to defaults const handleResetToDefaults = useCallback(() => { setLocalColor(NEON_GLASS_DEFAULTS.primaryColor); setLocalBlur(NEON_GLASS_DEFAULTS.blurRadius); setLocalOpacity(NEON_GLASS_DEFAULTS.bgOpacity); + setLocalChatOpacity(NEON_GLASS_DEFAULTS.chatOpacity); setLocalGlow(NEON_GLASS_DEFAULTS.glowRadius); - setColorError(false); + setLocalBubbleGlow(NEON_GLASS_DEFAULTS.bubbleGlow); setPrimaryColor(NEON_GLASS_DEFAULTS.primaryColor); setBlurRadius(NEON_GLASS_DEFAULTS.blurRadius); setBgOpacity(NEON_GLASS_DEFAULTS.bgOpacity); + setChatOpacity(NEON_GLASS_DEFAULTS.chatOpacity); setGlowRadius(NEON_GLASS_DEFAULTS.glowRadius); - }, [setPrimaryColor, setBlurRadius, setBgOpacity, setGlowRadius]); + setBubbleGlow(NEON_GLASS_DEFAULTS.bubbleGlow); + setApplyReply(NEON_GLASS_DEFAULTS.applyReply); + }, [setPrimaryColor, setBlurRadius, setBgOpacity, setChatOpacity, setGlowRadius, setBubbleGlow, setApplyReply]); - // Apply a preset color - const handlePresetColor = useCallback( - (color: string) => { - setLocalColor(color); - setColorError(false); - debounceSaveColor(color); - }, - [debounceSaveColor] - ); - - // Export theme as JSON const handleExportTheme = useCallback(() => { - const theme: NeonGlassPrefs = { - primaryColor: localColor, - blurRadius: localBlur, - bgOpacity: localOpacity, - glowRadius: localGlow, - applySidebar, - applyChat, - applyModals, - }; - - const json = JSON.stringify(theme, null, 2); - const blob = new Blob([json], { type: 'application/json' }); + const cssText = `/* +@sable-theme +--- +id: custom-neon-glass-${Date.now()} +name: Custom Neon Glass Theme +author: NeonGlassBuilder +kind: dark +contrast: low +tags: neon, glassmorphism, generated +ng_color: ${localColor} +ng_blur: ${localBlur} +ng_opacity: ${localOpacity} +ng_chat_opacity: ${localChatOpacity} +ng_glow: ${localGlow} +ng_bubble_glow: ${localBubbleGlow} +ng_sidebar: ${applySidebar} +ng_chat: ${applyChat} +ng_modals: ${applyModals} +ng_reply: ${applyReply} +*/ + +:root { + /* This theme relies on the Neon Glass settings overlay. */ +} +`; + const blob = new Blob([cssText], { type: 'text/css' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `neon-glass-theme-${new Date().toISOString().split('T')[0]}.json`; + a.download = `custom-neon-glass-${new Date().toISOString().split('T')[0]}.sable.css`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - }, [localColor, localBlur, localOpacity, localGlow, applySidebar, applyChat, applyModals]); + }, [localColor, localBlur, localOpacity, localChatOpacity, localGlow, localBubbleGlow, applySidebar, applyChat, applyModals, applyReply]); + + const handleImportTheme = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.css,.sable.css'; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target?.result as string; + // Import using the metadata parser from ThemeCatalogSettings + import('../../../theme/metadata').then(({ parseSableThemeMetadata }) => { + const meta = parseSableThemeMetadata(text); + if (meta.defaults?.neonGlass) { + const ng = meta.defaults.neonGlass; + if (ng.primaryColor) { + setLocalColor(ng.primaryColor); + setPrimaryColor(ng.primaryColor); + } + if (ng.blurRadius !== undefined) { + setLocalBlur(ng.blurRadius); + setBlurRadius(ng.blurRadius); + } + if (ng.bgOpacity !== undefined) { + setLocalOpacity(ng.bgOpacity); + setBgOpacity(ng.bgOpacity); + } + if (ng.chatOpacity !== undefined) { + setLocalChatOpacity(ng.chatOpacity); + setChatOpacity(ng.chatOpacity); + } + if (ng.glowRadius !== undefined) { + setLocalGlow(ng.glowRadius); + setGlowRadius(ng.glowRadius); + } + if (ng.bubbleGlow !== undefined) { + setLocalBubbleGlow(ng.bubbleGlow); + setBubbleGlow(ng.bubbleGlow); + } + if (ng.applySidebar !== undefined) setApplySidebar(ng.applySidebar); + if (ng.applyChat !== undefined) setApplyChat(ng.applyChat); + if (ng.applyModals !== undefined) setApplyModals(ng.applyModals); + if (ng.applyReply !== undefined) setApplyReply(ng.applyReply); + setEnabled(true); + } + }).catch(console.error); + }; + reader.readAsText(file); + }; + input.click(); + }, [ + setPrimaryColor, + setBlurRadius, + setBgOpacity, + setChatOpacity, + setGlowRadius, + setBubbleGlow, + setApplySidebar, + setApplyChat, + setApplyModals, + setApplyReply, + setEnabled, + ]); + + const renderSlider = ( + value: number, + min: number, + max: number, + step: number, + onChange: (v: number) => void, + ariaLabel: string + ) => ( + onChange(Number.parseFloat(e.target.value))} + style={{ + width: toRem(160), + cursor: 'pointer', + appearance: 'none', + height: toRem(6), + borderRadius: config.radii.Pill, + backgroundColor: 'var(--sable-surface-container-line)', + accentColor: 'var(--sable-primary-main)', + }} + /> + ); return ( - - ✨ Neon Glass Builder + + {/* Neon Glass Main Section */} + + Neon Glass + + {/* Live Preview Section */} + + + Live Preview + + + {/* Mini Sidebar */} + + + + + - {/* Live Preview Section */} - - - Live Preview - - - {/* Mini Sidebar */} - + {/* Mini Chat */} - - + > + + + + - {/* Mini Chat */} + {/* Mini Modals Preview */} - - + + Modal + - - - {/* Mini Modals Preview */} - - + + {/* Enable / disable toggle */} + + setEnabled(v)} />} /> - - Modal - - - - - {/* Enable / disable toggle */} - - setEnabled(v)} />} - /> - + + {enabled && ( <> - {/* Neon accent colour with presets */} - - - - - {localColor.toUpperCase()} - - - } - /> - - - {/* Color Presets */} - - - {COLOR_PRESETS.map((preset) => ( - handlePresetColor(preset.color)} + {/* Colors Section */} + + Neon Colors + + + } + > + {(onOpen, opened) => ( + + )} + + handleColorUpdate(e.currentTarget.value)} + placeholder="#FFFFFF" + variant="Background" + size="300" + radii="300" style={{ - width: 32, - height: 32, - borderRadius: 6, - backgroundColor: preset.color, - cursor: 'pointer', - border: localColor === preset.color ? `2px solid white` : '1px solid rgba(255,255,255,0.2)', - boxShadow: - localColor === preset.color - ? `0 0 8px ${preset.color}, inset 0 0 4px rgba(255,255,255,0.3)` - : 'none', - transition: 'all 0.2s ease', + textTransform: 'uppercase', + fontFamily: 'monospace', + width: '100px', }} - title={preset.name} - role="button" - aria-label={`Select ${preset.name} color`} /> - ))} - - } - /> - - - {/* Blur radius */} - - - } - /> - - - {/* Background opacity */} - - - } - /> - - - {/* Glow intensity */} - - - } - /> - - - {/* Performance warning */} - {isSlow && ( - - - ⚠️ High values may impact performance on lower-end devices - - - )} + handleColorUpdate(NEON_GLASS_DEFAULTS.primaryColor)} + title="Reset color" + > + + + + } + /> + + {COLOR_PRESETS.map((preset) => ( + handlePresetColor(preset.color)} + style={{ + width: 32, + height: 32, + borderRadius: config.radii.R300, + backgroundColor: preset.color, + cursor: 'pointer', + border: localColor.toUpperCase() === preset.color.toUpperCase() ? `2px solid white` : '1px solid var(--sable-surface-container-line)', + boxShadow: + localColor.toUpperCase() === preset.color.toUpperCase() + ? `0 0 8px ${preset.color}, inset 0 0 4px rgba(255,255,255,0.3)` + : 'none', + transition: 'all 0.2s ease', + }} + title={preset.name} + role="button" + aria-label={`Select ${preset.name} color`} + /> + ))} + + } + /> + + - - Apply effects to: - + {/* Effects Section */} + + Effect Intensity + + { + setLocalBlur(v); + debounceSaveBlur(v); + }, 'Blur radius slider')} + /> + { + setLocalOpacity(v); + debounceSaveOpacity(v); + }, 'Background opacity slider')} + /> + { + setLocalChatOpacity(v); + debounceSaveChatOpacity(v); + }, 'Chat opacity slider')} + /> + { + setLocalGlow(v); + debounceSaveGlow(v); + }, 'Glow intensity slider')} + /> + { + setLocalBubbleGlow(v); + debounceSaveBubbleGlow(v); + }, 'Bubble glow slider')} + /> + + + {/* Performance warning */} + {isSlow && ( + + + ⚠️ High values may impact performance on lower-end devices + + + )} + - {/* Granular Toggles */} - - } - /> - - } - /> - - } - /> - + {/* Granular Toggles Section */} + + Apply Effects To + + } + /> + } + /> + } + /> + } + /> + + {/* Action Buttons */} - + + diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx index e602c7dfc..b628b3fd6 100644 --- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx @@ -57,6 +57,7 @@ export type CatalogPreviewRow = ThemePair & { contrast: SableThemeContrast; tags: string[]; fullInstallUrl: string; + defaults?: SableThemeMetadata['defaults']; }; export type LocalPreviewRow = ThemeRemoteFavorite & { @@ -68,6 +69,7 @@ export type LocalPreviewRow = ThemeRemoteFavorite & { contrast: SableThemeContrast; tags: string[]; importedLocal?: boolean; + defaults?: SableThemeMetadata['defaults']; }; export type CatalogTweakRow = TweakCatalogEntry & { @@ -355,6 +357,7 @@ export function ThemeCatalogSettings({ contrast, tags: meta.tags ?? [], fullInstallUrl, + defaults: meta.defaults, }; }) ); @@ -484,6 +487,7 @@ export function ThemeCatalogSettings({ contrast, tags: meta.tags ?? [], importedLocal: fav.importedLocal, + defaults: meta.defaults, ...(authorTrim ? { author: authorTrim } : {}), }; return row; @@ -559,30 +563,81 @@ export function ThemeCatalogSettings({ const applyFavoriteToLight = useCallback( (row: LocalPreviewRow) => { - patchSettings({ + const patch: Partial = { themeRemoteLightFullUrl: row.fullUrl, themeRemoteLightKind: row.kind, - }); + }; + if (row.defaults?.neonGlass) { + const ng = row.defaults.neonGlass; + if (ng.primaryColor) patch.neonGlassPrimaryColor = ng.primaryColor; + if (ng.blurRadius !== undefined) patch.neonGlassBlur = ng.blurRadius; + if (ng.bgOpacity !== undefined) patch.neonGlassBgOpacity = ng.bgOpacity; + if (ng.chatOpacity !== undefined) patch.neonGlassChatOpacity = ng.chatOpacity; + if (ng.glowRadius !== undefined) patch.neonGlassGlow = ng.glowRadius; + if (ng.bubbleGlow !== undefined) patch.neonGlassBubbleGlow = ng.bubbleGlow; + if (ng.applySidebar !== undefined) patch.neonGlassApplySidebar = ng.applySidebar; + if (ng.applyChat !== undefined) patch.neonGlassApplyChat = ng.applyChat; + if (ng.applyModals !== undefined) patch.neonGlassApplyModals = ng.applyModals; + if (ng.applyReply !== undefined) patch.neonGlassApplyReply = ng.applyReply; + patch.neonGlassEnabled = true; + } else { + patch.neonGlassEnabled = false; + } + patchSettings(patch); }, [patchSettings] ); const applyFavoriteToDark = useCallback( (row: LocalPreviewRow) => { - patchSettings({ + const patch: Partial = { themeRemoteDarkFullUrl: row.fullUrl, themeRemoteDarkKind: row.kind, - }); + }; + if (row.defaults?.neonGlass) { + const ng = row.defaults.neonGlass; + if (ng.primaryColor) patch.neonGlassPrimaryColor = ng.primaryColor; + if (ng.blurRadius !== undefined) patch.neonGlassBlur = ng.blurRadius; + if (ng.bgOpacity !== undefined) patch.neonGlassBgOpacity = ng.bgOpacity; + if (ng.chatOpacity !== undefined) patch.neonGlassChatOpacity = ng.chatOpacity; + if (ng.glowRadius !== undefined) patch.neonGlassGlow = ng.glowRadius; + if (ng.bubbleGlow !== undefined) patch.neonGlassBubbleGlow = ng.bubbleGlow; + if (ng.applySidebar !== undefined) patch.neonGlassApplySidebar = ng.applySidebar; + if (ng.applyChat !== undefined) patch.neonGlassApplyChat = ng.applyChat; + if (ng.applyModals !== undefined) patch.neonGlassApplyModals = ng.applyModals; + if (ng.applyReply !== undefined) patch.neonGlassApplyReply = ng.applyReply; + patch.neonGlassEnabled = true; + } else { + patch.neonGlassEnabled = false; + } + patchSettings(patch); }, [patchSettings] ); const applyFavoriteToManual = useCallback( (row: LocalPreviewRow) => { - patchSettings({ + const patch: Partial = { themeRemoteManualFullUrl: row.fullUrl, themeRemoteManualKind: row.kind, - }); + }; + if (row.defaults?.neonGlass) { + const ng = row.defaults.neonGlass; + if (ng.primaryColor) patch.neonGlassPrimaryColor = ng.primaryColor; + if (ng.blurRadius !== undefined) patch.neonGlassBlur = ng.blurRadius; + if (ng.bgOpacity !== undefined) patch.neonGlassBgOpacity = ng.bgOpacity; + if (ng.chatOpacity !== undefined) patch.neonGlassChatOpacity = ng.chatOpacity; + if (ng.glowRadius !== undefined) patch.neonGlassGlow = ng.glowRadius; + if (ng.bubbleGlow !== undefined) patch.neonGlassBubbleGlow = ng.bubbleGlow; + if (ng.applySidebar !== undefined) patch.neonGlassApplySidebar = ng.applySidebar; + if (ng.applyChat !== undefined) patch.neonGlassApplyChat = ng.applyChat; + if (ng.applyModals !== undefined) patch.neonGlassApplyModals = ng.applyModals; + if (ng.applyReply !== undefined) patch.neonGlassApplyReply = ng.applyReply; + patch.neonGlassEnabled = true; + } else { + patch.neonGlassEnabled = false; + } + patchSettings(patch); }, [patchSettings] ); @@ -697,11 +752,28 @@ export function ThemeCatalogSettings({ ]; } - patchSettings({ + const patch: Partial = { themeRemoteLightFullUrl: row.fullInstallUrl, themeRemoteLightKind: kind, themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), - }); + }; + if (row.defaults?.neonGlass) { + const ng = row.defaults.neonGlass; + if (ng.primaryColor) patch.neonGlassPrimaryColor = ng.primaryColor; + if (ng.blurRadius !== undefined) patch.neonGlassBlur = ng.blurRadius; + if (ng.bgOpacity !== undefined) patch.neonGlassBgOpacity = ng.bgOpacity; + if (ng.chatOpacity !== undefined) patch.neonGlassChatOpacity = ng.chatOpacity; + if (ng.glowRadius !== undefined) patch.neonGlassGlow = ng.glowRadius; + if (ng.bubbleGlow !== undefined) patch.neonGlassBubbleGlow = ng.bubbleGlow; + if (ng.applySidebar !== undefined) patch.neonGlassApplySidebar = ng.applySidebar; + if (ng.applyChat !== undefined) patch.neonGlassApplyChat = ng.applyChat; + if (ng.applyModals !== undefined) patch.neonGlassApplyModals = ng.applyModals; + if (ng.applyReply !== undefined) patch.neonGlassApplyReply = ng.applyReply; + patch.neonGlassEnabled = true; + } else { + patch.neonGlassEnabled = false; + } + patchSettings(patch); }, [darkRemoteFullUrl, favorites, manualRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites] ); @@ -732,11 +804,28 @@ export function ThemeCatalogSettings({ ]; } - patchSettings({ + const patch: Partial = { themeRemoteDarkFullUrl: row.fullInstallUrl, themeRemoteDarkKind: kind, themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), - }); + }; + if (row.defaults?.neonGlass) { + const ng = row.defaults.neonGlass; + if (ng.primaryColor) patch.neonGlassPrimaryColor = ng.primaryColor; + if (ng.blurRadius !== undefined) patch.neonGlassBlur = ng.blurRadius; + if (ng.bgOpacity !== undefined) patch.neonGlassBgOpacity = ng.bgOpacity; + if (ng.chatOpacity !== undefined) patch.neonGlassChatOpacity = ng.chatOpacity; + if (ng.glowRadius !== undefined) patch.neonGlassGlow = ng.glowRadius; + if (ng.bubbleGlow !== undefined) patch.neonGlassBubbleGlow = ng.bubbleGlow; + if (ng.applySidebar !== undefined) patch.neonGlassApplySidebar = ng.applySidebar; + if (ng.applyChat !== undefined) patch.neonGlassApplyChat = ng.applyChat; + if (ng.applyModals !== undefined) patch.neonGlassApplyModals = ng.applyModals; + if (ng.applyReply !== undefined) patch.neonGlassApplyReply = ng.applyReply; + patch.neonGlassEnabled = true; + } else { + patch.neonGlassEnabled = false; + } + patchSettings(patch); }, [ favorites, @@ -774,11 +863,28 @@ export function ThemeCatalogSettings({ ]; } - patchSettings({ + const patch: Partial = { themeRemoteManualFullUrl: row.fullInstallUrl, themeRemoteManualKind: kind, themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), - }); + }; + if (row.defaults?.neonGlass) { + const ng = row.defaults.neonGlass; + if (ng.primaryColor) patch.neonGlassPrimaryColor = ng.primaryColor; + if (ng.blurRadius !== undefined) patch.neonGlassBlur = ng.blurRadius; + if (ng.bgOpacity !== undefined) patch.neonGlassBgOpacity = ng.bgOpacity; + if (ng.chatOpacity !== undefined) patch.neonGlassChatOpacity = ng.chatOpacity; + if (ng.glowRadius !== undefined) patch.neonGlassGlow = ng.glowRadius; + if (ng.bubbleGlow !== undefined) patch.neonGlassBubbleGlow = ng.bubbleGlow; + if (ng.applySidebar !== undefined) patch.neonGlassApplySidebar = ng.applySidebar; + if (ng.applyChat !== undefined) patch.neonGlassApplyChat = ng.applyChat; + if (ng.applyModals !== undefined) patch.neonGlassApplyModals = ng.applyModals; + if (ng.applyReply !== undefined) patch.neonGlassApplyReply = ng.applyReply; + patch.neonGlassEnabled = true; + } else { + patch.neonGlassEnabled = false; + } + patchSettings(patch); }, [darkRemoteFullUrl, favorites, lightRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites] ); diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 6e35f35f7..933ade67c 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -68,10 +68,13 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { const [neonGlassPrimaryColor] = useSetting(settingsAtom, 'neonGlassPrimaryColor'); const [neonGlassBlur] = useSetting(settingsAtom, 'neonGlassBlur'); const [neonGlassBgOpacity] = useSetting(settingsAtom, 'neonGlassBgOpacity'); + const [neonGlassChatOpacity] = useSetting(settingsAtom, 'neonGlassChatOpacity'); const [neonGlassGlow] = useSetting(settingsAtom, 'neonGlassGlow'); + const [neonGlassBubbleGlow] = useSetting(settingsAtom, 'neonGlassBubbleGlow'); const [neonGlassApplySidebar] = useSetting(settingsAtom, 'neonGlassApplySidebar'); const [neonGlassApplyChat] = useSetting(settingsAtom, 'neonGlassApplyChat'); const [neonGlassApplyModals] = useSetting(settingsAtom, 'neonGlassApplyModals'); + const [neonGlassApplyReply] = useSetting(settingsAtom, 'neonGlassApplyReply'); useEffect(() => { document.body.className = ''; @@ -105,10 +108,13 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { primaryColor: neonGlassPrimaryColor, blurRadius: neonGlassBlur, bgOpacity: neonGlassBgOpacity, + chatOpacity: neonGlassChatOpacity, glowRadius: neonGlassGlow, + bubbleGlow: neonGlassBubbleGlow, applySidebar: neonGlassApplySidebar, applyChat: neonGlassApplyChat, applyModals: neonGlassApplyModals, + applyReply: neonGlassApplyReply, }); } else { ThemeEngine.resetNeonGlass(); @@ -118,10 +124,13 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { neonGlassPrimaryColor, neonGlassBlur, neonGlassBgOpacity, + neonGlassChatOpacity, neonGlassGlow, + neonGlassBubbleGlow, neonGlassApplySidebar, neonGlassApplyChat, neonGlassApplyModals, + neonGlassApplyReply, ]); useEffect(() => { diff --git a/src/app/services/ThemeEngine.ts b/src/app/services/ThemeEngine.ts index 7f604ea9d..aa4236ab6 100644 --- a/src/app/services/ThemeEngine.ts +++ b/src/app/services/ThemeEngine.ts @@ -8,10 +8,13 @@ export interface NeonGlassPrefs { primaryColor: string; blurRadius: number; bgOpacity: number; + chatOpacity: number; glowRadius: number; + bubbleGlow: number; applySidebar: boolean; applyChat: boolean; applyModals: boolean; + applyReply: boolean; enableTransition?: boolean; } @@ -19,10 +22,13 @@ export const NEON_GLASS_DEFAULTS: NeonGlassPrefs = { primaryColor: '#00f0ff', blurRadius: 14, bgOpacity: 0.42, + chatOpacity: 0.15, glowRadius: 12, + bubbleGlow: 4, applySidebar: true, applyChat: true, applyModals: true, + applyReply: true, enableTransition: true, }; @@ -35,7 +41,9 @@ class ThemeEngineService { const primary = this.sanitizeHexColor(prefs.primaryColor) ?? NEON_GLASS_DEFAULTS.primaryColor; const blur = this.sanitizeNumber(prefs.blurRadius, 0, 32) ?? NEON_GLASS_DEFAULTS.blurRadius; const opacity = this.sanitizeNumber(prefs.bgOpacity, 0.05, 1.0) ?? NEON_GLASS_DEFAULTS.bgOpacity; + const chatOp = this.sanitizeNumber(prefs.chatOpacity, 0.0, 1.0) ?? NEON_GLASS_DEFAULTS.chatOpacity; const glow = this.sanitizeNumber(prefs.glowRadius, 0, 30) ?? NEON_GLASS_DEFAULTS.glowRadius; + const bubbleGlow = this.sanitizeNumber(prefs.bubbleGlow, 0, 20) ?? NEON_GLASS_DEFAULTS.bubbleGlow; const shouldTransition = prefs.enableTransition ?? NEON_GLASS_DEFAULTS.enableTransition; // Enable transition for smooth activation @@ -44,19 +52,30 @@ class ThemeEngineService { } root.style.setProperty('--sable-primary-main', primary); - root.style.setProperty('--sable-primary-on-main', '#ffffff'); - + const rgb = this.hexToRgb(primary); - if (rgb) root.style.setProperty('--sable-primary-main-rgb', rgb); + if (rgb) { + root.style.setProperty('--sable-primary-main-rgb', rgb); + const [r, g, b] = rgb.split(',').map(Number); + // Calculate relative luminance to decide text color (black or white) + const luminance = (0.299 * (r ?? 0) + 0.587 * (g ?? 0) + 0.114 * (b ?? 0)) / 255; + root.style.setProperty('--sable-primary-on-main', luminance > 0.5 ? '#000000' : '#ffffff'); + } root.style.setProperty('--ng-blur', `${blur}px`); root.style.setProperty('--ng-opacity', String(opacity)); - root.style.setProperty('--ng-glow', `0 0 ${glow}px ${primary}`); + root.style.setProperty('--ng-chat-opacity', String(chatOp)); + + // Create a vibrant, multi-layered glow. + const spread = glow > 0 ? glow / 5 : 0; + root.style.setProperty('--ng-glow', `0 0 ${glow}px ${spread}px ${primary}, 0 0 ${glow / 2}px ${primary}`); + root.style.setProperty('--ng-bubble-glow', `0 0 ${bubbleGlow}px ${primary}`); document.body.dataset.neonGlass = 'true'; document.body.dataset.ngSidebar = String(prefs.applySidebar ?? NEON_GLASS_DEFAULTS.applySidebar); document.body.dataset.ngChat = String(prefs.applyChat ?? NEON_GLASS_DEFAULTS.applyChat); document.body.dataset.ngModals = String(prefs.applyModals ?? NEON_GLASS_DEFAULTS.applyModals); + document.body.dataset.ngReply = String(prefs.applyReply ?? NEON_GLASS_DEFAULTS.applyReply); // Clean up transition after completion if (this.transitionTimeout) clearTimeout(this.transitionTimeout); @@ -72,7 +91,7 @@ class ThemeEngineService { resetNeonGlass(): void { try { const root = document.documentElement; - + // Enable smooth transition for reset document.body.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; @@ -81,12 +100,15 @@ class ThemeEngineService { root.style.removeProperty('--sable-primary-main-rgb'); root.style.removeProperty('--ng-blur'); root.style.removeProperty('--ng-opacity'); + root.style.removeProperty('--ng-chat-opacity'); root.style.removeProperty('--ng-glow'); - + root.style.removeProperty('--ng-bubble-glow'); + delete document.body.dataset.neonGlass; delete document.body.dataset.ngSidebar; delete document.body.dataset.ngChat; delete document.body.dataset.ngModals; + delete document.body.dataset.ngReply; // Clean up transition if (this.transitionTimeout) clearTimeout(this.transitionTimeout); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b5a8b368b..45cc799cd 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -127,11 +127,13 @@ export interface Settings { neonGlassEnabled: boolean; neonGlassPrimaryColor?: string; neonGlassBlur?: number; - neonGlassBgOpacity?: number; + neonGlassChatOpacity?: number; neonGlassGlow?: number; + neonGlassBubbleGlow?: number; neonGlassApplySidebar: boolean; neonGlassApplyChat: boolean; neonGlassApplyModals: boolean; + neonGlassApplyReply: boolean; // Sable features! sendPresence: boolean; @@ -260,10 +262,13 @@ export const defaultSettings: Settings = { neonGlassPrimaryColor: '#00f0ff', neonGlassBlur: 14, neonGlassBgOpacity: 0.42, + neonGlassChatOpacity: 0.15, neonGlassGlow: 12, + neonGlassBubbleGlow: 4, neonGlassApplySidebar: true, neonGlassApplyChat: true, neonGlassApplyModals: true, + neonGlassApplyReply: true, // Sable features! sendPresence: true, diff --git a/src/app/styles/NeonGlass.css b/src/app/styles/NeonGlass.css index 9b74372c6..378068214 100644 --- a/src/app/styles/NeonGlass.css +++ b/src/app/styles/NeonGlass.css @@ -1,26 +1,24 @@ /* * Neon Glass Theme — Professional Overlay for Sable - * Fixed: Light Theme "Blackout" in Dialogs & Menus. + * Fully rewritten for maximum compatibility with all themes and shapes. */ :root { - /* Default variables (Dark Theme) */ --ng-glass-color: 15, 15, 25; - --ng-border-color: rgba(255, 255, 255, 0.1); + --ng-border-color: rgba(255, 255, 255, 0.12); --ng-text-contrast: #ffffff; } -/* --- Theme Awareness --- */ - -/* Light Theme Settings */ +/* Light Theme Variables */ body.light-theme[data-neon-glass="true"] { --ng-glass-color: 255, 255, 255; - --ng-border-color: rgba(0, 0, 0, 0.12); + --ng-border-color: rgba(0, 0, 0, 0.1); --ng-text-contrast: #18181b; - background-color: #f0f0f5 !important; + background-color: #f5f5fa !important; + color: #18181b; } -/* Dark Theme Settings */ +/* Dark Theme Variables */ body.dark-theme[data-neon-glass="true"] { --ng-glass-color: 10, 10, 18; --ng-border-color: rgba(255, 255, 255, 0.08); @@ -28,117 +26,157 @@ body.dark-theme[data-neon-glass="true"] { background-color: #0a0a12 !important; } -/* Combined Glass Variable */ +/* Global Glass Background */ body[data-neon-glass="true"] { --ng-glass-bg: rgba(var(--ng-glass-color), var(--ng-opacity)); - + --ng-chat-glass-bg: rgba(var(--ng-glass-color), var(--ng-chat-opacity)); background-image: - radial-gradient(circle at 0% 0%, rgba(var(--sable-primary-main-rgb), 0.12) 0%, transparent 45%), - radial-gradient(circle at 100% 100%, rgba(var(--sable-primary-main-rgb), 0.08) 0%, transparent 45%) !important; + radial-gradient(circle at 10% 10%, rgba(var(--sable-primary-main-rgb), calc(var(--ng-chat-opacity) * 1.5)) 0%, transparent 45%), + radial-gradient(circle at 90% 90%, rgba(var(--sable-primary-main-rgb), calc(var(--ng-chat-opacity) * 1.0)) 0%, transparent 45%) !important; background-attachment: fixed !important; } -/* --- Global Transparency Fix --- - * This ensures Sable's internal boxes don't block the glass effect. - */ -body[data-neon-glass="true"] [class*="ContainerColor_variant_Background"], -body[data-neon-glass="true"] [class*="ContainerColor_variant_Surface"], -body[data-neon-glass="true"] [class*="ContainerColor_variant_SurfaceVariant"], +/* Base Transparency to allow Glass to show through */ body[data-neon-glass="true"] [class*="Sidebar_Sidebar"], body[data-neon-glass="true"] [class*="RoomView_RoomView"], -body[data-neon-glass="true"] [class*="Page_Page"] { +body[data-neon-glass="true"] [class*="Page_Page"], +body[data-neon-glass="true"] [class*="SidebarNav_SidebarNav"], +body[data-neon-glass="true"] aside, +body[data-neon-glass="true"] main { background-color: transparent !important; box-shadow: none !important; } -/* --- Sidebar Glass --- */ +/* Give structural containers a very subtle glass tint so they look organized, not empty */ +body[data-neon-glass="true"] [class*="ContainerColor_variant_Background"], +body[data-neon-glass="true"] [class*="ContainerColor_variant_Surface"], +body[data-neon-glass="true"] [class*="ContainerColor_variant_SurfaceVariant"], +body[data-neon-glass="true"] [data-sequence-card="true"] { + background-color: rgba(var(--ng-glass-color), 0.1) !important; + box-shadow: none !important; + border: 1px solid var(--ng-border-color) !important; + backdrop-filter: blur(calc(var(--ng-blur) * 0.4)) !important; +} + +/* Beautiful, subtle hover/active states for buttons and interactive items */ +body[data-neon-glass="true"] button[class*="ContainerColor_variant_Surface"]:hover, +body[data-neon-glass="true"] button[class*="ContainerColor_variant_SurfaceVariant"]:hover, +body[data-neon-glass="true"] [role="button"][class*="ContainerColor_variant_Surface"]:hover, +body[data-neon-glass="true"] [class*="ContainerColor_variant_Surface"][aria-pressed="true"] { + background-color: rgba(var(--sable-primary-main-rgb), 0.2) !important; + border-color: rgba(var(--sable-primary-main-rgb), 0.3) !important; +} + +/* --- 1. Sidebar Glass --- */ body[data-ng-sidebar="true"] [class*="Sidebar_Sidebar"], body[data-ng-sidebar="true"] [class*="SidebarNav_SidebarNav"], body[data-ng-sidebar="true"] aside { backdrop-filter: blur(var(--ng-blur)) saturate(160%) !important; background-color: var(--ng-glass-bg) !important; border-right: 1px solid var(--ng-border-color) !important; - overflow: hidden; } -/* Aesthetic Polish: Fully rounded sidebar avatars (Profile, Search, etc.) */ -body[data-ng-sidebar="true"] [class*="SidebarAvatar_SidebarAvatar"], -body[data-ng-sidebar="true"] [class*="SidebarFolder_SidebarFolder"] { - border-radius: 50% !important; - overflow: hidden !important; -} -body[data-ng-sidebar="true"] [class*="SidebarAvatar_SidebarAvatar"] *, -body[data-ng-sidebar="true"] [class*="SidebarFolder_SidebarFolder"] * { - border-radius: 50% !important; -} - -/* --- Chat & Main Content Area --- */ +/* --- 2. Chat Glass --- */ body[data-ng-chat="true"] [class*="RoomView_RoomView"], body[data-ng-chat="true"] [class*="Page_Page"], body[data-ng-chat="true"] main { backdrop-filter: blur(var(--ng-blur)) saturate(160%) !important; - background-color: var(--ng-glass-bg) !important; + background-color: var(--ng-chat-glass-bg) !important; } -/* Aesthetic Polish: Rounded Glassy Chat Bubbles */ +/* Chat Bubbles */ body[data-ng-chat="true"] [class*="layout_BubbleContent"] { backdrop-filter: blur(calc(var(--ng-blur) * 0.8)) saturate(160%) !important; - background-color: var(--ng-glass-bg) !important; + background-color: var(--ng-chat-glass-bg) !important; border: 1px solid var(--ng-border-color) !important; - border-radius: 18px !important; /* Generous roundness */ -} -/* Preserve bubble tails if present */ -body[data-ng-chat="true"] [class*="layout_BubbleContentArrowLeft"] { - border-top-left-radius: 4px !important; -} -body[data-ng-chat="true"] [class*="layout_BubbleContentArrowRight"] { - border-top-right-radius: 4px !important; + box-shadow: var(--ng-bubble-glow) !important; } body[data-ng-chat="true"] [class*="layout_BubbleLeftArrow"] path, body[data-ng-chat="true"] [class*="layout_BubbleRightArrow"] path { - fill: var(--ng-border-color) !important; /* Hide solid tail color, make it match border */ + fill: var(--ng-border-color) !important; + filter: drop-shadow(var(--ng-bubble-glow)) !important; } -/* --- Modals, Menus, Dialogs & Popouts --- - * We target the Portal-rendered elements specifically. - */ +/* Bottom Bar (Read Receipts) */ +body[data-ng-chat="true"] [class*="RoomViewFollowing_RoomViewFollowing"] { + backdrop-filter: blur(var(--ng-blur)) saturate(160%) !important; + background-color: var(--ng-chat-glass-bg) !important; + border-top: 1px solid var(--ng-border-color) !important; + margin-top: 2px; +} + +/* Replied / Targeted Messages */ +body[data-ng-reply="true"] [class*="messageJumpHighlight"], +body[data-ng-reply="true"] [class*="SelectedVariant_true"] { + box-shadow: inset 0 0 16px var(--sable-primary-main) !important; + background-color: rgba(var(--sable-primary-main-rgb), 0.1) !important; + border-radius: inherit; +} + +/* --- 3. Modals Glass --- */ +/* Target ONLY specific floating surfaces to prevent ugly double-glass and protruding borders on wrappers */ body[data-ng-modals="true"] [class*="Modal_Modal"], body[data-ng-modals="true"] [class*="Dialog_Dialog"], -body[data-ng-modals="true"] [class*="PopOut_PopOut"], -body[data-ng-modals="true"] [class*="Menu_Menu"], -body[data-ng-modals="true"] [class*="Overlay"], -body[data-ng-modals="true"] [role="dialog"], -body[data-ng-modals="true"] [role="menu"] { +body[data-ng-modals="true"] [class*="Menu_Menu"] { backdrop-filter: blur(var(--ng-blur)) saturate(180%) !important; background-color: var(--ng-glass-bg) !important; border: 1px solid var(--ng-border-color) !important; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35) !important; - /* Preserve original border radius but ensure blur doesn't leak */ - overflow: hidden !important; + border-radius: 12px !important; + overflow: hidden; +} + +/* Fix: Redesign 'Create Space' and 'Join with Address' menu items (SequenceCard inside Menu) */ +body[data-neon-glass="true"] [class*="Menu_Menu"] [data-sequence-card="true"] { + background-color: transparent !important; + border: none !important; + border-radius: 10px !important; + margin: 6px 12px !important; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important; } -/* Glow effects for active elements */ -body[data-neon-glass="true"] [aria-selected="true"], -body[data-neon-glass="true"] [data-active="true"], -body[data-neon-glass="true"] [class*="active_true"], -body[data-neon-glass="true"] [class*="variant_Primary"] { +/* Special Neon Hover for Menu Items */ +body[data-neon-glass="true"] [class*="Menu_Menu"] [data-sequence-card="true"]:hover { + background-color: rgba(var(--sable-primary-main-rgb), 0.12) !important; + border-color: rgba(var(--sable-primary-main-rgb), 0.2) !important; + box-shadow: 0 0 15px rgba(var(--sable-primary-main-rgb), 0.3) !important; + transform: scale(1.02) !important; +} + +/* Highlight the icons in the menu so they look 'Neon' */ +body[data-neon-glass="true"] [class*="Menu_Menu"] [class*="Icon"] { + color: var(--sable-primary-main) !important; + filter: drop-shadow(0 0 4px var(--sable-primary-main)); +} + +/* --- 4. Neon Glows --- */ +/* + We use box-shadow so the glow can have a strong spread and multi-layered intensity. +*/ +body[data-neon-glass="true"] [class*="SidebarItem_active_true"] [class*="Avatar"], +body[data-neon-glass="true"] [class*="SidebarItem_active_true"] [class*="SidebarAvatar"], +body[data-neon-glass="true"] [class*="SidebarItem_active_true"] [class*="SidebarFolder"], +body[data-neon-glass="true"] [class*="SpaceNavItem"] [aria-selected="true"] [class*="Avatar"], +body[data-neon-glass="true"] [class*="SidebarAvatar_outlined_true"][class*="variant_Surface"], /* '+' button when open */ +body[data-neon-glass="true"] [class*="variant_Primary"]:not([role="menuitem"]):not(body) { box-shadow: var(--ng-glow) !important; } -/* Fix for Light Theme Text Contrast */ -body.light-theme[data-neon-glass="true"] { - color: #18181b; +/* Fix overlapping elements clipping the drop-shadow */ +body[data-neon-glass="true"] [class*="SidebarItem_active_true"], +body[data-neon-glass="true"] [class*="SidebarItem"]:hover, +body[data-neon-glass="true"] [class*="SpaceNavItem"]:hover, +body[data-neon-glass="true"] [class*="SpaceNavItem"] [aria-selected="true"] { + z-index: 2 !important; + position: relative !important; +} + +/* Hide the standard Sidebar active indicator line since we use neon glows to show active state */ +body[data-neon-glass="true"] [class*="SidebarItem_active_true"]::before { + display: none !important; } /* Scrollbar Integration */ body[data-neon-glass="true"] ::-webkit-scrollbar-thumb { background: rgba(var(--sable-primary-main-rgb), 0.2) !important; - border-radius: 10px !important; -} - -/* Focused inputs ring */ -body[data-neon-glass="true"] input:focus, -body[data-neon-glass="true"] textarea:focus { - border-color: var(--sable-primary-main) !important; - box-shadow: 0 0 0 2px rgba(var(--sable-primary-main-rgb), 0.2) !important; } diff --git a/src/app/theme/metadata.test.ts b/src/app/theme/metadata.test.ts index c0c68b0b3..91ba82fb4 100644 --- a/src/app/theme/metadata.test.ts +++ b/src/app/theme/metadata.test.ts @@ -27,6 +27,42 @@ kind: light expect(meta.kind).toBe(ThemeKind.Light); }); + it('reads neon glass defaults with ng_ prefix', () => { + const css = `/* +@sable-theme +id: neon +ng_color: #ff00ff +ng_blur: 20 +ng_opacity: 0.5 +ng_chat_opacity: 0.2 +ng_glow: 15 +*/ +`; + const meta = parseSableThemeMetadata(css); + expect(meta.defaults?.neonGlass?.primaryColor).toBe('#ff00ff'); + expect(meta.defaults?.neonGlass?.blurRadius).toBe(20); + expect(meta.defaults?.neonGlass?.bgOpacity).toBe(0.5); + expect(meta.defaults?.neonGlass?.chatOpacity).toBe(0.2); + expect(meta.defaults?.neonGlass?.glowRadius).toBe(15); + }); + + it('reads neon glass boolean defaults', () => { + const css = `/* +@sable-theme +id: neon-flags +ng_sidebar: true +ng_chat: false +ng_modals: true +ng_chat_opacity: 0.2 +*/ +`; + const meta = parseSableThemeMetadata(css); + expect(meta.defaults?.neonGlass?.applySidebar).toBe(true); + expect(meta.defaults?.neonGlass?.applyChat).toBe(false); + expect(meta.defaults?.neonGlass?.applyModals).toBe(true); + expect(meta.defaults?.neonGlass?.chatOpacity).toBe(0.2); + }); + it('returns empty when only a non-metadata comment exists', () => { const css = `/* just a license */`; expect(parseSableThemeMetadata(css)).toEqual({}); diff --git a/src/app/theme/metadata.ts b/src/app/theme/metadata.ts index 444cba3a9..b4a170e6a 100644 --- a/src/app/theme/metadata.ts +++ b/src/app/theme/metadata.ts @@ -10,6 +10,20 @@ export type SableThemeMetadata = { contrast: SableThemeContrast; tags: string[]; fullThemeUrl?: string; + defaults?: { + neonGlass?: { + primaryColor?: string; + blurRadius?: number; + bgOpacity?: number; + chatOpacity?: number; + glowRadius?: number; + bubbleGlow?: number; + applySidebar?: boolean; + applyChat?: boolean; + applyModals?: boolean; + applyReply?: boolean; + }; + }; }; export type SableTweakMetadata = { @@ -97,6 +111,57 @@ export function parseSableThemeMetadata(cssText: string): Partial