aura dark theme for Goose (#10283)

Co-authored-by: kursad <kursadk@neoplecxus.com>
This commit is contained in:
kursad-k
2026-08-19 16:38:45 +00:00
committed by GitHub
parent 485b932e2f
commit 481cc0ca27
21 changed files with 216 additions and 48 deletions
@@ -1,5 +1,5 @@
import React from 'react';
import { Moon, Sliders, Sun } from 'lucide-react';
import { Moon, Sliders, Sparkles, Sun } from 'lucide-react';
import { Button } from '../ui/button';
import { useTheme } from '../../contexts/ThemeContext';
import { defineMessages, useIntl } from '../../i18n';
@@ -17,6 +17,10 @@ const i18n = defineMessages({
id: 'themeSelector.dark',
defaultMessage: 'Dark',
},
aura: {
id: 'themeSelector.aura',
defaultMessage: 'Aura',
},
system: {
id: 'themeSelector.system',
defaultMessage: 'System',
@@ -41,7 +45,7 @@ const ThemeSelector: React.FC<ThemeSelectorProps> = ({
<div className={`${!horizontal ? 'px-1 py-2 space-y-2' : ''} ${className}`}>
{!hideTitle && <div className="text-xs text-text-primary px-3">{intl.formatMessage(i18n.theme)}</div>}
<div
className={`${horizontal ? 'flex' : 'grid grid-cols-3'} gap-1 ${!horizontal ? 'px-3' : ''}`}
className={`${horizontal ? 'flex' : 'grid grid-cols-4'} gap-1 ${!horizontal ? 'px-3' : ''}`}
>
<Button
data-testid="light-mode-button"
@@ -73,6 +77,21 @@ const ThemeSelector: React.FC<ThemeSelectorProps> = ({
<span>{intl.formatMessage(i18n.dark)}</span>
</Button>
<Button
data-testid="aura-mode-button"
onClick={() => setUserThemePreference('aura')}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-colors text-xs ${
userThemePreference === 'aura'
? 'bg-background-inverse text-text-inverse border-text-inverse hover:!bg-background-inverse hover:!text-text-inverse'
: 'border-border-primary hover:!bg-background-secondary text-text-secondary hover:text-text-primary'
}`}
variant="ghost"
size="sm"
>
<Sparkles className="h-3 w-3" />
<span>{intl.formatMessage(i18n.aura)}</span>
</Button>
<Button
data-testid="system-mode-button"
onClick={() => setUserThemePreference('system')}
+28 -30
View File
@@ -1,13 +1,15 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { applyThemeTokens, buildMcpHostStyles } from '../theme/theme-tokens';
import React, { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react';
import { applyThemeTokens, buildMcpHostStyles, themes } from '../theme/theme-tokens';
import type { ThemeId, ThemeVariant } from '../theme/theme-tokens';
import type { McpUiHostStyles } from '@modelcontextprotocol/ext-apps/app-bridge';
type ThemePreference = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
type ThemePreference = 'light' | 'dark' | 'aura' | 'system';
type ResolvedTheme = ThemeVariant;
interface ThemeContextValue {
userThemePreference: ThemePreference;
setUserThemePreference: (pref: ThemePreference) => void;
resolvedThemeId: ThemeId;
resolvedTheme: ResolvedTheme;
mcpHostStyles: McpUiHostStyles;
}
@@ -18,7 +20,9 @@ function getSystemTheme(): ResolvedTheme {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function resolveTheme(preference: ThemePreference): ResolvedTheme {
// Resolve a user preference to a concrete theme id. 'system' picks the light or
// dark built-in from the OS; named themes (light/dark/aura) map to themselves.
function resolveThemeId(preference: ThemePreference): ThemeId {
if (preference === 'system') {
return getSystemTheme();
}
@@ -32,9 +36,6 @@ function applyThemeToDocument(theme: ResolvedTheme): void {
document.documentElement.style.colorScheme = theme;
}
// Built once — light-dark() values are theme-independent
const mcpHostStyles = buildMcpHostStyles();
interface ThemeProviderProps {
children: React.ReactNode;
}
@@ -42,7 +43,9 @@ interface ThemeProviderProps {
export function ThemeProvider({ children }: ThemeProviderProps) {
// Start with light theme to avoid flash, will update once settings load
const [userThemePreference, setUserThemePreferenceState] = useState<ThemePreference>('light');
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');
const [resolvedThemeId, setResolvedThemeId] = useState<ThemeId>('light');
const resolvedTheme = themes[resolvedThemeId].variant;
const mcpHostStyles = useMemo(() => buildMcpHostStyles(resolvedThemeId), [resolvedThemeId]);
useEffect(() => {
async function loadThemeFromSettings() {
@@ -52,15 +55,10 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
window.electron.getSetting('theme'),
]);
let preference: ThemePreference;
if (useSystemTheme) {
preference = 'system';
} else {
preference = savedTheme;
}
const preference: ThemePreference = useSystemTheme ? 'system' : savedTheme;
setUserThemePreferenceState(preference);
setResolvedTheme(resolveTheme(preference));
setResolvedThemeId(resolveThemeId(preference));
} catch (error) {
console.warn('[ThemeContext] Failed to load theme settings:', error);
}
@@ -72,8 +70,8 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
const setUserThemePreference = useCallback(async (preference: ThemePreference) => {
setUserThemePreferenceState(preference);
const resolved = resolveTheme(preference);
setResolvedTheme(resolved);
const resolvedId = resolveThemeId(preference);
setResolvedThemeId(resolvedId);
// Save to settings
try {
@@ -89,9 +87,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
// Broadcast to other windows via Electron
window.electron?.broadcastThemeChange({
mode: resolved,
mode: themes[resolvedId].variant,
useSystemTheme: preference === 'system',
theme: resolved,
theme: resolvedId,
});
}, []);
@@ -102,7 +100,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
setResolvedTheme(getSystemTheme());
setResolvedThemeId(getSystemTheme());
};
mediaQuery.addEventListener('change', handleChange);
@@ -114,15 +112,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
if (!window.electron) return;
const handleThemeChanged = (_event: unknown, ...args: unknown[]) => {
const themeData = args[0] as { useSystemTheme: boolean; theme: string };
const themeData = args[0] as { useSystemTheme: boolean; theme: ThemeId };
const newPreference: ThemePreference = themeData.useSystemTheme
? 'system'
: themeData.theme === 'dark'
? 'dark'
: 'light';
: themeData.theme;
setUserThemePreferenceState(newPreference);
setResolvedTheme(resolveTheme(newPreference));
setResolvedThemeId(resolveThemeId(newPreference));
// Save to settings (don't await, fire and forget)
if (newPreference === 'system') {
@@ -139,15 +135,17 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
};
}, []);
// Apply theme class and CSS tokens whenever resolvedTheme changes
// Apply theme class and CSS tokens whenever the resolved theme changes
useEffect(() => {
applyThemeToDocument(resolvedTheme);
applyThemeTokens(resolvedTheme);
}, [resolvedTheme]);
applyThemeToDocument(themes[resolvedThemeId].variant);
applyThemeTokens(resolvedThemeId);
document.documentElement.dataset.theme = resolvedThemeId;
}, [resolvedThemeId]);
const value: ThemeContextValue = {
userThemePreference,
setUserThemePreference,
resolvedThemeId,
resolvedTheme,
mcpHostStyles,
};
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Telemetrie-Einstellungen konnten nicht aktualisiert werden."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Dunkel"
},
+3
View File
@@ -4469,6 +4469,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Failed to update telemetry settings."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Dark"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "No se pudieron actualizar los ajustes de telemetría."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Oscuro"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Échec de la mise à jour des paramètres de télémétrie."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Sombre"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "टेलीमेट्री सेटिंग अपडेट करने में विफल."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "अंधेरा"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Gagal memperbarui pengaturan telemetri."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Gelap"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Impossibile aggiornare le impostazioni di telemetria."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Scuro"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "テレメトリ設定を更新できませんでした。"
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "ダーク"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "원격 분석 설정을 업데이트하지 못했습니다."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "다크"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Gagal mengemas kini tetapan telemetri."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Gelap"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Falha ao atualizar as definições de telemetria."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Escuro"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Не удалось обновить настройки телеметрии."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Темная"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Telemetri ayarları güncellenemedi."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Karanlık"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "Không thể cập nhật cài đặt đo từ xa."
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "Tối"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "更新遥测设置失败。"
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "深色"
},
+3
View File
@@ -4439,6 +4439,9 @@
"telemetrySettings.updateError": {
"defaultMessage": "無法更新遙測設定。"
},
"themeSelector.aura": {
"defaultMessage": "Aura"
},
"themeSelector.dark": {
"defaultMessage": "深色"
},
+10
View File
@@ -979,3 +979,13 @@ pre:has(> code.bg-inline-code) {
.mcp-app-container.mcp-enter-inline {
animation: mcp-enter-inline 180ms cubic-bezier(0.2, 0, 0, 1) both;
}
/*
AURA THEME component overrides
User message bubbles use the inverted bg-text-primary surface,
which turns light in Aura. Restore Aura's dark chat bubble.
*/
[data-theme='aura'] .user-message-bubble {
background-color: #393647;
color: #edecee;
}
+108 -15
View File
@@ -200,11 +200,96 @@ const darkColorTokens: ColorTokens = {
'--shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2)',
};
// ---------------------------------------------------------------------------
// Aura theme — colors & shadows (dark variant)
// Ported from OpenChamber's Aura preset: purple-black surfaces, purple accent,
// mint/peach/cyan/coral status colors, monospace typography.
// ---------------------------------------------------------------------------
const auraColorTokens: ColorTokens = {
// Backgrounds
'--color-background-primary': '#15141b',
'--color-background-secondary': '#1a1921',
'--color-background-tertiary': '#201e2b',
'--color-background-inverse': '#a277ff',
'--color-background-ghost': 'transparent',
'--color-background-info': '#82e2ff',
'--color-background-danger': '#ff6767',
'--color-background-success': '#61ffca',
'--color-background-warning': '#ffca85',
'--color-background-disabled': '#25232f',
// Text
'--color-text-primary': '#edecee',
'--color-text-secondary': '#8a8282',
'--color-text-tertiary': '#6d6d6d',
'--color-text-inverse': '#15141b',
'--color-text-ghost': '#8a8282',
'--color-text-info': '#82e2ff',
'--color-text-danger': '#ff6767',
'--color-text-success': '#61ffca',
'--color-text-warning': '#ffca85',
'--color-text-disabled': '#525b68',
// Borders
'--color-border-primary': '#2d2b38',
'--color-border-secondary': '#47415a',
'--color-border-tertiary': '#4e496c',
'--color-border-inverse': '#edecee',
'--color-border-ghost': 'transparent',
'--color-border-info': '#82e2ff',
'--color-border-danger': '#ff6767',
'--color-border-success': '#61ffca',
'--color-border-warning': '#ffca85',
'--color-border-disabled': '#2d2b38',
// Rings
'--color-ring-primary': '#47415a',
'--color-ring-secondary': '#2d2b38',
'--color-ring-inverse': '#15141b',
'--color-ring-info': '#82e2ff',
'--color-ring-danger': '#ff6767',
'--color-ring-success': '#61ffca',
'--color-ring-warning': '#ffca85',
// Shadows (dark)
'--shadow-hairline': '0 0 0 1px rgba(0, 0, 0, 0.2)',
'--shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.2)',
'--shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2)',
'--shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2)',
};
// Aura is monospace-first — override the shared sans family.
const auraFontTokens: Partial<Pick<ThemeTokens, BaseTokenKey>> = {
'--font-sans': 'ui-monospace, "SFMono-Regular", "Menlo", "Cascadia Mono", "Segoe UI Mono", monospace',
'--font-mono': 'ui-monospace, "SFMono-Regular", "Menlo", "Cascadia Mono", "Segoe UI Mono", monospace',
};
// ---------------------------------------------------------------------------
// Merged token maps — used by applyThemeTokens() and buildMcpHostStyles()
// ---------------------------------------------------------------------------
export const lightTokens: ThemeTokens = { ...baseTokens, ...lightColorTokens };
export const darkTokens: ThemeTokens = { ...baseTokens, ...darkColorTokens };
export const auraTokens: ThemeTokens = { ...baseTokens, ...auraFontTokens, ...auraColorTokens };
// ---------------------------------------------------------------------------
// Theme registry — the set of selectable named themes.
// `variant` drives the .dark/.light class and colorScheme for anything outside
// the token system; `tokens` is the map applied to :root. Adding a future theme
// is a single entry here plus its token map above.
// ---------------------------------------------------------------------------
export type ThemeId = 'light' | 'dark' | 'aura';
export type ThemeVariant = 'light' | 'dark';
interface ThemeDefinition {
variant: ThemeVariant;
tokens: ThemeTokens;
}
export const themes: Record<ThemeId, ThemeDefinition> = {
light: { variant: 'light', tokens: lightTokens },
dark: { variant: 'dark', tokens: darkTokens },
aura: { variant: 'dark', tokens: auraTokens },
};
// ---------------------------------------------------------------------------
// Helpers
@@ -244,43 +329,51 @@ const HOST_FONT_CSS = `
/**
* Build the McpUiHostStyles object for MCP apps.
* Color keys use light-dark() so a single payload works for both themes.
* Non-color keys (fonts, radii, shadows) use plain values from baseTokens
* (or light as the default when values differ, e.g. shadows).
*
* For the built-in light/dark pair, color keys use light-dark() so a single
* payload resolves correctly against the guest's color-scheme. Custom themes
* (e.g. aura) share a variant with dark but carry their own palette and fonts,
* so light-dark() can't express them — emit that theme's concrete token values
* instead. Non-color keys always use the theme's own values so overrides like
* Aura's monospace font family reach the guest.
* css.fonts provides @font-face rules so sandboxed apps can load host fonts.
*/
export function buildMcpHostStyles(): McpUiHostStyles {
export function buildMcpHostStyles(themeId: ThemeId = 'light'): McpUiHostStyles {
const tokens = (themes[themeId] ?? themes.light).tokens;
const isBuiltinVariant = themeId === 'light' || themeId === 'dark';
const variables: McpUiStyles = {} as McpUiStyles;
for (const key of Object.keys(lightTokens) as McpUiStyleVariableKey[]) {
const light = lightTokens[key];
const dark = darkTokens[key];
if (key.startsWith('--color-')) {
variables[key] = `light-dark(${light}, ${dark})`;
if (key.startsWith('--color-') && isBuiltinVariant) {
variables[key] = `light-dark(${lightTokens[key]}, ${darkTokens[key]})`;
} else {
variables[key] = light;
variables[key] = tokens[key];
}
}
return { variables, css: { fonts: HOST_FONT_CSS } };
}
/**
* Resolve the current theme from localStorage / system preference.
* Resolve the current theme id from localStorage / system preference.
* Best-effort pre-paint resolution; the authoritative preference lives in the
* Electron settings store and is applied by ThemeContext once loaded.
*/
export function getResolvedTheme(): 'light' | 'dark' {
export function getResolvedTheme(): ThemeId {
const useSystem = localStorage.getItem('use_system_theme') !== 'false';
if (useSystem) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
const stored = localStorage.getItem('theme');
if (stored === 'aura') return 'aura';
return stored === 'dark' ? 'dark' : 'light';
}
/**
* Apply theme tokens to the document root as CSS custom properties.
* Apply a theme's tokens to the document root as CSS custom properties.
* When called without an argument, resolves the theme from localStorage.
*/
export function applyThemeTokens(theme?: 'light' | 'dark'): void {
export function applyThemeTokens(theme?: ThemeId): void {
const resolved = theme ?? getResolvedTheme();
const tokens = resolved === 'dark' ? darkTokens : lightTokens;
const { tokens } = themes[resolved] ?? themes.light;
const root = document.documentElement;
for (const [key, value] of Object.entries(tokens)) {
root.style.setProperty(key, value);
+1 -1
View File
@@ -48,7 +48,7 @@ export interface Settings {
keyboardShortcuts: KeyboardShortcuts;
// UI preferences (migrated from localStorage)
theme: 'dark' | 'light';
theme: 'dark' | 'light' | 'aura';
useSystemTheme: boolean;
language: LanguageSetting;
responseStyle: string;