Add adjustable text size and emoji size preferences (#71)

Text was too small on high-DPI screens with no in-app fix beyond
browser zoom. Adds a text-size setting (scales the whole app via a
root font-size percentage), auto-large rendering for emoji-only
messages, and an independent emoji-size preference that also scales
reaction pills without affecting the emoji picker's fixed-size grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ksmithandClaude Sonnet 5 committed 2026-08-30 18:30:49 -06:00
1 parent cd6296d079
commit 520b971247
16 files changed
+481 -22

No files matched your search

+94 -2
View File
@@ -5,7 +5,9 @@ import {
me,
removeAvatar,
revokeSession,
updateEmojiScale,
updateProfile,
updateTextScale,
updateTheme,
uploadAvatar,
} from '../api/auth'
@@ -20,8 +22,8 @@ import {
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors, UserSession } from '../types'
import { applyTextScale, applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors, EmojiScale, TextScale, UserSession } from '../types'
import { ThemeBuilderModal } from './ThemeBuilderModal'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
@@ -33,6 +35,26 @@ const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: st
{ name: 'sunset', label: 'Sunset' },
]
// #71: the "Aa" preview scales with each option's own size, the standard
// way a text-size picker shows what it does without a separate demo area.
const TEXT_SCALE_OPTIONS: { name: TextScale; label: string; previewSize: string }[] = [
{ name: 'small', label: 'Small', previewSize: '0.8rem' },
{ name: 'normal', label: 'Normal', previewSize: '1rem' },
{ name: 'large', label: 'Large', previewSize: '1.25rem' },
{ name: 'xlarge', label: 'Extra large', previewSize: '1.5rem' },
]
// #71: independent of text size -- only scales emoji rendered in message
// text (see MessageContent.tsx's --emoji-scale). The preview uses an
// actual emoji so it demonstrates itself the same way the text-size
// options do with "Aa".
const EMOJI_SCALE_OPTIONS: { name: EmojiScale; label: string; previewSize: string }[] = [
{ name: 'small', label: 'Small', previewSize: '1rem' },
{ name: 'normal', label: 'Normal', previewSize: '1.25rem' },
{ name: 'large', label: 'Large', previewSize: '1.6rem' },
{ name: 'xlarge', label: 'Extra large', previewSize: '2rem' },
]
const CUSTOM_COLOR_FIELDS: { key: keyof Omit<CustomThemeColors, 'color_scheme'>; label: string }[] = [
{ key: 'void', label: 'Background' },
{ key: 'void_2', label: 'Sidebar background' },
@@ -60,6 +82,8 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(null)
const [textScaleError, setTextScaleError] = useState<string | null>(null)
const [emojiScaleError, setEmojiScaleError] = useState<string | null>(null)
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
@@ -150,6 +174,32 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
}
}
async function handleSelectTextScale(scale: TextScale) {
// Same instant-apply-then-persist pattern as handleSelectPreset above.
applyTextScale(scale)
setTextScaleError(null)
try {
const updated = await updateTextScale(scale)
updateUser(updated)
} catch (err) {
applyTextScale(user?.text_scale ?? null)
setTextScaleError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleSelectEmojiScale(scale: EmojiScale) {
// No instant-apply DOM mutation here (unlike theme/text scale) -- it's
// just a value MessageContent reads from `user` on its next render, so
// persisting and updating that is the whole job.
setEmojiScaleError(null)
try {
const updated = await updateEmojiScale(scale)
updateUser(updated)
} catch (err) {
setEmojiScaleError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleActivateCustomTheme(theme: CustomTheme) {
applyTheme('custom', theme.colors)
setThemeError(null)
@@ -360,6 +410,48 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</div>
{themeError && <p className="modal-error">{themeError}</p>}
<div className="modal-field-label">Text size</div>
<div className="text-scale-options">
{TEXT_SCALE_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`text-scale-option${
(user.text_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
}`}
onClick={() => handleSelectTextScale(option.name)}
aria-pressed={(user.text_scale ?? 'normal') === option.name}
>
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
Aa
</span>
<span className="text-scale-option-label">{option.label}</span>
</button>
))}
</div>
{textScaleError && <p className="modal-error">{textScaleError}</p>}
<div className="modal-field-label">Emoji size</div>
<div className="text-scale-options">
{EMOJI_SCALE_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`text-scale-option${
(user.emoji_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
}`}
onClick={() => handleSelectEmojiScale(option.name)}
aria-pressed={(user.emoji_scale ?? 'normal') === option.name}
>
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
🎉
</span>
<span className="text-scale-option-label">{option.label}</span>
</button>
))}
</div>
{emojiScaleError && <p className="modal-error">{emojiScaleError}</p>}
<div className="modal-field-label">My custom themes</div>
<div className="theme-swatch-grid">
{customThemes.map((theme) => {