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
119 changes: 96 additions & 23 deletions apps/desktop/src/app/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { type ChangeEvent, useEffect, useRef, useState } from 'react'

import { LanguageSwitcher } from '@/components/language-switcher'
import { Button } from '@/components/ui/button'
import { KbdCombo } from '@/components/ui/kbd'
import { SegmentedControl } from '@/components/ui/segmented-control'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { Check, Download, Eye, Loader2, Palette, Trash2, Upload } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $backdrop, setBackdrop } from '@/store/backdrop'
import { $decorativeBackdrop, type DecorativeBackdropMode, setDecorativeBackdrop } from '@/store/backdrop'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { $zoomPercent, setZoomPercent } from '@/store/zoom'
import { getBaseColors, useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { installHermesThemeFromText, installVscodeThemeFromMarketplace } from '@/themes/install'
import type { DesktopTheme } from '@/themes/types'
import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes'

Expand Down Expand Up @@ -249,13 +250,16 @@ export function AppearanceSettings() {
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const translucency = useStore($translucency)
const backdrop = useStore($backdrop)
const installs = useStore($marketplaceInstalls)
const decorativeBackdrop = useStore($decorativeBackdrop)
const profiles = useStore($profiles)

const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance

const [query, setQuery] = useState('')
const [importError, setImportError] = useState<string | null>(null)
const importInputRef = useRef<HTMLInputElement | null>(null)

// One box does double duty: filter installed themes live (below), and run a
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
Expand Down Expand Up @@ -293,17 +297,53 @@ export function AppearanceSettings() {
{ id: 'off', label: a.embedsOff }
] as const satisfies readonly { id: EmbedMode; label: string }[]

const backdropOptions = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please source these labels and the other new visible strings in this component from the existing Appearance i18n schema and add them to every locale catalog. Current main localizes the existing backdrop row rather than embedding English text here.

{ id: 'off', label: a.backdropOff },
{ id: 'subtle', label: a.backdropSubtle },
{ id: 'full', label: a.backdropFull }
] as const satisfies readonly { id: DecorativeBackdropMode; label: string }[]

const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` }))

const matchedScalePreset = matchUiScalePreset(zoomPercent)

const importTheme = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''

if (!file) {
return
}

setImportError(null)

try {
const installed = installHermesThemeFromText(await file.text())
const first = installed[0]

if (!first) {
throw new Error(a.importEmpty)
}

triggerHaptic('crisp')
setTheme(first.name)
} catch (error) {
setImportError(error instanceof Error ? error.message : a.importError)
}
}

return (
<SettingsContent>
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
<p className="mt-2 flex items-center gap-1.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>{a.commandPaletteTipPrefix}</span>
<KbdCombo combo="mod+k" size="sm" />
<span>{a.commandPaletteTipSuffix}</span>
</p>

<div className="mt-2">
<ListRow
Expand Down Expand Up @@ -384,8 +424,37 @@ export function AppearanceSettings() {
</div>
)
})}
<button
className={cn('w-full p-2 text-left', selectableCardClass({ prominent: true }))}
onClick={() => importInputRef.current?.click()}
type="button"
>
<div className="grid h-20 place-items-center rounded-xl border border-dashed border-(--ui-stroke-secondary) bg-(--ui-bg-quinary)">
<Upload className="size-5 text-(--ui-text-tertiary)" />
</div>
<div className="mt-3 px-1">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{a.importTheme}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.importThemeDesc}
</div>
</div>
</button>
</div>
)}
<input
accept=".json,application/json"
className="hidden"
onChange={event => void importTheme(event)}
ref={importInputRef}
type="file"
/>
{importError && (
<p className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">
{importError}
</p>
)}
<MarketplaceThemeResults installs={installs} onInstalled={name => setTheme(name)} query={query} />
</div>
{showProfileNote && (
Expand All @@ -412,6 +481,28 @@ export function AppearanceSettings() {
wide
/>

<div className="mt-4 border-t border-(--ui-stroke-tertiary) pt-4">
<SectionHeading icon={Eye} title={a.accessibilityTitle} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.accessibilityIntro}
</p>
</div>

<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setDecorativeBackdrop(id)
}}
options={backdropOptions}
value={decorativeBackdrop}
/>
}
description={a.decorativeBackdropDesc}
title={a.decorativeBackdropTitle}
/>

<ListRow
action={
<SegmentedControl
Expand Down Expand Up @@ -453,24 +544,6 @@ export function AppearanceSettings() {
title={a.translucencyTitle}
/>

<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setBackdrop(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={backdrop ? 'on' : 'off'}
/>
}
description={a.backdropDesc}
title={a.backdropTitle}
/>

<ListRow
action={
<SegmentedControl
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/components/Backdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { Leva, useControls } from 'leva'
import { type CSSProperties, useEffect, useState } from 'react'

import { $backdrop } from '@/store/backdrop'
import { $decorativeBackdrop, BACKDROP_OPACITY } from '@/store/backdrop'

const BLEND_MODES = [
'normal',
Expand All @@ -28,7 +28,8 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/

export function Backdrop() {
const [controlsOpen, setControlsOpen] = useState(false)
const on = useStore($backdrop)
const decorativeBackdrop = useStore($decorativeBackdrop)
const configuredOpacity = BACKDROP_OPACITY[decorativeBackdrop]

useEffect(() => {
if (!import.meta.env.DEV) {
Expand Down Expand Up @@ -72,7 +73,7 @@ export function Backdrop() {
'Backdrop / Statue',
{
enabled: { value: true, label: 'on' },
opacity: { value: 0.025, min: 0, max: 1, step: 0.005 },
opacity: { value: configuredOpacity, min: 0, max: 1, step: 0.005 },
blendMode: { value: 'difference' as BlendMode, options: BLEND_MODES, label: 'blend' },
invert: { value: true, label: 'invert color' },
saturate: { value: 1, min: 0, max: 3, step: 0.05, label: 'saturate' },
Expand All @@ -91,13 +92,13 @@ export function Backdrop() {
<>
<Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} />

{on && statue.enabled && (
{statue.enabled && (import.meta.env.DEV ? statue.opacity > 0 : configuredOpacity > 0) && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-2"
style={{
mixBlendMode: statue.blendMode as CSSProperties['mixBlendMode'],
opacity: statue.opacity
opacity: import.meta.env.DEV ? statue.opacity : configuredOpacity
}}
>
<img
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,15 @@ export const en: Translations = {
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
backdropTitle: 'Chat Backdrop',
backdropDesc: 'The faint statue image behind the conversation.',
backdropOff: 'Off',
backdropSubtle: 'Subtle',
backdropFull: 'Full',
decorativeBackdropTitle: 'Decorative backdrop',
decorativeBackdropDesc: 'Control the background artwork behind translucent chat surfaces.',
accessibilityTitle: 'Accessibility',
accessibilityIntro: 'Adjust visual effects that can affect readability and comfort.',
commandPaletteTipPrefix: 'Tip: press',
commandPaletteTipSuffix: 'to open the command palette.',
embedsTitle: 'Inline Embeds',
embedsDesc:
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',
Expand All @@ -429,6 +438,10 @@ export const en: Translations = {
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,
importTheme: 'Import theme',
importThemeDesc: 'Load a Hermes theme JSON file',
importEmpty: 'Theme file did not contain any themes.',
importError: 'Could not import that theme.',
installTitle: 'Install from VS Code',
installDesc:
'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.',
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,15 @@ export const ja = defineLocale({
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
backdropTitle: 'チャット背景',
backdropDesc: '会話の背後に表示される淡い彫像の画像。',
backdropOff: 'オフ',
backdropSubtle: '控えめ',
backdropFull: '通常',
decorativeBackdropTitle: '装飾背景',
decorativeBackdropDesc: '半透明のチャット面の背後にある背景アートを調整します。',
accessibilityTitle: 'アクセシビリティ',
accessibilityIntro: '読みやすさや快適さに影響する視覚効果を調整します。',
commandPaletteTipPrefix: 'ヒント:',
commandPaletteTipSuffix: 'でコマンドパレットを開けます。',
embedsTitle: 'インライン埋め込み',
embedsDesc:
'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
Expand All @@ -316,6 +325,10 @@ export const ja = defineLocale({
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile =>
`「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
importTheme: 'テーマをインポート',
importThemeDesc: 'Hermes テーマ JSON ファイルを読み込みます',
importEmpty: 'テーマファイルにテーマが含まれていません。',
importError: 'そのテーマをインポートできませんでした。',
installTitle: 'VS Code から導入',
installDesc:
'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,15 @@ export interface Translations {
translucencyDesc: string
backdropTitle: string
backdropDesc: string
backdropOff: string
backdropSubtle: string
backdropFull: string
decorativeBackdropTitle: string
decorativeBackdropDesc: string
accessibilityTitle: string
accessibilityIntro: string
commandPaletteTipPrefix: string
commandPaletteTipSuffix: string
embedsTitle: string
embedsDesc: string
embedsAsk: string
Expand All @@ -344,6 +353,10 @@ export interface Translations {
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
importTheme: string
importThemeDesc: string
importEmpty: string
importError: string
installTitle: string
installDesc: string
installPlaceholder: string
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,15 @@ export const zhHant = defineLocale({
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '對話後方那張淡淡的雕像圖片。',
backdropOff: '關閉',
backdropSubtle: '輕微',
backdropFull: '完整',
decorativeBackdropTitle: '裝飾背景',
decorativeBackdropDesc: '控制半透明聊天介面後方的背景圖案。',
accessibilityTitle: '無障礙',
accessibilityIntro: '調整可能影響可讀性與舒適度的視覺效果。',
commandPaletteTipPrefix: '提示:按',
commandPaletteTipSuffix: '開啟命令面板。',
embedsTitle: '內嵌預覽',
embedsDesc:
'豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
Expand All @@ -307,6 +316,10 @@ export const zhHant = defineLocale({
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,
importTheme: '匯入主題',
importThemeDesc: '載入 Hermes 主題 JSON 檔案',
importEmpty: '主題檔案不包含任何主題。',
importError: '無法匯入該主題。',
installTitle: '從 VS Code 安裝',
installDesc: '貼上 Marketplace 擴充功能 ID(例如 dracula-theme.theme-dracula),將其配色主題轉換為桌面調色盤。',
installPlaceholder: 'publisher.extension',
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ export const zh: Translations = {
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '对话后方那张淡淡的雕像图片。',
backdropOff: '关闭',
backdropSubtle: '轻微',
backdropFull: '完整',
decorativeBackdropTitle: '装饰背景',
decorativeBackdropDesc: '控制半透明聊天界面后方的背景图案。',
accessibilityTitle: '无障碍',
accessibilityIntro: '调整可能影响可读性和舒适度的视觉效果。',
commandPaletteTipPrefix: '提示:按',
commandPaletteTipSuffix: '打开命令面板。',
embedsTitle: '内嵌预览',
embedsDesc:
'富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
Expand All @@ -418,6 +427,10 @@ export const zh: Translations = {
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,
importTheme: '导入主题',
importThemeDesc: '加载 Hermes 主题 JSON 文件',
importEmpty: '主题文件不包含任何主题。',
importError: '无法导入该主题。',
installTitle: '从 VS Code 安装',
installDesc: '粘贴 Marketplace 扩展 ID(例如 dracula-theme.theme-dracula),将其配色主题转换为桌面调色板。',
installPlaceholder: 'publisher.extension',
Expand Down
Loading