Merge pull request #18 from ruhanirabin/ui-font-appearance

UI font appearance
This commit is contained in:
MarlburroW
2026-03-05 10:01:30 +01:00
committed by GitHub
10 changed files with 675 additions and 118 deletions

54
package-lock.json generated
View File

@@ -1791,6 +1791,60 @@
"node": ">=14.0.0" "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": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",

View File

@@ -1,10 +1,11 @@
import { useEffect } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Volume2, VolumeOff } from 'lucide-react'; import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Volume2, VolumeOff, Type, CaseSensitive } from 'lucide-react';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
import { useSendShortcut } from '../hooks/useSendShortcut'; import { useSendShortcut } from '../hooks/useSendShortcut';
import { useT, useLocale } from '../hooks/useLocale'; import { useT, useLocale } from '../hooks/useLocale';
import { setLocale, supportedLocales, localeLabels } from '../lib/i18n'; import { setLocale, supportedLocales, localeLabels } from '../lib/i18n';
import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef'; import type { ThemeName, AccentColor, UiFont, MonoFont } from '../contexts/ThemeContextDef';
import { uiFontStacks, monoFontStacks } from '../contexts/ThemeContextDef';
import type { TranslationKey } from '../lib/i18n'; import type { TranslationKey } from '../lib/i18n';
const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [ const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [
@@ -62,9 +63,51 @@ function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange
export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Props) { export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Props) {
const t = useT(); const t = useT();
const { theme, accent, setTheme, setAccent } = useTheme(); const {
theme,
accent,
uiFont,
monoFont,
uiFontSize,
monoFontSize,
setTheme,
setAccent,
setUiFont,
setMonoFont,
setUiFontSize,
setMonoFontSize,
} = useTheme();
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut(); const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
const currentLocale = useLocale(); const currentLocale = useLocale();
type TabId = 'appearance' | 'typography' | 'chat' | 'notifications';
const [tab, setTab] = useState<TabId>('appearance');
const tabs = useMemo(() => {
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', label: t('settings.tab.notifications') });
return base;
}, [onToggleSound, t]);
const uiFontOptions: { value: UiFont; label: string; sample: string }[] = [
{ value: 'system', label: 'System', sample: 'Segoe/UI' },
{ value: 'inter', label: 'Inter', sample: 'Inter' },
{ value: 'segoe', label: 'Segoe UI', sample: 'Segoe' },
{ value: 'sf', label: 'SF Pro', sample: 'SF' },
];
const monoFontOptions: { value: MonoFont; label: string; sample: string }[] = [
{ value: 'jetbrains', label: 'JetBrains Mono', sample: 'JetBrains' },
{ value: 'fira', label: 'Fira Code', sample: 'Fira' },
{ value: 'cascadia', label: 'Cascadia Code', sample: 'Cascadia' },
{ value: 'system-mono', label: 'System mono', sample: 'System' },
];
const uiSizes = [14, 15, 16, 17, 18];
const monoSizes = [13, 14, 15, 16, 17];
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -90,7 +133,7 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr
{/* Modal */} {/* Modal */}
<div <div
className="relative w-full max-w-sm mx-4 rounded-3xl border border-pc-border bg-[var(--pc-bg-base)]/95 backdrop-blur-xl shadow-2xl animate-fade-in" className="relative w-full max-w-xl mx-4 rounded-3xl border border-pc-border bg-[var(--pc-bg-base)]/95 backdrop-blur-xl shadow-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
@@ -110,96 +153,222 @@ export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Pr
{/* Body */} {/* Body */}
<div className="px-6 py-4 max-h-[60vh] overflow-y-auto"> <div className="px-6 py-4 max-h-[60vh] overflow-y-auto">
{/* Appearance */} <div className="flex gap-2 mb-4">
<SectionTitle>{t('settings.appearance')}</SectionTitle> {tabs.map(tTab => (
{/* Theme mode */}
<div className="mb-3">
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.mode')}</div>
<div className="flex gap-1.5">
{themeOptions.map(opt => {
const Icon = opt.icon;
const active = theme === opt.value;
return (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={active}
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${
active
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
>
<Icon size={16} />
<span>{t(opt.labelKey)}</span>
</button>
);
})}
</div>
</div>
{/* Accent color */}
<div className="mb-1">
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.accent')}</div>
<div className="flex gap-2">
{accentOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAccent(opt.value)}
className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center"
aria-pressed={accent === opt.value}
aria-label={`${opt.value} accent`}
style={{
backgroundColor: opt.color,
borderColor: accent === opt.value ? opt.color : 'transparent',
boxShadow: accent === opt.value ? `0 0 8px ${opt.color}40` : 'none',
}}
title={opt.value}
>
{accent === opt.value && <Check size={14} className="text-white drop-shadow" />}
</button>
))}
</div>
</div>
{/* Language */}
<SectionTitle>{t('settings.language')}</SectionTitle>
<div className="flex flex-wrap gap-1.5">
{supportedLocales.map(loc => (
<button <button
key={loc} key={tTab.id}
onClick={() => setLocale(loc)} onClick={() => setTab(tTab.id)}
aria-pressed={currentLocale === loc} className={`flex-1 rounded-xl border px-3 py-2 text-xs font-medium transition-colors ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs transition-all ${ tab === tTab.id
currentLocale === loc ? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light font-medium'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]' : 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`} }`}
> >
{currentLocale === loc && <Check size={12} />} {tTab.label}
{localeLabels[loc] || loc.toUpperCase()}
</button> </button>
))} ))}
</div> </div>
{/* Chat */} {tab === 'appearance' && (
<SectionTitle>{t('settings.chat')}</SectionTitle> <>
<div className="flex items-center justify-between py-1.5"> <SectionTitle>{t('settings.appearance')}</SectionTitle>
<div className="text-xs text-pc-text-secondary">{t('settings.sendShortcut')}</div> <div className="mb-3">
<button <div className="text-xs text-pc-text-secondary mb-2">{t('theme.mode')}</div>
onClick={toggleSendShortcut} <div className="flex gap-1.5">
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-pc-border text-xs text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors" {themeOptions.map(opt => {
> const Icon = opt.icon;
{sendOnEnter const active = theme === opt.value;
? t('settings.sendEnter') return (
: (isMac ? '⌘+' : 'Ctrl+') + t('settings.sendEnter') <button
} key={opt.value}
</button> onClick={() => setTheme(opt.value)}
</div> aria-pressed={active}
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${
active
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
>
<Icon size={16} />
<span>{t(opt.labelKey)}</span>
</button>
);
})}
</div>
</div>
{/* Notifications */} <div className="mb-4">
{onToggleSound && ( <div className="text-xs text-pc-text-secondary mb-2">{t('theme.accent')}</div>
<div className="flex gap-2">
{accentOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAccent(opt.value)}
className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center"
aria-pressed={accent === opt.value}
aria-label={`${opt.value} accent`}
style={{
backgroundColor: opt.color,
borderColor: accent === opt.value ? opt.color : 'transparent',
boxShadow: accent === opt.value ? `0 0 8px ${opt.color}40` : 'none',
}}
title={opt.value}
>
{accent === opt.value && <Check size={14} className="text-white drop-shadow" />}
</button>
))}
</div>
</div>
<SectionTitle>{t('settings.language')}</SectionTitle>
<div className="flex flex-wrap gap-1.5">
{supportedLocales.map(loc => (
<button
key={loc}
onClick={() => setLocale(loc)}
aria-pressed={currentLocale === loc}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs transition-all ${
currentLocale === loc
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light font-medium'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
>
{currentLocale === loc && <Check size={12} />}
{localeLabels[loc] || loc.toUpperCase()}
</button>
))}
</div>
</>
)}
{tab === 'typography' && (
<>
<SectionTitle>{t('settings.typography')}</SectionTitle>
<div className="mb-4">
<div className="flex items-center gap-2 text-xs text-pc-text-secondary mb-2">
<Type size={14} />
{t('settings.fontUi')}
</div>
<div className="flex flex-wrap gap-1.5">
{uiFontOptions.map(opt => (
<button
key={opt.value}
onClick={() => setUiFont(opt.value)}
aria-pressed={uiFont === opt.value}
className={`flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all ${
uiFont === opt.value
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
style={{ fontFamily: uiFontStacks[opt.value] }}
>
<span className="text-sm">{opt.sample}</span>
<span className="text-[11px] text-pc-text-faint">{opt.label}</span>
</button>
))}
</div>
</div>
<div className="mb-4">
<div className="flex items-center gap-2 text-xs text-pc-text-secondary mb-2">
<CaseSensitive size={14} />
{t('settings.fontMono')}
</div>
<div className="flex flex-wrap gap-1.5">
{monoFontOptions.map(opt => (
<button
key={opt.value}
onClick={() => setMonoFont(opt.value)}
aria-pressed={monoFont === opt.value}
className={`flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all ${
monoFont === opt.value
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
style={{ fontFamily: monoFontStacks[opt.value] }}
>
<span className="text-sm">{opt.sample}</span>
<span className="text-[11px] text-pc-text-faint">{opt.label}</span>
</button>
))}
</div>
</div>
<div className="text-[11px] text-pc-text-faint mb-4 text-center">{t('settings.fontNote')}</div>
<div className="mb-4">
<div className="text-xs text-pc-text-secondary mb-2">{t('settings.fontSize')}</div>
<div className="flex gap-1.5 flex-wrap">
{uiSizes.map(size => (
<button
key={size}
onClick={() => setUiFontSize(size)}
aria-pressed={uiFontSize === size}
className={`px-3 py-1.5 rounded-lg border text-xs transition-all ${
uiFontSize === size
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
>
{size}px
</button>
))}
</div>
</div>
<div className="mb-4">
<div className="text-xs text-pc-text-secondary mb-2">{t('settings.fontMonoSize')}</div>
<div className="flex gap-1.5 flex-wrap">
{monoSizes.map(size => (
<button
key={size}
onClick={() => setMonoFontSize(size)}
aria-pressed={monoFontSize === size}
className={`px-3 py-1.5 rounded-lg border text-xs transition-all ${
monoFontSize === size
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
>
{size}px
</button>
))}
</div>
</div>
<div className="rounded-2xl border border-pc-border bg-[var(--pc-bg-surface)]/70 p-3">
<div className="text-[11px] uppercase tracking-wide text-pc-text-faint mb-2">{t('settings.preview')}</div>
<div className="text-sm text-pc-text mb-2" style={{ fontFamily: 'var(--pc-font-ui)', fontSize: 'var(--pc-font-size)' }}>
{t('settings.previewText')}
</div>
<div className="rounded-xl border border-dashed border-pc-border p-2 bg-[var(--pc-bg-code)] text-[13px]" style={{ fontFamily: 'var(--pc-font-mono)', fontSize: 'var(--pc-font-size-mono)' }}>
const hello = 'PinchChat'; // typography preview
</div>
<div className="text-[11px] text-pc-text-faint mt-2 text-center">{t('settings.fontNote')}</div>
</div>
</>
)}
{tab === 'chat' && (
<>
<SectionTitle>{t('settings.chat')}</SectionTitle>
<div className="flex items-center justify-between py-1.5">
<div className="text-xs text-pc-text-secondary">{t('settings.sendShortcut')}</div>
<button
onClick={toggleSendShortcut}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-pc-border text-xs text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors"
>
{sendOnEnter
? t('settings.sendEnter')
: (isMac ? '⌘+' : 'Ctrl+') + t('settings.sendEnter')
}
</button>
</div>
</>
)}
{tab === 'notifications' && onToggleSound && (
<> <>
<SectionTitle>{t('settings.notifications')}</SectionTitle> <SectionTitle>{t('settings.notifications')}</SectionTitle>
<div className="flex items-center justify-between py-1.5"> <div className="flex items-center justify-between py-1.5">

View File

@@ -1,15 +1,9 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'; import { useState, useEffect, useCallback, type ReactNode } from 'react';
import { ThemeContext, type ThemeName, type AccentColor } from './ThemeContextDef'; import { ThemeContext, type ThemeName, type AccentColor, type UiFont, type MonoFont, uiFontStacks, monoFontStacks } from './ThemeContextDef';
import { loadStored, STORAGE_KEY } from './themeStorage';
export type { ThemeName, AccentColor } from './ThemeContextDef'; export type { ThemeName, AccentColor } from './ThemeContextDef';
const STORAGE_KEY = 'pinchchat-theme';
interface StoredTheme {
theme: ThemeName;
accent: AccentColor;
}
type ConcreteTheme = 'dark' | 'light' | 'oled'; type ConcreteTheme = 'dark' | 'light' | 'oled';
const themes: Record<ConcreteTheme, Record<string, string>> = { const themes: Record<ConcreteTheme, Record<string, string>> = {
dark: { dark: {
@@ -134,56 +128,128 @@ function resolveTheme(name: ThemeName): 'dark' | 'light' | 'oled' {
return name; return name;
} }
function loadStored(): StoredTheme { interface ThemeSettings {
try { theme: ThemeName;
const raw = localStorage.getItem(STORAGE_KEY); accent: AccentColor;
if (raw) { uiFont: UiFont;
const parsed = JSON.parse(raw); monoFont: MonoFont;
if ((parsed.theme in themes || parsed.theme === 'system') && parsed.accent in accents) return parsed; uiFontSize: number;
} monoFontSize: number;
} catch { /* ignore invalid stored JSON */ } }
return { theme: 'dark', accent: 'cyan' };
function fontVars(uiFont: UiFont, monoFont: MonoFont, uiFontSize: number, monoFontSize: number) {
return {
'--pc-font-ui': uiFontStacks[uiFont],
'--pc-font-mono': monoFontStacks[monoFont],
'--pc-font-size': `${uiFontSize}px`,
'--pc-font-size-mono': `${monoFontSize}px`,
};
} }
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [stored] = useState(loadStored); const [stored] = useState(loadStored);
const [theme, setThemeState] = useState<ThemeName>(stored.theme); const [theme, setThemeState] = useState<ThemeName>(stored.theme);
const [accent, setAccentState] = useState<AccentColor>(stored.accent); const [accent, setAccentState] = useState<AccentColor>(stored.accent);
const [uiFont, setUiFontState] = useState<UiFont>(stored.uiFont);
const [monoFont, setMonoFontState] = useState<MonoFont>(stored.monoFont);
const [uiFontSize, setUiFontSizeState] = useState<number>(stored.uiFontSize);
const [monoFontSize, setMonoFontSizeState] = useState<number>(stored.monoFontSize);
const persist = useCallback((t: ThemeName, a: AccentColor) => { const persist = useCallback((s: ThemeSettings) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: t, accent: a })); localStorage.setItem(STORAGE_KEY, JSON.stringify({
theme: s.theme,
accent: s.accent,
uiFont: s.uiFont,
monoFont: s.monoFont,
uiFontSize: s.uiFontSize,
monoFontSize: s.monoFontSize,
}));
}, []);
const applyAll = useCallback((s: ThemeSettings) => {
applyVars({
...themes[resolveTheme(s.theme)],
...accents[s.accent],
...fontVars(s.uiFont, s.monoFont, s.uiFontSize, s.monoFontSize),
});
}, []); }, []);
const setTheme = useCallback((t: ThemeName) => { const setTheme = useCallback((t: ThemeName) => {
setThemeState(t); setThemeState(t);
applyVars(themes[resolveTheme(t)]); const next: ThemeSettings = { theme: t, accent, uiFont, monoFont, uiFontSize, monoFontSize };
persist(t, accent); applyAll(next);
}, [accent, persist]); persist(next);
}, [accent, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]);
const setAccent = useCallback((a: AccentColor) => { const setAccent = useCallback((a: AccentColor) => {
setAccentState(a); setAccentState(a);
applyVars(accents[a]); const next: ThemeSettings = { theme, accent: a, uiFont, monoFont, uiFontSize, monoFontSize };
persist(theme, a); applyAll(next);
}, [theme, persist]); persist(next);
}, [theme, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]);
const setUiFont = useCallback((f: UiFont) => {
setUiFontState(f);
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);
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);
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);
const next: ThemeSettings = { theme, accent, uiFont, monoFont, uiFontSize, monoFontSize: clamped };
applyAll(next);
persist(next);
}, [accent, theme, applyAll, persist, uiFont, monoFont, uiFontSize]);
// Apply on mount // Apply on mount
useEffect(() => { useEffect(() => {
applyVars({ ...themes[resolveTheme(theme)], ...accents[accent] }); applyAll({ theme, accent, uiFont, monoFont, uiFontSize, monoFontSize });
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Listen to OS color scheme changes when theme is 'system' // Listen to OS color scheme changes when theme is 'system'
useEffect(() => { useEffect(() => {
if (theme !== 'system') return; if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: light)'); const mq = window.matchMedia('(prefers-color-scheme: light)');
const handler = () => applyVars(themes[mq.matches ? 'light' : 'dark']); const handler = () => applyAll({ theme: mq.matches ? 'light' : 'dark', accent, uiFont, monoFont, uiFontSize, monoFontSize });
mq.addEventListener('change', handler); mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler); return () => mq.removeEventListener('change', handler);
}, [theme]); }, [theme, accent, applyAll, uiFont, monoFont, uiFontSize, monoFontSize]);
const resolvedTheme = resolveTheme(theme); const resolvedTheme = resolveTheme(theme);
return ( return (
<ThemeContext.Provider value={{ theme, accent, resolvedTheme, setTheme, setAccent }}> <ThemeContext.Provider value={{
theme,
accent,
uiFont,
monoFont,
uiFontSize,
monoFontSize,
resolvedTheme,
setTheme,
setAccent,
setUiFont,
setMonoFont,
setUiFontSize,
setMonoFontSize,
}}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );

View File

@@ -2,20 +2,52 @@ import { createContext } from 'react';
export type ThemeName = 'dark' | 'light' | 'oled' | 'system'; export type ThemeName = 'dark' | 'light' | 'oled' | 'system';
export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue'; export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue';
export type UiFont = 'system' | 'inter' | 'segoe' | 'sf';
export type MonoFont = 'jetbrains' | 'fira' | 'cascadia' | 'system-mono';
export const uiFontStacks: Record<UiFont, string> = {
system: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
inter: "'Inter', 'Segoe UI', system-ui, sans-serif",
segoe: "'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif",
sf: "'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
};
export const monoFontStacks: Record<MonoFont, string> = {
jetbrains: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fira: "'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
cascadia: "'Cascadia Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
'system-mono': "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
};
export interface ThemeContextValue { export interface ThemeContextValue {
theme: ThemeName; theme: ThemeName;
accent: AccentColor; accent: AccentColor;
uiFont: UiFont;
monoFont: MonoFont;
uiFontSize: number;
monoFontSize: number;
/** Resolved concrete theme (never 'system'). */ /** Resolved concrete theme (never 'system'). */
resolvedTheme: 'dark' | 'light' | 'oled'; resolvedTheme: 'dark' | 'light' | 'oled';
setTheme: (t: ThemeName) => void; setTheme: (t: ThemeName) => void;
setAccent: (a: AccentColor) => void; setAccent: (a: AccentColor) => void;
setUiFont: (f: UiFont) => void;
setMonoFont: (f: MonoFont) => void;
setUiFontSize: (size: number) => void;
setMonoFontSize: (size: number) => void;
} }
export const ThemeContext = createContext<ThemeContextValue>({ export const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark', theme: 'dark',
accent: 'cyan', accent: 'cyan',
uiFont: 'system',
monoFont: 'jetbrains',
uiFontSize: 15,
monoFontSize: 14,
resolvedTheme: 'dark', resolvedTheme: 'dark',
setTheme: () => {}, setTheme: () => {},
setAccent: () => {}, setAccent: () => {},
setUiFont: () => {},
setMonoFont: () => {},
setUiFontSize: () => {},
setMonoFontSize: () => {},
}); });

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { loadStored, STORAGE_KEY } from '../themeStorage';
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');
});
});

View File

@@ -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;
}

View File

@@ -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 "tailwindcss";
@import "highlight.js/styles/base16/material-palenight.min.css"; @import "highlight.js/styles/base16/material-palenight.min.css";
@@ -42,6 +43,10 @@
--pc-hover: rgba(255,255,255,0.05); --pc-hover: rgba(255,255,255,0.05);
--pc-hover-strong: rgba(255,255,255,0.08); --pc-hover-strong: rgba(255,255,255,0.08);
--pc-separator: rgba(255,255,255,0.05); --pc-separator: rgba(255,255,255,0.05);
--pc-font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--pc-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--pc-font-size: 15px;
--pc-font-size-mono: 14px;
} }
/* Global focus-visible ring for keyboard navigation (a11y). /* Global focus-visible ring for keyboard navigation (a11y).
@@ -117,11 +122,16 @@ html, body {
margin: 0; margin: 0;
background: var(--pc-bg-base); background: var(--pc-bg-base);
color: var(--pc-text-primary); color: var(--pc-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--pc-font-ui);
font-size: var(--pc-font-size);
overflow-x: hidden; overflow-x: hidden;
max-width: 100vw; max-width: 100vw;
} }
code, kbd, pre {
font-family: var(--pc-font-mono);
}
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -160,7 +170,8 @@ html, body {
padding: 16px; padding: 16px;
overflow-x: auto; overflow-x: auto;
margin: 8px 0; margin: 8px 0;
font-size: 0.82em; font-family: var(--pc-font-mono);
font-size: calc(var(--pc-font-size-mono) * 0.9);
line-height: 1.6; line-height: 1.6;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -176,7 +187,8 @@ html, body {
background: var(--pc-accent-glow); background: var(--pc-accent-glow);
padding: 2px 6px; padding: 2px 6px;
border-radius: 6px; border-radius: 6px;
font-size: 0.85em; font-size: calc(var(--pc-font-size-mono) * 0.95);
font-family: var(--pc-font-mono);
} }
/* Override highlight.js theme bg to match */ /* Override highlight.js theme bg to match */

View File

@@ -166,6 +166,7 @@ const en = {
// Send shortcut setting // Send shortcut setting
'settings.title': 'Settings', 'settings.title': 'Settings',
'settings.appearance': 'Appearance', 'settings.appearance': 'Appearance',
'settings.typography': 'Typography',
'settings.chat': 'Chat', 'settings.chat': 'Chat',
'settings.notifications': 'Notifications', 'settings.notifications': 'Notifications',
'settings.notificationSound': 'Notification sound', 'settings.notificationSound': 'Notification sound',
@@ -173,6 +174,17 @@ const en = {
'settings.sendShortcut': 'Send with', 'settings.sendShortcut': 'Send with',
'settings.sendEnter': 'Enter', 'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter', 'settings.sendCtrlEnter': 'Ctrl+Enter',
'settings.tab.appearance': 'Appearance',
'settings.tab.typography': 'Typography',
'settings.tab.chat': 'Chat',
'settings.tab.notifications': 'Notifications',
'settings.fontUi': 'UI font',
'settings.fontMono': 'Code font',
'settings.fontSize': 'UI font size',
'settings.fontMonoSize': 'Code font size',
'settings.preview': 'Preview',
'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 // Bookmarks
'message.bookmark': 'Bookmark message', 'message.bookmark': 'Bookmark message',
@@ -337,6 +349,7 @@ const fr: Record<keyof typeof en, string> = {
'settings.title': 'Paramètres', 'settings.title': 'Paramètres',
'settings.appearance': 'Apparence', 'settings.appearance': 'Apparence',
'settings.typography': 'Typographie',
'settings.chat': 'Chat', 'settings.chat': 'Chat',
'settings.notifications': 'Notifications', 'settings.notifications': 'Notifications',
'settings.notificationSound': 'Son de notification', 'settings.notificationSound': 'Son de notification',
@@ -344,6 +357,17 @@ const fr: Record<keyof typeof en, string> = {
'settings.sendShortcut': 'Envoyer avec', 'settings.sendShortcut': 'Envoyer avec',
'settings.sendEnter': 'Entrée', 'settings.sendEnter': 'Entrée',
'settings.sendCtrlEnter': 'Ctrl+Entrée', 'settings.sendCtrlEnter': 'Ctrl+Entrée',
'settings.tab.appearance': 'Apparence',
'settings.tab.typography': 'Typographie',
'settings.tab.chat': 'Chat',
'settings.tab.notifications': 'Notifications',
'settings.fontUi': 'Police UI',
'settings.fontMono': 'Police code',
'settings.fontSize': 'Taille police UI',
'settings.fontMonoSize': 'Taille police code',
'settings.preview': 'Aperçu',
'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.bookmark': 'Marquer le message',
'message.removeBookmark': 'Retirer le marque-page', 'message.removeBookmark': 'Retirer le marque-page',
@@ -507,6 +531,7 @@ const es: Record<keyof typeof en, string> = {
'settings.title': 'Ajustes', 'settings.title': 'Ajustes',
'settings.appearance': 'Apariencia', 'settings.appearance': 'Apariencia',
'settings.typography': 'Tipografía',
'settings.chat': 'Chat', 'settings.chat': 'Chat',
'settings.notifications': 'Notificaciones', 'settings.notifications': 'Notificaciones',
'settings.notificationSound': 'Sonido de notificación', 'settings.notificationSound': 'Sonido de notificación',
@@ -514,6 +539,17 @@ const es: Record<keyof typeof en, string> = {
'settings.sendShortcut': 'Enviar con', 'settings.sendShortcut': 'Enviar con',
'settings.sendEnter': 'Enter', 'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter', 'settings.sendCtrlEnter': 'Ctrl+Enter',
'settings.tab.appearance': 'Apariencia',
'settings.tab.typography': 'Tipografía',
'settings.tab.chat': 'Chat',
'settings.tab.notifications': 'Notificaciones',
'settings.fontUi': 'Fuente UI',
'settings.fontMono': 'Fuente de código',
'settings.fontSize': 'Tamaño fuente UI',
'settings.fontMonoSize': 'Tamaño fuente código',
'settings.preview': 'Vista previa',
'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.bookmark': 'Marcar mensaje',
'message.removeBookmark': 'Quitar marcador', 'message.removeBookmark': 'Quitar marcador',
@@ -679,6 +715,7 @@ const de: Record<keyof typeof en, string> = {
'settings.title': 'Einstellungen', 'settings.title': 'Einstellungen',
'settings.appearance': 'Darstellung', 'settings.appearance': 'Darstellung',
'settings.typography': 'Typografie',
'settings.chat': 'Chat', 'settings.chat': 'Chat',
'settings.notifications': 'Benachrichtigungen', 'settings.notifications': 'Benachrichtigungen',
'settings.notificationSound': 'Benachrichtigungston', 'settings.notificationSound': 'Benachrichtigungston',
@@ -686,6 +723,17 @@ const de: Record<keyof typeof en, string> = {
'settings.sendShortcut': 'Senden mit', 'settings.sendShortcut': 'Senden mit',
'settings.sendEnter': 'Enter', 'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Strg+Enter', 'settings.sendCtrlEnter': 'Strg+Enter',
'settings.tab.appearance': 'Darstellung',
'settings.tab.typography': 'Typografie',
'settings.tab.chat': 'Chat',
'settings.tab.notifications': 'Benachrichtigungen',
'settings.fontUi': 'UI-Schriftart',
'settings.fontMono': 'Code-Schriftart',
'settings.fontSize': 'UI-Schriftgröße',
'settings.fontMonoSize': 'Code-Schriftgröße',
'settings.preview': 'Vorschau',
'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.bookmark': 'Nachricht markieren',
'message.reply': 'Antworten', 'message.reply': 'Antworten',
@@ -849,6 +897,7 @@ const ja: Record<keyof typeof en, string> = {
'settings.title': '設定', 'settings.title': '設定',
'settings.appearance': '外観', 'settings.appearance': '外観',
'settings.typography': 'タイポグラフィ',
'settings.chat': 'チャット', 'settings.chat': 'チャット',
'settings.notifications': '通知', 'settings.notifications': '通知',
'settings.notificationSound': '通知音', 'settings.notificationSound': '通知音',
@@ -856,6 +905,17 @@ const ja: Record<keyof typeof en, string> = {
'settings.sendShortcut': '送信キー', 'settings.sendShortcut': '送信キー',
'settings.sendEnter': 'Enter', 'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter', 'settings.sendCtrlEnter': 'Ctrl+Enter',
'settings.tab.appearance': '外観',
'settings.tab.typography': 'タイポグラフィ',
'settings.tab.chat': 'チャット',
'settings.tab.notifications': '通知',
'settings.fontUi': 'UIフォント',
'settings.fontMono': 'コードフォント',
'settings.fontSize': 'UIフォントサイズ',
'settings.fontMonoSize': 'コードフォントサイズ',
'settings.preview': 'プレビュー',
'settings.previewText': '素早い茶色の狐はのんびり犬を飛び越える — 1234567890',
'settings.fontNote': 'Inter / JetBrains / Fira は自動読み込み。Cascadia と SF はシステムにある場合のみ使用。',
'message.bookmark': 'メッセージをブックマーク', 'message.bookmark': 'メッセージをブックマーク',
'message.reply': '返信', 'message.reply': '返信',
@@ -1019,6 +1079,7 @@ const pt: Record<keyof typeof en, string> = {
'settings.title': 'Configurações', 'settings.title': 'Configurações',
'settings.appearance': 'Aparência', 'settings.appearance': 'Aparência',
'settings.typography': 'Tipografia',
'settings.chat': 'Chat', 'settings.chat': 'Chat',
'settings.notifications': 'Notificações', 'settings.notifications': 'Notificações',
'settings.notificationSound': 'Som de notificação', 'settings.notificationSound': 'Som de notificação',
@@ -1026,6 +1087,17 @@ const pt: Record<keyof typeof en, string> = {
'settings.sendShortcut': 'Tecla de envio', 'settings.sendShortcut': 'Tecla de envio',
'settings.sendEnter': 'Enter', 'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter', 'settings.sendCtrlEnter': 'Ctrl+Enter',
'settings.tab.appearance': 'Aparência',
'settings.tab.typography': 'Tipografia',
'settings.tab.chat': 'Chat',
'settings.tab.notifications': 'Notificações',
'settings.fontUi': 'Fonte da UI',
'settings.fontMono': 'Fonte de código',
'settings.fontSize': 'Tamanho da fonte UI',
'settings.fontMonoSize': 'Tamanho da fonte de código',
'settings.preview': 'Pré-visualização',
'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.bookmark': 'Marcar mensagem',
'message.reply': 'Responder', 'message.reply': 'Responder',
@@ -1189,6 +1261,7 @@ const zh: Record<keyof typeof en, string> = {
'settings.title': '设置', 'settings.title': '设置',
'settings.appearance': '外观', 'settings.appearance': '外观',
'settings.typography': '排版',
'settings.chat': '聊天', 'settings.chat': '聊天',
'settings.notifications': '通知', 'settings.notifications': '通知',
'settings.notificationSound': '通知声音', 'settings.notificationSound': '通知声音',
@@ -1196,6 +1269,17 @@ const zh: Record<keyof typeof en, string> = {
'settings.sendShortcut': '发送方式', 'settings.sendShortcut': '发送方式',
'settings.sendEnter': 'Enter', 'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter', 'settings.sendCtrlEnter': 'Ctrl+Enter',
'settings.tab.appearance': '外观',
'settings.tab.typography': '排版',
'settings.tab.chat': '聊天',
'settings.tab.notifications': '通知',
'settings.fontUi': '界面字体',
'settings.fontMono': '代码字体',
'settings.fontSize': '界面字号',
'settings.fontMonoSize': '代码字号',
'settings.preview': '预览',
'settings.previewText': '敏捷的棕狐跳过懒狗 — 1234567890',
'settings.fontNote': 'Inter / JetBrains / Fira 自动加载Cascadia 和 SF 依赖系统已安装字体。',
'message.bookmark': '收藏消息', 'message.bookmark': '收藏消息',
'message.reply': '回复', 'message.reply': '回复',
@@ -1359,6 +1443,7 @@ const it: Record<keyof typeof en, string> = {
'settings.title': 'Impostazioni', 'settings.title': 'Impostazioni',
'settings.appearance': 'Aspetto', 'settings.appearance': 'Aspetto',
'settings.typography': 'Tipografia',
'settings.chat': 'Chat', 'settings.chat': 'Chat',
'settings.notifications': 'Notifiche', 'settings.notifications': 'Notifiche',
'settings.notificationSound': 'Suono di notifica', 'settings.notificationSound': 'Suono di notifica',
@@ -1366,6 +1451,17 @@ const it: Record<keyof typeof en, string> = {
'settings.sendShortcut': 'Invia con', 'settings.sendShortcut': 'Invia con',
'settings.sendEnter': 'Invio', 'settings.sendEnter': 'Invio',
'settings.sendCtrlEnter': 'Ctrl+Invio', 'settings.sendCtrlEnter': 'Ctrl+Invio',
'settings.tab.appearance': 'Aspetto',
'settings.tab.typography': 'Tipografia',
'settings.tab.chat': 'Chat',
'settings.tab.notifications': 'Notifiche',
'settings.fontUi': 'Font UI',
'settings.fontMono': 'Font codice',
'settings.fontSize': 'Dimensione font UI',
'settings.fontMonoSize': 'Dimensione font codice',
'settings.preview': 'Anteprima',
'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.bookmark': 'Aggiungi ai segnalibri',
'message.reply': 'Rispondi', 'message.reply': 'Rispondi',

29
src/test/setup.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Test environment polyfills for Vitest running in `node` mode.
* Provides a minimal localStorage implementation with clear() to satisfy hooks.
*/
if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.clear !== 'function') {
const store = new Map<string, string>();
globalThis.localStorage = {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(String(key), String(value));
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
get length() {
return store.size;
},
} as unknown as Storage;
}

View File

@@ -7,6 +7,7 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
setupFiles: ['src/test/setup.ts'],
include: ['src/**/__tests__/**/*.test.{ts,tsx}'], include: ['src/**/__tests__/**/*.test.{ts,tsx}'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',