feat: strengthen private runtime and adaptive UI behavior
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b3fdf96962
commit
e8dc4d03fd
@@ -12,6 +12,7 @@ import type { AgentEvent, DesktopApi } from '../../shared/contracts'
|
||||
import App from './App'
|
||||
|
||||
let agentListener: ((event: AgentEvent) => void) | undefined
|
||||
let newConversationListener: (() => void) | undefined
|
||||
const run = vi.fn<DesktopApi['agent']['run']>()
|
||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
const projectId = '00000000-0000-4000-8000-000000000101'
|
||||
@@ -38,7 +39,12 @@ const api: DesktopApi = {
|
||||
show: vi.fn(async () => {}),
|
||||
hide: vi.fn(async () => {}),
|
||||
clearLocalData: vi.fn(async () => {}),
|
||||
onNewConversation: vi.fn(() => () => {}),
|
||||
onNewConversation: vi.fn((listener) => {
|
||||
newConversationListener = listener
|
||||
return () => {
|
||||
newConversationListener = undefined
|
||||
}
|
||||
}),
|
||||
onOpenSettings: vi.fn(() => () => {})
|
||||
},
|
||||
agent: {
|
||||
@@ -380,7 +386,10 @@ const api: DesktopApi = {
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.clearAllMocks()
|
||||
newConversationListener = undefined
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'model',
|
||||
label: 'sonnet-5',
|
||||
@@ -434,6 +443,93 @@ describe('App', () => {
|
||||
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => {
|
||||
vi.mocked(api.agent.getStatus).mockReturnValue(
|
||||
new Promise(() => {})
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
const composer = screen.getByLabelText('向 GoodBuddy 提问')
|
||||
fireEvent.change(composer, {
|
||||
target: { value: '等待 Runtime' }
|
||||
})
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
|
||||
expect(composer).toHaveValue('等待 Runtime')
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: '设置中心' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByText('Agent Runtime 正在加载,请稍后重试')
|
||||
).toBeInTheDocument()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a new-conversation draft in chat when the runtime is unavailable', async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'setup',
|
||||
label: '需要配置模型',
|
||||
available: false,
|
||||
supportsToolExecution: false,
|
||||
detail: '请配置模型'
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '设置中心' })
|
||||
).toBeInTheDocument()
|
||||
const newConversation = screen.getByRole('button', {
|
||||
name: /新建对话/u
|
||||
})
|
||||
fireEvent.click(newConversation)
|
||||
|
||||
const composer = screen.getByLabelText('向 GoodBuddy 提问')
|
||||
await waitFor(() => expect(composer).toHaveFocus())
|
||||
fireEvent.change(composer, {
|
||||
target: { value: '保留这条草稿' }
|
||||
})
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
|
||||
expect(composer).toHaveValue('保留这条草稿')
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: '设置中心' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByText(/请先配置可用的模型或 Agent Runtime/u)
|
||||
).toBeInTheDocument()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens chat and focuses the composer for tray conversations', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByText('本地工作区'))
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '设置中心' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
act(() => newConversationListener?.())
|
||||
|
||||
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
|
||||
await waitFor(() => expect(composer).toHaveFocus())
|
||||
})
|
||||
|
||||
it('applies and persists a dark appearance from Settings', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByText('本地工作区'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: /暗色/u }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
)
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(localStorage.getItem('goodbuddy.appearance-theme')).toBe(
|
||||
'dark'
|
||||
)
|
||||
})
|
||||
|
||||
it('loads token usage in activity and refreshes it when a run finishes', async () => {
|
||||
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
|
||||
totals: {
|
||||
|
||||
+84
-26
@@ -80,6 +80,15 @@ import {
|
||||
type SidebarArtifact
|
||||
} from './RightAssistantSidebar'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
|
||||
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
|
||||
import {
|
||||
applyAppearanceTheme,
|
||||
loadAppearanceTheme,
|
||||
resolveAppearanceTheme,
|
||||
saveAppearanceTheme,
|
||||
type AppearanceTheme
|
||||
} from './theme'
|
||||
|
||||
type ToolActivity = {
|
||||
callId?: string
|
||||
@@ -454,6 +463,17 @@ function App(): React.JSX.Element {
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
||||
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
|
||||
const [appearanceTheme, setAppearanceTheme] =
|
||||
useState<AppearanceTheme>(loadAppearanceTheme)
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(
|
||||
() =>
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
)
|
||||
const resolvedAppearanceTheme = resolveAppearanceTheme(
|
||||
appearanceTheme,
|
||||
systemPrefersDark
|
||||
)
|
||||
const effectiveWorkMode =
|
||||
workMode === 'execute' && runtime?.supportsToolExecution === false
|
||||
? 'ask'
|
||||
@@ -495,6 +515,47 @@ function App(): React.JSX.Element {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const startNewConversation = useCallback((projectId?: string): void => {
|
||||
const conversation = createConversation(projectId)
|
||||
setConversations((current) => [conversation, ...current])
|
||||
setActiveId(conversation.id)
|
||||
setView('chat')
|
||||
setInput('')
|
||||
setAttachments((current) => {
|
||||
for (const attachment of current) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
return []
|
||||
})
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
saveAppearanceTheme(appearanceTheme)
|
||||
}, [appearanceTheme])
|
||||
|
||||
useEffect(() => {
|
||||
applyAppearanceTheme(resolvedAppearanceTheme)
|
||||
}, [resolvedAppearanceTheme])
|
||||
|
||||
useEffect(() => {
|
||||
if (appearanceTheme !== 'system') {
|
||||
return
|
||||
}
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
return
|
||||
}
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const updateSystemTheme = (): void => {
|
||||
setSystemPrefersDark(systemTheme.matches)
|
||||
}
|
||||
updateSystemTheme()
|
||||
systemTheme.addEventListener('change', updateSystemTheme)
|
||||
return () => {
|
||||
systemTheme.removeEventListener('change', updateSystemTheme)
|
||||
}
|
||||
}, [appearanceTheme])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
return
|
||||
@@ -1352,18 +1413,9 @@ function App(): React.JSX.Element {
|
||||
window.goodbuddy.agent.onEvent(handleAgentEvent)
|
||||
const removeNewConversationListener =
|
||||
window.goodbuddy.app.onNewConversation(() => {
|
||||
const conversation = createConversation(
|
||||
startNewConversation(
|
||||
activeProjectIdRef.current || undefined
|
||||
)
|
||||
setConversations((current) => [conversation, ...current])
|
||||
setActiveId(conversation.id)
|
||||
setAttachments((current) => {
|
||||
for (const attachment of current) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
return []
|
||||
})
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
const removeOpenSettingsListener =
|
||||
window.goodbuddy.app.onOpenSettings(() => setView('settings'))
|
||||
@@ -1372,7 +1424,7 @@ function App(): React.JSX.Element {
|
||||
removeNewConversationListener()
|
||||
removeOpenSettingsListener()
|
||||
}
|
||||
}, [handleAgentEvent])
|
||||
}, [handleAgentEvent, startNewConversation])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
@@ -1429,16 +1481,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const newConversation = (): void => {
|
||||
const conversation = createConversation(activeProjectId || undefined)
|
||||
setConversations((current) => [conversation, ...current])
|
||||
setActiveId(conversation.id)
|
||||
setView('chat')
|
||||
setInput('')
|
||||
for (const attachment of attachments) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
setAttachments([])
|
||||
inputRef.current?.focus()
|
||||
startNewConversation(activeProjectId || undefined)
|
||||
}
|
||||
|
||||
const setMemoryStatus = async (
|
||||
@@ -1576,8 +1619,11 @@ function App(): React.JSX.Element {
|
||||
if (!prompt || !activeConversation) {
|
||||
return
|
||||
}
|
||||
if (!runtime?.available) {
|
||||
setView('settings')
|
||||
if (!runtime) {
|
||||
setNotice('Agent Runtime 正在加载,请稍后重试')
|
||||
return
|
||||
}
|
||||
if (!runtime.available) {
|
||||
setNotice('请先配置可用的模型或 Agent Runtime')
|
||||
return
|
||||
}
|
||||
@@ -1980,7 +2026,15 @@ function App(): React.JSX.Element {
|
||||
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
|
||||
<div className="brand">
|
||||
<div className="brand__mark">
|
||||
<Bot size={20} strokeWidth={2.4} />
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
src={
|
||||
resolvedAppearanceTheme === 'dark'
|
||||
? goodbuddyDarkIcon
|
||||
: goodbuddyLightIcon
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="brand__copy">
|
||||
<strong>GoodBuddy</strong>
|
||||
@@ -2233,7 +2287,9 @@ function App(): React.JSX.Element {
|
||||
title={runtime?.detail}
|
||||
>
|
||||
<span className="runtime-status__dot" />
|
||||
{runtime?.label ?? '正在检测运行时'}
|
||||
<span className="runtime-status__label">
|
||||
{runtime?.label ?? '正在检测运行时'}
|
||||
</span>
|
||||
{runtime?.capability === 'image-generation' && (
|
||||
<span className="runtime-capability-badge">生图</span>
|
||||
)}
|
||||
@@ -3018,7 +3074,7 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
) : view === 'heartbeat' ? (
|
||||
<div className="workspace-panel-scroll">
|
||||
<div className="workspace-panel-scroll workspace-panel-scroll--heartbeat">
|
||||
<HeartbeatCenter
|
||||
configs={assistantHeartbeats}
|
||||
entries={heartbeatEntries}
|
||||
@@ -3037,7 +3093,9 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
) : view === 'settings' ? (
|
||||
<SettingsPanel
|
||||
appearanceTheme={appearanceTheme}
|
||||
heartbeats={assistantHeartbeats}
|
||||
onAppearanceThemeChange={setAppearanceTheme}
|
||||
onClearLocalData={clearLocalData}
|
||||
onClose={() => setView('chat')}
|
||||
onCreateHeartbeat={createHeartbeat}
|
||||
|
||||
@@ -25,6 +25,9 @@ const ready = true
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('region', { name: '表格,可横向滚动' })
|
||||
).toContainElement(screen.getByRole('table'))
|
||||
expect(screen.getByText('const ready = true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,19 @@ const components: Components = {
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
table: ({ children, node, ...properties }) => {
|
||||
void node
|
||||
return (
|
||||
<div
|
||||
aria-label="表格,可横向滚动"
|
||||
className="markdown-table-scroll"
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
>
|
||||
<table {...properties}>{children}</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,28 @@ describe('SettingsPanel runtime files', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('offers system, light, and dark appearance modes', async () => {
|
||||
const onAppearanceThemeChange = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
appearanceTheme="system"
|
||||
onAppearanceThemeChange={onAppearanceThemeChange}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
|
||||
expect(
|
||||
screen.getByRole('radio', { name: /跟随系统/u })
|
||||
).toBeChecked()
|
||||
fireEvent.click(screen.getByRole('radio', { name: /暗色/u }))
|
||||
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
||||
})
|
||||
|
||||
it('automatically detects runtimes and displays path, version, and detail', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -230,6 +252,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(
|
||||
screen.getByText(/仅在实际请求高风险工具时暂停/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/不会匿名加载远程默认模型/)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
KeyRound,
|
||||
LockKeyhole,
|
||||
Plus,
|
||||
SunMoon,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
X
|
||||
@@ -28,8 +29,10 @@ import {
|
||||
import { McpSettingsSection } from './McpSettingsSection'
|
||||
import { SkillsSettingsSection } from './SkillsSettingsSection'
|
||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||
import type { AppearanceTheme } from './theme'
|
||||
|
||||
type SettingsTab =
|
||||
| 'appearance'
|
||||
| 'model'
|
||||
| 'runtime'
|
||||
| 'security'
|
||||
@@ -55,6 +58,8 @@ type SettingsPanelProps = {
|
||||
) => Promise<void>
|
||||
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
|
||||
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
||||
appearanceTheme?: AppearanceTheme
|
||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||
}
|
||||
|
||||
const credentialLabels: Record<
|
||||
@@ -86,7 +91,9 @@ export function SettingsPanel({
|
||||
onCreateHeartbeat,
|
||||
onSetHeartbeatPaused,
|
||||
onRemoveHeartbeat,
|
||||
onRunHeartbeat
|
||||
onRunHeartbeat,
|
||||
appearanceTheme = 'system',
|
||||
onAppearanceThemeChange = () => {}
|
||||
}: SettingsPanelProps): React.JSX.Element | null {
|
||||
const [settings, setSettings] = useState<RuntimeSettings>()
|
||||
const [provider, setProvider] =
|
||||
@@ -517,6 +524,16 @@ export function SettingsPanel({
|
||||
|
||||
<div className="settings-panel__body">
|
||||
<nav aria-label="设置分类" className="settings-tabs">
|
||||
<button
|
||||
aria-label="外观"
|
||||
aria-selected={activeTab === 'appearance'}
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<strong>外观</strong>
|
||||
<small>亮色、暗色与系统主题</small>
|
||||
</button>
|
||||
<button
|
||||
aria-label="模型连接"
|
||||
aria-selected={activeTab === 'model'}
|
||||
@@ -580,6 +597,50 @@ export function SettingsPanel({
|
||||
</nav>
|
||||
|
||||
<div className="settings-panel__content">
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="settings-section appearance-settings">
|
||||
<div className="settings-section__title">
|
||||
<SunMoon size={17} />
|
||||
<div>
|
||||
<strong>界面主题</strong>
|
||||
<small>选择后立即应用,并保存在此设备</small>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="界面主题"
|
||||
className="appearance-options"
|
||||
role="radiogroup"
|
||||
>
|
||||
{(
|
||||
[
|
||||
['system', '跟随系统', '随操作系统自动切换'],
|
||||
['light', '亮色', '明亮、清晰的工作界面'],
|
||||
['dark', '暗色', '降低暗光环境下的亮度']
|
||||
] as const
|
||||
).map(([value, label, description]) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={appearanceTheme === value}
|
||||
name="appearance-theme"
|
||||
onChange={() => onAppearanceThemeChange(value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`appearance-options__preview appearance-options__preview--${value}`}
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<strong>{label}</strong>
|
||||
<small>{description}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'runtime' && (
|
||||
<>
|
||||
{settings?.warning && (
|
||||
@@ -827,7 +888,7 @@ export function SettingsPanel({
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="platform">使用 Continue 平台默认</option>
|
||||
<option value="platform">使用指定的 Continue 配置文件</option>
|
||||
{modelProfiles.map((profile) => (
|
||||
<option
|
||||
disabled={!isContinueCompatible(profile)}
|
||||
@@ -843,7 +904,7 @@ export function SettingsPanel({
|
||||
</select>
|
||||
<small>
|
||||
Continue 支持 Anthropic Messages、OpenAI Chat
|
||||
Completions 和无认证本机模型。
|
||||
Completions 和无认证本机模型。未选择独立连接时,必须在下方指定配置文件。
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
@@ -887,7 +948,7 @@ export function SettingsPanel({
|
||||
onChange={(event) =>
|
||||
setContinueConfigPath(event.target.value)
|
||||
}
|
||||
placeholder="留空使用工具默认配置"
|
||||
placeholder="选择可信的本地 Continue 配置文件"
|
||||
value={continueConfigPath}
|
||||
/>
|
||||
<button
|
||||
@@ -912,6 +973,12 @@ export function SettingsPanel({
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{continueModelSource.kind === 'platform' &&
|
||||
!continueConfigPath && (
|
||||
<p className="settings-warning">
|
||||
未指定配置文件时 Continue 将保持不可用,不会匿名加载远程默认模型。
|
||||
</p>
|
||||
)}
|
||||
{continueBinaryPath && (
|
||||
<p className="settings-warning">
|
||||
自定义 Continue 可执行文件将以当前用户权限运行,请仅选择可信文件。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -1,6 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import {
|
||||
applyAppearanceTheme,
|
||||
loadAppearanceTheme,
|
||||
resolveAppearanceTheme
|
||||
} from './theme'
|
||||
import './styles.css'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
@@ -9,6 +14,14 @@ if (!root) {
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
applyAppearanceTheme(
|
||||
resolveAppearanceTheme(
|
||||
loadAppearanceTheme(),
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
)
|
||||
)
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+552
-11
@@ -88,6 +88,13 @@ textarea:focus-visible {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.brand__mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brand__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -439,10 +446,12 @@ textarea:focus-visible {
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: 0 21px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@@ -467,6 +476,8 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.topbar__expert {
|
||||
width: clamp(86px, 11vw, 130px);
|
||||
min-width: 0;
|
||||
max-width: 130px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
@@ -821,6 +832,7 @@ textarea:focus-visible {
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 13px;
|
||||
container: heartbeat-settings / inline-size;
|
||||
}
|
||||
|
||||
.heartbeat-settings__intro h3 {
|
||||
@@ -1045,6 +1057,7 @@ textarea:focus-visible {
|
||||
|
||||
.conversation-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: 7px 9px;
|
||||
border-radius: 8px;
|
||||
@@ -1057,6 +1070,16 @@ textarea:focus-visible {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.conversation-title span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-title svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.conversation-title:hover {
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
@@ -1064,13 +1087,17 @@ textarea:focus-visible {
|
||||
|
||||
.topbar__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
margin-left: auto;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: clamp(108px, 18vw, 220px);
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d9d9d9;
|
||||
@@ -1082,6 +1109,16 @@ textarea:focus-visible {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runtime-status__dot {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.runtime-status__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runtime-status__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -1149,7 +1186,10 @@ textarea:focus-visible {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(180px, 100%), 1fr)
|
||||
);
|
||||
}
|
||||
|
||||
.quick-actions button {
|
||||
@@ -1349,10 +1389,24 @@ textarea:focus-visible {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-table-scroll {
|
||||
max-width: 100%;
|
||||
margin: 0.8em 0;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.markdown-table-scroll:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 2px solid #1677ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
border-collapse: collapse;
|
||||
margin: 0.8em 0;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -1870,7 +1924,7 @@ textarea:focus-visible {
|
||||
padding: 3px;
|
||||
border-radius: 9px;
|
||||
background: #f5f5f5;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.settings-tabs button {
|
||||
@@ -2561,6 +2615,10 @@ textarea:focus-visible {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.workspace-panel-scroll--heartbeat {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.knowledge-workspace {
|
||||
width: 100%;
|
||||
min-height: max(520px, calc(100dvh - 114px));
|
||||
@@ -3074,6 +3132,7 @@ textarea:focus-visible {
|
||||
|
||||
.activity-panel__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -3109,6 +3168,8 @@ textarea:focus-visible {
|
||||
.activity-item__header,
|
||||
.activity-item__labels {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
@@ -3159,6 +3220,7 @@ textarea:focus-visible {
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
container: heartbeat-center / inline-size;
|
||||
}
|
||||
|
||||
.heartbeat-center__hero {
|
||||
@@ -3857,6 +3919,485 @@ textarea:focus-visible {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.appearance-settings {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.appearance-options {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.appearance-options label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.appearance-options label:has(input:checked) {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgb(22 119 255 / 12%);
|
||||
}
|
||||
|
||||
.appearance-options label:has(input:focus-visible) {
|
||||
outline: 2px solid #1677ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.appearance-options input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.appearance-options__preview {
|
||||
display: grid;
|
||||
height: 76px;
|
||||
padding: 9px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
gap: 5px;
|
||||
grid-template-columns: 30% 1fr;
|
||||
grid-template-rows: 12px 1fr;
|
||||
}
|
||||
|
||||
.appearance-options__preview i {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.appearance-options__preview i:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.appearance-options__preview i:nth-child(2) {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.appearance-options__preview i:nth-child(3) {
|
||||
background: #e6f4ff;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.appearance-options__preview--dark {
|
||||
border-color: #344258;
|
||||
background: #0b111b;
|
||||
}
|
||||
|
||||
.appearance-options__preview--dark i {
|
||||
background: #1c2738;
|
||||
}
|
||||
|
||||
.appearance-options__preview--dark i:nth-child(3) {
|
||||
background: #15345f;
|
||||
}
|
||||
|
||||
.appearance-options__preview--system {
|
||||
background: linear-gradient(90deg, #f5f5f5 0 50%, #0b111b 50% 100%);
|
||||
}
|
||||
|
||||
.appearance-options__preview--system i {
|
||||
background: linear-gradient(90deg, #fff 0 50%, #1c2738 50% 100%);
|
||||
}
|
||||
|
||||
.appearance-options__preview--system i:nth-child(3) {
|
||||
background: linear-gradient(90deg, #e6f4ff 0 50%, #15345f 50% 100%);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color: #edf4ff;
|
||||
background: #0b111b;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-shell {
|
||||
background: #0b111b;
|
||||
color: #edf4ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.settings-page,
|
||||
.composer-wrap
|
||||
) {
|
||||
background: #0b111b;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.sidebar,
|
||||
.topbar,
|
||||
.assistant-sidebar,
|
||||
.project-create-card,
|
||||
.settings-panel,
|
||||
.composer,
|
||||
.runtime-picker__menu,
|
||||
.knowledge-scope__popover,
|
||||
.knowledge-panel__document,
|
||||
.token-usage,
|
||||
.activity-item,
|
||||
.heartbeat-center__section,
|
||||
.heartbeat-center__metrics > div,
|
||||
.heartbeat-center__plans .heartbeat-settings,
|
||||
.heartbeat-settings__item,
|
||||
.capability-card,
|
||||
.quick-actions button,
|
||||
.secondary-button
|
||||
) {
|
||||
border-color: #293548;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.app-shell,
|
||||
.sidebar,
|
||||
.topbar,
|
||||
.assistant-sidebar,
|
||||
.settings-panel,
|
||||
.project-create-card,
|
||||
.composer,
|
||||
.workspace-panel-scroll
|
||||
) :where(input, textarea, select) {
|
||||
border-color: #344258;
|
||||
background: #172033;
|
||||
color: #edf4ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.app-shell,
|
||||
.sidebar,
|
||||
.topbar,
|
||||
.assistant-sidebar,
|
||||
.settings-panel,
|
||||
.project-create-card,
|
||||
.composer
|
||||
) :where(input, textarea)::placeholder {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.sidebar-search,
|
||||
.settings-section,
|
||||
.settings-tabs,
|
||||
.settings-panel__footer,
|
||||
.assistant-sidebar__row,
|
||||
.assistant-sidebar__library,
|
||||
.assistant-sidebar__schedule,
|
||||
.assistant-sidebar__diff,
|
||||
.assistant-sidebar__preview pre,
|
||||
.tool-activity,
|
||||
.approval-card,
|
||||
.context-chip,
|
||||
.token-usage__group,
|
||||
.token-usage__stats div,
|
||||
.activity-panel__stats div,
|
||||
.activity-filter,
|
||||
.heartbeat-center__tab,
|
||||
.heartbeat-center__config-card,
|
||||
.heartbeat-center__suggestion,
|
||||
.heartbeat-center__run,
|
||||
.heartbeat-settings--sidebar .heartbeat-settings__item,
|
||||
.knowledge-graph__toolbar,
|
||||
.markdown-content pre,
|
||||
.markdown-content :not(pre) > code
|
||||
) {
|
||||
border-color: #293548;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.primary-nav,
|
||||
.sidebar-footer,
|
||||
.topbar,
|
||||
.assistant-sidebar,
|
||||
.assistant-sidebar__header,
|
||||
.assistant-sidebar__tabs,
|
||||
.assistant-sidebar__context,
|
||||
.assistant-sidebar__preview > header,
|
||||
.message + .message,
|
||||
.divider,
|
||||
.runtime-picker__divider,
|
||||
.settings-panel__header,
|
||||
.settings-panel__footer,
|
||||
.knowledge-workspace__sidebar,
|
||||
.knowledge-workspace__header,
|
||||
.knowledge-panel__header,
|
||||
.activity-panel__header,
|
||||
.heartbeat-center__hero,
|
||||
.heartbeat-center__config-card,
|
||||
.heartbeat-center__suggestion,
|
||||
.heartbeat-center__run
|
||||
) {
|
||||
border-color: #293548;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
.conversation-title,
|
||||
.welcome h1,
|
||||
.message__meta strong,
|
||||
.markdown-content,
|
||||
.settings-section__title strong,
|
||||
.field,
|
||||
.capability-card strong,
|
||||
.knowledge-panel__document-info strong,
|
||||
.token-usage__header h3,
|
||||
.activity-item h3,
|
||||
.heartbeat-center__latest > p,
|
||||
.heartbeat-center__run strong
|
||||
) {
|
||||
color: #edf4ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-shell strong {
|
||||
color: #edf4ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.nav-item,
|
||||
.conversation-item,
|
||||
.user-card,
|
||||
.icon-button,
|
||||
.assistant-sidebar__row,
|
||||
.runtime-status,
|
||||
.welcome__description,
|
||||
.tool-activity,
|
||||
.context-chip,
|
||||
.settings-tabs button,
|
||||
.credential-state,
|
||||
.capability-card p,
|
||||
.secondary-button,
|
||||
.check-field,
|
||||
.knowledge-panel__limits,
|
||||
.knowledge-panel__summary,
|
||||
.token-usage th,
|
||||
.token-usage td,
|
||||
.activity-filter,
|
||||
.activity-item p,
|
||||
.heartbeat-center__hero p:not(.eyebrow),
|
||||
.heartbeat-center__tab,
|
||||
.heartbeat-center__config-card,
|
||||
.heartbeat-center__suggestion > p,
|
||||
.heartbeat-center__run small
|
||||
) {
|
||||
color: #aebbd0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.brand__copy span,
|
||||
.nav-item__hint,
|
||||
.section-label,
|
||||
.conversation-item small,
|
||||
.user-card__copy small,
|
||||
.assistant-sidebar__tab,
|
||||
.assistant-sidebar__empty,
|
||||
.runtime-picker__menu > strong,
|
||||
.runtime-picker__menu > button > small,
|
||||
.message__meta span,
|
||||
.message__status,
|
||||
.composer-hint,
|
||||
.settings-panel__description,
|
||||
.settings-tabs button small,
|
||||
.settings-section__title small,
|
||||
.field small,
|
||||
.settings-notice,
|
||||
.settings-empty,
|
||||
.knowledge-panel__empty,
|
||||
.knowledge-panel__loading,
|
||||
.activity-panel__empty,
|
||||
.heartbeat-center__live,
|
||||
.heartbeat-center__empty,
|
||||
.heartbeat-center__legend
|
||||
) {
|
||||
color: #8290a6;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.icon-button:hover,
|
||||
.icon-button--active,
|
||||
.nav-item:hover,
|
||||
.conversation-item:hover,
|
||||
.conversation-item--active,
|
||||
.user-card:hover,
|
||||
.secondary-button:hover,
|
||||
.activity-filter--active,
|
||||
.settings-tabs button[aria-selected='true'],
|
||||
.heartbeat-center__tab--active
|
||||
) {
|
||||
background: #1f2a3d;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .settings-page
|
||||
.settings-tabs
|
||||
button[aria-selected='true'] {
|
||||
background: #1f2a3d;
|
||||
color: #69adff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.nav-item--active,
|
||||
.brand__mark,
|
||||
.composer__mode--ask,
|
||||
.composer__mode--plan,
|
||||
.runtime-capability-badge,
|
||||
.model-capability-badge,
|
||||
.heartbeat-center__tab > span
|
||||
) {
|
||||
border-color: #245fa8;
|
||||
background: #15345f;
|
||||
color: #69adff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.settings-warning,
|
||||
.approval-card,
|
||||
.heartbeat-center__error
|
||||
) {
|
||||
border-color: #70511d;
|
||||
background: #302511;
|
||||
color: #f3c969;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .markdown-content blockquote {
|
||||
border-left-color: #4c9aff;
|
||||
color: #aebbd0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .message--user .message__content {
|
||||
border-color: #245fa8;
|
||||
background: #15345f;
|
||||
color: #edf4ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .heartbeat-center__hero p:not(.eyebrow),
|
||||
:root[data-theme='dark'] .heartbeat-center__metrics small {
|
||||
color: #aebbd0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .assistant-sidebar__tab--active {
|
||||
background: #15345f;
|
||||
color: #69adff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.runtime-picker__menu > button,
|
||||
.quick-actions strong,
|
||||
.user-card__copy strong
|
||||
) {
|
||||
color: #edf4ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .markdown-content :where(th, td) {
|
||||
border-color: #344258;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .appearance-options label {
|
||||
border-color: #344258;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .appearance-options label:has(input:checked) {
|
||||
border-color: #4c9aff;
|
||||
box-shadow: 0 0 0 2px rgb(76 154 255 / 20%);
|
||||
}
|
||||
|
||||
@container heartbeat-settings (max-width: 680px) {
|
||||
.heartbeat-settings__form,
|
||||
.heartbeat-settings__form--weekly {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.heartbeat-settings__form .primary-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.heartbeat-settings__item {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.heartbeat-settings__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@container heartbeat-settings (max-width: 420px) {
|
||||
.heartbeat-settings__form,
|
||||
.heartbeat-settings__form--weekly {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heartbeat-settings__form .primary-button {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.heartbeat-settings__actions button {
|
||||
padding-inline: 8px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@container heartbeat-center (max-width: 820px) {
|
||||
.heartbeat-center__metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.heartbeat-center__overview-grid,
|
||||
.heartbeat-center__suggestions,
|
||||
.heartbeat-center__history {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@container heartbeat-center (max-width: 620px) {
|
||||
.heartbeat-center__hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.heartbeat-center__hero-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heartbeat-center__hero-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heartbeat-center__config-card > div:last-child {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@container heartbeat-center (max-width: 460px) {
|
||||
.heartbeat-center__metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heartbeat-center__tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.heartbeat-center__tab {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.heartbeat-center__trend-row {
|
||||
grid-template-columns: 70px minmax(0, 1fr) 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -3896,14 +4437,6 @@ textarea:focus-visible {
|
||||
min-width: 206px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-actions button {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.assistant-sidebar--open {
|
||||
width: min(390px, calc(100vw - 36px));
|
||||
flex-basis: min(390px, calc(100vw - 36px));
|
||||
@@ -3912,11 +4445,15 @@ textarea:focus-visible {
|
||||
.settings-page .settings-panel__body {
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs {
|
||||
position: static;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@@ -3957,6 +4494,10 @@ textarea:focus-visible {
|
||||
.heartbeat-center__history {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.appearance-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyAppearanceTheme,
|
||||
loadAppearanceTheme,
|
||||
resolveAppearanceTheme,
|
||||
saveAppearanceTheme
|
||||
} from './theme'
|
||||
|
||||
describe('appearance theme', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
document.documentElement.style.colorScheme = ''
|
||||
})
|
||||
|
||||
it('defaults to system and persists valid user choices', () => {
|
||||
expect(loadAppearanceTheme()).toBe('system')
|
||||
saveAppearanceTheme('dark')
|
||||
expect(loadAppearanceTheme()).toBe('dark')
|
||||
localStorage.setItem('goodbuddy.appearance-theme', 'invalid')
|
||||
expect(loadAppearanceTheme()).toBe('system')
|
||||
})
|
||||
|
||||
it('resolves system preference and applies it to the document', () => {
|
||||
expect(resolveAppearanceTheme('system', true)).toBe('dark')
|
||||
expect(resolveAppearanceTheme('system', false)).toBe('light')
|
||||
expect(resolveAppearanceTheme('light', true)).toBe('light')
|
||||
|
||||
applyAppearanceTheme('dark')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
export type AppearanceTheme = 'system' | 'light' | 'dark'
|
||||
export type ResolvedAppearanceTheme = 'light' | 'dark'
|
||||
|
||||
const storageKey = 'goodbuddy.appearance-theme'
|
||||
|
||||
export function loadAppearanceTheme(): AppearanceTheme {
|
||||
try {
|
||||
const value = localStorage.getItem(storageKey)
|
||||
return value === 'light' || value === 'dark' ? value : 'system'
|
||||
} catch {
|
||||
return 'system'
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAppearanceTheme(theme: AppearanceTheme): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
} catch {
|
||||
// Theme persistence is optional when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAppearanceTheme(
|
||||
theme: AppearanceTheme,
|
||||
systemPrefersDark: boolean
|
||||
): ResolvedAppearanceTheme {
|
||||
return theme === 'system'
|
||||
? systemPrefersDark
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: theme
|
||||
}
|
||||
|
||||
export function applyAppearanceTheme(
|
||||
theme: ResolvedAppearanceTheme
|
||||
): void {
|
||||
document.documentElement.dataset.theme = theme
|
||||
document.documentElement.style.colorScheme = theme
|
||||
}
|
||||
Reference in New Issue
Block a user