diff --git a/.gitignore b/.gitignore index 76af75542..991d3fe26 100644 Binary files a/.gitignore and b/.gitignore differ 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..8c33f4580 --- /dev/null +++ b/src/app/features/settings/cosmetics/NeonGlassBuilder.tsx @@ -0,0 +1,608 @@ +import { useEffect, useCallback, useState } from 'react'; +import { Box, Switch, Text, Line, config, Button, ProgressBar, Badge, toRem, Input, IconButton, Icon, Icons } from 'folds'; +import { HexColorPicker } from 'react-colorful'; +import { HexColorPickerPopOut } from '$components/HexColorPickerPopOut'; +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. + */ +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 [chatOpacity, setChatOpacity] = useSetting(settingsAtom, 'neonGlassChatOpacity'); + const [glowRadius, setGlowRadius] = useSetting(settingsAtom, 'neonGlassGlow'); + const [bubbleGlow, setBubbleGlow] = useSetting(settingsAtom, 'neonGlassBubbleGlow'); + + const [applySidebar, setApplySidebar] = useSetting(settingsAtom, 'neonGlassApplySidebar'); + const [applyChat, setApplyChat] = useSetting(settingsAtom, 'neonGlassApplyChat'); + const [applyModals, setApplyModals] = useSetting(settingsAtom, 'neonGlassApplyModals'); + const [applyReply, setApplyReply] = useSetting(settingsAtom, 'neonGlassApplyReply'); + + // Local slider state for immediate UI feedback; debounced before hitting ThemeEngine + const [localColor, setLocalColor] = useState(primaryColor ?? NEON_GLASS_DEFAULTS.primaryColor); + const [localBlur, setLocalBlur] = useState(blurRadius ?? NEON_GLASS_DEFAULTS.blurRadius); + const [localOpacity, setLocalOpacity] = useState(bgOpacity ?? NEON_GLASS_DEFAULTS.bgOpacity); + const [localChatOpacity, setLocalChatOpacity] = useState(chatOpacity ?? NEON_GLASS_DEFAULTS.chatOpacity); + const [localGlow, setLocalGlow] = useState(glowRadius ?? NEON_GLASS_DEFAULTS.glowRadius); + const [localBubbleGlow, setLocalBubbleGlow] = useState(bubbleGlow ?? NEON_GLASS_DEFAULTS.bubbleGlow); + + // Debounced saves + const debounceSaveColor = useDebounce( + useCallback((v: string) => setPrimaryColor(v), [setPrimaryColor]), + { wait: 50 } + ); + const debounceSaveBlur = useDebounce( + useCallback((v: number) => setBlurRadius(v), [setBlurRadius]), + { wait: 50 } + ); + const debounceSaveOpacity = useDebounce( + useCallback((v: number) => setBgOpacity(v), [setBgOpacity]), + { wait: 50 } + ); + 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 } + ); + + const isSlow = localBlur > 20 || localGlow > 20; + + useEffect(() => { + if (!enabled) { + ThemeEngine.resetNeonGlass(); + return; + } + ThemeEngine.applyNeonGlass({ + primaryColor: localColor, + blurRadius: localBlur, + bgOpacity: localOpacity, + chatOpacity: localChatOpacity, + glowRadius: localGlow, + bubbleGlow: localBubbleGlow, + applySidebar, + applyChat, + applyModals, + applyReply, + enableTransition: true, + }); + }, [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 handlePresetColor = useCallback( + (color: string) => { + setLocalColor(color); + debounceSaveColor(color); + }, + [debounceSaveColor] + ); + + 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); + 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); + setBubbleGlow(NEON_GLASS_DEFAULTS.bubbleGlow); + setApplyReply(NEON_GLASS_DEFAULTS.applyReply); + }, [setPrimaryColor, setBlurRadius, setBgOpacity, setChatOpacity, setGlowRadius, setBubbleGlow, setApplyReply]); + + const handleExportTheme = useCallback(() => { + 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 = `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, 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 Main Section */} + + Neon Glass + + {/* Live Preview Section */} + + + Live Preview + + + {/* Mini Sidebar */} + + + + + + + {/* Mini Chat */} + + + + + + + + {/* Mini Modals Preview */} + + + + Modal + + + + + {/* Enable / disable toggle */} + + setEnabled(v)} />} + /> + + + + {enabled && ( + <> + {/* Colors Section */} + + Neon Colors + + + } + > + {(onOpen, opened) => ( + + )} + + handleColorUpdate(e.currentTarget.value)} + placeholder="#FFFFFF" + variant="Background" + size="300" + radii="300" + style={{ + textTransform: 'uppercase', + fontFamily: 'monospace', + width: '100px', + }} + /> + 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`} + /> + ))} + + } + /> + + + + {/* 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 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 9dae91949..933ade67c 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,18 @@ 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 [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 = ''; document.body.classList.add(configClass, varsClass); @@ -89,6 +102,37 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } }, [activeTheme, saturation, underlineLinks, reducedMotion]); + useEffect(() => { + if (neonGlassEnabled) { + ThemeEngine.applyNeonGlass({ + primaryColor: neonGlassPrimaryColor, + blurRadius: neonGlassBlur, + bgOpacity: neonGlassBgOpacity, + chatOpacity: neonGlassChatOpacity, + glowRadius: neonGlassGlow, + bubbleGlow: neonGlassBubbleGlow, + applySidebar: neonGlassApplySidebar, + applyChat: neonGlassApplyChat, + applyModals: neonGlassApplyModals, + applyReply: neonGlassApplyReply, + }); + } else { + ThemeEngine.resetNeonGlass(); + } + }, [ + neonGlassEnabled, + neonGlassPrimaryColor, + neonGlassBlur, + neonGlassBgOpacity, + neonGlassChatOpacity, + neonGlassGlow, + neonGlassBubbleGlow, + neonGlassApplySidebar, + neonGlassApplyChat, + neonGlassApplyModals, + neonGlassApplyReply, + ]); + 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..aa4236ab6 --- /dev/null +++ b/src/app/services/ThemeEngine.ts @@ -0,0 +1,144 @@ +/** + * 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; + chatOpacity: number; + glowRadius: number; + bubbleGlow: number; + applySidebar: boolean; + applyChat: boolean; + applyModals: boolean; + applyReply: boolean; + enableTransition?: boolean; +} + +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, +}; + +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 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 + if (shouldTransition) { + document.body.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + } + + root.style.setProperty('--sable-primary-main', primary); + + const rgb = this.hexToRgb(primary); + 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-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); + 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-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); + 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..45cc799cd 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -123,6 +123,18 @@ export interface Settings { captionPosition: CaptionPosition; customDMCards: boolean; + // Neon Glass Builder + neonGlassEnabled: boolean; + neonGlassPrimaryColor?: string; + neonGlassBlur?: number; + neonGlassChatOpacity?: number; + neonGlassGlow?: number; + neonGlassBubbleGlow?: number; + neonGlassApplySidebar: boolean; + neonGlassApplyChat: boolean; + neonGlassApplyModals: boolean; + neonGlassApplyReply: boolean; + // Sable features! sendPresence: boolean; mobileGestures: boolean; @@ -245,6 +257,19 @@ export const defaultSettings: Settings = { captionPosition: CaptionPosition.Below, customDMCards: true, + // Neon Glass Builder - Optimized Defaults + neonGlassEnabled: false, + 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, mobileGestures: true, diff --git a/src/app/styles/NeonGlass.css b/src/app/styles/NeonGlass.css new file mode 100644 index 000000000..378068214 --- /dev/null +++ b/src/app/styles/NeonGlass.css @@ -0,0 +1,182 @@ +/* + * Neon Glass Theme — Professional Overlay for Sable + * Fully rewritten for maximum compatibility with all themes and shapes. + */ + +:root { + --ng-glass-color: 15, 15, 25; + --ng-border-color: rgba(255, 255, 255, 0.12); + --ng-text-contrast: #ffffff; +} + +/* Light Theme Variables */ +body.light-theme[data-neon-glass="true"] { + --ng-glass-color: 255, 255, 255; + --ng-border-color: rgba(0, 0, 0, 0.1); + --ng-text-contrast: #18181b; + background-color: #f5f5fa !important; + color: #18181b; +} + +/* 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); + --ng-text-contrast: #ffffff; + background-color: #0a0a12 !important; +} + +/* 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 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; +} + +/* 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*="SidebarNav_SidebarNav"], +body[data-neon-glass="true"] aside, +body[data-neon-glass="true"] main { + background-color: transparent !important; + box-shadow: none !important; +} + +/* 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; +} + +/* --- 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-chat-glass-bg) !important; +} + +/* 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-chat-glass-bg) !important; + border: 1px solid var(--ng-border-color) !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; + filter: drop-shadow(var(--ng-bubble-glow)) !important; +} + +/* 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*="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; + 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; +} + +/* 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 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; +} 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