From f428c5674ac488a71755829dd18a0baab0cf13f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kotowski?= Date: Tue, 6 Oct 2026 18:54:49 +0200 Subject: [PATCH 1/3] feat(native-chat): let users pick the chat text color per theme Adds Text color (light theme) and Text color (dark theme) to the Chat settings page. Each overrides the chat's body and strong text (messages, headings, links and the message box) in that theme only; empty keeps the theme color. Faint text, code and tool output keep their theme colors. The colors are stored as typed so a half-entered hex survives, and only a valid hex is applied. The chat root publishes --chat-text-color-light/-dark; its children pick the one for the current theme and fall back to the theme value the root captures, so no theme default is duplicated. Co-Authored-By: Claude Opus 5.5 --- .../native-chat-appearance-style.test.ts | 8 +++ .../native-chat-appearance-style.ts | 11 ++-- .../native-chat/native-chat-appearance.css | 17 ++++++ .../settings/AppearanceChatSection.tsx | 37 ++++++++++++- .../settings/chat-appearance-search.ts | 24 +++++++++ src/renderer/src/i18n/locales/en.json | 7 ++- .../native-chat-appearance-settings.test.ts | 10 ++++ src/shared/native-chat-appearance-settings.ts | 52 +++++++++++++++---- 8 files changed, 151 insertions(+), 15 deletions(-) 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..8b89feaaa829 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,12 @@ 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') + }) }) 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..632ff29f020e 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,8 @@ 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 } // Width buckets keep a pixel-by-pixel resize from re-deriving the entire transcript. @@ -28,9 +30,8 @@ export function nativeChatAppearanceStyle( settings: Pick | null | undefined, measuredColumnWidthPx?: number | null ): NativeChatAppearanceStyle { - const { fontSize, codeFontSize, width } = resolveNativeChatAppearanceSettings( - settings?.nativeChatAppearance - ) + const { fontSize, codeFontSize, width, textColorLight, textColorDark } = + resolveNativeChatAppearanceSettings(settings?.nativeChatAppearance) const maxWidthPx = width === 'wide' ? 960 : width === 'full' ? Number.POSITIVE_INFINITY : 736 const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx) const columnWidthPx = Math.min( @@ -46,7 +47,9 @@ 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 } : {}) } } 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..5d62af975ced 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,20 @@ .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); +} + +: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)); +} + +.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)); +} diff --git a/src/renderer/src/components/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index 067b0768cb72..041c9911445e 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -9,11 +9,20 @@ 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' softened chat text so the picker opens near the current color. +const DEFAULT_CHAT_TEXT_COLOR_LIGHT = '#363636' +const DEFAULT_CHAT_TEXT_COLOR_DARK = '#c8c8c8' + export type AppearanceChatSectionProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void @@ -90,6 +99,32 @@ export function AppearanceChatSection({ } /> + + update({ textColorLight })} + /> + + + update({ textColorDark })} + /> + 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 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 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') + ] + }, 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..e3932518fc2c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -233,7 +233,12 @@ "wide": "Wide", "full": "Full", "pixels": "px", - "reset": "Reset" + "reset": "Reset", + "textColorLight": "Text color (light theme)", + "textColorLightDescription": "Messages and the message box in the light theme. Leave empty for the theme color.", + "textColorDark": "Text color (dark theme)", + "textColorDarkDescription": "Messages and the message box in the dark theme. Leave empty for the theme color.", + "fontColorKeyword": "font color" } }, "browser": { diff --git a/src/shared/native-chat-appearance-settings.test.ts b/src/shared/native-chat-appearance-settings.test.ts index 4a52b28fda57..c586ed48bf6f 100644 --- a/src/shared/native-chat-appearance-settings.test.ts +++ b/src/shared/native-chat-appearance-settings.test.ts @@ -71,4 +71,14 @@ 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' }) + const fromNewerVersion = { textColorLight: '#fff', 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..c5a4a39f5282 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,6 +7,17 @@ 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 +} + +export type ResolvedNativeChatAppearanceSettings = Required< + Pick +> & { + textColorLight: string | undefined + textColorDark: string | undefined } export type NativeChatGlobalSettings = { @@ -14,6 +27,21 @@ export type NativeChatGlobalSettings = { export const DEFAULT_NATIVE_CHAT_FONT_SIZE = 14 export const DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE = 12 +const KNOWN_KEYS = ['fontSize', 'codeFontSize', 'width', 'textColorLight', 'textColorDark'] + +// Why: kept as typed so a half-entered hex survives the next write; validated where it is applied. +function normalizeTextColorDraft(value: unknown): string | undefined { + const trimmed = typeof value === 'string' ? value.trim().slice(0, 32) : '' + return trimmed || undefined +} + +function resolveTextColor(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 +63,33 @@ export function normalizeNativeChatAppearanceSettings( ) const width = 'width' in value && (value.width === 'wide' || value.width === 'full') ? value.width : undefined + const textColorLight = normalizeTextColorDraft( + 'textColorLight' in value ? value.textColorLight : undefined + ) + const textColorDark = normalizeTextColorDraft( + 'textColorDark' in value ? value.textColorDark : undefined + ) 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 } : {}), + ...(textColorLight ? { textColorLight } : {}), + ...(textColorDark ? { textColorDark } : {}) } 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: resolveTextColor(normalized?.textColorLight), + textColorDark: resolveTextColor(normalized?.textColorDark) } } @@ -62,9 +98,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)) ) ) } From b54d2b012b0c7139bf0080826e9e1a887b4e3dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kotowski?= Date: Tue, 6 Oct 2026 19:11:51 +0200 Subject: [PATCH 2/3] fix(native-chat): carry the custom text color into markdown code and quotes Inline code and plain code blocks read --chat-code-foreground and quotes use the shared renderer's muted color, so they kept the theme color after a custom text color was set. Code now takes the custom color and quotes a 70% version of it; without a custom color both keep their theme colors. Syntax-highlighted code keeps its highlighter theme. Co-Authored-By: Claude Opus 5.5 --- .../src/components/native-chat/native-chat-appearance.css | 6 ++++++ .../src/components/native-chat/native-chat-markdown.css | 5 +++++ .../src/components/settings/chat-appearance-search.ts | 4 ++-- src/renderer/src/i18n/locales/en.json | 4 ++-- 4 files changed, 15 insertions(+), 4 deletions(-) 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 5d62af975ced..c3fabf2b4d43 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance.css +++ b/src/renderer/src/components/native-chat/native-chat-appearance.css @@ -13,14 +13,20 @@ .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); } :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); } .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); } 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/chat-appearance-search.ts b/src/renderer/src/components/settings/chat-appearance-search.ts index 786d892c23d0..7c5d8d7660fe 100644 --- a/src/renderer/src/components/settings/chat-appearance-search.ts +++ b/src/renderer/src/components/settings/chat-appearance-search.ts @@ -37,7 +37,7 @@ const getChatAppearanceCatalog = createLocalizedCatalog( title: translate('settings.appearance.chat.textColorLight', 'Text color (light theme)'), description: translate( 'settings.appearance.chat.textColorLightDescription', - 'Messages and the message box in the light theme. Leave empty for the theme color.' + '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'), @@ -49,7 +49,7 @@ const getChatAppearanceCatalog = createLocalizedCatalog( title: translate('settings.appearance.chat.textColorDark', 'Text color (dark theme)'), description: translate( 'settings.appearance.chat.textColorDarkDescription', - 'Messages and the message box in the dark theme. Leave empty for the theme color.' + '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'), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e3932518fc2c..1de4eed3a5d3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -235,9 +235,9 @@ "pixels": "px", "reset": "Reset", "textColorLight": "Text color (light theme)", - "textColorLightDescription": "Messages and the message box in the light theme. Leave empty for the theme color.", + "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 and the message box in the dark theme. Leave empty for the theme color.", + "textColorDarkDescription": "Messages, their markdown and code, and the message box in the dark theme. Leave empty for the theme color.", "fontColorKeyword": "font color" } }, From 767b2360155a110d6147d5c00d60eeb4f3f22e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kotowski?= Date: Tue, 6 Oct 2026 19:21:47 +0200 Subject: [PATCH 3/3] feat(native-chat): let users pick their message bubble color per theme Adds Your message bubble (light theme) and (dark theme) to the Chat settings page, applied through --chat-user-surface with the same per-theme fallback as the text color. The four chat color settings now share one key list for normalizing, resolving, resetting and rendering. Co-Authored-By: Claude Opus 5.5 --- .../native-chat-appearance-style.test.ts | 5 ++ .../native-chat-appearance-style.ts | 17 ++++-- .../native-chat/native-chat-appearance.css | 3 ++ .../settings/AppearanceChatSection.tsx | 54 +++++++++---------- .../settings/chat-appearance-search.ts | 30 +++++++++++ src/renderer/src/i18n/locales/en.json | 7 ++- .../native-chat-appearance-settings.test.ts | 8 ++- src/shared/native-chat-appearance-settings.ts | 48 +++++++++++------ 8 files changed, 122 insertions(+), 50 deletions(-) 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 8b89feaaa829..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 @@ -53,5 +53,10 @@ describe('chat root appearance style', () => { 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 632ff29f020e..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 @@ -17,6 +17,8 @@ export type NativeChatAppearanceStyle = CSSProperties & { '--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. @@ -30,8 +32,15 @@ export function nativeChatAppearanceStyle( settings: Pick | null | undefined, measuredColumnWidthPx?: number | null ): NativeChatAppearanceStyle { - const { fontSize, codeFontSize, width, textColorLight, textColorDark } = - 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( @@ -49,7 +58,9 @@ export function nativeChatAppearanceStyle( Math.floor((((96 * columnWidthPx) / 736) * 14) / fontSize) ), ...(textColorLight ? { '--chat-text-color-light': textColorLight } : {}), - ...(textColorDark ? { '--chat-text-color-dark': textColorDark } : {}) + ...(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 c3fabf2b4d43..b23486123e61 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance.css +++ b/src/renderer/src/components/native-chat/native-chat-appearance.css @@ -14,6 +14,7 @@ --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 > * { @@ -22,6 +23,7 @@ --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 > * { @@ -29,4 +31,5 @@ --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/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index 041c9911445e..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 @@ -19,9 +21,13 @@ import { getChatAppearanceEntriesByKey, getChatWidthOptions } from './chat-appea import { writeNativeChatAppearance } from '../native-chat/native-chat-appearance-write' import type { GlobalSettings } from '../../../../shared/global-settings-types' -// Why: approximate the themes' softened chat text so the picker opens near the current color. -const DEFAULT_CHAT_TEXT_COLOR_LIGHT = '#363636' -const DEFAULT_CHAT_TEXT_COLOR_DARK = '#c8c8c8' +// 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 @@ -99,32 +105,22 @@ export function AppearanceChatSection({ } /> - - update({ textColorLight })} - /> - - - update({ textColorDark })} - /> - + {NATIVE_CHAT_COLOR_KEYS.map((key) => ( + + update({ [key]: color })} + /> + + ))} { expect( resolveNativeChatAppearanceSettings({ textColorLight: '#12', textColorDark: 'a0b1c2' }) ).toMatchObject({ textColorLight: undefined, textColorDark: '#a0b1c2' }) - const fromNewerVersion = { textColorLight: '#fff', contrast: 120 } + 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 c5a4a39f5282..568bf3c587ba 100644 --- a/src/shared/native-chat-appearance-settings.ts +++ b/src/shared/native-chat-appearance-settings.ts @@ -11,14 +11,24 @@ export type NativeChatAppearanceSettings = { 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 -> & { - textColorLight: string | undefined - textColorDark: string | undefined -} +> & + Record export type NativeChatGlobalSettings = { nativeChatAppearance?: NativeChatAppearanceSettings @@ -27,15 +37,20 @@ export type NativeChatGlobalSettings = { export const DEFAULT_NATIVE_CHAT_FONT_SIZE = 14 export const DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE = 12 -const KNOWN_KEYS = ['fontSize', 'codeFontSize', 'width', 'textColorLight', 'textColorDark'] +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 normalizeTextColorDraft(value: unknown): string | undefined { +function normalizeColorDraft(value: unknown): string | undefined { const trimmed = typeof value === 'string' ? value.trim().slice(0, 32) : '' return trimmed || undefined } -function resolveTextColor(value: string | undefined): string | undefined { +function resolveColor(value: string | undefined): string | undefined { if (!value || !HEX_COLOR_RE.test(value)) { return undefined } @@ -63,19 +78,18 @@ export function normalizeNativeChatAppearanceSettings( ) const width = 'width' in value && (value.width === 'wide' || value.width === 'full') ? value.width : undefined - const textColorLight = normalizeTextColorDraft( - 'textColorLight' in value ? value.textColorLight : undefined - ) - const textColorDark = normalizeTextColorDraft( - 'textColorDark' in value ? value.textColorDark : 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]) => !KNOWN_KEYS.includes(key))), ...(fontSize !== DEFAULT_NATIVE_CHAT_FONT_SIZE ? { fontSize } : {}), ...(codeFontSize !== DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE ? { codeFontSize } : {}), ...(width ? { width } : {}), - ...(textColorLight ? { textColorLight } : {}), - ...(textColorDark ? { textColorDark } : {}) + ...colors } return Object.keys(normalized).length ? normalized : undefined } @@ -88,8 +102,10 @@ export function resolveNativeChatAppearanceSettings( fontSize: normalized?.fontSize ?? DEFAULT_NATIVE_CHAT_FONT_SIZE, codeFontSize: normalized?.codeFontSize ?? DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE, width: normalized?.width ?? 'comfortable', - textColorLight: resolveTextColor(normalized?.textColorLight), - textColorDark: resolveTextColor(normalized?.textColorDark) + textColorLight: resolveColor(normalized?.textColorLight), + textColorDark: resolveColor(normalized?.textColorDark), + userBubbleColorLight: resolveColor(normalized?.userBubbleColorLight), + userBubbleColorDark: resolveColor(normalized?.userBubbleColorDark) } }