diff --git a/lib/__tests__/builtin-themes.test.ts b/lib/__tests__/builtin-themes.test.ts index 937e5c1e..04c616a8 100644 --- a/lib/__tests__/builtin-themes.test.ts +++ b/lib/__tests__/builtin-themes.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { BUILTIN_THEMES } from '../builtin-themes'; describe('BUILTIN_THEMES', () => { - it('contains exactly 5 themes', () => { - expect(BUILTIN_THEMES).toHaveLength(5); + it('contains exactly 6 themes', () => { + expect(BUILTIN_THEMES).toHaveLength(6); }); it('all themes have required fields', () => { @@ -44,6 +44,7 @@ describe('BUILTIN_THEMES', () => { expect(names).toContain('Catppuccin'); expect(names).toContain('Solarized'); expect(names).toContain('Roundcube Elastic'); + expect(names).toContain('Aurora Glass'); }); it('theme IDs are unique', () => { diff --git a/lib/builtin-themes.ts b/lib/builtin-themes.ts index 16093461..0fc940fd 100644 --- a/lib/builtin-themes.ts +++ b/lib/builtin-themes.ts @@ -635,6 +635,245 @@ body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r .border border-left-width: 3px !important; }`; +// "Aurora Glass" - an ultra-modern glassmorphism skin. Vibrant indigo→cyan +// accents float over a fixed gradient-mesh canvas; every structural surface +// (nav rail, folder sidebar, cards, popovers, menus, dialogs, toolbars) is a +// translucent frosted pane with heavy backdrop-blur + saturation, hairline +// luminous borders and soft glow on the primary action. +// +// The colour tokens stay near-solid for legible text contrast; the frosted +// translucency + blur all live in the skin, where a single selector can pair +// an rgba background with the matching backdrop-filter. +const auroraCSS = ` +:root { + --color-border: rgba(13, 18, 45, 0.10); + --color-input: rgba(13, 18, 45, 0.12); + --color-ring: #6d5cff; + --color-background: #eef1fb; + --color-foreground: #14162e; + --color-primary: #6d5cff; + --color-primary-foreground: #ffffff; + --color-secondary: #e3e7f7; + --color-secondary-foreground: #14162e; + --color-muted: #e7eaf6; + --color-muted-foreground: #5b6080; + --color-accent: #e0e7ff; + --color-accent-foreground: #4338ca; + --color-destructive: #f43f5e; + --color-destructive-foreground: #ffffff; + --color-popover: #ffffff; + --color-popover-foreground: #14162e; + --color-sidebar: #eef1fb; + --color-sidebar-foreground: #14162e; + --color-sidebar-border: rgba(13, 18, 45, 0.08); + --color-sidebar-accent: rgba(109, 92, 255, 0.10); + --color-sidebar-accent-foreground: #4338ca; + --color-card: #ffffff; + --color-card-foreground: #14162e; + --color-success: #10b981; + --color-success-foreground: #ffffff; + --color-warning: #f59e0b; + --color-warning-foreground: #14162e; + --color-info: #06b6d4; + --color-info-foreground: #ffffff; + --color-selection: rgba(109, 92, 255, 0.14); + --color-selection-foreground: #4338ca; + --color-unread: #6d5cff; + --color-chart-1: #6d5cff; + --color-chart-2: #06b6d4; + --color-chart-3: #ec4899; + --color-chart-4: #f59e0b; + --color-chart-5: #10b981; +} +.dark { + --color-border: rgba(255, 255, 255, 0.08); + --color-input: rgba(255, 255, 255, 0.10); + --color-ring: #8b7bff; + --color-background: #06070f; + --color-foreground: #eef0ff; + --color-primary: #8b7bff; + --color-primary-foreground: #ffffff; + --color-secondary: rgba(255, 255, 255, 0.06); + --color-secondary-foreground: #eef0ff; + --color-muted: rgba(255, 255, 255, 0.05); + --color-muted-foreground: #9aa0c4; + --color-accent: rgba(139, 123, 255, 0.16); + --color-accent-foreground: #c4bbff; + --color-destructive: #fb7185; + --color-destructive-foreground: #1a0b10; + --color-popover: #12132a; + --color-popover-foreground: #eef0ff; + --color-sidebar: #08091a; + --color-sidebar-foreground: #eef0ff; + --color-sidebar-border: rgba(255, 255, 255, 0.07); + --color-sidebar-accent: rgba(139, 123, 255, 0.18); + --color-sidebar-accent-foreground: #c4bbff; + --color-card: rgba(255, 255, 255, 0.04); + --color-card-foreground: #eef0ff; + --color-success: #34d399; + --color-success-foreground: #052e1f; + --color-warning: #fbbf24; + --color-warning-foreground: #1a1405; + --color-info: #22d3ee; + --color-info-foreground: #04222a; + --color-selection: rgba(139, 123, 255, 0.22); + --color-selection-foreground: #c4bbff; + --color-unread: #8b7bff; + --color-chart-1: #8b7bff; + --color-chart-2: #22d3ee; + --color-chart-3: #f472b6; + --color-chart-4: #fbbf24; + --color-chart-5: #34d399; +}`; + +// Glassmorphism skin: the frosted translucency, backdrop-blur, gradient-mesh +// canvas, luminous borders and glow that the colour tokens alone can't carry. +// Scoped under the skin body attribute so it detaches cleanly on switch-off. +const auroraSkin = ` +body[data-theme-skin="builtin-aurora-glass"] { + font-family: "Inter", "SF Pro Display", "Segoe UI", system-ui, -apple-system, sans-serif; + letter-spacing: -0.01em; +} + +/* ── Gradient-mesh canvas ──────────────────────────────────────── */ +/* A multi-stop radial mesh painted onto the app-shell root (the unique + .bg-background.overflow-hidden container) so the frosted panes have + something luminous to blur. Painting it directly on the shell - rather than + a fixed body::before with z-indexed children - avoids creating stacking + contexts on portaled popups/dialogs (Radix portals render as direct + children), which would otherwise break their layering. */ +body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden { + background-color: var(--color-background); + background-image: + radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.22), transparent 60%), + radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.18), transparent 55%), + radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.16), transparent 60%), + radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.12), transparent 55%); +} +.dark body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden { + background-image: + radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.16), transparent 58%), + radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.11), transparent 52%), + radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.10), transparent 58%), + radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.07), transparent 52%); +} + +/* ── Pill-soft radii everywhere ───────────────────────────────── */ +body[data-theme-skin="builtin-aurora-glass"] .rounded-md, +body[data-theme-skin="builtin-aurora-glass"] .rounded-lg { + border-radius: 0.85rem !important; +} +body[data-theme-skin="builtin-aurora-glass"] .rounded-xl, +body[data-theme-skin="builtin-aurora-glass"] .rounded-2xl { + border-radius: 1.25rem !important; +} + +/* ── Frosted panes: the shared glass recipe ───────────────────── */ +/* Nav rail, folder sidebar, content backing, cards, popovers, dialogs and + menus all share one frosted treatment - translucent fill + heavy backdrop + blur + saturation so the mesh and neighbours bleed through softly. */ +body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary, +body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r { + background-color: rgba(255, 255, 255, 0.55) !important; + backdrop-filter: blur(26px) saturate(180%); + -webkit-backdrop-filter: blur(26px) saturate(180%); + border-color: rgba(13, 18, 45, 0.08) !important; +} +.dark body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary, +.dark body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r { + background-color: rgba(8, 9, 22, 0.82) !important; + border-color: rgba(255, 255, 255, 0.07) !important; +} + +/* The "no conversation selected" empty pane sits inside an opaque + bg-background column, so it can't inherit the shell mesh - paint the same + gradient mesh directly onto it so the placeholder reads as glass too. */ +body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 { + background-color: var(--color-background) !important; + background-image: + radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.22), transparent 60%), + radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.18), transparent 55%), + radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.16), transparent 60%) !important; +} +.dark body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 { + background-image: + radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.18), transparent 58%), + radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.12), transparent 52%), + radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.11), transparent 58%) !important; +} +/* The reading-pane message backing goes clear glass so the frosted panes and + gradient bleed through softly. */ +body[data-theme-skin="builtin-aurora-glass"] .bg-muted\\/30 { + background: transparent !important; +} + +/* Cards, popovers and dialogs: brighter frosted glass with a luminous edge. */ +body[data-theme-skin="builtin-aurora-glass"] .bg-card, +body[data-theme-skin="builtin-aurora-glass"] .bg-popover, +body[data-theme-skin="builtin-aurora-glass"] [role="menu"], +body[data-theme-skin="builtin-aurora-glass"] [role="dialog"], +body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] { + background-color: rgba(255, 255, 255, 0.72) !important; + backdrop-filter: blur(28px) saturate(190%); + -webkit-backdrop-filter: blur(28px) saturate(190%); + border: 1px solid rgba(255, 255, 255, 0.6) !important; + box-shadow: 0 12px 40px -12px rgba(20, 22, 46, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.7) !important; +} +.dark body[data-theme-skin="builtin-aurora-glass"] .bg-card, +.dark body[data-theme-skin="builtin-aurora-glass"] .bg-popover, +.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"], +.dark body[data-theme-skin="builtin-aurora-glass"] [role="dialog"], +.dark body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] { + background-color: rgba(16, 17, 38, 0.86) !important; + border: 1px solid rgba(255, 255, 255, 0.10) !important; + box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important; +} + +/* ── Primary action: gradient fill + glow ─────────────────────── */ +body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground { + background-image: linear-gradient(135deg, #7c5cff 0%, #6d5cff 45%, #4f9bff 100%) !important; + border: none !important; + color: #ffffff !important; + box-shadow: 0 6px 20px -4px rgba(109, 92, 255, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.35) !important; + transition: box-shadow 160ms ease, filter 160ms ease; +} +body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground:hover { + filter: brightness(1.08); + box-shadow: 0 10px 30px -4px rgba(109, 92, 255, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.4) !important; +} +.dark body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground { + background-image: linear-gradient(135deg, #8b7bff 0%, #6d5cff 50%, #22d3ee 130%) !important; + box-shadow: 0 6px 24px -4px rgba(139, 123, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2) !important; +} + +/* ── Selected folder / row: soft tinted glass + accent bar ────── */ +body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r .border-l-2.border-primary { + border-left-width: 3px !important; +} + +/* ── On/off switch: gradient when active ──────────────────────── */ +body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] { + background-image: linear-gradient(135deg, #7c5cff, #4f9bff) !important; + border-color: transparent !important; + box-shadow: 0 2px 10px -1px rgba(109, 92, 255, 0.6) !important; +} +.dark body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] { + background-image: linear-gradient(135deg, #8b7bff, #22d3ee) !important; +} + +/* ── Menu hover: tinted accent slab (keeps glass legible) ─────── */ +body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover, +body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus, +body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible { + background-color: rgba(109, 92, 255, 0.14) !important; + color: var(--color-foreground) !important; +} +.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover, +.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus, +.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible { + background-color: rgba(139, 123, 255, 0.22) !important; +}`; + export const BUILTIN_THEMES: InstalledTheme[] = [ { id: 'builtin-qui', @@ -693,4 +932,17 @@ export const BUILTIN_THEMES: InstalledTheme[] = [ enabled: true, builtIn: true, }, + { + id: 'builtin-aurora-glass', + name: 'Aurora Glass', + version: '1.0.0', + author: 'Built-in', + description: 'Ultra-modern glassmorphism - frosted blurred panes floating over a vibrant indigo→cyan gradient mesh, with glowing gradient actions', + css: auroraCSS, + skin: auroraSkin, + variants: ['light', 'dark'], + typography: { fontSans: '"Inter", "SF Pro Display", "Segoe UI", system-ui, sans-serif' }, + enabled: true, + builtIn: true, + }, ]; diff --git a/stores/theme-store.ts b/stores/theme-store.ts index a737dc89..1313ca60 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -451,11 +451,25 @@ export const useThemeStore = create()( onRehydrateStorage: () => { return (state) => { if (state) { - // Ensure built-in themes are always present after rehydration + // Ensure built-in themes are always present after rehydration. + // Drop persisted themes that were flagged builtIn but no longer + // exist in BUILTIN_THEMES (e.g. a built-in removed from source) so + // stale built-ins don't survive as phantom "user" themes. const builtInIds = new Set(BUILTIN_THEMES.map(t => t.id)); - const userThemes = state.installedThemes.filter(t => !builtInIds.has(t.id)); + const userThemes = state.installedThemes.filter( + t => !builtInIds.has(t.id) && !t.builtIn + ); state.installedThemes = [...BUILTIN_THEMES, ...userThemes]; + // If the active theme pointed at a now-removed built-in, fall back + // to the default so the UI doesn't reference a missing theme. + if ( + state.activeThemeId && + !state.installedThemes.some(t => t.id === state.activeThemeId) + ) { + state.activeThemeId = null; + } + // Re-apply theme immediately after rehydration const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme; applyTheme(resolvedTheme);