diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index 051a62b087cf..d797fe4740f3 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -46,4 +46,17 @@ describe('chat root appearance style', () => { ] ).toBe('none') }) + it('publishes a custom text color only for the themes that set a valid one', () => { + const style = nativeChatAppearanceStyle({ + nativeChatAppearance: { textColorDark: '#e0d0c0', textColorLight: '#zz' } + }) + expect(style['--chat-text-color-dark']).toBe('#e0d0c0') + expect(style).not.toHaveProperty('--chat-text-color-light') + expect(nativeChatAppearanceStyle(undefined)).not.toHaveProperty('--chat-text-color-dark') + expect( + nativeChatAppearanceStyle({ nativeChatAppearance: { userBubbleColorLight: 'abc' } })[ + '--chat-user-bubble-light' + ] + ).toBe('#abc') + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts index 5bab3f292d73..b40ae77787cc 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts @@ -15,6 +15,10 @@ export type NativeChatAppearanceStyle = CSSProperties & { '--chat-inline-code-ratio': string '--chat-estimated-line-height': number '--chat-estimated-chars-per-line': number + '--chat-text-color-light'?: string + '--chat-text-color-dark'?: string + '--chat-user-bubble-light'?: string + '--chat-user-bubble-dark'?: string } // Width buckets keep a pixel-by-pixel resize from re-deriving the entire transcript. @@ -28,9 +32,15 @@ export function nativeChatAppearanceStyle( settings: Pick | null | undefined, measuredColumnWidthPx?: number | null ): NativeChatAppearanceStyle { - const { fontSize, codeFontSize, width } = resolveNativeChatAppearanceSettings( - settings?.nativeChatAppearance - ) + const { + fontSize, + codeFontSize, + width, + textColorLight, + textColorDark, + userBubbleColorLight, + userBubbleColorDark + } = resolveNativeChatAppearanceSettings(settings?.nativeChatAppearance) const maxWidthPx = width === 'wide' ? 960 : width === 'full' ? Number.POSITIVE_INFINITY : 736 const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx) const columnWidthPx = Math.min( @@ -46,7 +56,11 @@ export function nativeChatAppearanceStyle( '--chat-estimated-chars-per-line': Math.max( 1, Math.floor((((96 * columnWidthPx) / 736) * 14) / fontSize) - ) + ), + ...(textColorLight ? { '--chat-text-color-light': textColorLight } : {}), + ...(textColorDark ? { '--chat-text-color-dark': textColorDark } : {}), + ...(userBubbleColorLight ? { '--chat-user-bubble-light': userBubbleColorLight } : {}), + ...(userBubbleColorDark ? { '--chat-user-bubble-dark': userBubbleColorDark } : {}) } } diff --git a/src/renderer/src/components/native-chat/native-chat-appearance.css b/src/renderer/src/components/native-chat/native-chat-appearance.css index 3405ff6f001a..b23486123e61 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance.css +++ b/src/renderer/src/components/native-chat/native-chat-appearance.css @@ -7,3 +7,29 @@ .native-chat-message-text { font-size: var(--chat-font-size, 14px); } + +/* Why: children, not the root, pick the custom color so the root can keep the theme value as the fallback + (a property can't fall back to its own inherited value). Only one theme's color applies at a time. */ +.native-chat-appearance { + --chat-theme-foreground: var(--chat-foreground); + --chat-theme-foreground-strong: var(--chat-foreground-strong); + --chat-theme-code-foreground: var(--chat-code-foreground); + --chat-theme-user-surface: var(--chat-user-surface); +} + +:root:not(.dark) .native-chat-appearance > * { + --chat-foreground: var(--chat-text-color-light, var(--chat-theme-foreground)); + --chat-foreground-strong: var(--chat-text-color-light, var(--chat-theme-foreground-strong)); + --chat-code-foreground: var(--chat-text-color-light, var(--chat-theme-code-foreground)); + /* Invalid (so unset) without a custom color, which keeps the markdown quote's own color. */ + --chat-quote-foreground: color-mix(in srgb, var(--chat-text-color-light) 70%, transparent); + --chat-user-surface: var(--chat-user-bubble-light, var(--chat-theme-user-surface)); +} + +.dark .native-chat-appearance > * { + --chat-foreground: var(--chat-text-color-dark, var(--chat-theme-foreground)); + --chat-foreground-strong: var(--chat-text-color-dark, var(--chat-theme-foreground-strong)); + --chat-code-foreground: var(--chat-text-color-dark, var(--chat-theme-code-foreground)); + --chat-quote-foreground: color-mix(in srgb, var(--chat-text-color-dark) 70%, transparent); + --chat-user-surface: var(--chat-user-bubble-dark, var(--chat-theme-user-surface)); +} diff --git a/src/renderer/src/components/native-chat/native-chat-markdown.css b/src/renderer/src/components/native-chat/native-chat-markdown.css index 8ee32071e86f..e1ec29dc89b9 100644 --- a/src/renderer/src/components/native-chat/native-chat-markdown.css +++ b/src/renderer/src/components/native-chat/native-chat-markdown.css @@ -15,6 +15,11 @@ font-size: 1.0714285714em; } +/* Why revert-layer: without a custom chat text color the shared renderer's muted quote color stays. */ +.native-chat-markdown blockquote { + color: var(--chat-quote-foreground, revert-layer); +} + .native-chat-markdown a { word-break: normal; overflow-wrap: anywhere; diff --git a/src/renderer/src/components/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index 067b0768cb72..4d572b868257 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -1,5 +1,7 @@ import { + NATIVE_CHAT_COLOR_KEYS, normalizeNativeChatAppearanceSettings, + type NativeChatColorKey, resetNativeChatAppearanceSettings, resolveNativeChatAppearanceSettings, type NativeChatAppearanceSettings @@ -9,11 +11,24 @@ import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' -import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' +import { + ColorField, + NumberField, + SettingsRow, + SettingsSegmentedControl +} from './SettingsFormControls' import { getChatAppearanceEntriesByKey, getChatWidthOptions } from './chat-appearance-search' import { writeNativeChatAppearance } from '../native-chat/native-chat-appearance-write' import type { GlobalSettings } from '../../../../shared/global-settings-types' +// Why: approximate the themes' chat colors so a picker opens near the current color. +const CHAT_COLOR_PICKER_START: Record = { + textColorLight: '#363636', + textColorDark: '#c8c8c8', + userBubbleColorLight: '#f5f5f5', + userBubbleColorDark: '#262626' +} + export type AppearanceChatSectionProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void @@ -90,6 +105,22 @@ export function AppearanceChatSection({ } /> + {NATIVE_CHAT_COLOR_KEYS.map((key) => ( + + update({ [key]: color })} + /> + + ))} option.label) ] }, + textColorLight: { + targetSectionId: 'chat-text-color-light', + title: translate('settings.appearance.chat.textColorLight', 'Text color (light theme)'), + description: translate( + 'settings.appearance.chat.textColorLightDescription', + 'Messages, their markdown and code, and the message box in the light theme. Leave empty for the theme color.' + ), + keywords: [ + translate('settings.appearance.chat.title', 'Chat'), + translate('settings.appearance.chat.fontColorKeyword', 'font color') + ] + }, + textColorDark: { + targetSectionId: 'chat-text-color-dark', + title: translate('settings.appearance.chat.textColorDark', 'Text color (dark theme)'), + description: translate( + 'settings.appearance.chat.textColorDarkDescription', + 'Messages, their markdown and code, and the message box in the dark theme. Leave empty for the theme color.' + ), + keywords: [ + translate('settings.appearance.chat.title', 'Chat'), + translate('settings.appearance.chat.fontColorKeyword', 'font color') + ] + }, + userBubbleColorLight: { + targetSectionId: 'chat-user-bubble-color-light', + title: translate( + 'settings.appearance.chat.userBubbleColorLight', + 'Your message bubble (light theme)' + ), + description: translate( + 'settings.appearance.chat.userBubbleColorLightDescription', + 'Background of your messages in the light theme. Leave empty for the theme color.' + ), + keywords: [ + translate('settings.appearance.chat.title', 'Chat'), + translate('settings.appearance.chat.bubbleKeyword', 'bubble') + ] + }, + userBubbleColorDark: { + targetSectionId: 'chat-user-bubble-color-dark', + title: translate( + 'settings.appearance.chat.userBubbleColorDark', + 'Your message bubble (dark theme)' + ), + description: translate( + 'settings.appearance.chat.userBubbleColorDarkDescription', + 'Background of your messages in the dark theme. Leave empty for the theme color.' + ), + keywords: [ + translate('settings.appearance.chat.title', 'Chat'), + translate('settings.appearance.chat.bubbleKeyword', 'bubble') + ] + }, reset: { targetSectionId: 'chat-reset', title: translate('settings.appearance.chat.resetAppearance', 'Reset chat appearance'), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fe2aff1199c7..d0ff32acc70f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -233,7 +233,17 @@ "wide": "Wide", "full": "Full", "pixels": "px", - "reset": "Reset" + "reset": "Reset", + "textColorLight": "Text color (light theme)", + "textColorLightDescription": "Messages, their markdown and code, and the message box in the light theme. Leave empty for the theme color.", + "textColorDark": "Text color (dark theme)", + "textColorDarkDescription": "Messages, their markdown and code, and the message box in the dark theme. Leave empty for the theme color.", + "fontColorKeyword": "font color", + "userBubbleColorLight": "Your message bubble (light theme)", + "userBubbleColorLightDescription": "Background of your messages in the light theme. Leave empty for the theme color.", + "userBubbleColorDark": "Your message bubble (dark theme)", + "userBubbleColorDarkDescription": "Background of your messages in the dark theme. Leave empty for the theme color.", + "bubbleKeyword": "bubble" } }, "browser": { diff --git a/src/shared/native-chat-appearance-settings.test.ts b/src/shared/native-chat-appearance-settings.test.ts index 4a52b28fda57..8ab065eaf0fb 100644 --- a/src/shared/native-chat-appearance-settings.test.ts +++ b/src/shared/native-chat-appearance-settings.test.ts @@ -71,4 +71,20 @@ describe('native chat appearance normalization', () => { expect(normalizeNativeChatAppearanceSettings(value)).toBeUndefined() } }) + it('keeps typed text colors and applies only valid hex per theme', () => { + expect( + normalizeNativeChatAppearanceSettings({ textColorLight: ' #12', textColorDark: '' }) + ).toEqual({ textColorLight: '#12' }) + expect( + resolveNativeChatAppearanceSettings({ textColorLight: '#12', textColorDark: 'a0b1c2' }) + ).toMatchObject({ textColorLight: undefined, textColorDark: '#a0b1c2' }) + expect( + resolveNativeChatAppearanceSettings({ + userBubbleColorDark: '#334455', + userBubbleColorLight: 'x' + }) + ).toMatchObject({ userBubbleColorDark: '#334455', userBubbleColorLight: undefined }) + const fromNewerVersion = { textColorLight: '#fff', userBubbleColorDark: '#000', contrast: 120 } + expect(resetNativeChatAppearanceSettings(fromNewerVersion)).toEqual({ contrast: 120 }) + }) }) diff --git a/src/shared/native-chat-appearance-settings.ts b/src/shared/native-chat-appearance-settings.ts index 2dd1924a3685..568bf3c587ba 100644 --- a/src/shared/native-chat-appearance-settings.ts +++ b/src/shared/native-chat-appearance-settings.ts @@ -1,3 +1,5 @@ +import { HEX_COLOR_RE } from './color-validation' + export type NativeChatAppearanceSettings = { /** Chat text size in px, 12–20; absent = 14. */ fontSize?: number @@ -5,8 +7,29 @@ export type NativeChatAppearanceSettings = { codeFontSize?: number /** Transcript and composer width; absent = comfortable (46rem). */ width?: 'comfortable' | 'wide' | 'full' + /** Chat text color in the light theme, as typed (hex when valid); absent = theme default. */ + textColorLight?: string + /** Chat text color in the dark theme, as typed (hex when valid); absent = theme default. */ + textColorDark?: string + /** Your message bubble color in the light theme, as typed (hex when valid); absent = theme default. */ + userBubbleColorLight?: string + /** Your message bubble color in the dark theme, as typed (hex when valid); absent = theme default. */ + userBubbleColorDark?: string } +export const NATIVE_CHAT_COLOR_KEYS = [ + 'textColorLight', + 'textColorDark', + 'userBubbleColorLight', + 'userBubbleColorDark' +] as const +export type NativeChatColorKey = (typeof NATIVE_CHAT_COLOR_KEYS)[number] + +export type ResolvedNativeChatAppearanceSettings = Required< + Pick +> & + Record + export type NativeChatGlobalSettings = { nativeChatAppearance?: NativeChatAppearanceSettings } @@ -14,6 +37,26 @@ export type NativeChatGlobalSettings = { export const DEFAULT_NATIVE_CHAT_FONT_SIZE = 14 export const DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE = 12 +const KNOWN_KEYS: readonly string[] = [ + 'fontSize', + 'codeFontSize', + 'width', + ...NATIVE_CHAT_COLOR_KEYS +] + +// Why: kept as typed so a half-entered hex survives the next write; validated where it is applied. +function normalizeColorDraft(value: unknown): string | undefined { + const trimmed = typeof value === 'string' ? value.trim().slice(0, 32) : '' + return trimmed || undefined +} + +function resolveColor(value: string | undefined): string | undefined { + if (!value || !HEX_COLOR_RE.test(value)) { + return undefined + } + return value.startsWith('#') ? value : `#${value}` +} + function normalizeSize(value: unknown, min: number, max: number, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) @@ -35,25 +78,34 @@ export function normalizeNativeChatAppearanceSettings( ) const width = 'width' in value && (value.width === 'wide' || value.width === 'full') ? value.width : undefined + const colors = Object.fromEntries( + NATIVE_CHAT_COLOR_KEYS.flatMap((key) => { + const color = normalizeColorDraft(Object.getOwnPropertyDescriptor(value, key)?.value) + return color ? [[key, color]] : [] + }) + ) const normalized: NativeChatAppearanceSettings = { - ...Object.fromEntries( - Object.entries(value).filter(([key]) => !['fontSize', 'codeFontSize', 'width'].includes(key)) - ), + ...Object.fromEntries(Object.entries(value).filter(([key]) => !KNOWN_KEYS.includes(key))), ...(fontSize !== DEFAULT_NATIVE_CHAT_FONT_SIZE ? { fontSize } : {}), ...(codeFontSize !== DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE ? { codeFontSize } : {}), - ...(width ? { width } : {}) + ...(width ? { width } : {}), + ...colors } return Object.keys(normalized).length ? normalized : undefined } export function resolveNativeChatAppearanceSettings( value: unknown -): Required { +): ResolvedNativeChatAppearanceSettings { const normalized = normalizeNativeChatAppearanceSettings(value) return { fontSize: normalized?.fontSize ?? DEFAULT_NATIVE_CHAT_FONT_SIZE, codeFontSize: normalized?.codeFontSize ?? DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE, - width: normalized?.width ?? 'comfortable' + width: normalized?.width ?? 'comfortable', + textColorLight: resolveColor(normalized?.textColorLight), + textColorDark: resolveColor(normalized?.textColorDark), + userBubbleColorLight: resolveColor(normalized?.userBubbleColorLight), + userBubbleColorDark: resolveColor(normalized?.userBubbleColorDark) } } @@ -62,9 +114,7 @@ export function resetNativeChatAppearanceSettings( ): NativeChatAppearanceSettings | undefined { return normalizeNativeChatAppearanceSettings( Object.fromEntries( - Object.entries(appearance ?? {}).filter( - ([key]) => key !== 'fontSize' && key !== 'codeFontSize' && key !== 'width' - ) + Object.entries(appearance ?? {}).filter(([key]) => !KNOWN_KEYS.includes(key)) ) ) }