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