From 090e39016fc1b0716ec88524ed5dd8aabea5f956 Mon Sep 17 00:00:00 2001 From: Ruhani Rabin Date: Wed, 4 Mar 2026 11:08:31 +0800 Subject: [PATCH 1/4] feat: Font options for the UI in larger screens, for code and screen fonts display --- package-lock.json | 81 +++++++ package.json | 1 + src/components/SettingsModal.tsx | 350 +++++++++++++++++++++++-------- src/contexts/ThemeContext.tsx | 131 ++++++++++-- src/contexts/ThemeContextDef.ts | 18 ++ src/index.css | 17 +- src/lib/i18n.ts | 80 +++++++ src/test/setup.ts | 29 +++ vitest.config.ts | 1 + 9 files changed, 604 insertions(+), 104 deletions(-) create mode 100644 src/test/setup.ts 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', From 989b74f48a6b3be17634b78f02af39577bb18777 Mon Sep 17 00:00:00 2001 From: Ruhani Rabin Date: Wed, 4 Mar 2026 11:10:39 +0800 Subject: [PATCH 2/4] fix: Fixed TypeScript error in SettingsModal.tsx --- src/components/SettingsModal.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 233d2d1..d366fe5 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -78,15 +78,16 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr } = useTheme(); const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut(); const currentLocale = useLocale(); - const [tab, setTab] = useState<'appearance' | 'typography' | 'chat' | 'notifications'>('appearance'); + type TabId = 'appearance' | 'typography' | 'chat' | 'notifications'; + const [tab, setTab] = useState('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') }, + const base: { id: TabId; label: string }[] = [ + { id: 'appearance', label: t('settings.tab.appearance') }, + { id: 'typography', label: t('settings.tab.typography') }, + { id: 'chat', label: t('settings.tab.chat') }, ]; - if (onToggleSound) base.push({ id: 'notifications' as const, label: t('settings.tab.notifications') }); + if (onToggleSound) base.push({ id: 'notifications', label: t('settings.tab.notifications') }); return base; }, [onToggleSound, t]); From 87bdaaee37f58a9ec79d5f5f0cf0cd2b4fb78bf6 Mon Sep 17 00:00:00 2001 From: Ruhani Rabin Date: Wed, 4 Mar 2026 11:45:28 +0800 Subject: [PATCH 3/4] fix: polish typography settings and storage helpers --- package-lock.json | 135 ++++++++------------ package.json | 1 - src/components/SettingsModal.tsx | 22 +--- src/contexts/ThemeContext.tsx | 119 ++++++----------- src/contexts/ThemeContextDef.ts | 14 ++ src/contexts/__tests__/ThemeContext.test.ts | 55 ++++++++ src/contexts/themeStorage.ts | 44 +++++++ src/index.css | 1 + src/lib/i18n.ts | 16 +++ 9 files changed, 232 insertions(+), 175 deletions(-) create mode 100644 src/contexts/__tests__/ThemeContext.test.ts create mode 100644 src/contexts/themeStorage.ts diff --git a/package-lock.json b/package-lock.json index 0849335..4e23e59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,6 @@ "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", @@ -1792,6 +1791,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -2046,23 +2099,6 @@ "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", @@ -3492,47 +3528,6 @@ "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", @@ -6574,28 +6569,6 @@ "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 9710a66..f0b906c 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "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 d366fe5..a94b6f4 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -5,6 +5,7 @@ import { useSendShortcut } from '../hooks/useSendShortcut'; import { useT, useLocale } from '../hooks/useLocale'; import { setLocale, supportedLocales, localeLabels } from '../lib/i18n'; import type { ThemeName, AccentColor, UiFont, MonoFont } from '../contexts/ThemeContextDef'; +import { uiFontStacks, monoFontStacks } from '../contexts/ThemeContextDef'; import type { TranslationKey } from '../lib/i18n'; const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [ @@ -91,20 +92,6 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr 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' }, @@ -146,7 +133,7 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr {/* Modal */}
e.stopPropagation()} > {/* Header */} @@ -308,6 +295,8 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr
+
{t('settings.fontNote')}
+
{t('settings.fontSize')}
@@ -351,11 +340,12 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr
{t('settings.preview')}
- The quick brown fox jumps over the lazy dog — 1234567890 + {t('settings.previewText')}
const hello = 'PinchChat'; // typography preview
+
{t('settings.fontNote')}
)} diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index bfb545a..1e6dc6e 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -1,19 +1,9 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'; -import { ThemeContext, type ThemeName, type AccentColor, type UiFont, type MonoFont } from './ThemeContextDef'; +import { ThemeContext, type ThemeName, type AccentColor, type UiFont, type MonoFont, uiFontStacks, monoFontStacks } from './ThemeContextDef'; +import { loadStored, STORAGE_KEY } from './themeStorage'; export type { ThemeName, AccentColor } from './ThemeContextDef'; -const STORAGE_KEY = 'pinchchat-theme'; - -interface StoredTheme { - theme: ThemeName; - accent: AccentColor; - uiFont: UiFont; - monoFont: MonoFont; - uiFontSize: number; - monoFontSize: number; -} - type ConcreteTheme = 'dark' | 'light' | 'oled'; const themes: Record> = { dark: { @@ -123,20 +113,6 @@ 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)) { @@ -152,36 +128,19 @@ function resolveTheme(name: ThemeName): 'dark' | 'light' | 'oled' { return name; } -function loadStored(): StoredTheme { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - 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', uiFont: 'system', monoFont: 'jetbrains', uiFontSize: 15, monoFontSize: 14 }; +interface ThemeSettings { + theme: ThemeName; + accent: AccentColor; + uiFont: UiFont; + monoFont: MonoFont; + uiFontSize: number; + monoFontSize: number; } function fontVars(uiFont: UiFont, monoFont: MonoFont, uiFontSize: number, monoFontSize: number) { return { - '--pc-font-ui': uiFonts[uiFont], - '--pc-font-mono': monoFonts[monoFont], + '--pc-font-ui': uiFontStacks[uiFont], + '--pc-font-mono': monoFontStacks[monoFont], '--pc-font-size': `${uiFontSize}px`, '--pc-font-size-mono': `${monoFontSize}px`, }; @@ -196,73 +155,79 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const [uiFontSize, setUiFontSizeState] = useState(stored.uiFontSize); const [monoFontSize, setMonoFontSizeState] = useState(stored.monoFontSize); - const persist = useCallback((t: ThemeName, a: AccentColor, ui: UiFont, mono: MonoFont, uiSize: number, monoSize: number) => { + const persist = useCallback((s: ThemeSettings) => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ - theme: t, - accent: a, - uiFont: ui, - monoFont: mono, - uiFontSize: uiSize, - monoFontSize: monoSize, + theme: s.theme, + accent: s.accent, + uiFont: s.uiFont, + monoFont: s.monoFont, + uiFontSize: s.uiFontSize, + monoFontSize: s.monoFontSize, })); }, []); - const applyAll = useCallback((nextTheme: ThemeName, nextAccent: AccentColor, nextUiFont: UiFont, nextMonoFont: MonoFont, nextUiSize: number, nextMonoSize: number) => { + const applyAll = useCallback((s: ThemeSettings) => { applyVars({ - ...themes[resolveTheme(nextTheme)], - ...accents[nextAccent], - ...fontVars(nextUiFont, nextMonoFont, nextUiSize, nextMonoSize), + ...themes[resolveTheme(s.theme)], + ...accents[s.accent], + ...fontVars(s.uiFont, s.monoFont, s.uiFontSize, s.monoFontSize), }); }, []); const setTheme = useCallback((t: ThemeName) => { setThemeState(t); - applyAll(t, accent, uiFont, monoFont, uiFontSize, monoFontSize); - persist(t, accent, uiFont, monoFont, uiFontSize, monoFontSize); + const next: ThemeSettings = { theme: t, accent, uiFont, monoFont, uiFontSize, monoFontSize }; + applyAll(next); + persist(next); }, [accent, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]); const setAccent = useCallback((a: AccentColor) => { setAccentState(a); - applyAll(theme, a, uiFont, monoFont, uiFontSize, monoFontSize); - persist(theme, a, uiFont, monoFont, uiFontSize, monoFontSize); + const next: ThemeSettings = { theme, accent: a, uiFont, monoFont, uiFontSize, monoFontSize }; + applyAll(next); + persist(next); }, [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); + const next: ThemeSettings = { theme, accent, uiFont: f, monoFont, uiFontSize, monoFontSize }; + applyAll(next); + persist(next); }, [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); + const next: ThemeSettings = { theme, accent, uiFont, monoFont: f, uiFontSize, monoFontSize }; + applyAll(next); + persist(next); }, [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); + const next: ThemeSettings = { theme, accent, uiFont, monoFont, uiFontSize: clamped, monoFontSize }; + applyAll(next); + persist(next); }, [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); + const next: ThemeSettings = { theme, accent, uiFont, monoFont, uiFontSize, monoFontSize: clamped }; + applyAll(next); + persist(next); }, [accent, theme, applyAll, persist, uiFont, monoFont, uiFontSize]); // Apply on mount useEffect(() => { - applyAll(theme, accent, uiFont, monoFont, uiFontSize, monoFontSize); + 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 = () => applyAll(mq.matches ? 'light' : 'dark', accent, uiFont, monoFont, uiFontSize, monoFontSize); + const handler = () => applyAll({ theme: mq.matches ? 'light' : 'dark', accent, uiFont, monoFont, uiFontSize, monoFontSize }); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, [theme, accent, applyAll, uiFont, monoFont, uiFontSize, monoFontSize]); diff --git a/src/contexts/ThemeContextDef.ts b/src/contexts/ThemeContextDef.ts index 87b4409..e97e109 100644 --- a/src/contexts/ThemeContextDef.ts +++ b/src/contexts/ThemeContextDef.ts @@ -5,6 +5,20 @@ export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'bl export type UiFont = 'system' | 'inter' | 'segoe' | 'sf'; export type MonoFont = 'jetbrains' | 'fira' | 'cascadia' | 'system-mono'; +export 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", +}; + +export 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", +}; + export interface ThemeContextValue { theme: ThemeName; accent: AccentColor; diff --git a/src/contexts/__tests__/ThemeContext.test.ts b/src/contexts/__tests__/ThemeContext.test.ts new file mode 100644 index 0000000..78a8f69 --- /dev/null +++ b/src/contexts/__tests__/ThemeContext.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { loadStored, STORAGE_KEY } from '../themeStorage'; + +const STORAGE_KEY = 'pinchchat-theme'; + +describe('ThemeContext loadStoredForTest', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('returns defaults when storage is empty', () => { + expect(loadStored()).toEqual({ + theme: 'dark', + accent: 'cyan', + uiFont: 'system', + monoFont: 'jetbrains', + uiFontSize: 15, + monoFontSize: 14, + }); + }); + + it('reads valid stored values and clamps sizes', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + theme: 'light', + accent: 'rose', + uiFont: 'inter', + monoFont: 'fira', + uiFontSize: 25, // should clamp to 20 + monoFontSize: 10, // should clamp to 12 + })); + + expect(loadStored()).toEqual({ + theme: 'light', + accent: 'rose', + uiFont: 'inter', + monoFont: 'fira', + uiFontSize: 20, + monoFontSize: 12, + }); + }); + + it('falls back to defaults on invalid font keys', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + theme: 'dark', + accent: 'cyan', + uiFont: 'nonexistent', + monoFont: 'nope', + uiFontSize: 16, + monoFontSize: 16, + })); + + expect(loadStored().uiFont).toBe('system'); + expect(loadStored().monoFont).toBe('jetbrains'); + }); +}); diff --git a/src/contexts/themeStorage.ts b/src/contexts/themeStorage.ts new file mode 100644 index 0000000..df46208 --- /dev/null +++ b/src/contexts/themeStorage.ts @@ -0,0 +1,44 @@ +import type { ThemeName, AccentColor, UiFont, MonoFont } from './ThemeContextDef'; +import { uiFontStacks, monoFontStacks } from './ThemeContextDef'; + +export const STORAGE_KEY = 'pinchchat-theme'; + +export interface StoredTheme { + theme: ThemeName; + accent: AccentColor; + uiFont: UiFont; + monoFont: MonoFont; + uiFontSize: number; + monoFontSize: number; +} + +const DEFAULTS: StoredTheme = { + theme: 'dark', + accent: 'cyan', + uiFont: 'system', + monoFont: 'jetbrains', + uiFontSize: 15, + monoFontSize: 14, +}; + +const validThemes: ThemeName[] = ['dark', 'light', 'oled', 'system']; +const validAccents: AccentColor[] = ['cyan', 'violet', 'emerald', 'amber', 'rose', 'blue']; + +export function loadStored(): StoredTheme { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + const themeValid = validThemes.includes(parsed.theme); + const accentValid = validAccents.includes(parsed.accent); + const uiFont: UiFont = uiFontStacks[parsed.uiFont as UiFont] ? parsed.uiFont : DEFAULTS.uiFont; + const monoFont: MonoFont = monoFontStacks[parsed.monoFont as MonoFont] ? parsed.monoFont : DEFAULTS.monoFont; + const uiFontSize = Number.isFinite(parsed.uiFontSize) ? Math.min(20, Math.max(12, Number(parsed.uiFontSize))) : DEFAULTS.uiFontSize; + const monoFontSize = Number.isFinite(parsed.monoFontSize) ? Math.min(20, Math.max(12, Number(parsed.monoFontSize))) : DEFAULTS.monoFontSize; + if (themeValid && accentValid) { + return { theme: parsed.theme, accent: parsed.accent, uiFont, monoFont, uiFontSize, monoFontSize }; + } + } + } catch { /* ignore malformed storage */ } + return DEFAULTS; +} diff --git a/src/index.css b/src/index.css index 99fdad5..5a77ab3 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,4 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600&family=Fira+Code:wght@400;600&display=swap'); @import "tailwindcss"; @import "highlight.js/styles/base16/material-palenight.min.css"; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 869bda2..b71c48a 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -183,6 +183,8 @@ const en = { 'settings.fontSize': 'UI font size', 'settings.fontMonoSize': 'Code font size', 'settings.preview': 'Preview', + 'settings.previewText': 'The quick brown fox jumps over the lazy dog — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira load automatically; Cascadia and SF use your system if installed.', // Bookmarks 'message.bookmark': 'Bookmark message', @@ -364,6 +366,8 @@ const fr: Record = { 'settings.fontSize': 'Taille police UI', 'settings.fontMonoSize': 'Taille police code', 'settings.preview': 'Aperçu', + 'settings.previewText': 'Le vif renard brun saute par-dessus le chien paresseux — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira se chargent automatiquement ; Cascadia et SF dépendent des polices installées.', 'message.bookmark': 'Marquer le message', 'message.removeBookmark': 'Retirer le marque-page', @@ -544,6 +548,8 @@ const es: Record = { 'settings.fontSize': 'Tamaño fuente UI', 'settings.fontMonoSize': 'Tamaño fuente código', 'settings.preview': 'Vista previa', + 'settings.previewText': 'El veloz zorro marrón salta sobre el perro perezoso — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira se cargan automáticamente; Cascadia y SF dependen de que estén instaladas.', 'message.bookmark': 'Marcar mensaje', 'message.removeBookmark': 'Quitar marcador', @@ -726,6 +732,8 @@ const de: Record = { 'settings.fontSize': 'UI-Schriftgröße', 'settings.fontMonoSize': 'Code-Schriftgröße', 'settings.preview': 'Vorschau', + 'settings.previewText': 'Falsches Üben von Xylophonmusik quält jeden größeren Zwerg — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira werden automatisch geladen; Cascadia und SF erfordern Systeminstallation.', 'message.bookmark': 'Nachricht markieren', 'message.reply': 'Antworten', @@ -906,6 +914,8 @@ const ja: Record = { 'settings.fontSize': 'UIフォントサイズ', 'settings.fontMonoSize': 'コードフォントサイズ', 'settings.preview': 'プレビュー', + 'settings.previewText': '素早い茶色の狐はのんびり犬を飛び越える — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira は自動読み込み。Cascadia と SF はシステムにある場合のみ使用。', 'message.bookmark': 'メッセージをブックマーク', 'message.reply': '返信', @@ -1086,6 +1096,8 @@ const pt: Record = { 'settings.fontSize': 'Tamanho da fonte UI', 'settings.fontMonoSize': 'Tamanho da fonte de código', 'settings.preview': 'Pré-visualização', + 'settings.previewText': 'A rápida raposa marrom pula sobre o cão preguiçoso — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira carregam automaticamente; Cascadia e SF dependem de estarem instaladas.', 'message.bookmark': 'Marcar mensagem', 'message.reply': 'Responder', @@ -1266,6 +1278,8 @@ const zh: Record = { 'settings.fontSize': '界面字号', 'settings.fontMonoSize': '代码字号', 'settings.preview': '预览', + 'settings.previewText': '敏捷的棕狐跳过懒狗 — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira 自动加载;Cascadia 和 SF 依赖系统已安装字体。', 'message.bookmark': '收藏消息', 'message.reply': '回复', @@ -1446,6 +1460,8 @@ const it: Record = { 'settings.fontSize': 'Dimensione font UI', 'settings.fontMonoSize': 'Dimensione font codice', 'settings.preview': 'Anteprima', + 'settings.previewText': 'Quel vitello jazz fonde sciolto whiskey e cioccolato — 1234567890', + 'settings.fontNote': 'Inter / JetBrains / Fira si caricano automaticamente; Cascadia e SF richiedono font di sistema.', 'message.bookmark': 'Aggiungi ai segnalibri', 'message.reply': 'Rispondi', From 5712d0da40ffb9c4129012860c416b1e01a7a795 Mon Sep 17 00:00:00 2001 From: Ruhani Rabin Date: Thu, 5 Mar 2026 11:11:41 +0800 Subject: [PATCH 4/4] fix: storage key re-declaration removed --- src/contexts/__tests__/ThemeContext.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/contexts/__tests__/ThemeContext.test.ts b/src/contexts/__tests__/ThemeContext.test.ts index 78a8f69..eb8b395 100644 --- a/src/contexts/__tests__/ThemeContext.test.ts +++ b/src/contexts/__tests__/ThemeContext.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { loadStored, STORAGE_KEY } from '../themeStorage'; -const STORAGE_KEY = 'pinchchat-theme'; describe('ThemeContext loadStoredForTest', () => { beforeEach(() => {