feat: Font options for the UI in larger screens, for code and screen fonts display
This commit is contained in:
81
package-lock.json
generated
81
package-lock.json
generated
@@ -34,6 +34,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.0",
|
"eslint-plugin-react-refresh": "^0.5.0",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"happy-dom": "^20.8.3",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.55.0",
|
"typescript-eslint": "^8.55.0",
|
||||||
@@ -2045,6 +2046,23 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.55.0",
|
"version": "8.55.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
||||||
@@ -3474,6 +3492,47 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/happy-dom": {
|
||||||
|
"version": "20.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz",
|
||||||
|
"integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": ">=20.0.0",
|
||||||
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"whatwg-mimetype": "^3.0.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/happy-dom/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/happy-dom/node_modules/whatwg-mimetype": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -6515,6 +6574,28 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.0",
|
"eslint-plugin-react-refresh": "^0.5.0",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"happy-dom": "^20.8.3",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.55.0",
|
"typescript-eslint": "^8.55.0",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Volume2, VolumeOff } from 'lucide-react';
|
import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Volume2, VolumeOff, Type, CaseSensitive } from 'lucide-react';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../hooks/useTheme';
|
||||||
import { useSendShortcut } from '../hooks/useSendShortcut';
|
import { useSendShortcut } from '../hooks/useSendShortcut';
|
||||||
import { useT, useLocale } from '../hooks/useLocale';
|
import { useT, useLocale } from '../hooks/useLocale';
|
||||||
import { setLocale, supportedLocales, localeLabels } from '../lib/i18n';
|
import { setLocale, supportedLocales, localeLabels } from '../lib/i18n';
|
||||||
import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef';
|
import type { ThemeName, AccentColor, UiFont, MonoFont } from '../contexts/ThemeContextDef';
|
||||||
import type { TranslationKey } from '../lib/i18n';
|
import type { TranslationKey } from '../lib/i18n';
|
||||||
|
|
||||||
const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [
|
const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [
|
||||||
@@ -62,9 +62,64 @@ function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange
|
|||||||
|
|
||||||
export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Props) {
|
export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { theme, accent, setTheme, setAccent } = useTheme();
|
const {
|
||||||
|
theme,
|
||||||
|
accent,
|
||||||
|
uiFont,
|
||||||
|
monoFont,
|
||||||
|
uiFontSize,
|
||||||
|
monoFontSize,
|
||||||
|
setTheme,
|
||||||
|
setAccent,
|
||||||
|
setUiFont,
|
||||||
|
setMonoFont,
|
||||||
|
setUiFontSize,
|
||||||
|
setMonoFontSize,
|
||||||
|
} = useTheme();
|
||||||
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
||||||
const currentLocale = useLocale();
|
const currentLocale = useLocale();
|
||||||
|
const [tab, setTab] = useState<'appearance' | 'typography' | 'chat' | 'notifications'>('appearance');
|
||||||
|
|
||||||
|
const tabs = useMemo(() => {
|
||||||
|
const base = [
|
||||||
|
{ id: 'appearance' as const, label: t('settings.tab.appearance') },
|
||||||
|
{ id: 'typography' as const, label: t('settings.tab.typography') },
|
||||||
|
{ id: 'chat' as const, label: t('settings.tab.chat') },
|
||||||
|
];
|
||||||
|
if (onToggleSound) base.push({ id: 'notifications' as const, label: t('settings.tab.notifications') });
|
||||||
|
return base;
|
||||||
|
}, [onToggleSound, t]);
|
||||||
|
|
||||||
|
const uiFontStacks: Record<UiFont, string> = {
|
||||||
|
system: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||||
|
inter: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||||
|
segoe: "'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif",
|
||||||
|
sf: "'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
const monoFontStacks: Record<MonoFont, string> = {
|
||||||
|
jetbrains: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
fira: "'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
cascadia: "'Cascadia Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
'system-mono': "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
};
|
||||||
|
|
||||||
|
const uiFontOptions: { value: UiFont; label: string; sample: string }[] = [
|
||||||
|
{ value: 'system', label: 'System', sample: 'Segoe/UI' },
|
||||||
|
{ value: 'inter', label: 'Inter', sample: 'Inter' },
|
||||||
|
{ value: 'segoe', label: 'Segoe UI', sample: 'Segoe' },
|
||||||
|
{ value: 'sf', label: 'SF Pro', sample: 'SF' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const monoFontOptions: { value: MonoFont; label: string; sample: string }[] = [
|
||||||
|
{ value: 'jetbrains', label: 'JetBrains Mono', sample: 'JetBrains' },
|
||||||
|
{ value: 'fira', label: 'Fira Code', sample: 'Fira' },
|
||||||
|
{ value: 'cascadia', label: 'Cascadia Code', sample: 'Cascadia' },
|
||||||
|
{ value: 'system-mono', label: 'System mono', sample: 'System' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const uiSizes = [14, 15, 16, 17, 18];
|
||||||
|
const monoSizes = [13, 14, 15, 16, 17];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -90,7 +145,7 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr
|
|||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-sm mx-4 rounded-3xl border border-pc-border bg-[var(--pc-bg-base)]/95 backdrop-blur-xl shadow-2xl animate-fade-in"
|
className="relative w-full max-w-md mx-4 rounded-3xl border border-pc-border bg-[var(--pc-bg-base)]/95 backdrop-blur-xl shadow-2xl animate-fade-in"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -110,96 +165,219 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-6 py-4 max-h-[60vh] overflow-y-auto">
|
<div className="px-6 py-4 max-h-[60vh] overflow-y-auto">
|
||||||
{/* Appearance */}
|
<div className="flex gap-2 mb-4">
|
||||||
<SectionTitle>{t('settings.appearance')}</SectionTitle>
|
{tabs.map(tTab => (
|
||||||
|
|
||||||
{/* Theme mode */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.mode')}</div>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{themeOptions.map(opt => {
|
|
||||||
const Icon = opt.icon;
|
|
||||||
const active = theme === opt.value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
onClick={() => setTheme(opt.value)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${
|
|
||||||
active
|
|
||||||
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
|
|
||||||
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
<span>{t(opt.labelKey)}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Accent color */}
|
|
||||||
<div className="mb-1">
|
|
||||||
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.accent')}</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{accentOptions.map(opt => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
onClick={() => setAccent(opt.value)}
|
|
||||||
className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center"
|
|
||||||
aria-pressed={accent === opt.value}
|
|
||||||
aria-label={`${opt.value} accent`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: opt.color,
|
|
||||||
borderColor: accent === opt.value ? opt.color : 'transparent',
|
|
||||||
boxShadow: accent === opt.value ? `0 0 8px ${opt.color}40` : 'none',
|
|
||||||
}}
|
|
||||||
title={opt.value}
|
|
||||||
>
|
|
||||||
{accent === opt.value && <Check size={14} className="text-white drop-shadow" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language */}
|
|
||||||
<SectionTitle>{t('settings.language')}</SectionTitle>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{supportedLocales.map(loc => (
|
|
||||||
<button
|
<button
|
||||||
key={loc}
|
key={tTab.id}
|
||||||
onClick={() => setLocale(loc)}
|
onClick={() => setTab(tTab.id)}
|
||||||
aria-pressed={currentLocale === loc}
|
className={`flex-1 rounded-xl border px-3 py-2 text-xs font-medium transition-colors ${
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs transition-all ${
|
tab === tTab.id
|
||||||
currentLocale === loc
|
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
|
||||||
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light font-medium'
|
|
||||||
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentLocale === loc && <Check size={12} />}
|
{tTab.label}
|
||||||
{localeLabels[loc] || loc.toUpperCase()}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat */}
|
{tab === 'appearance' && (
|
||||||
<SectionTitle>{t('settings.chat')}</SectionTitle>
|
<>
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<SectionTitle>{t('settings.appearance')}</SectionTitle>
|
||||||
<div className="text-xs text-pc-text-secondary">{t('settings.sendShortcut')}</div>
|
<div className="mb-3">
|
||||||
<button
|
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.mode')}</div>
|
||||||
onClick={toggleSendShortcut}
|
<div className="flex gap-1.5">
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-pc-border text-xs text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors"
|
{themeOptions.map(opt => {
|
||||||
>
|
const Icon = opt.icon;
|
||||||
{sendOnEnter
|
const active = theme === opt.value;
|
||||||
? t('settings.sendEnter')
|
return (
|
||||||
: (isMac ? '⌘+' : 'Ctrl+') + t('settings.sendEnter')
|
<button
|
||||||
}
|
key={opt.value}
|
||||||
</button>
|
onClick={() => setTheme(opt.value)}
|
||||||
</div>
|
aria-pressed={active}
|
||||||
|
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${
|
||||||
|
active
|
||||||
|
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
|
||||||
|
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
<span>{t(opt.labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notifications */}
|
<div className="mb-4">
|
||||||
{onToggleSound && (
|
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.accent')}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{accentOptions.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setAccent(opt.value)}
|
||||||
|
className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center"
|
||||||
|
aria-pressed={accent === opt.value}
|
||||||
|
aria-label={`${opt.value} accent`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: opt.color,
|
||||||
|
borderColor: accent === opt.value ? opt.color : 'transparent',
|
||||||
|
boxShadow: accent === opt.value ? `0 0 8px ${opt.color}40` : 'none',
|
||||||
|
}}
|
||||||
|
title={opt.value}
|
||||||
|
>
|
||||||
|
{accent === opt.value && <Check size={14} className="text-white drop-shadow" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionTitle>{t('settings.language')}</SectionTitle>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{supportedLocales.map(loc => (
|
||||||
|
<button
|
||||||
|
key={loc}
|
||||||
|
onClick={() => setLocale(loc)}
|
||||||
|
aria-pressed={currentLocale === loc}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs transition-all ${
|
||||||
|
currentLocale === loc
|
||||||
|
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light font-medium'
|
||||||
|
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentLocale === loc && <Check size={12} />}
|
||||||
|
{localeLabels[loc] || loc.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'typography' && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>{t('settings.typography')}</SectionTitle>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-pc-text-secondary mb-2">
|
||||||
|
<Type size={14} />
|
||||||
|
{t('settings.fontUi')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{uiFontOptions.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setUiFont(opt.value)}
|
||||||
|
aria-pressed={uiFont === opt.value}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all ${
|
||||||
|
uiFont === opt.value
|
||||||
|
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
|
||||||
|
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: uiFontStacks[opt.value] }}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{opt.sample}</span>
|
||||||
|
<span className="text-[11px] text-pc-text-faint">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-pc-text-secondary mb-2">
|
||||||
|
<CaseSensitive size={14} />
|
||||||
|
{t('settings.fontMono')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{monoFontOptions.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setMonoFont(opt.value)}
|
||||||
|
aria-pressed={monoFont === opt.value}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all ${
|
||||||
|
monoFont === opt.value
|
||||||
|
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
|
||||||
|
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: monoFontStacks[opt.value] }}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{opt.sample}</span>
|
||||||
|
<span className="text-[11px] text-pc-text-faint">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-xs text-pc-text-secondary mb-2">{t('settings.fontSize')}</div>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{uiSizes.map(size => (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
onClick={() => setUiFontSize(size)}
|
||||||
|
aria-pressed={uiFontSize === size}
|
||||||
|
className={`px-3 py-1.5 rounded-lg border text-xs transition-all ${
|
||||||
|
uiFontSize === size
|
||||||
|
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
|
||||||
|
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{size}px
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-xs text-pc-text-secondary mb-2">{t('settings.fontMonoSize')}</div>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{monoSizes.map(size => (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
onClick={() => setMonoFontSize(size)}
|
||||||
|
aria-pressed={monoFontSize === size}
|
||||||
|
className={`px-3 py-1.5 rounded-lg border text-xs transition-all ${
|
||||||
|
monoFontSize === size
|
||||||
|
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
|
||||||
|
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{size}px
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-pc-border bg-[var(--pc-bg-surface)]/70 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-pc-text-faint mb-2">{t('settings.preview')}</div>
|
||||||
|
<div className="text-sm text-pc-text mb-2" style={{ fontFamily: 'var(--pc-font-ui)', fontSize: 'var(--pc-font-size)' }}>
|
||||||
|
The quick brown fox jumps over the lazy dog — 1234567890
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dashed border-pc-border p-2 bg-[var(--pc-bg-code)] text-[13px]" style={{ fontFamily: 'var(--pc-font-mono)', fontSize: 'var(--pc-font-size-mono)' }}>
|
||||||
|
const hello = 'PinchChat'; // typography preview
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'chat' && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>{t('settings.chat')}</SectionTitle>
|
||||||
|
<div className="flex items-center justify-between py-1.5">
|
||||||
|
<div className="text-xs text-pc-text-secondary">{t('settings.sendShortcut')}</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleSendShortcut}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-pc-border text-xs text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
{sendOnEnter
|
||||||
|
? t('settings.sendEnter')
|
||||||
|
: (isMac ? '⌘+' : 'Ctrl+') + t('settings.sendEnter')
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'notifications' && onToggleSound && (
|
||||||
<>
|
<>
|
||||||
<SectionTitle>{t('settings.notifications')}</SectionTitle>
|
<SectionTitle>{t('settings.notifications')}</SectionTitle>
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
import { ThemeContext, type ThemeName, type AccentColor } from './ThemeContextDef';
|
import { ThemeContext, type ThemeName, type AccentColor, type UiFont, type MonoFont } from './ThemeContextDef';
|
||||||
|
|
||||||
export type { ThemeName, AccentColor } from './ThemeContextDef';
|
export type { ThemeName, AccentColor } from './ThemeContextDef';
|
||||||
|
|
||||||
@@ -8,6 +8,10 @@ const STORAGE_KEY = 'pinchchat-theme';
|
|||||||
interface StoredTheme {
|
interface StoredTheme {
|
||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
accent: AccentColor;
|
accent: AccentColor;
|
||||||
|
uiFont: UiFont;
|
||||||
|
monoFont: MonoFont;
|
||||||
|
uiFontSize: number;
|
||||||
|
monoFontSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConcreteTheme = 'dark' | 'light' | 'oled';
|
type ConcreteTheme = 'dark' | 'light' | 'oled';
|
||||||
@@ -119,6 +123,20 @@ const accents: Record<AccentColor, Record<string, string>> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uiFonts: Record<UiFont, string> = {
|
||||||
|
system: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||||
|
inter: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||||
|
segoe: "'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif",
|
||||||
|
sf: "'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
const monoFonts: Record<MonoFont, string> = {
|
||||||
|
jetbrains: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
fira: "'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
cascadia: "'Cascadia Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
'system-mono': "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
};
|
||||||
|
|
||||||
function applyVars(vars: Record<string, string>) {
|
function applyVars(vars: Record<string, string>) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const [k, v] of Object.entries(vars)) {
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
@@ -139,51 +157,134 @@ function loadStored(): StoredTheme {
|
|||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if ((parsed.theme in themes || parsed.theme === 'system') && parsed.accent in accents) return parsed;
|
const themeValid = parsed.theme in themes || parsed.theme === 'system';
|
||||||
|
const accentValid = parsed.accent in accents;
|
||||||
|
const uiFont: UiFont = uiFonts[parsed.uiFont as UiFont] ? parsed.uiFont : 'system';
|
||||||
|
const monoFont: MonoFont = monoFonts[parsed.monoFont as MonoFont] ? parsed.monoFont : 'jetbrains';
|
||||||
|
const uiFontSize = Number.isFinite(parsed.uiFontSize) ? Math.min(20, Math.max(12, Number(parsed.uiFontSize))) : 15;
|
||||||
|
const monoFontSize = Number.isFinite(parsed.monoFontSize) ? Math.min(20, Math.max(12, Number(parsed.monoFontSize))) : 14;
|
||||||
|
if (themeValid && accentValid) {
|
||||||
|
return {
|
||||||
|
theme: parsed.theme,
|
||||||
|
accent: parsed.accent,
|
||||||
|
uiFont,
|
||||||
|
monoFont,
|
||||||
|
uiFontSize,
|
||||||
|
monoFontSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore invalid stored JSON */ }
|
} catch { /* ignore invalid stored JSON */ }
|
||||||
return { theme: 'dark', accent: 'cyan' };
|
return { theme: 'dark', accent: 'cyan', uiFont: 'system', monoFont: 'jetbrains', uiFontSize: 15, monoFontSize: 14 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontVars(uiFont: UiFont, monoFont: MonoFont, uiFontSize: number, monoFontSize: number) {
|
||||||
|
return {
|
||||||
|
'--pc-font-ui': uiFonts[uiFont],
|
||||||
|
'--pc-font-mono': monoFonts[monoFont],
|
||||||
|
'--pc-font-size': `${uiFontSize}px`,
|
||||||
|
'--pc-font-size-mono': `${monoFontSize}px`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [stored] = useState(loadStored);
|
const [stored] = useState(loadStored);
|
||||||
const [theme, setThemeState] = useState<ThemeName>(stored.theme);
|
const [theme, setThemeState] = useState<ThemeName>(stored.theme);
|
||||||
const [accent, setAccentState] = useState<AccentColor>(stored.accent);
|
const [accent, setAccentState] = useState<AccentColor>(stored.accent);
|
||||||
|
const [uiFont, setUiFontState] = useState<UiFont>(stored.uiFont);
|
||||||
|
const [monoFont, setMonoFontState] = useState<MonoFont>(stored.monoFont);
|
||||||
|
const [uiFontSize, setUiFontSizeState] = useState<number>(stored.uiFontSize);
|
||||||
|
const [monoFontSize, setMonoFontSizeState] = useState<number>(stored.monoFontSize);
|
||||||
|
|
||||||
const persist = useCallback((t: ThemeName, a: AccentColor) => {
|
const persist = useCallback((t: ThemeName, a: AccentColor, ui: UiFont, mono: MonoFont, uiSize: number, monoSize: number) => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: t, accent: a }));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
theme: t,
|
||||||
|
accent: a,
|
||||||
|
uiFont: ui,
|
||||||
|
monoFont: mono,
|
||||||
|
uiFontSize: uiSize,
|
||||||
|
monoFontSize: monoSize,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyAll = useCallback((nextTheme: ThemeName, nextAccent: AccentColor, nextUiFont: UiFont, nextMonoFont: MonoFont, nextUiSize: number, nextMonoSize: number) => {
|
||||||
|
applyVars({
|
||||||
|
...themes[resolveTheme(nextTheme)],
|
||||||
|
...accents[nextAccent],
|
||||||
|
...fontVars(nextUiFont, nextMonoFont, nextUiSize, nextMonoSize),
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setTheme = useCallback((t: ThemeName) => {
|
const setTheme = useCallback((t: ThemeName) => {
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
applyVars(themes[resolveTheme(t)]);
|
applyAll(t, accent, uiFont, monoFont, uiFontSize, monoFontSize);
|
||||||
persist(t, accent);
|
persist(t, accent, uiFont, monoFont, uiFontSize, monoFontSize);
|
||||||
}, [accent, persist]);
|
}, [accent, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]);
|
||||||
|
|
||||||
const setAccent = useCallback((a: AccentColor) => {
|
const setAccent = useCallback((a: AccentColor) => {
|
||||||
setAccentState(a);
|
setAccentState(a);
|
||||||
applyVars(accents[a]);
|
applyAll(theme, a, uiFont, monoFont, uiFontSize, monoFontSize);
|
||||||
persist(theme, a);
|
persist(theme, a, uiFont, monoFont, uiFontSize, monoFontSize);
|
||||||
}, [theme, persist]);
|
}, [theme, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]);
|
||||||
|
|
||||||
|
const setUiFont = useCallback((f: UiFont) => {
|
||||||
|
setUiFontState(f);
|
||||||
|
applyAll(theme, accent, f, monoFont, uiFontSize, monoFontSize);
|
||||||
|
persist(theme, accent, f, monoFont, uiFontSize, monoFontSize);
|
||||||
|
}, [accent, theme, applyAll, persist, monoFont, uiFontSize, monoFontSize]);
|
||||||
|
|
||||||
|
const setMonoFont = useCallback((f: MonoFont) => {
|
||||||
|
setMonoFontState(f);
|
||||||
|
applyAll(theme, accent, uiFont, f, uiFontSize, monoFontSize);
|
||||||
|
persist(theme, accent, uiFont, f, uiFontSize, monoFontSize);
|
||||||
|
}, [accent, theme, applyAll, persist, uiFont, uiFontSize, monoFontSize]);
|
||||||
|
|
||||||
|
const setUiFontSize = useCallback((size: number) => {
|
||||||
|
const clamped = Math.min(20, Math.max(12, size));
|
||||||
|
setUiFontSizeState(clamped);
|
||||||
|
applyAll(theme, accent, uiFont, monoFont, clamped, monoFontSize);
|
||||||
|
persist(theme, accent, uiFont, monoFont, clamped, monoFontSize);
|
||||||
|
}, [accent, theme, applyAll, persist, uiFont, monoFont, monoFontSize]);
|
||||||
|
|
||||||
|
const setMonoFontSize = useCallback((size: number) => {
|
||||||
|
const clamped = Math.min(20, Math.max(12, size));
|
||||||
|
setMonoFontSizeState(clamped);
|
||||||
|
applyAll(theme, accent, uiFont, monoFont, uiFontSize, clamped);
|
||||||
|
persist(theme, accent, uiFont, monoFont, uiFontSize, clamped);
|
||||||
|
}, [accent, theme, applyAll, persist, uiFont, monoFont, uiFontSize]);
|
||||||
|
|
||||||
// Apply on mount
|
// Apply on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyVars({ ...themes[resolveTheme(theme)], ...accents[accent] });
|
applyAll(theme, accent, uiFont, monoFont, uiFontSize, monoFontSize);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Listen to OS color scheme changes when theme is 'system'
|
// Listen to OS color scheme changes when theme is 'system'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme !== 'system') return;
|
if (theme !== 'system') return;
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: light)');
|
const mq = window.matchMedia('(prefers-color-scheme: light)');
|
||||||
const handler = () => applyVars(themes[mq.matches ? 'light' : 'dark']);
|
const handler = () => applyAll(mq.matches ? 'light' : 'dark', accent, uiFont, monoFont, uiFontSize, monoFontSize);
|
||||||
mq.addEventListener('change', handler);
|
mq.addEventListener('change', handler);
|
||||||
return () => mq.removeEventListener('change', handler);
|
return () => mq.removeEventListener('change', handler);
|
||||||
}, [theme]);
|
}, [theme, accent, applyAll, uiFont, monoFont, uiFontSize, monoFontSize]);
|
||||||
|
|
||||||
const resolvedTheme = resolveTheme(theme);
|
const resolvedTheme = resolveTheme(theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, accent, resolvedTheme, setTheme, setAccent }}>
|
<ThemeContext.Provider value={{
|
||||||
|
theme,
|
||||||
|
accent,
|
||||||
|
uiFont,
|
||||||
|
monoFont,
|
||||||
|
uiFontSize,
|
||||||
|
monoFontSize,
|
||||||
|
resolvedTheme,
|
||||||
|
setTheme,
|
||||||
|
setAccent,
|
||||||
|
setUiFont,
|
||||||
|
setMonoFont,
|
||||||
|
setUiFontSize,
|
||||||
|
setMonoFontSize,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,20 +2,38 @@ import { createContext } from 'react';
|
|||||||
|
|
||||||
export type ThemeName = 'dark' | 'light' | 'oled' | 'system';
|
export type ThemeName = 'dark' | 'light' | 'oled' | 'system';
|
||||||
export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue';
|
export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue';
|
||||||
|
export type UiFont = 'system' | 'inter' | 'segoe' | 'sf';
|
||||||
|
export type MonoFont = 'jetbrains' | 'fira' | 'cascadia' | 'system-mono';
|
||||||
|
|
||||||
export interface ThemeContextValue {
|
export interface ThemeContextValue {
|
||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
accent: AccentColor;
|
accent: AccentColor;
|
||||||
|
uiFont: UiFont;
|
||||||
|
monoFont: MonoFont;
|
||||||
|
uiFontSize: number;
|
||||||
|
monoFontSize: number;
|
||||||
/** Resolved concrete theme (never 'system'). */
|
/** Resolved concrete theme (never 'system'). */
|
||||||
resolvedTheme: 'dark' | 'light' | 'oled';
|
resolvedTheme: 'dark' | 'light' | 'oled';
|
||||||
setTheme: (t: ThemeName) => void;
|
setTheme: (t: ThemeName) => void;
|
||||||
setAccent: (a: AccentColor) => void;
|
setAccent: (a: AccentColor) => void;
|
||||||
|
setUiFont: (f: UiFont) => void;
|
||||||
|
setMonoFont: (f: MonoFont) => void;
|
||||||
|
setUiFontSize: (size: number) => void;
|
||||||
|
setMonoFontSize: (size: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeContext = createContext<ThemeContextValue>({
|
export const ThemeContext = createContext<ThemeContextValue>({
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
accent: 'cyan',
|
accent: 'cyan',
|
||||||
|
uiFont: 'system',
|
||||||
|
monoFont: 'jetbrains',
|
||||||
|
uiFontSize: 15,
|
||||||
|
monoFontSize: 14,
|
||||||
resolvedTheme: 'dark',
|
resolvedTheme: 'dark',
|
||||||
setTheme: () => {},
|
setTheme: () => {},
|
||||||
setAccent: () => {},
|
setAccent: () => {},
|
||||||
|
setUiFont: () => {},
|
||||||
|
setMonoFont: () => {},
|
||||||
|
setUiFontSize: () => {},
|
||||||
|
setMonoFontSize: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
--pc-hover: rgba(255,255,255,0.05);
|
--pc-hover: rgba(255,255,255,0.05);
|
||||||
--pc-hover-strong: rgba(255,255,255,0.08);
|
--pc-hover-strong: rgba(255,255,255,0.08);
|
||||||
--pc-separator: rgba(255,255,255,0.05);
|
--pc-separator: rgba(255,255,255,0.05);
|
||||||
|
--pc-font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--pc-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
--pc-font-size: 15px;
|
||||||
|
--pc-font-size-mono: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global focus-visible ring for keyboard navigation (a11y).
|
/* Global focus-visible ring for keyboard navigation (a11y).
|
||||||
@@ -117,11 +121,16 @@ html, body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--pc-bg-base);
|
background: var(--pc-bg-base);
|
||||||
color: var(--pc-text-primary);
|
color: var(--pc-text-primary);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: var(--pc-font-ui);
|
||||||
|
font-size: var(--pc-font-size);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code, kbd, pre {
|
||||||
|
font-family: var(--pc-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -160,7 +169,8 @@ html, body {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
font-size: 0.82em;
|
font-family: var(--pc-font-mono);
|
||||||
|
font-size: calc(var(--pc-font-size-mono) * 0.9);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -176,7 +186,8 @@ html, body {
|
|||||||
background: var(--pc-accent-glow);
|
background: var(--pc-accent-glow);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.85em;
|
font-size: calc(var(--pc-font-size-mono) * 0.95);
|
||||||
|
font-family: var(--pc-font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override highlight.js theme bg to match */
|
/* Override highlight.js theme bg to match */
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ const en = {
|
|||||||
// Send shortcut setting
|
// Send shortcut setting
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
'settings.appearance': 'Appearance',
|
'settings.appearance': 'Appearance',
|
||||||
|
'settings.typography': 'Typography',
|
||||||
'settings.chat': 'Chat',
|
'settings.chat': 'Chat',
|
||||||
'settings.notifications': 'Notifications',
|
'settings.notifications': 'Notifications',
|
||||||
'settings.notificationSound': 'Notification sound',
|
'settings.notificationSound': 'Notification sound',
|
||||||
@@ -173,6 +174,15 @@ const en = {
|
|||||||
'settings.sendShortcut': 'Send with',
|
'settings.sendShortcut': 'Send with',
|
||||||
'settings.sendEnter': 'Enter',
|
'settings.sendEnter': 'Enter',
|
||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
'settings.tab.appearance': 'Appearance',
|
||||||
|
'settings.tab.typography': 'Typography',
|
||||||
|
'settings.tab.chat': 'Chat',
|
||||||
|
'settings.tab.notifications': 'Notifications',
|
||||||
|
'settings.fontUi': 'UI font',
|
||||||
|
'settings.fontMono': 'Code font',
|
||||||
|
'settings.fontSize': 'UI font size',
|
||||||
|
'settings.fontMonoSize': 'Code font size',
|
||||||
|
'settings.preview': 'Preview',
|
||||||
|
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
'message.bookmark': 'Bookmark message',
|
'message.bookmark': 'Bookmark message',
|
||||||
@@ -337,6 +347,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'settings.title': 'Paramètres',
|
'settings.title': 'Paramètres',
|
||||||
'settings.appearance': 'Apparence',
|
'settings.appearance': 'Apparence',
|
||||||
|
'settings.typography': 'Typographie',
|
||||||
'settings.chat': 'Chat',
|
'settings.chat': 'Chat',
|
||||||
'settings.notifications': 'Notifications',
|
'settings.notifications': 'Notifications',
|
||||||
'settings.notificationSound': 'Son de notification',
|
'settings.notificationSound': 'Son de notification',
|
||||||
@@ -344,6 +355,15 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendShortcut': 'Envoyer avec',
|
'settings.sendShortcut': 'Envoyer avec',
|
||||||
'settings.sendEnter': 'Entrée',
|
'settings.sendEnter': 'Entrée',
|
||||||
'settings.sendCtrlEnter': 'Ctrl+Entrée',
|
'settings.sendCtrlEnter': 'Ctrl+Entrée',
|
||||||
|
'settings.tab.appearance': 'Apparence',
|
||||||
|
'settings.tab.typography': 'Typographie',
|
||||||
|
'settings.tab.chat': 'Chat',
|
||||||
|
'settings.tab.notifications': 'Notifications',
|
||||||
|
'settings.fontUi': 'Police UI',
|
||||||
|
'settings.fontMono': 'Police code',
|
||||||
|
'settings.fontSize': 'Taille police UI',
|
||||||
|
'settings.fontMonoSize': 'Taille police code',
|
||||||
|
'settings.preview': 'Aperçu',
|
||||||
|
|
||||||
'message.bookmark': 'Marquer le message',
|
'message.bookmark': 'Marquer le message',
|
||||||
'message.removeBookmark': 'Retirer le marque-page',
|
'message.removeBookmark': 'Retirer le marque-page',
|
||||||
@@ -507,6 +527,7 @@ const es: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'settings.title': 'Ajustes',
|
'settings.title': 'Ajustes',
|
||||||
'settings.appearance': 'Apariencia',
|
'settings.appearance': 'Apariencia',
|
||||||
|
'settings.typography': 'Tipografía',
|
||||||
'settings.chat': 'Chat',
|
'settings.chat': 'Chat',
|
||||||
'settings.notifications': 'Notificaciones',
|
'settings.notifications': 'Notificaciones',
|
||||||
'settings.notificationSound': 'Sonido de notificación',
|
'settings.notificationSound': 'Sonido de notificación',
|
||||||
@@ -514,6 +535,15 @@ const es: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendShortcut': 'Enviar con',
|
'settings.sendShortcut': 'Enviar con',
|
||||||
'settings.sendEnter': 'Enter',
|
'settings.sendEnter': 'Enter',
|
||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
'settings.tab.appearance': 'Apariencia',
|
||||||
|
'settings.tab.typography': 'Tipografía',
|
||||||
|
'settings.tab.chat': 'Chat',
|
||||||
|
'settings.tab.notifications': 'Notificaciones',
|
||||||
|
'settings.fontUi': 'Fuente UI',
|
||||||
|
'settings.fontMono': 'Fuente de código',
|
||||||
|
'settings.fontSize': 'Tamaño fuente UI',
|
||||||
|
'settings.fontMonoSize': 'Tamaño fuente código',
|
||||||
|
'settings.preview': 'Vista previa',
|
||||||
|
|
||||||
'message.bookmark': 'Marcar mensaje',
|
'message.bookmark': 'Marcar mensaje',
|
||||||
'message.removeBookmark': 'Quitar marcador',
|
'message.removeBookmark': 'Quitar marcador',
|
||||||
@@ -679,6 +709,7 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'settings.title': 'Einstellungen',
|
'settings.title': 'Einstellungen',
|
||||||
'settings.appearance': 'Darstellung',
|
'settings.appearance': 'Darstellung',
|
||||||
|
'settings.typography': 'Typografie',
|
||||||
'settings.chat': 'Chat',
|
'settings.chat': 'Chat',
|
||||||
'settings.notifications': 'Benachrichtigungen',
|
'settings.notifications': 'Benachrichtigungen',
|
||||||
'settings.notificationSound': 'Benachrichtigungston',
|
'settings.notificationSound': 'Benachrichtigungston',
|
||||||
@@ -686,6 +717,15 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendShortcut': 'Senden mit',
|
'settings.sendShortcut': 'Senden mit',
|
||||||
'settings.sendEnter': 'Enter',
|
'settings.sendEnter': 'Enter',
|
||||||
'settings.sendCtrlEnter': 'Strg+Enter',
|
'settings.sendCtrlEnter': 'Strg+Enter',
|
||||||
|
'settings.tab.appearance': 'Darstellung',
|
||||||
|
'settings.tab.typography': 'Typografie',
|
||||||
|
'settings.tab.chat': 'Chat',
|
||||||
|
'settings.tab.notifications': 'Benachrichtigungen',
|
||||||
|
'settings.fontUi': 'UI-Schriftart',
|
||||||
|
'settings.fontMono': 'Code-Schriftart',
|
||||||
|
'settings.fontSize': 'UI-Schriftgröße',
|
||||||
|
'settings.fontMonoSize': 'Code-Schriftgröße',
|
||||||
|
'settings.preview': 'Vorschau',
|
||||||
|
|
||||||
'message.bookmark': 'Nachricht markieren',
|
'message.bookmark': 'Nachricht markieren',
|
||||||
'message.reply': 'Antworten',
|
'message.reply': 'Antworten',
|
||||||
@@ -849,6 +889,7 @@ const ja: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'settings.title': '設定',
|
'settings.title': '設定',
|
||||||
'settings.appearance': '外観',
|
'settings.appearance': '外観',
|
||||||
|
'settings.typography': 'タイポグラフィ',
|
||||||
'settings.chat': 'チャット',
|
'settings.chat': 'チャット',
|
||||||
'settings.notifications': '通知',
|
'settings.notifications': '通知',
|
||||||
'settings.notificationSound': '通知音',
|
'settings.notificationSound': '通知音',
|
||||||
@@ -856,6 +897,15 @@ const ja: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendShortcut': '送信キー',
|
'settings.sendShortcut': '送信キー',
|
||||||
'settings.sendEnter': 'Enter',
|
'settings.sendEnter': 'Enter',
|
||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
'settings.tab.appearance': '外観',
|
||||||
|
'settings.tab.typography': 'タイポグラフィ',
|
||||||
|
'settings.tab.chat': 'チャット',
|
||||||
|
'settings.tab.notifications': '通知',
|
||||||
|
'settings.fontUi': 'UIフォント',
|
||||||
|
'settings.fontMono': 'コードフォント',
|
||||||
|
'settings.fontSize': 'UIフォントサイズ',
|
||||||
|
'settings.fontMonoSize': 'コードフォントサイズ',
|
||||||
|
'settings.preview': 'プレビュー',
|
||||||
|
|
||||||
'message.bookmark': 'メッセージをブックマーク',
|
'message.bookmark': 'メッセージをブックマーク',
|
||||||
'message.reply': '返信',
|
'message.reply': '返信',
|
||||||
@@ -1019,6 +1069,7 @@ const pt: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'settings.title': 'Configurações',
|
'settings.title': 'Configurações',
|
||||||
'settings.appearance': 'Aparência',
|
'settings.appearance': 'Aparência',
|
||||||
|
'settings.typography': 'Tipografia',
|
||||||
'settings.chat': 'Chat',
|
'settings.chat': 'Chat',
|
||||||
'settings.notifications': 'Notificações',
|
'settings.notifications': 'Notificações',
|
||||||
'settings.notificationSound': 'Som de notificação',
|
'settings.notificationSound': 'Som de notificação',
|
||||||
@@ -1026,6 +1077,15 @@ const pt: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendShortcut': 'Tecla de envio',
|
'settings.sendShortcut': 'Tecla de envio',
|
||||||
'settings.sendEnter': 'Enter',
|
'settings.sendEnter': 'Enter',
|
||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
'settings.tab.appearance': 'Aparência',
|
||||||
|
'settings.tab.typography': 'Tipografia',
|
||||||
|
'settings.tab.chat': 'Chat',
|
||||||
|
'settings.tab.notifications': 'Notificações',
|
||||||
|
'settings.fontUi': 'Fonte da UI',
|
||||||
|
'settings.fontMono': 'Fonte de código',
|
||||||
|
'settings.fontSize': 'Tamanho da fonte UI',
|
||||||
|
'settings.fontMonoSize': 'Tamanho da fonte de código',
|
||||||
|
'settings.preview': 'Pré-visualização',
|
||||||
|
|
||||||
'message.bookmark': 'Marcar mensagem',
|
'message.bookmark': 'Marcar mensagem',
|
||||||
'message.reply': 'Responder',
|
'message.reply': 'Responder',
|
||||||
@@ -1189,6 +1249,7 @@ const zh: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'settings.title': '设置',
|
'settings.title': '设置',
|
||||||
'settings.appearance': '外观',
|
'settings.appearance': '外观',
|
||||||
|
'settings.typography': '排版',
|
||||||
'settings.chat': '聊天',
|
'settings.chat': '聊天',
|
||||||
'settings.notifications': '通知',
|
'settings.notifications': '通知',
|
||||||
'settings.notificationSound': '通知声音',
|
'settings.notificationSound': '通知声音',
|
||||||
@@ -1196,6 +1257,15 @@ const zh: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendShortcut': '发送方式',
|
'settings.sendShortcut': '发送方式',
|
||||||
'settings.sendEnter': 'Enter',
|
'settings.sendEnter': 'Enter',
|
||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
'settings.tab.appearance': '外观',
|
||||||
|
'settings.tab.typography': '排版',
|
||||||
|
'settings.tab.chat': '聊天',
|
||||||
|
'settings.tab.notifications': '通知',
|
||||||
|
'settings.fontUi': '界面字体',
|
||||||
|
'settings.fontMono': '代码字体',
|
||||||
|
'settings.fontSize': '界面字号',
|
||||||
|
'settings.fontMonoSize': '代码字号',
|
||||||
|
'settings.preview': '预览',
|
||||||
|
|
||||||
'message.bookmark': '收藏消息',
|
'message.bookmark': '收藏消息',
|
||||||
'message.reply': '回复',
|
'message.reply': '回复',
|
||||||
@@ -1359,6 +1429,7 @@ const it: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'settings.title': 'Impostazioni',
|
'settings.title': 'Impostazioni',
|
||||||
'settings.appearance': 'Aspetto',
|
'settings.appearance': 'Aspetto',
|
||||||
|
'settings.typography': 'Tipografia',
|
||||||
'settings.chat': 'Chat',
|
'settings.chat': 'Chat',
|
||||||
'settings.notifications': 'Notifiche',
|
'settings.notifications': 'Notifiche',
|
||||||
'settings.notificationSound': 'Suono di notifica',
|
'settings.notificationSound': 'Suono di notifica',
|
||||||
@@ -1366,6 +1437,15 @@ const it: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendShortcut': 'Invia con',
|
'settings.sendShortcut': 'Invia con',
|
||||||
'settings.sendEnter': 'Invio',
|
'settings.sendEnter': 'Invio',
|
||||||
'settings.sendCtrlEnter': 'Ctrl+Invio',
|
'settings.sendCtrlEnter': 'Ctrl+Invio',
|
||||||
|
'settings.tab.appearance': 'Aspetto',
|
||||||
|
'settings.tab.typography': 'Tipografia',
|
||||||
|
'settings.tab.chat': 'Chat',
|
||||||
|
'settings.tab.notifications': 'Notifiche',
|
||||||
|
'settings.fontUi': 'Font UI',
|
||||||
|
'settings.fontMono': 'Font codice',
|
||||||
|
'settings.fontSize': 'Dimensione font UI',
|
||||||
|
'settings.fontMonoSize': 'Dimensione font codice',
|
||||||
|
'settings.preview': 'Anteprima',
|
||||||
|
|
||||||
'message.bookmark': 'Aggiungi ai segnalibri',
|
'message.bookmark': 'Aggiungi ai segnalibri',
|
||||||
'message.reply': 'Rispondi',
|
'message.reply': 'Rispondi',
|
||||||
|
|||||||
29
src/test/setup.ts
Normal file
29
src/test/setup.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Test environment polyfills for Vitest running in `node` mode.
|
||||||
|
* Provides a minimal localStorage implementation with clear() to satisfy hooks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.clear !== 'function') {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem(key: string) {
|
||||||
|
return store.has(key) ? store.get(key)! : null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
store.set(String(key), String(value));
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(store.keys())[index] ?? null;
|
||||||
|
},
|
||||||
|
get length() {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
} as unknown as Storage;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
setupFiles: ['src/test/setup.ts'],
|
||||||
include: ['src/**/__tests__/**/*.test.{ts,tsx}'],
|
include: ['src/**/__tests__/**/*.test.{ts,tsx}'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
|
|||||||
Reference in New Issue
Block a user