diff --git a/package-lock.json b/package-lock.json index fac27e9..0849335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.0", "globals": "^16.5.0", + "happy-dom": "^20.8.3", "jsdom": "^28.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.55.0", @@ -2045,6 +2046,23 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "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": { "version": "8.55.0", "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==", "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6515,6 +6574,28 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index f0b906c..9710a66 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.0", "globals": "^16.5.0", + "happy-dom": "^20.8.3", "jsdom": "^28.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.55.0", diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index c23ba2a..233d2d1 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,10 +1,10 @@ -import { useEffect } from 'react'; -import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Volume2, VolumeOff } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Volume2, VolumeOff, Type, CaseSensitive } from 'lucide-react'; import { useTheme } from '../hooks/useTheme'; import { useSendShortcut } from '../hooks/useSendShortcut'; import { useT, useLocale } from '../hooks/useLocale'; 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'; 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) { 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 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 = { + 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 = { + 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(() => { if (!open) return; @@ -90,7 +145,7 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr {/* Modal */}
e.stopPropagation()} > {/* Header */} @@ -110,96 +165,219 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr {/* Body */}
- {/* Appearance */} - {t('settings.appearance')} - - {/* Theme mode */} -
-
{t('theme.mode')}
-
- {themeOptions.map(opt => { - const Icon = opt.icon; - const active = theme === opt.value; - return ( - - ); - })} -
-
- - {/* Accent color */} -
-
{t('theme.accent')}
-
- {accentOptions.map(opt => ( - - ))} -
-
- - {/* Language */} - {t('settings.language')} -
- {supportedLocales.map(loc => ( +
+ {tabs.map(tTab => ( ))}
- {/* Chat */} - {t('settings.chat')} -
-
{t('settings.sendShortcut')}
- -
+ {tab === 'appearance' && ( + <> + {t('settings.appearance')} +
+
{t('theme.mode')}
+
+ {themeOptions.map(opt => { + const Icon = opt.icon; + const active = theme === opt.value; + return ( + + ); + })} +
+
- {/* Notifications */} - {onToggleSound && ( +
+
{t('theme.accent')}
+
+ {accentOptions.map(opt => ( + + ))} +
+
+ + {t('settings.language')} +
+ {supportedLocales.map(loc => ( + + ))} +
+ + )} + + {tab === 'typography' && ( + <> + {t('settings.typography')} + +
+
+ + {t('settings.fontUi')} +
+
+ {uiFontOptions.map(opt => ( + + ))} +
+
+ +
+
+ + {t('settings.fontMono')} +
+
+ {monoFontOptions.map(opt => ( + + ))} +
+
+ +
+
{t('settings.fontSize')}
+
+ {uiSizes.map(size => ( + + ))} +
+
+ +
+
{t('settings.fontMonoSize')}
+
+ {monoSizes.map(size => ( + + ))} +
+
+ +
+
{t('settings.preview')}
+
+ The quick brown fox jumps over the lazy dog — 1234567890 +
+
+ const hello = 'PinchChat'; // typography preview +
+
+ + )} + + {tab === 'chat' && ( + <> + {t('settings.chat')} +
+
{t('settings.sendShortcut')}
+ +
+ + )} + + {tab === 'notifications' && onToggleSound && ( <> {t('settings.notifications')}
diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index eb8e7c9..bfb545a 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -1,5 +1,5 @@ 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'; @@ -8,6 +8,10 @@ const STORAGE_KEY = 'pinchchat-theme'; interface StoredTheme { theme: ThemeName; accent: AccentColor; + uiFont: UiFont; + monoFont: MonoFont; + uiFontSize: number; + monoFontSize: number; } type ConcreteTheme = 'dark' | 'light' | 'oled'; @@ -119,6 +123,20 @@ const accents: Record> = { }, }; +const uiFonts: Record = { + 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 = { + 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) { const root = document.documentElement; for (const [k, v] of Object.entries(vars)) { @@ -139,51 +157,134 @@ function loadStored(): StoredTheme { const raw = localStorage.getItem(STORAGE_KEY); if (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 */ } - 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 }) { const [stored] = useState(loadStored); const [theme, setThemeState] = useState(stored.theme); const [accent, setAccentState] = useState(stored.accent); + const [uiFont, setUiFontState] = useState(stored.uiFont); + const [monoFont, setMonoFontState] = useState(stored.monoFont); + const [uiFontSize, setUiFontSizeState] = useState(stored.uiFontSize); + const [monoFontSize, setMonoFontSizeState] = useState(stored.monoFontSize); - const persist = useCallback((t: ThemeName, a: AccentColor) => { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: t, accent: a })); + 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, + 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) => { setThemeState(t); - applyVars(themes[resolveTheme(t)]); - persist(t, accent); - }, [accent, persist]); + applyAll(t, accent, uiFont, monoFont, uiFontSize, monoFontSize); + persist(t, accent, uiFont, monoFont, uiFontSize, monoFontSize); + }, [accent, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]); const setAccent = useCallback((a: AccentColor) => { setAccentState(a); - applyVars(accents[a]); - persist(theme, a); - }, [theme, persist]); + applyAll(theme, a, uiFont, monoFont, uiFontSize, monoFontSize); + persist(theme, a, uiFont, monoFont, uiFontSize, monoFontSize); + }, [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 useEffect(() => { - applyVars({ ...themes[resolveTheme(theme)], ...accents[accent] }); + applyAll(theme, accent, uiFont, monoFont, uiFontSize, monoFontSize); }, []); // eslint-disable-line react-hooks/exhaustive-deps // Listen to OS color scheme changes when theme is 'system' useEffect(() => { if (theme !== 'system') return; 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); return () => mq.removeEventListener('change', handler); - }, [theme]); + }, [theme, accent, applyAll, uiFont, monoFont, uiFontSize, monoFontSize]); const resolvedTheme = resolveTheme(theme); return ( - + {children} ); diff --git a/src/contexts/ThemeContextDef.ts b/src/contexts/ThemeContextDef.ts index 950f704..87b4409 100644 --- a/src/contexts/ThemeContextDef.ts +++ b/src/contexts/ThemeContextDef.ts @@ -2,20 +2,38 @@ import { createContext } from 'react'; export type ThemeName = 'dark' | 'light' | 'oled' | 'system'; 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 { theme: ThemeName; accent: AccentColor; + uiFont: UiFont; + monoFont: MonoFont; + uiFontSize: number; + monoFontSize: number; /** Resolved concrete theme (never 'system'). */ resolvedTheme: 'dark' | 'light' | 'oled'; setTheme: (t: ThemeName) => 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({ theme: 'dark', accent: 'cyan', + uiFont: 'system', + monoFont: 'jetbrains', + uiFontSize: 15, + monoFontSize: 14, resolvedTheme: 'dark', setTheme: () => {}, setAccent: () => {}, + setUiFont: () => {}, + setMonoFont: () => {}, + setUiFontSize: () => {}, + setMonoFontSize: () => {}, }); diff --git a/src/index.css b/src/index.css index 0a0c70b..99fdad5 100644 --- a/src/index.css +++ b/src/index.css @@ -42,6 +42,10 @@ --pc-hover: rgba(255,255,255,0.05); --pc-hover-strong: rgba(255,255,255,0.08); --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). @@ -117,11 +121,16 @@ html, body { margin: 0; background: var(--pc-bg-base); 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; max-width: 100vw; } +code, kbd, pre { + font-family: var(--pc-font-mono); +} + @keyframes fade-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } @@ -160,7 +169,8 @@ html, body { padding: 16px; overflow-x: auto; 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; max-width: 100%; box-sizing: border-box; @@ -176,7 +186,8 @@ html, body { background: var(--pc-accent-glow); padding: 2px 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 */ diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index a9d7ab2..869bda2 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -166,6 +166,7 @@ const en = { // Send shortcut setting 'settings.title': 'Settings', 'settings.appearance': 'Appearance', + 'settings.typography': 'Typography', 'settings.chat': 'Chat', 'settings.notifications': 'Notifications', 'settings.notificationSound': 'Notification sound', @@ -173,6 +174,15 @@ const en = { 'settings.sendShortcut': 'Send with', 'settings.sendEnter': '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 'message.bookmark': 'Bookmark message', @@ -337,6 +347,7 @@ const fr: Record = { 'settings.title': 'Paramètres', 'settings.appearance': 'Apparence', + 'settings.typography': 'Typographie', 'settings.chat': 'Chat', 'settings.notifications': 'Notifications', 'settings.notificationSound': 'Son de notification', @@ -344,6 +355,15 @@ const fr: Record = { 'settings.sendShortcut': 'Envoyer avec', 'settings.sendEnter': '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.removeBookmark': 'Retirer le marque-page', @@ -507,6 +527,7 @@ const es: Record = { 'settings.title': 'Ajustes', 'settings.appearance': 'Apariencia', + 'settings.typography': 'Tipografía', 'settings.chat': 'Chat', 'settings.notifications': 'Notificaciones', 'settings.notificationSound': 'Sonido de notificación', @@ -514,6 +535,15 @@ const es: Record = { 'settings.sendShortcut': 'Enviar con', 'settings.sendEnter': '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.removeBookmark': 'Quitar marcador', @@ -679,6 +709,7 @@ const de: Record = { 'settings.title': 'Einstellungen', 'settings.appearance': 'Darstellung', + 'settings.typography': 'Typografie', 'settings.chat': 'Chat', 'settings.notifications': 'Benachrichtigungen', 'settings.notificationSound': 'Benachrichtigungston', @@ -686,6 +717,15 @@ const de: Record = { 'settings.sendShortcut': 'Senden mit', 'settings.sendEnter': '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.reply': 'Antworten', @@ -849,6 +889,7 @@ const ja: Record = { 'settings.title': '設定', 'settings.appearance': '外観', + 'settings.typography': 'タイポグラフィ', 'settings.chat': 'チャット', 'settings.notifications': '通知', 'settings.notificationSound': '通知音', @@ -856,6 +897,15 @@ const ja: Record = { 'settings.sendShortcut': '送信キー', 'settings.sendEnter': '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.reply': '返信', @@ -1019,6 +1069,7 @@ const pt: Record = { 'settings.title': 'Configurações', 'settings.appearance': 'Aparência', + 'settings.typography': 'Tipografia', 'settings.chat': 'Chat', 'settings.notifications': 'Notificações', 'settings.notificationSound': 'Som de notificação', @@ -1026,6 +1077,15 @@ const pt: Record = { 'settings.sendShortcut': 'Tecla de envio', 'settings.sendEnter': '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.reply': 'Responder', @@ -1189,6 +1249,7 @@ const zh: Record = { 'settings.title': '设置', 'settings.appearance': '外观', + 'settings.typography': '排版', 'settings.chat': '聊天', 'settings.notifications': '通知', 'settings.notificationSound': '通知声音', @@ -1196,6 +1257,15 @@ const zh: Record = { 'settings.sendShortcut': '发送方式', 'settings.sendEnter': '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.reply': '回复', @@ -1359,6 +1429,7 @@ const it: Record = { 'settings.title': 'Impostazioni', 'settings.appearance': 'Aspetto', + 'settings.typography': 'Tipografia', 'settings.chat': 'Chat', 'settings.notifications': 'Notifiche', 'settings.notificationSound': 'Suono di notifica', @@ -1366,6 +1437,15 @@ const it: Record = { 'settings.sendShortcut': 'Invia con', 'settings.sendEnter': '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.reply': 'Rispondi', diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..2d5d3b4 --- /dev/null +++ b/src/test/setup.ts @@ -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(); + + 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; +} diff --git a/vitest.config.ts b/vitest.config.ts index 97c86f4..adc48ad 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: ['src/test/setup.ts'], include: ['src/**/__tests__/**/*.test.{ts,tsx}'], coverage: { provider: 'v8',