Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,9 +32,15 @@ export function nativeChatAppearanceStyle(
settings: Pick<GlobalSettings, 'nativeChatAppearance'> | 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(
Expand All @@ -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 } : {})
}
}

Expand Down
26 changes: 26 additions & 0 deletions src/renderer/src/components/native-chat/native-chat-appearance.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 32 additions & 1 deletion src/renderer/src/components/settings/AppearanceChatSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
NATIVE_CHAT_COLOR_KEYS,
normalizeNativeChatAppearanceSettings,
type NativeChatColorKey,
resetNativeChatAppearanceSettings,
resolveNativeChatAppearanceSettings,
type NativeChatAppearanceSettings
Expand All @@ -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<NativeChatColorKey, string> = {
textColorLight: '#363636',
textColorDark: '#c8c8c8',
userBubbleColorLight: '#f5f5f5',
userBubbleColorDark: '#262626'
}

export type AppearanceChatSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
Expand Down Expand Up @@ -90,6 +105,22 @@ export function AppearanceChatSection({
}
/>
</SearchableSetting>
{NATIVE_CHAT_COLOR_KEYS.map((key) => (
<SearchableSetting
key={key}
id={entries[key].targetSectionId}
{...entries[key]}
forceVisible={forceVisiblePrimary}
>
<ColorField
label={entries[key].title}
description={entries[key].description}
value={settings.nativeChatAppearance?.[key] ?? ''}
fallback={CHAT_COLOR_PICKER_START[key]}
onChange={(color) => update({ [key]: color })}
/>
</SearchableSetting>
))}
<SearchableSetting
id={entries.reset.targetSectionId}
{...entries.reset}
Expand Down
54 changes: 54 additions & 0 deletions src/renderer/src/components/settings/chat-appearance-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,60 @@ const getChatAppearanceCatalog = createLocalizedCatalog(
...getChatWidthOptions().map((option) => 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'),
Expand Down
12 changes: 11 additions & 1 deletion src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
16 changes: 16 additions & 0 deletions src/shared/native-chat-appearance-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
68 changes: 59 additions & 9 deletions src/shared/native-chat-appearance-settings.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,62 @@
import { HEX_COLOR_RE } from './color-validation'

export type NativeChatAppearanceSettings = {
/** Chat text size in px, 12–20; absent = 14. */
fontSize?: number
/** Code text size in px, 10–18; absent = 12. */
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<NativeChatAppearanceSettings, 'fontSize' | 'codeFontSize' | 'width'>
> &
Record<NativeChatColorKey, string | undefined>

export type NativeChatGlobalSettings = {
nativeChatAppearance?: NativeChatAppearanceSettings
}

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)))
Expand All @@ -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<NativeChatAppearanceSettings> {
): 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)
}
}

Expand All @@ -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))
)
)
}