Files
NetMesh/components/settings/tabs/SettingsAppearanceTab.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

613 lines
25 KiB
TypeScript

import React, { memo, useCallback, useMemo, useState } from "react";
import { applyCustomCssToDocument } from "../../../lib/customCss";
import { DebouncedTextarea } from "../DebouncedTextarea";
import { Check, HelpCircle, Monitor, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
import { useStoredString } from "../../../application/state/useStoredString";
import { useStoredNumber } from "../../../application/state/useStoredNumber";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
import { useAvailableFonts } from "../../../application/state/fontStore";
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { APP_ICON_ELF_SVG, APP_ICON_VARIANT_ASSET_PATH, APP_ICON_VARIANT_GROUPS, APP_ICON_VARIANT_I18N_KEY, APP_ICON_VARIANT_TILE } from "../../../infrastructure/config/appIconVariants";
import {
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
} from "../../../infrastructure/config/storageKeys";
import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant";
import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts";
import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { TerminalFontSelect } from "../TerminalFontSelect";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../../ui/dialog";
import { LazyMessageResponse } from "../../ai-elements/LazyMessageResponse";
const CUSTOM_CSS_HELP_PROSE_CLASS =
"text-xs text-foreground/90 leading-relaxed [&>*:first-child]:mt-0 [&>*:last-child]:mb-0";
function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
resolvedTheme: "dark" | "light";
setTheme: (theme: "dark" | "light" | "system") => void;
lightUiThemeId: string;
setLightUiThemeId: (themeId: string) => void;
darkUiThemeId: string;
setDarkUiThemeId: (themeId: string) => void;
accentMode: "theme" | "custom";
setAccentMode: (mode: "theme" | "custom") => void;
customAccent: string;
setCustomAccent: (color: string) => void;
uiFontFamilyId: string;
setUiFontFamilyId: (fontId: string) => void;
uiLanguage: string;
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
showRecentHosts: boolean;
setShowRecentHosts: (enabled: boolean) => void;
hostClickBehavior: "connect" | "select";
setHostClickBehavior: (behavior: "connect" | "select") => void;
showOnlyUngroupedHostsInRoot: boolean;
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
showHostTreeSidebar: boolean;
setShowHostTreeSidebar: (enabled: boolean) => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
appIconVariant: AppIconVariant;
setAppIconVariant: (variant: AppIconVariant) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
// Note code fonts come from the monospace-only store (fontStore); the note
// body font follows the UI font setting instead.
const availableMonoFonts = useAvailableFonts();
const noteFontOptions = useMemo(() => [
{ id: "", name: t("notes.toolbar.defaultFont"), family: "", description: "", category: "monospace" as const },
...availableMonoFonts,
], [availableMonoFonts, t]);
const [customCssHelpOpen, setCustomCssHelpOpen] = useState(false);
const [autoImportSystemKnownHosts, setAutoImportSystemKnownHosts] = useStoredBoolean(
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
);
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
"",
);
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
14,
{ min: 10, max: 32 },
);
const handleSetNoteFontSize = useCallback((size: number) => {
setNoteFontSize(size);
persistNoteFontSize(size);
}, [persistNoteFontSize, setNoteFontSize]);
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
13,
{ min: 10, max: 32 },
);
const handleSetNoteCodeFontSize = useCallback((size: number) => {
setNoteCodeFontSize(size);
persistNoteCodeFontSize(size);
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
const {
theme,
resolvedTheme,
setTheme,
lightUiThemeId,
setLightUiThemeId,
darkUiThemeId,
setDarkUiThemeId,
accentMode,
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
uiLanguage,
setUiLanguage,
customCSS,
setCustomCSS,
showRecentHosts,
setShowRecentHosts,
hostClickBehavior,
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
windowOpacity,
setWindowOpacity,
appIconVariant,
setAppIconVariant,
} = props;
const resolvedAppIconVariant = resolveAppIconVariant(appIconVariant);
const WINDOW_OPACITY_PRESETS = [
{ label: '100%', value: 1 },
{ label: '85%', value: 0.85 },
{ label: '70%', value: 0.7 },
] as const;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}, []);
const ACCENT_COLORS = [
{ name: "Sky", value: "199 89% 48%" },
{ name: "Blue", value: "221.2 83.2% 53.3%" },
{ name: "Indigo", value: "234 89% 62%" },
{ name: "Violet", value: "262.1 83.3% 57.8%" },
{ name: "Purple", value: "271 81% 56%" },
{ name: "Fuchsia", value: "292 84% 61%" },
{ name: "Pink", value: "330 81% 60%" },
{ name: "Rose", value: "346.8 77.2% 49.8%" },
{ name: "Red", value: "0 84.2% 60.2%" },
{ name: "Orange", value: "24.6 95% 53.1%" },
{ name: "Amber", value: "38 92% 50%" },
{ name: "Yellow", value: "48 96% 53%" },
{ name: "Lime", value: "84 81% 44%" },
{ name: "Green", value: "142.1 76.2% 36.3%" },
{ name: "Emerald", value: "160 84% 39%" },
{ name: "Teal", value: "173 80% 40%" },
{ name: "Cyan", value: "189 94% 43%" },
{ name: "Slate", value: "215 16% 47%" },
];
const THEME_OPTIONS: { value: "light" | "system" | "dark"; icon: React.ReactNode; label: string }[] = [
{ value: "light", icon: <Sun size={14} />, label: t("settings.appearance.theme.light") },
{ value: "system", icon: <Monitor size={14} />, label: t("settings.appearance.theme.system") },
{ value: "dark", icon: <Moon size={14} />, label: t("settings.appearance.theme.dark") },
];
const renderThemeSwatches = (
options: { id: string; name: string; tokens: { background: string } }[],
value: string,
onChange: (next: string) => void,
) => (
<div className="flex min-w-0 flex-1 flex-wrap justify-end gap-2">
{options.map((preset) => (
<Tooltip key={preset.id}>
<TooltipTrigger asChild>
<button
onClick={() => onChange(preset.id)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
value === preset.id
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(preset.tokens.background)}
>
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{preset.name}</TooltipContent>
</Tooltip>
))}
</div>
);
const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId;
return (
<SettingsTabContent value="appearance">
<SectionHeader title={t("settings.appearance.language")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="appearance-language"
label={t("settings.appearance.language")}
description={t("settings.appearance.language.desc")}
>
<Select
value={uiLanguage}
options={SUPPORTED_UI_LOCALES.map((l) => ({ value: l.id, label: l.label }))}
onChange={(v) => setUiLanguage(v)}
className="w-40"
/>
</SettingRow>
<SettingRow
anchorId="appearance-ui-font"
label={t("settings.appearance.uiFont")}
description={t("settings.appearance.uiFont.desc")}
>
<FontSelect
value={uiFontFamilyId}
fonts={availableUIFonts}
onChange={(v) => setUiFontFamilyId(v)}
className="w-48"
ariaLabel={t("settings.appearance.uiFont")}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.windowOpacity")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="appearance-window-opacity"
label={t("settings.appearance.windowOpacity")}
description={t("settings.appearance.windowOpacity.desc")}
>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={100}
step={1}
value={Math.round(windowOpacity * 100)}
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{Math.round(windowOpacity * 100)}%
</span>
</div>
<div className="flex items-center gap-1.5">
{WINDOW_OPACITY_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setWindowOpacity(preset.value)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors border",
windowOpacity === preset.value
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:text-foreground",
)}
>
{preset.label}
</button>
))}
</div>
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow anchorId="appearance-theme" label={t("settings.appearance.theme")}>
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
theme === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</SettingRow>
<SettingsAnchor anchorId="appearance-theme-color">
<div className="flex items-start justify-between gap-4 py-3">
<div className="shrink-0 pt-0.5 text-sm font-medium">
{resolvedTheme === "dark"
? t("settings.appearance.themeColor.dark")
: t("settings.appearance.themeColor.light")}
</div>
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
</div>
</SettingsAnchor>
<SettingRow
anchorId="appearance-accent-mode"
label={t("settings.appearance.accentColor.mode")}
description={t("settings.appearance.accentColor.mode.desc")}
>
<div className="flex items-center gap-2">
<Toggle
checked={accentMode === "custom"}
onChange={(checked) => setAccentMode(checked ? "custom" : "theme")}
/>
</div>
</SettingRow>
{accentMode === "custom" && (
<div className="py-3 space-y-2">
<div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div>
<div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((c) => (
<Tooltip key={c.name}>
<TooltipTrigger asChild>
<button
onClick={() => setCustomAccent(c.value)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
customAccent === c.value
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(c.value)}
>
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{c.name}</TooltipContent>
</Tooltip>
))}
<Tooltip>
<TooltipTrigger asChild>
<label
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
!ACCENT_COLORS.some((c) => c.value === customAccent)
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
>
<input
type="color"
className="sr-only"
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
/>
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
<Check className="text-white drop-shadow-md" size={10} />
) : (
<Palette size={12} className="text-white drop-shadow-md" />
)}
</label>
</TooltipTrigger>
<TooltipContent>{t("settings.appearance.customColor")}</TooltipContent>
</Tooltip>
</div>
</div>
)}
</div>
<SectionHeader title={t("settings.appearance.appIcon")} />
<SettingsAnchor anchorId="appearance-app-icon" className="rounded-lg border bg-card px-4 py-3 space-y-4">
<p className="text-xs text-muted-foreground">
{t("settings.appearance.appIcon.desc")}
</p>
<div className="space-y-3">
{APP_ICON_VARIANT_GROUPS.map((group) => (
<div key={group.id} className="space-y-1.5">
<span className="text-[11px] text-muted-foreground">{t(group.labelKey)}</span>
<div className="flex flex-wrap gap-2">
{group.variants.map((variant) => (
<Tooltip key={variant}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setAppIconVariant(variant)}
className={cn(
"relative w-11 h-11 rounded-xl overflow-hidden transition-transform flex items-center justify-center",
resolvedAppIconVariant === variant
? "scale-105"
: "hover:scale-105 opacity-90 hover:opacity-100",
)}
style={{
background: APP_ICON_VARIANT_TILE[variant].background,
border: APP_ICON_VARIANT_TILE[variant].border,
}}
aria-label={t(APP_ICON_VARIANT_I18N_KEY[variant])}
>
<img
src={APP_ICON_ELF_SVG}
alt=""
className="w-8 h-8 object-contain drop-shadow-sm"
draggable={false}
/>
{resolvedAppIconVariant === variant && (
<span className="absolute inset-0 flex items-center justify-center bg-black/20">
<Check className="text-white drop-shadow-md" size={14} />
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>{t(APP_ICON_VARIANT_I18N_KEY[variant])}</TooltipContent>
</Tooltip>
))}
</div>
</div>
))}
</div>
</SettingsAnchor>
<SectionHeader title={t("settings.vault.title")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="appearance-vault-show-recent"
label={t('settings.vault.showRecentHosts')}
description={t('settings.vault.showRecentHostsDesc')}
>
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
</SettingRow>
<SettingRow
anchorId="appearance-vault-select-before-connect"
label={t('settings.vault.selectBeforeConnect')}
description={t('settings.vault.selectBeforeConnectDesc')}
>
<Toggle
checked={hostClickBehavior === 'select'}
onChange={(enabled) => setHostClickBehavior(enabled ? 'select' : 'connect')}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-ungrouped-root"
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
>
<Toggle
checked={showOnlyUngroupedHostsInRoot}
onChange={setShowOnlyUngroupedHostsInRoot}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-show-sftp-tab"
label={t('settings.vault.showSftpTab')}
description={t('settings.vault.showSftpTabDesc')}
>
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
</SettingRow>
<SettingRow
anchorId="appearance-vault-host-tree"
label={t('settings.vault.showHostTreeSidebar')}
description={t('settings.vault.showHostTreeSidebarDesc')}
>
<Toggle checked={showHostTreeSidebar} onChange={setShowHostTreeSidebar} />
</SettingRow>
<SettingRow
anchorId="appearance-vault-auto-import-known-hosts"
label={t('settings.vault.autoImportSystemKnownHosts')}
description={t('settings.vault.autoImportSystemKnownHostsDesc')}
>
<Toggle
checked={autoImportSystemKnownHosts}
onChange={setAutoImportSystemKnownHosts}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-font"
label={t('settings.vault.notesFont')}
description={t('settings.vault.notesFontDesc')}
>
<TerminalFontSelect
value={resolveNoteFontSelectionId(noteFontOptions, noteFontFamily)}
fonts={noteFontOptions}
onChange={(v) => setNoteFontFamily(resolveNoteFontSelectionFamily(noteFontOptions, v))}
className="w-48"
ariaLabel={t('settings.vault.notesFont')}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-font-size"
label={t('settings.vault.notesFontSize')}
description={t('settings.vault.notesFontSizeDesc')}
>
<div className="flex items-center gap-2">
<input
type="range"
min={12}
max={22}
step={1}
value={noteFontSize}
onChange={(e) => handleSetNoteFontSize(Number(e.target.value))}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{noteFontSize}px
</span>
</div>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-code-font-size"
label={t('settings.vault.notesCodeFontSize')}
description={t('settings.vault.notesCodeFontSizeDesc')}
>
<div className="flex items-center gap-2">
<input
type="range"
min={10}
max={22}
step={1}
value={noteCodeFontSize}
onChange={(e) => handleSetNoteCodeFontSize(Number(e.target.value))}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{noteCodeFontSize}px
</span>
</div>
</SettingRow>
</div>
<SettingsAnchor anchorId="appearance-custom-css">
<div className="mb-3 flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{t("settings.appearance.customCss")}
</h3>
<button
type="button"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label={t("settings.appearance.customCss.help.ariaLabel")}
onClick={() => setCustomCssHelpOpen(true)}
>
<HelpCircle size={13} />
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("settings.appearance.customCss.desc")}
</p>
<DebouncedTextarea
value={customCSS}
onCommit={setCustomCSS}
onDraftChange={applyCustomCssToDocument}
placeholder={t("settings.appearance.customCss.placeholder")}
className="w-full h-32 px-3 py-2 text-xs font-mono bg-muted/50 border border-border rounded-lg resize-y focus:outline-none focus:ring-2 focus:ring-primary/50"
spellCheck={false}
/>
</div>
</SettingsAnchor>
<Dialog open={customCssHelpOpen} onOpenChange={setCustomCssHelpOpen}>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{t("settings.appearance.customCss.help.title")}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{customCssHelpOpen ? (
<LazyMessageResponse className={CUSTOM_CSS_HELP_PROSE_CLASS}>
{t("settings.appearance.customCss.help.body")}
</LazyMessageResponse>
) : null}
</div>
</DialogContent>
</Dialog>
</SettingsTabContent>
);
}
export default memo(SettingsAppearanceTab);