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',