mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(web): add color theme presets (#1040)
* feat(web): add color theme presets * fix(web): preserve presets with custom colors * fix(web): sync color theme across tabs * fix(web): clear boot theme background * feat(web): integrate palette presets with display settings
This commit is contained in:
+35
-2
@@ -41,11 +41,30 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var lightColor = '#ffffff'
|
var lightColor = '#ffffff'
|
||||||
var darkColor = '#1c1c1e'
|
var darkColor = '#1c1c1e'
|
||||||
|
var oledColor = '#000000'
|
||||||
|
var themeBackgrounds = {
|
||||||
|
notion: { light: '#fafafa', dark: '#191919' },
|
||||||
|
one: { light: '#fbfbff', dark: '#1f2433' },
|
||||||
|
proof: { light: '#f8f7f2', dark: '#18231f' },
|
||||||
|
raycast: { light: '#ffffff', dark: '#201617' },
|
||||||
|
'rose-pine': { light: '#fffaf3', dark: '#191724' },
|
||||||
|
solarized: { light: '#fdf6e3', dark: '#002b36' },
|
||||||
|
vercel: { light: '#ffffff', dark: '#000000' },
|
||||||
|
'vs-code-plus': { light: '#ffffff', dark: '#1e1e1e' },
|
||||||
|
xcode: { light: '#f7f9ff', dark: '#1f2024' },
|
||||||
|
linear: { light: '#f7f7fb', dark: '#08090d' },
|
||||||
|
lobster: { light: '#fff7f4', dark: '#161821' },
|
||||||
|
material: { light: '#fffbfe', dark: '#1c1b1f' },
|
||||||
|
matrix: { light: '#f5fff7', dark: '#030806' },
|
||||||
|
monokai: { light: '#f8f7ef', dark: '#272822' },
|
||||||
|
'night-owl': { light: '#fbfdff', dark: '#011627' },
|
||||||
|
nord: { light: '#eceff4', dark: '#2e3440' },
|
||||||
|
}
|
||||||
|
|
||||||
function getInitialScheme() {
|
function getInitialScheme() {
|
||||||
try {
|
try {
|
||||||
var appearance = localStorage.getItem('hapi-appearance')
|
var appearance = localStorage.getItem('hapi-appearance')
|
||||||
if (appearance === 'dark' || appearance === 'light') return appearance
|
if (appearance === 'dark' || appearance === 'light' || appearance === 'oled') return appearance
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore storage errors.
|
// Ignore storage errors.
|
||||||
}
|
}
|
||||||
@@ -56,8 +75,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var scheme = getInitialScheme()
|
var scheme = getInitialScheme()
|
||||||
var color = scheme === 'dark' ? darkColor : lightColor
|
var colorTheme = 'default'
|
||||||
|
try {
|
||||||
|
var storedColorTheme = localStorage.getItem('hapi-color-theme')
|
||||||
|
if (storedColorTheme && themeBackgrounds[storedColorTheme]) colorTheme = storedColorTheme
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
var paletteScheme = scheme === 'light' ? 'light' : 'dark'
|
||||||
|
var color = colorTheme === 'default'
|
||||||
|
? (scheme === 'oled' ? oledColor : (scheme === 'dark' ? darkColor : lightColor))
|
||||||
|
: themeBackgrounds[colorTheme][paletteScheme]
|
||||||
document.documentElement.setAttribute('data-theme', scheme)
|
document.documentElement.setAttribute('data-theme', scheme)
|
||||||
|
document.documentElement.setAttribute('data-color-theme', colorTheme)
|
||||||
|
document.documentElement.style.backgroundColor = color
|
||||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', color)
|
document.querySelector('meta[name="theme-color"]').setAttribute('content', color)
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
@@ -65,6 +96,8 @@
|
|||||||
<style>
|
<style>
|
||||||
html { background: #fff }
|
html { background: #fff }
|
||||||
html[data-theme="dark"] { background: #1c1c1e; color-scheme: dark }
|
html[data-theme="dark"] { background: #1c1c1e; color-scheme: dark }
|
||||||
|
html[data-theme="oled"] { background: #000; color-scheme: dark }
|
||||||
|
html[data-color-theme]:not([data-color-theme="default"]) { background: inherit }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<title>HAPI</title>
|
<title>HAPI</title>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
applyColorTheme,
|
||||||
|
getColorThemeStorageKey,
|
||||||
|
getStoredColorTheme,
|
||||||
|
parseColorTheme,
|
||||||
|
useColorTheme,
|
||||||
|
} from './useColorTheme'
|
||||||
|
|
||||||
|
const THEME_VARS = ['--app-bg', '--app-fg', '--app-link', '--app-button', '--app-secondary-bg', '--app-chat-user-chip-bg']
|
||||||
|
|
||||||
|
describe('useColorTheme', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
document.documentElement.removeAttribute('data-color-theme')
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light')
|
||||||
|
for (const name of THEME_VARS) document.documentElement.style.removeProperty(name)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses invalid or missing stored values as default', () => {
|
||||||
|
expect(parseColorTheme(null)).toBe('default')
|
||||||
|
expect(parseColorTheme('unknown')).toBe('default')
|
||||||
|
expect(parseColorTheme('notion')).toBe('notion')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the stored color theme preference', () => {
|
||||||
|
localStorage.setItem(getColorThemeStorageKey(), 'rose-pine')
|
||||||
|
expect(getStoredColorTheme()).toBe('rose-pine')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a preset palette to the document css variables', () => {
|
||||||
|
applyColorTheme('one', 'light')
|
||||||
|
expect(document.documentElement).toHaveAttribute('data-color-theme', 'one')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#fbfbff')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-link')).toBe('#526fff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes palette css variables when reset to default', () => {
|
||||||
|
applyColorTheme('one', 'dark')
|
||||||
|
applyColorTheme('default', 'dark')
|
||||||
|
|
||||||
|
expect(document.documentElement).toHaveAttribute('data-color-theme', 'default')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-link')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists non-default selections and removes the key for default', () => {
|
||||||
|
const { result } = renderHook(() => useColorTheme())
|
||||||
|
|
||||||
|
act(() => result.current.setColorTheme('night-owl'))
|
||||||
|
expect(localStorage.getItem(getColorThemeStorageKey())).toBe('night-owl')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#fbfdff')
|
||||||
|
|
||||||
|
act(() => result.current.setColorTheme('default'))
|
||||||
|
expect(localStorage.getItem(getColorThemeStorageKey())).toBeNull()
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the current dark scheme when applying from the hook', () => {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
const { result } = renderHook(() => useColorTheme())
|
||||||
|
|
||||||
|
act(() => result.current.setColorTheme('notion'))
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#191919')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-fg')).toBe('#d9d9d8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses dark palette contrast values for OLED', () => {
|
||||||
|
applyColorTheme('one', 'oled')
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#1f2433')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-chat-user-chip-bg')).toBe('rgba(123, 140, 255, 0.24)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import type { ThemeColorKeyId } from './useThemeColors'
|
||||||
|
|
||||||
|
export type ColorScheme = 'light' | 'dark' | 'oled'
|
||||||
|
export type ColorThemePreset =
|
||||||
|
| 'default'
|
||||||
|
| 'notion'
|
||||||
|
| 'one'
|
||||||
|
| 'proof'
|
||||||
|
| 'raycast'
|
||||||
|
| 'rose-pine'
|
||||||
|
| 'solarized'
|
||||||
|
| 'vercel'
|
||||||
|
| 'vs-code-plus'
|
||||||
|
| 'xcode'
|
||||||
|
| 'linear'
|
||||||
|
| 'lobster'
|
||||||
|
| 'material'
|
||||||
|
| 'matrix'
|
||||||
|
| 'monokai'
|
||||||
|
| 'night-owl'
|
||||||
|
| 'nord'
|
||||||
|
|
||||||
|
export type ColorThemeOption = {
|
||||||
|
value: ColorThemePreset
|
||||||
|
labelKey: string
|
||||||
|
preview: {
|
||||||
|
light: string
|
||||||
|
dark: string
|
||||||
|
accent: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemePalette = {
|
||||||
|
accent: string
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
hint: string
|
||||||
|
secondary: string
|
||||||
|
dialog: string
|
||||||
|
surface: string
|
||||||
|
surfaceHover: string
|
||||||
|
code: string
|
||||||
|
border: string
|
||||||
|
subtle: string
|
||||||
|
buttonText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_THEME_KEY = 'hapi-color-theme'
|
||||||
|
|
||||||
|
const COLOR_THEME_OPTIONS: ReadonlyArray<ColorThemeOption> = [
|
||||||
|
{ value: 'default', labelKey: 'settings.display.colorTheme.default', preview: { light: '#ffffff', dark: '#1c1c1e', accent: '#111827' } },
|
||||||
|
{ value: 'notion', labelKey: 'settings.display.colorTheme.notion', preview: { light: '#fafafa', dark: '#191919', accent: '#3183d8' } },
|
||||||
|
{ value: 'one', labelKey: 'settings.display.colorTheme.one', preview: { light: '#fbfbff', dark: '#1f2433', accent: '#526fff' } },
|
||||||
|
{ value: 'proof', labelKey: 'settings.display.colorTheme.proof', preview: { light: '#f8f7f2', dark: '#18231f', accent: '#2f7d5b' } },
|
||||||
|
{ value: 'raycast', labelKey: 'settings.display.colorTheme.raycast', preview: { light: '#ffffff', dark: '#201617', accent: '#ff5555' } },
|
||||||
|
{ value: 'rose-pine', labelKey: 'settings.display.colorTheme.rosePine', preview: { light: '#fffaf3', dark: '#191724', accent: '#c65f7b' } },
|
||||||
|
{ value: 'solarized', labelKey: 'settings.display.colorTheme.solarized', preview: { light: '#fdf6e3', dark: '#002b36', accent: '#b58900' } },
|
||||||
|
{ value: 'vercel', labelKey: 'settings.display.colorTheme.vercel', preview: { light: '#ffffff', dark: '#000000', accent: '#0070f3' } },
|
||||||
|
{ value: 'vs-code-plus', labelKey: 'settings.display.colorTheme.vsCodePlus', preview: { light: '#ffffff', dark: '#1e1e1e', accent: '#007acc' } },
|
||||||
|
{ value: 'xcode', labelKey: 'settings.display.colorTheme.xcode', preview: { light: '#f7f9ff', dark: '#1f2024', accent: '#0a84ff' } },
|
||||||
|
{ value: 'linear', labelKey: 'settings.display.colorTheme.linear', preview: { light: '#f7f7fb', dark: '#08090d', accent: '#5e6ad2' } },
|
||||||
|
{ value: 'lobster', labelKey: 'settings.display.colorTheme.lobster', preview: { light: '#fff7f4', dark: '#161821', accent: '#ff5b6e' } },
|
||||||
|
{ value: 'material', labelKey: 'settings.display.colorTheme.material', preview: { light: '#fffbfe', dark: '#1c1b1f', accent: '#6750a4' } },
|
||||||
|
{ value: 'matrix', labelKey: 'settings.display.colorTheme.matrix', preview: { light: '#f5fff7', dark: '#030806', accent: '#00ff66' } },
|
||||||
|
{ value: 'monokai', labelKey: 'settings.display.colorTheme.monokai', preview: { light: '#f8f7ef', dark: '#272822', accent: '#a6e22e' } },
|
||||||
|
{ value: 'night-owl', labelKey: 'settings.display.colorTheme.nightOwl', preview: { light: '#fbfdff', dark: '#011627', accent: '#82aaff' } },
|
||||||
|
{ value: 'nord', labelKey: 'settings.display.colorTheme.nord', preview: { light: '#eceff4', dark: '#2e3440', accent: '#88c0d0' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PALETTES: Record<Exclude<ColorThemePreset, 'default'>, Record<'light' | 'dark', ThemePalette>> = {
|
||||||
|
notion: {
|
||||||
|
light: palette('#3183d8', '#fafafa', '#37352f', '#787774', '#f1f1ef'),
|
||||||
|
dark: palette('#3183d8', '#191919', '#d9d9d8', '#9b9a97', '#252525'),
|
||||||
|
},
|
||||||
|
one: {
|
||||||
|
light: palette('#526fff', '#fbfbff', '#383a42', '#696c77', '#f0f2ff'),
|
||||||
|
dark: palette('#7b8cff', '#1f2433', '#d8dee9', '#aab2c0', '#2c3245'),
|
||||||
|
},
|
||||||
|
proof: {
|
||||||
|
light: palette('#2f7d5b', '#f8f7f2', '#26352f', '#64706b', '#eef1ea'),
|
||||||
|
dark: palette('#7fc8a6', '#18231f', '#d9e4dc', '#9dafaa', '#23332d'),
|
||||||
|
},
|
||||||
|
raycast: {
|
||||||
|
light: palette('#ff4d4d', '#ffffff', '#2b2b31', '#72727a', '#fff0f0'),
|
||||||
|
dark: palette('#ff6363', '#201617', '#f3eded', '#c9bfc0', '#321f21'),
|
||||||
|
},
|
||||||
|
'rose-pine': {
|
||||||
|
light: palette('#d7827e', '#fffaf3', '#575279', '#797593', '#f2e9e1'),
|
||||||
|
dark: palette('#ebbcba', '#191724', '#e0def4', '#908caa', '#26233a'),
|
||||||
|
},
|
||||||
|
solarized: {
|
||||||
|
light: palette('#b58900', '#fdf6e3', '#586e75', '#839496', '#eee8d5'),
|
||||||
|
dark: palette('#268bd2', '#002b36', '#93a1a1', '#839496', '#073642'),
|
||||||
|
},
|
||||||
|
vercel: {
|
||||||
|
light: palette('#0070f3', '#ffffff', '#111111', '#666666', '#f5f5f5'),
|
||||||
|
dark: palette('#3291ff', '#000000', '#ededed', '#a1a1a1', '#111111'),
|
||||||
|
},
|
||||||
|
'vs-code-plus': {
|
||||||
|
light: palette('#007acc', '#ffffff', '#24292f', '#6e7781', '#f3f6f8'),
|
||||||
|
dark: palette('#3794ff', '#1e1e1e', '#d4d4d4', '#9cdcfe', '#252526'),
|
||||||
|
},
|
||||||
|
xcode: {
|
||||||
|
light: palette('#0a84ff', '#f7f9ff', '#1f2328', '#59636e', '#eef4ff'),
|
||||||
|
dark: palette('#409cff', '#1f2024', '#eef2ff', '#a9b0bd', '#2a2c33'),
|
||||||
|
},
|
||||||
|
linear: {
|
||||||
|
light: palette('#5e6ad2', '#f7f7fb', '#25262f', '#70717d', '#eeeeF8'),
|
||||||
|
dark: palette('#8a91f6', '#08090d', '#f7f8ff', '#a6a8b5', '#151722'),
|
||||||
|
},
|
||||||
|
lobster: {
|
||||||
|
light: palette('#e84d68', '#fff7f4', '#33262b', '#7d636a', '#ffe9e2'),
|
||||||
|
dark: palette('#ff5b6e', '#161821', '#f5d7dc', '#be9ca5', '#242333'),
|
||||||
|
},
|
||||||
|
material: {
|
||||||
|
light: palette('#6750a4', '#fffbfe', '#1c1b1f', '#6f6a73', '#f2edf7'),
|
||||||
|
dark: palette('#d0bcff', '#1c1b1f', '#e6e1e5', '#cac4cf', '#2b2930'),
|
||||||
|
},
|
||||||
|
matrix: {
|
||||||
|
light: palette('#10883a', '#f5fff7', '#102016', '#55735f', '#e8f8ec'),
|
||||||
|
dark: palette('#00ff66', '#030806', '#d8ffe4', '#6aff9b', '#07150d'),
|
||||||
|
},
|
||||||
|
monokai: {
|
||||||
|
light: palette('#7a8f00', '#f8f7ef', '#3b3a32', '#747065', '#efeee4'),
|
||||||
|
dark: palette('#a6e22e', '#272822', '#f8f8f2', '#cfcfc2', '#33342d'),
|
||||||
|
},
|
||||||
|
'night-owl': {
|
||||||
|
light: palette('#4876d9', '#fbfdff', '#25354a', '#60708a', '#eef6ff'),
|
||||||
|
dark: palette('#82aaff', '#011627', '#d6deeb', '#7fdbca', '#0b2942'),
|
||||||
|
},
|
||||||
|
nord: {
|
||||||
|
light: palette('#5e81ac', '#eceff4', '#2e3440', '#667085', '#e5e9f0'),
|
||||||
|
dark: palette('#88c0d0', '#2e3440', '#eceff4', '#d8dee9', '#3b4252'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function palette(accent: string, background: string, foreground: string, hint: string, secondary: string): ThemePalette {
|
||||||
|
const isDark = relativeLuminance(background) < 0.5
|
||||||
|
return {
|
||||||
|
accent,
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
hint,
|
||||||
|
secondary,
|
||||||
|
dialog: isDark ? mix(background, '#ffffff', 0.05) : mix(background, '#000000', 0.015),
|
||||||
|
surface: isDark ? mix(background, '#ffffff', 0.09) : mix(background, '#000000', 0.035),
|
||||||
|
surfaceHover: isDark ? mix(background, '#ffffff', 0.14) : mix(background, '#000000', 0.065),
|
||||||
|
code: isDark ? mix(background, '#ffffff', 0.11) : mix(background, '#000000', 0.045),
|
||||||
|
border: withAlpha(isDark ? '#ffffff' : '#0f172a', isDark ? 0.11 : 0.10),
|
||||||
|
subtle: withAlpha(isDark ? '#ffffff' : '#0f172a', isDark ? 0.06 : 0.045),
|
||||||
|
buttonText: readableText(accent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBrowser(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeGetItem(key: string): string | null {
|
||||||
|
if (!isBrowser()) return null
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSetItem(key: string, value: string): void {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRemoveItem(key: string): void {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseColorTheme(raw: string | null): ColorThemePreset {
|
||||||
|
return COLOR_THEME_OPTIONS.some((option) => option.value === raw) ? raw as ColorThemePreset : 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredColorTheme(): ColorThemePreset {
|
||||||
|
return parseColorTheme(safeGetItem(COLOR_THEME_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorThemeOptions(): ReadonlyArray<ColorThemeOption> {
|
||||||
|
return COLOR_THEME_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorThemePreview(theme: ColorThemePreset): ColorThemeOption['preview'] {
|
||||||
|
return COLOR_THEME_OPTIONS.find((option) => option.value === theme)?.preview ?? COLOR_THEME_OPTIONS[0]!.preview
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorThemeStorageKey(): string {
|
||||||
|
return COLOR_THEME_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorThemeBackground(theme: ColorThemePreset, scheme: ColorScheme): string | null {
|
||||||
|
return theme === 'default' ? null : PALETTES[theme][toPaletteScheme(scheme)].background
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorThemePickerValue(theme: ColorThemePreset, scheme: ColorScheme, id: ThemeColorKeyId): string | null {
|
||||||
|
if (theme === 'default') return null
|
||||||
|
const palette = PALETTES[theme][toPaletteScheme(scheme)]
|
||||||
|
const values: Record<ThemeColorKeyId, string> = {
|
||||||
|
background: palette.background,
|
||||||
|
surface: palette.secondary,
|
||||||
|
text: palette.foreground,
|
||||||
|
hint: palette.hint,
|
||||||
|
accent: palette.accent,
|
||||||
|
border: compositeOnBackground(palette.border, palette.background),
|
||||||
|
userBubble: palette.surface,
|
||||||
|
}
|
||||||
|
return values[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyColorTheme(theme: ColorThemePreset = getStoredColorTheme(), scheme: ColorScheme): void {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
|
||||||
|
const root = document.documentElement
|
||||||
|
root.setAttribute('data-color-theme', theme)
|
||||||
|
|
||||||
|
if (theme === 'default') {
|
||||||
|
removeThemeProperties(root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = PALETTES[theme][toPaletteScheme(scheme)]
|
||||||
|
const properties: Record<string, string> = {
|
||||||
|
'--app-bg': values.background,
|
||||||
|
'--app-fg': values.foreground,
|
||||||
|
'--app-hint': values.hint,
|
||||||
|
'--app-link': values.accent,
|
||||||
|
'--app-button': values.accent,
|
||||||
|
'--app-button-text': values.buttonText,
|
||||||
|
'--app-banner-bg': values.accent,
|
||||||
|
'--app-banner-text': values.buttonText,
|
||||||
|
'--app-secondary-bg': values.secondary,
|
||||||
|
'--app-dialog-bg': values.dialog,
|
||||||
|
'--app-chat-user-bg': values.surface,
|
||||||
|
'--app-chat-user-fg': values.foreground,
|
||||||
|
'--app-chat-user-chip-bg': withAlpha(values.accent, toPaletteScheme(scheme) === 'dark' ? 0.24 : 0.15),
|
||||||
|
'--app-chat-user-chip-fg': values.accent,
|
||||||
|
'--app-tool-card-bg': values.surface,
|
||||||
|
'--app-tool-card-hover-bg': values.surfaceHover,
|
||||||
|
'--app-tool-card-accent': values.hint,
|
||||||
|
'--app-tool-card-muted-action-fg': withAlpha(values.hint, 0.72),
|
||||||
|
'--app-tool-card-subtitle': values.hint,
|
||||||
|
'--app-code-header-bg': values.surfaceHover,
|
||||||
|
'--app-code-header-fg': values.hint,
|
||||||
|
'--app-code-bg': values.code,
|
||||||
|
'--app-inline-code-bg': values.code,
|
||||||
|
'--app-inline-code-fg': values.foreground,
|
||||||
|
'--app-md-quote-bg': values.surface,
|
||||||
|
'--app-md-quote-border': withAlpha(values.accent, 0.35),
|
||||||
|
'--app-md-quote-fg': values.foreground,
|
||||||
|
'--app-md-table-bg': values.surface,
|
||||||
|
'--app-md-table-head-bg': values.surfaceHover,
|
||||||
|
'--app-reasoning-bg': values.surface,
|
||||||
|
'--app-border': values.border,
|
||||||
|
'--app-divider': values.border,
|
||||||
|
'--app-subtle-bg': values.subtle,
|
||||||
|
'--app-scrollbar-thumb': withAlpha(values.hint, 0.38),
|
||||||
|
'--app-scrollbar-thumb-hover': withAlpha(values.hint, 0.56),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
root.style.setProperty(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeThemeProperties(root: HTMLElement): void {
|
||||||
|
const properties = [
|
||||||
|
'--app-bg', '--app-fg', '--app-hint', '--app-link', '--app-button', '--app-button-text', '--app-banner-bg', '--app-banner-text',
|
||||||
|
'--app-secondary-bg', '--app-dialog-bg', '--app-chat-user-bg', '--app-chat-user-fg', '--app-chat-user-chip-bg',
|
||||||
|
'--app-chat-user-chip-fg', '--app-tool-card-bg', '--app-tool-card-hover-bg', '--app-tool-card-accent',
|
||||||
|
'--app-tool-card-muted-action-fg', '--app-tool-card-subtitle', '--app-code-header-bg', '--app-code-header-fg', '--app-code-bg',
|
||||||
|
'--app-inline-code-bg', '--app-inline-code-fg', '--app-md-quote-bg', '--app-md-quote-border', '--app-md-quote-fg', '--app-md-table-bg',
|
||||||
|
'--app-md-table-head-bg', '--app-reasoning-bg', '--app-border', '--app-divider', '--app-subtle-bg', '--app-scrollbar-thumb', '--app-scrollbar-thumb-hover',
|
||||||
|
]
|
||||||
|
for (const property of properties) root.style.removeProperty(property)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getDocumentColorScheme(): ColorScheme {
|
||||||
|
if (!isBrowser()) return 'light'
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme')
|
||||||
|
return theme === 'dark' || theme === 'oled' ? theme : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPaletteScheme(scheme: ColorScheme): 'light' | 'dark' {
|
||||||
|
return scheme === 'light' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorTheme(): { colorTheme: ColorThemePreset; setColorTheme: (theme: ColorThemePreset) => void } {
|
||||||
|
const [colorTheme, setColorThemeState] = useState<ColorThemePreset>(getStoredColorTheme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== COLOR_THEME_KEY) return
|
||||||
|
const nextTheme = parseColorTheme(event.newValue)
|
||||||
|
setColorThemeState(nextTheme)
|
||||||
|
applyColorTheme(nextTheme, getDocumentColorScheme())
|
||||||
|
window.dispatchEvent(new CustomEvent('hapi-color-theme-change', { detail: nextTheme }))
|
||||||
|
}
|
||||||
|
window.addEventListener('storage', onStorage)
|
||||||
|
return () => window.removeEventListener('storage', onStorage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setColorTheme = useCallback((theme: ColorThemePreset) => {
|
||||||
|
setColorThemeState(theme)
|
||||||
|
applyColorTheme(theme, getDocumentColorScheme())
|
||||||
|
if (theme === 'default') {
|
||||||
|
safeRemoveItem(COLOR_THEME_KEY)
|
||||||
|
} else {
|
||||||
|
safeSetItem(COLOR_THEME_KEY, theme)
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('hapi-color-theme-change', { detail: theme }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { colorTheme, setColorTheme }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(a: string, b: string, weight: number): string {
|
||||||
|
const ca = parseHex(a)
|
||||||
|
const cb = parseHex(b)
|
||||||
|
const channel = (x: number, y: number) => Math.round(x * (1 - weight) + y * weight)
|
||||||
|
return toHex(channel(ca.r, cb.r), channel(ca.g, cb.g), channel(ca.b, cb.b))
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAlpha(hex: string, alpha: number): string {
|
||||||
|
const color = parseHex(hex)
|
||||||
|
return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compositeOnBackground(rgba: string, background: string): string {
|
||||||
|
const match = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(rgba)
|
||||||
|
if (!match) return rgba
|
||||||
|
const base = parseHex(background)
|
||||||
|
const alpha = Number(match[4])
|
||||||
|
const blend = (foreground: number, behind: number) => Math.round(foreground * alpha + behind * (1 - alpha))
|
||||||
|
return toHex(blend(Number(match[1]), base.r), blend(Number(match[2]), base.g), blend(Number(match[3]), base.b))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readableText(hex: string): string {
|
||||||
|
return relativeLuminance(hex) > 0.45 ? '#111827' : '#ffffff'
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeLuminance(hex: string): number {
|
||||||
|
const { r, g, b } = parseHex(hex)
|
||||||
|
const convert = (value: number) => {
|
||||||
|
const next = value / 255
|
||||||
|
return next <= 0.03928 ? next / 12.92 : ((next + 0.055) / 1.055) ** 2.4
|
||||||
|
}
|
||||||
|
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHex(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const clean = hex.replace('#', '')
|
||||||
|
const value = clean.length === 3
|
||||||
|
? clean.split('').map((char) => `${char}${char}`).join('')
|
||||||
|
: clean
|
||||||
|
return {
|
||||||
|
r: parseInt(value.slice(0, 2), 16),
|
||||||
|
g: parseInt(value.slice(2, 4), 16),
|
||||||
|
b: parseInt(value.slice(4, 6), 16),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(r: number, g: number, b: number): string {
|
||||||
|
return `#${[r, g, b].map((value) => value.toString(16).padStart(2, '0')).join('')}`
|
||||||
|
}
|
||||||
@@ -26,6 +26,33 @@ describe('useTheme', () => {
|
|||||||
expect(meta?.hasAttribute('media')).toBe(false)
|
expect(meta?.hasAttribute('media')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('clears the boot-time inline html background when runtime theme initializes', () => {
|
||||||
|
document.documentElement.style.backgroundColor = '#fbfbff'
|
||||||
|
localStorage.setItem('hapi-color-theme', 'one')
|
||||||
|
|
||||||
|
initializeTheme()
|
||||||
|
|
||||||
|
expect(document.documentElement.style.backgroundColor).toBe('')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#fbfbff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates browser theme color after a cross-tab color theme change', () => {
|
||||||
|
localStorage.setItem('hapi-color-theme', 'one')
|
||||||
|
initializeTheme()
|
||||||
|
expect(document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content).toBe('#fbfbff')
|
||||||
|
|
||||||
|
localStorage.setItem('hapi-color-theme', 'notion')
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: 'hapi-color-theme', newValue: 'notion' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content).toBe('#fafafa')
|
||||||
|
})
|
||||||
|
|
||||||
it('updates the browser theme color when appearance changes', () => {
|
it('updates the browser theme color when appearance changes', () => {
|
||||||
const { result } = renderHook(() => useAppearance())
|
const { result } = renderHook(() => useAppearance())
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||||
import { getTelegramWebApp } from './useTelegram'
|
import { getTelegramWebApp } from './useTelegram'
|
||||||
|
import { applyColorTheme, getColorThemeBackground, getColorThemeStorageKey, getStoredColorTheme, type ColorScheme } from './useColorTheme'
|
||||||
type ColorScheme = 'light' | 'dark' | 'oled'
|
|
||||||
|
|
||||||
export type AppearancePreference = 'system' | 'dark' | 'light' | 'oled'
|
export type AppearancePreference = 'system' | 'dark' | 'light' | 'oled'
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ function applyBrowserThemeColor(scheme: ColorScheme): void {
|
|||||||
document.head.appendChild(meta)
|
document.head.appendChild(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.content = THEME_COLORS[scheme]
|
meta.content = getColorThemeBackground(getStoredColorTheme(), scheme) ?? THEME_COLORS[scheme]
|
||||||
meta.removeAttribute('media')
|
meta.removeAttribute('media')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +101,9 @@ export function getThemeColor(scheme: ColorScheme): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(scheme: ColorScheme): void {
|
function applyTheme(scheme: ColorScheme): void {
|
||||||
|
document.documentElement.style.removeProperty('background-color')
|
||||||
document.documentElement.setAttribute('data-theme', scheme)
|
document.documentElement.setAttribute('data-theme', scheme)
|
||||||
|
applyColorTheme(getStoredColorTheme(), scheme)
|
||||||
applyBrowserThemeColor(scheme)
|
applyBrowserThemeColor(scheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +129,9 @@ function getSnapshot(): ColorScheme {
|
|||||||
return currentScheme
|
return currentScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScheme(): void {
|
function updateScheme(force = false): void {
|
||||||
const newScheme = getColorScheme()
|
const newScheme = getColorScheme()
|
||||||
if (newScheme !== currentScheme) {
|
if (force || newScheme !== currentScheme) {
|
||||||
currentScheme = newScheme
|
currentScheme = newScheme
|
||||||
applyTheme(newScheme)
|
applyTheme(newScheme)
|
||||||
listeners.forEach((cb) => cb())
|
listeners.forEach((cb) => cb())
|
||||||
@@ -190,18 +191,20 @@ export function initializeTheme(): void {
|
|||||||
const tg = getTelegramWebApp()
|
const tg = getTelegramWebApp()
|
||||||
if (tg?.onEvent) {
|
if (tg?.onEvent) {
|
||||||
// Telegram theme changes
|
// Telegram theme changes
|
||||||
tg.onEvent('themeChanged', updateScheme)
|
tg.onEvent('themeChanged', () => updateScheme())
|
||||||
} else if (typeof window !== 'undefined' && window.matchMedia) {
|
} else if (typeof window !== 'undefined' && window.matchMedia) {
|
||||||
// Browser system preference changes
|
// Browser system preference changes
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
mediaQuery.addEventListener('change', updateScheme)
|
mediaQuery.addEventListener('change', () => updateScheme())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-tab appearance sync: update theme when another tab changes localStorage
|
// Cross-tab appearance sync: update theme when another tab changes localStorage
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('storage', (event: StorageEvent) => {
|
window.addEventListener('storage', (event: StorageEvent) => {
|
||||||
if (event.key === APPEARANCE_KEY) updateScheme()
|
if (event.key === APPEARANCE_KEY) updateScheme()
|
||||||
|
if (event.key === getColorThemeStorageKey()) updateScheme(true)
|
||||||
})
|
})
|
||||||
|
window.addEventListener('hapi-color-theme-change', () => updateScheme(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,53 @@ describe('useThemeColors', () => {
|
|||||||
expect(localStorage.getItem('hapi-theme-colors')).toBeNull()
|
expect(localStorage.getItem('hapi-theme-colors')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('preserves color theme preset variables when no custom colors are stored', () => {
|
||||||
|
localStorage.setItem('hapi-color-theme', 'one')
|
||||||
|
setScheme('light')
|
||||||
|
|
||||||
|
applyThemeColors()
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#fbfbff')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-link').trim()).toBe('#526fff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('layers custom colors over color theme presets', () => {
|
||||||
|
localStorage.setItem('hapi-color-theme', 'one')
|
||||||
|
localStorage.setItem('hapi-theme-colors', JSON.stringify({ light: { background: '#123456' } }))
|
||||||
|
setScheme('light')
|
||||||
|
|
||||||
|
applyThemeColors()
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#123456')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-link').trim()).toBe('#526fff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the active color theme as the custom color picker baseline', () => {
|
||||||
|
localStorage.setItem('hapi-color-theme', 'one')
|
||||||
|
setScheme('light')
|
||||||
|
const { result } = renderHook(() => useThemeColors())
|
||||||
|
|
||||||
|
expect(result.current.getPickerValue('background')).toBe('#fbfbff')
|
||||||
|
expect(result.current.getPickerValue('accent')).toBe('#526fff')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('reapplies color theme preset changes from cross-tab storage events without Settings mounted', () => {
|
||||||
|
localStorage.setItem('hapi-color-theme', 'one')
|
||||||
|
setScheme('light')
|
||||||
|
initializeThemeColors()
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#fbfbff')
|
||||||
|
|
||||||
|
localStorage.setItem('hapi-color-theme', 'notion')
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: 'hapi-color-theme', newValue: 'notion' }))
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#fafafa')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--app-link').trim()).toBe('#3183d8')
|
||||||
|
})
|
||||||
|
|
||||||
it('reapplies stored colors for the active appearance during initialization', () => {
|
it('reapplies stored colors for the active appearance during initialization', () => {
|
||||||
localStorage.setItem('hapi-theme-colors', JSON.stringify({ oled: { background: '#0b0b0b' } }))
|
localStorage.setItem('hapi-theme-colors', JSON.stringify({ oled: { background: '#0b0b0b' } }))
|
||||||
setScheme('oled')
|
setScheme('oled')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { applyColorTheme, getColorThemePickerValue, getColorThemeStorageKey, getStoredColorTheme, type ColorScheme } from './useColorTheme'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-appearance "key color" customization.
|
* Per-appearance "key color" customization.
|
||||||
@@ -243,24 +244,24 @@ export function applyThemeColors(): void {
|
|||||||
if (!isBrowser()) return
|
if (!isBrowser()) return
|
||||||
|
|
||||||
const scheme = getThemeScheme()
|
const scheme = getThemeScheme()
|
||||||
|
applyColorTheme(getStoredColorTheme(), scheme as ColorScheme)
|
||||||
|
|
||||||
const overrides = getStoredThemeColors()[scheme] ?? {}
|
const overrides = getStoredThemeColors()[scheme] ?? {}
|
||||||
const rootStyle = document.documentElement.style
|
const rootStyle = document.documentElement.style
|
||||||
|
|
||||||
for (const key of THEME_COLOR_KEYS) {
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
const override = overrides[key.id]
|
const override = overrides[key.id]
|
||||||
const hex = override && isHexColor(override) ? override : null
|
const hex = override && isHexColor(override) ? override : null
|
||||||
|
if (!hex) continue
|
||||||
|
|
||||||
for (const cssVar of key.targets) {
|
for (const cssVar of key.targets) {
|
||||||
if (hex) rootStyle.setProperty(cssVar, hex)
|
rootStyle.setProperty(cssVar, hex)
|
||||||
else rootStyle.removeProperty(cssVar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.derivedTargets) {
|
if (key.derivedTargets && key.derive) {
|
||||||
const derived = hex && key.derive ? key.derive(hex, scheme) : {}
|
const derived = key.derive(hex, scheme)
|
||||||
for (const cssVar of key.derivedTargets) {
|
for (const cssVar of key.derivedTargets) {
|
||||||
const value = derived[cssVar]
|
rootStyle.setProperty(cssVar, derived[cssVar]!)
|
||||||
if (value) rootStyle.setProperty(cssVar, value)
|
|
||||||
else rootStyle.removeProperty(cssVar)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +269,7 @@ export function applyThemeColors(): void {
|
|||||||
|
|
||||||
export function getThemeColorPickerValue(scheme: ThemeScheme, id: ThemeColorKeyId): string {
|
export function getThemeColorPickerValue(scheme: ThemeScheme, id: ThemeColorKeyId): string {
|
||||||
const override = getStoredThemeColors()[scheme]?.[id]
|
const override = getStoredThemeColors()[scheme]?.[id]
|
||||||
return override ?? DEFAULT_HEX[scheme][id]
|
return override ?? getColorThemePickerValue(getStoredColorTheme(), scheme, id) ?? DEFAULT_HEX[scheme][id]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initializeThemeColors(): void {
|
export function initializeThemeColors(): void {
|
||||||
@@ -280,8 +281,9 @@ export function initializeThemeColors(): void {
|
|||||||
initialized = true
|
initialized = true
|
||||||
|
|
||||||
window.addEventListener('storage', (event: StorageEvent) => {
|
window.addEventListener('storage', (event: StorageEvent) => {
|
||||||
if (event.key === STORAGE_KEY) applyThemeColors()
|
if (event.key === STORAGE_KEY || event.key === getColorThemeStorageKey()) applyThemeColors()
|
||||||
})
|
})
|
||||||
|
window.addEventListener('hapi-color-theme-change', applyThemeColors)
|
||||||
|
|
||||||
const themeObserver = new MutationObserver(() => {
|
const themeObserver = new MutationObserver(() => {
|
||||||
applyThemeColors()
|
applyThemeColors()
|
||||||
@@ -323,13 +325,16 @@ export function useThemeColors(): {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onStorage = (event: StorageEvent) => {
|
const onStorage = (event: StorageEvent) => {
|
||||||
if (event.key === STORAGE_KEY) refresh()
|
if (event.key === STORAGE_KEY || event.key === getColorThemeStorageKey()) refresh()
|
||||||
}
|
}
|
||||||
|
const onColorThemeChange = () => refresh()
|
||||||
window.addEventListener('storage', onStorage)
|
window.addEventListener('storage', onStorage)
|
||||||
|
window.addEventListener('hapi-color-theme-change', onColorThemeChange)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
themeObserver.disconnect()
|
themeObserver.disconnect()
|
||||||
window.removeEventListener('storage', onStorage)
|
window.removeEventListener('storage', onStorage)
|
||||||
|
window.removeEventListener('hapi-color-theme-change', onColorThemeChange)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -374,7 +379,7 @@ export function useThemeColors(): {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getPickerValue = useCallback(
|
const getPickerValue = useCallback(
|
||||||
(id: ThemeColorKeyId) => overrides[id] ?? DEFAULT_HEX[scheme][id],
|
(id: ThemeColorKeyId) => overrides[id] ?? getColorThemePickerValue(getStoredColorTheme(), scheme, id) ?? DEFAULT_HEX[scheme][id],
|
||||||
[overrides, scheme],
|
[overrides, scheme],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -563,6 +563,24 @@ export default {
|
|||||||
'settings.display.appearance.dark': 'Dark',
|
'settings.display.appearance.dark': 'Dark',
|
||||||
'settings.display.appearance.oled': 'OLED Black',
|
'settings.display.appearance.oled': 'OLED Black',
|
||||||
'settings.display.appearance.light': 'Light',
|
'settings.display.appearance.light': 'Light',
|
||||||
|
'settings.display.colorTheme': 'Color theme',
|
||||||
|
'settings.display.colorTheme.default': 'Default',
|
||||||
|
'settings.display.colorTheme.notion': 'Notion',
|
||||||
|
'settings.display.colorTheme.one': 'One',
|
||||||
|
'settings.display.colorTheme.proof': 'Proof',
|
||||||
|
'settings.display.colorTheme.raycast': 'Raycast',
|
||||||
|
'settings.display.colorTheme.rosePine': 'Rose Pine',
|
||||||
|
'settings.display.colorTheme.solarized': 'Solarized',
|
||||||
|
'settings.display.colorTheme.vercel': 'Vercel',
|
||||||
|
'settings.display.colorTheme.vsCodePlus': 'VS Code Plus',
|
||||||
|
'settings.display.colorTheme.xcode': 'Xcode',
|
||||||
|
'settings.display.colorTheme.linear': 'Linear',
|
||||||
|
'settings.display.colorTheme.lobster': 'Lobster',
|
||||||
|
'settings.display.colorTheme.material': 'Material',
|
||||||
|
'settings.display.colorTheme.matrix': 'Matrix',
|
||||||
|
'settings.display.colorTheme.monokai': 'Monokai',
|
||||||
|
'settings.display.colorTheme.nightOwl': 'Night Owl',
|
||||||
|
'settings.display.colorTheme.nord': 'Nord',
|
||||||
'settings.display.themeColors.title': 'Custom colors',
|
'settings.display.themeColors.title': 'Custom colors',
|
||||||
'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.',
|
'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.',
|
||||||
'settings.display.themeColors.reset': 'Reset',
|
'settings.display.themeColors.reset': 'Reset',
|
||||||
|
|||||||
@@ -567,6 +567,24 @@ export default {
|
|||||||
'settings.display.appearance.dark': '深色',
|
'settings.display.appearance.dark': '深色',
|
||||||
'settings.display.appearance.oled': 'OLED 纯黑',
|
'settings.display.appearance.oled': 'OLED 纯黑',
|
||||||
'settings.display.appearance.light': '浅色',
|
'settings.display.appearance.light': '浅色',
|
||||||
|
'settings.display.colorTheme': '颜色主题',
|
||||||
|
'settings.display.colorTheme.default': '默认',
|
||||||
|
'settings.display.colorTheme.notion': 'Notion',
|
||||||
|
'settings.display.colorTheme.one': 'One',
|
||||||
|
'settings.display.colorTheme.proof': 'Proof',
|
||||||
|
'settings.display.colorTheme.raycast': 'Raycast',
|
||||||
|
'settings.display.colorTheme.rosePine': 'Rose Pine',
|
||||||
|
'settings.display.colorTheme.solarized': 'Solarized',
|
||||||
|
'settings.display.colorTheme.vercel': 'Vercel',
|
||||||
|
'settings.display.colorTheme.vsCodePlus': 'VS Code Plus',
|
||||||
|
'settings.display.colorTheme.xcode': 'Xcode',
|
||||||
|
'settings.display.colorTheme.linear': 'Linear',
|
||||||
|
'settings.display.colorTheme.lobster': 'Lobster',
|
||||||
|
'settings.display.colorTheme.material': 'Material',
|
||||||
|
'settings.display.colorTheme.matrix': 'Matrix',
|
||||||
|
'settings.display.colorTheme.monokai': 'Monokai',
|
||||||
|
'settings.display.colorTheme.nightOwl': 'Night Owl',
|
||||||
|
'settings.display.colorTheme.nord': 'Nord',
|
||||||
'settings.display.themeColors.title': '自定义配色',
|
'settings.display.themeColors.title': '自定义配色',
|
||||||
'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。',
|
'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。',
|
||||||
'settings.display.themeColors.reset': '重置',
|
'settings.display.themeColors.reset': '重置',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from '@/lib/use-translation'
|
import { useTranslation } from '@/lib/use-translation'
|
||||||
import { getAppearanceOptions, useAppearance } from '@/hooks/useTheme'
|
import { getAppearanceOptions, useAppearance } from '@/hooks/useTheme'
|
||||||
|
import { getColorThemeOptions, getColorThemePreview, useColorTheme, type ColorThemePreset } from '@/hooks/useColorTheme'
|
||||||
import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale'
|
import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale'
|
||||||
import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize'
|
import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize'
|
||||||
import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
|
import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
|
||||||
@@ -17,6 +18,46 @@ function PlusIcon() {
|
|||||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ColorThemePicker() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { colorTheme, setColorTheme } = useColorTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="px-3 py-3">
|
||||||
|
<legend className="mb-2 text-[var(--app-fg)]">{t('settings.display.colorTheme')}</legend>
|
||||||
|
<div role="radiogroup" aria-label={t('settings.display.colorTheme')} className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{getColorThemeOptions().map((option) => (
|
||||||
|
<ColorThemeOption
|
||||||
|
key={option.value}
|
||||||
|
theme={option.value}
|
||||||
|
label={t(option.labelKey)}
|
||||||
|
selected={colorTheme === option.value}
|
||||||
|
onSelect={setColorTheme}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorThemeOption(props: { theme: ColorThemePreset; label: string; selected: boolean; onSelect: (theme: ColorThemePreset) => void }) {
|
||||||
|
const preview = getColorThemePreview(props.theme)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`flex min-w-0 items-center gap-2 rounded-lg border px-2 py-2 text-left text-sm transition-colors ${props.selected
|
||||||
|
? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)]'
|
||||||
|
: 'border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}
|
||||||
|
>
|
||||||
|
<input type="radio" name="color-theme" value={props.theme} checked={props.selected} onChange={() => props.onSelect(props.theme)} className="sr-only" />
|
||||||
|
<span className="relative h-7 w-7 shrink-0 overflow-hidden rounded-md border border-[var(--app-border)]" style={{ backgroundColor: preview.light }} aria-hidden="true">
|
||||||
|
<span className="absolute inset-y-0 right-0 w-1/2" style={{ backgroundColor: preview.dark }} />
|
||||||
|
<span className="absolute inset-x-1 bottom-1 h-1 rounded-full" style={{ backgroundColor: preview.accent }} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate font-medium">{props.label}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SessionPreviewLimitControl() {
|
function SessionPreviewLimitControl() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit()
|
const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit()
|
||||||
@@ -105,6 +146,7 @@ export default function SettingsDisplayPage() {
|
|||||||
options={getAppearanceOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
|
options={getAppearanceOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
|
||||||
onChange={setAppearance}
|
onChange={setAppearance}
|
||||||
/>
|
/>
|
||||||
|
<ColorThemePicker />
|
||||||
<ThemeColorControls />
|
<ThemeColorControls />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,27 @@ import SettingsVoicePage from './voice'
|
|||||||
import SettingsVoiceVoicesPage from './voice-voices'
|
import SettingsVoiceVoicesPage from './voice-voices'
|
||||||
import SettingsVoiceAdvancedPage from './voice-advanced'
|
import SettingsVoiceAdvancedPage from './voice-advanced'
|
||||||
|
|
||||||
const { navigate, setAppearance, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({
|
const { navigate, setAppearance, setColorTheme, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({
|
||||||
navigate: vi.fn(),
|
navigate: vi.fn(),
|
||||||
setAppearance: vi.fn(),
|
setAppearance: vi.fn(),
|
||||||
|
setColorTheme: vi.fn(),
|
||||||
setFontScale: vi.fn(),
|
setFontScale: vi.fn(),
|
||||||
setTerminalFontSize: vi.fn(),
|
setTerminalFontSize: vi.fn(),
|
||||||
setComposerEnterBehavior: vi.fn(),
|
setComposerEnterBehavior: vi.fn(),
|
||||||
setVoice: vi.fn(),
|
setVoice: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useColorTheme', () => ({
|
||||||
|
useColorTheme: () => ({ colorTheme: 'default', setColorTheme }),
|
||||||
|
getColorThemeOptions: () => [
|
||||||
|
{ value: 'default', labelKey: 'settings.display.colorTheme.default' },
|
||||||
|
{ value: 'nord', labelKey: 'settings.display.colorTheme.nord' },
|
||||||
|
],
|
||||||
|
getColorThemePreview: (theme: string) => theme === 'nord'
|
||||||
|
? { light: '#eceff4', dark: '#2e3440', accent: '#88c0d0' }
|
||||||
|
: { light: '#ffffff', dark: '#1c1c1e', accent: '#111827' },
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@tanstack/react-router', () => ({
|
vi.mock('@tanstack/react-router', () => ({
|
||||||
useNavigate: () => navigate,
|
useNavigate: () => navigate,
|
||||||
}))
|
}))
|
||||||
@@ -172,6 +184,8 @@ describe('responsive settings pages', () => {
|
|||||||
it('renders compact display controls without dropdown popovers', () => {
|
it('renders compact display controls without dropdown popovers', () => {
|
||||||
renderPage(<SettingsDisplayPage />)
|
renderPage(<SettingsDisplayPage />)
|
||||||
expect(screen.getByRole('radio', { name: 'OLED Black' })).toBeInTheDocument()
|
expect(screen.getByRole('radio', { name: 'OLED Black' })).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Nord' }))
|
||||||
|
expect(setColorTheme).toHaveBeenCalledWith('nord')
|
||||||
expect(screen.getByRole('radio', { name: '120%' })).toBeInTheDocument()
|
expect(screen.getByRole('radio', { name: '120%' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('spinbutton', { name: 'Sessions Before Folding' })).toHaveValue(8)
|
expect(screen.getByRole('spinbutton', { name: 'Sessions Before Folding' })).toHaveValue(8)
|
||||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||||
|
|||||||
Reference in New Issue
Block a user