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:
2026-08-30 18:30:49 -06:00
co-authored by Claude Sonnet 5
parent cd6296d079
commit 520b971247
16 changed files with 481 additions and 22 deletions
@@ -0,0 +1,32 @@
"""add user text_scale preference
Revision ID: 339b78011a4f
Revises: a318850726ee
Create Date: 2026-08-30 18:03:16.159924
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '339b78011a4f'
down_revision: Union[str, Sequence[str], None] = 'a318850726ee'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('text_scale', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'text_scale')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""add user emoji_scale preference
Revision ID: e81c9bcc82b9
Revises: 339b78011a4f
Create Date: 2026-08-30 18:14:26.045186
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e81c9bcc82b9'
down_revision: Union[str, Sequence[str], None] = '339b78011a4f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('emoji_scale', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'emoji_scale')
# ### end Alembic commands ###
+11
View File
@@ -19,6 +19,17 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20))
# #71: null means "normal" (the pre-existing default before this
# setting existed) -- a preset name, not a raw scale factor, so it's
# validated/enumerable the same way `theme` already is rather than
# accepting an arbitrary float.
text_scale: Mapped[str | None] = mapped_column(String(20))
# #71: independent of text_scale above -- scales emoji rendered in
# message text specifically, not the whole UI (see
# frontend/src/components/MessageContent.tsx's --emoji-scale, scoped
# to message content only so it can't also inflate the emoji picker's
# grid or reaction pills).
emoji_scale: Mapped[str | None] = mapped_column(String(20))
# Only meaningful when theme == "custom" -- which of this user's saved
# CustomTheme rows (app/models/custom_theme.py) is currently active.
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
+4
View File
@@ -100,6 +100,10 @@ async def update_profile(
current_user.display_name = display_name or None
if "theme" in updates:
current_user.theme = updates["theme"]
if "text_scale" in updates:
current_user.text_scale = updates["text_scale"]
if "emoji_scale" in updates:
current_user.emoji_scale = updates["emoji_scale"]
if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"]
await db.commit()
+6
View File
@@ -23,6 +23,8 @@ class UserRead(BaseModel):
is_site_admin: bool
display_name: str | None
theme: str | None
text_scale: str | None
emoji_scale: str | None
# Resolved, not just an id -- the frontend needs the actual palette to
# paint on load without a second round trip (see lib/theme.ts).
active_custom_theme: CustomThemeRead | None
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
# bare theme name with nothing to point it at.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
# #71: kept in sync with frontend/src/lib/theme.ts's TEXT_SCALE_PERCENT map.
text_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
# #71: kept in sync with MessageContent.tsx's EMOJI_SCALE_MULTIPLIER map.
emoji_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
appear_offline: bool | None = Field(default=None)
+56
View File
@@ -102,6 +102,62 @@ async def test_theme_custom_rejected_on_generic_profile_update(client, db_sessio
assert resp.status_code == 422
async def test_update_text_scale_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"text_scale": "large"})
assert resp.status_code == 200, resp.text
assert resp.json()["text_scale"] == "large"
me = await client.get("/api/auth/me")
assert me.json()["text_scale"] == "large"
async def test_invalid_text_scale_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"text_scale": "huge"})
assert resp.status_code == 422
async def test_updating_text_scale_does_not_clobber_theme(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"theme": "sunset"})
resp = await client.patch("/api/auth/me", json={"text_scale": "xlarge"})
assert resp.status_code == 200
assert resp.json()["theme"] == "sunset"
assert resp.json()["text_scale"] == "xlarge"
async def test_update_emoji_scale_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"emoji_scale": "xlarge"})
assert resp.status_code == 200, resp.text
assert resp.json()["emoji_scale"] == "xlarge"
me = await client.get("/api/auth/me")
assert me.json()["emoji_scale"] == "xlarge"
async def test_invalid_emoji_scale_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"emoji_scale": "huge"})
assert resp.status_code == 422
async def test_updating_emoji_scale_does_not_clobber_text_scale(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"text_scale": "large"})
resp = await client.patch("/api/auth/me", json={"emoji_scale": "small"})
assert resp.status_code == 200
assert resp.json()["text_scale"] == "large"
assert resp.json()["emoji_scale"] == "small"
async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
+18 -1
View File
@@ -1,5 +1,5 @@
import { apiFetch, ApiError, NetworkError } from './client'
import type { User, UserSession } from '../types'
import type { EmojiScale, TextScale, User, UserSession } from '../types'
// No register() here: this is an invite-only site. Accounts are created by
// an operator via the backend CLI (`python -m app.cli create-user`), not
@@ -50,6 +50,23 @@ export function updateTheme(theme: 'dark' | 'light' | 'midnight' | 'sunset'): Pr
})
}
// #71: its own call, same reasoning as updateTheme above -- the backend
// only applies fields actually present in the request body, so this can't
// clobber theme (or vice versa).
export function updateTextScale(textScale: TextScale): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ text_scale: textScale }),
})
}
export function updateEmojiScale(emojiScale: EmojiScale): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ emoji_scale: emojiScale }),
})
}
export function removeAvatar(): Promise<User> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
}
+31 -3
View File
@@ -4,10 +4,38 @@
own font-size and this just tracks it. Kept in this file (imported
directly by MessageContent.tsx) rather than MessageList.css so it's
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage
included, not just the message list. */
included, not just the message list.
#71: also multiplied by --emoji-scale, the manual "make emoji bigger"
preference -- but that variable is only ever set on MessageContent's own
wrapper div (inline style, scoped to that element and its descendants),
never at :root, so var(..., 1) correctly falls back to a no-op multiplier
everywhere else this class is reused (the picker's grid, reaction pills)
instead of also inflating those and breaking their fixed-size layout. */
.message-custom-emoji {
height: 1.2em;
width: 1.2em;
height: calc(1.2em * var(--emoji-scale, 1));
width: calc(1.2em * var(--emoji-scale, 1));
object-fit: contain;
vertical-align: -0.25em;
}
/* #71: a raw unicode emoji wrapped by wrapEmojiGlyphs -- same --emoji-scale
multiplier as the custom-emoji image above, so "make emoji bigger"
applies uniformly regardless of which kind of emoji it is. */
.inline-emoji {
display: inline-block;
font-size: calc(1em * var(--emoji-scale, 1));
}
/* #71: Discord/Slack-style large rendering for a message that's nothing
but emoji (see isEmojiOnlyMessage) -- em-relative like everything else
here, so it scales on top of the text-size setting rather than
overriding it, and also honors --emoji-scale on top of its own 2.5x (a
message that's only emoji AND has "Extra large" emoji picked should be
bigger still, not capped at a fixed size regardless of that setting).
.message-custom-emoji's own em-sizing means a custom emoji picks this up
for free, no separate rule needed. */
.message-text-emoji-only {
font-size: calc(2.5em * var(--emoji-scale, 1));
line-height: 1.2;
}
+105 -7
View File
@@ -1,7 +1,8 @@
import Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { getCustomEmojiUrl } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './MessageContent.css'
@@ -85,6 +86,14 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
/>
)
}
// #71: a raw unicode emoji has no element of its own to size independently
// of the surrounding text -- it's just characters in a string. Wrapping
// each one individually (see wrapEmojiGlyphs below) gives it one, purely
// so the emoji-size preference can scale it via CSS the same way it
// already scales a custom emoji's <img>.
if (href === 'glyph:') {
return <span className="inline-emoji">{children}</span>
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
@@ -247,7 +256,12 @@ export function EmojiGlyph({ value }: EmojiGlyphProps) {
/>
)
}
return <>{value}</>
// Wrapped the same way wrapEmojiGlyphs wraps a raw emoji in message text
// (see .inline-emoji), so a --emoji-scale set on an ancestor (the
// reaction pill's own span in MessageList.tsx) scales this the same way
// it scales the .message-custom-emoji img above -- and falls back to a
// no-op 1x everywhere else (the picker) with no --emoji-scale set at all.
return <span className="inline-emoji">{value}</span>
}
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
@@ -370,14 +384,98 @@ export function preprocessMarkdown(text: string): { text: string; headingIds: Ma
return extractHeadingIds(convertSubSuperscript(text))
}
// #71: Discord/Slack-style -- a message that's *nothing but* emoji renders
// them noticeably larger, no manual control needed. `\p{Extended_Pictographic}`
// is the standard way to match emoji in a JS regex (widely supported);
// `\p{Emoji_Modifier}` covers skin-tone modifiers, `\u200D` (zero-width
// joiner) covers compound emoji like family/profession sequences, and
// `\uFE0F` (variation selector-16) is the explicit emoji-presentation
// marker some single-codepoint emoji carry -- without all three a real
// multi-codepoint emoji cluster gets rejected partway through. A custom
// emoji's `:shortcode:` has no glyph to test against, so it's swapped for
// a placeholder pictograph first -- same substitution shape as
// convertCustomEmojiShortcodes above, just standing in for "yes, this is
// one emoji" rather than an actual image.
const EMOJI_ONLY_TEST = /^[\p{Extended_Pictographic}\p{Emoji_Modifier}\u200D\uFE0F]+$/u
// Discord's own cutoff for this treatment -- past a handful, "unusually
// large emoji" reads as spam rather than expressive, so it reverts to
// normal size instead of scaling a wall of them up.
const MAX_EMOJI_ONLY_COUNT = 20
export function isEmojiOnlyMessage(content: string, customShortcodes: Set<string>): boolean {
const withBuiltinGlyphs = content.replace(SHORTCODE_PATTERN, (match, name) => EMOJI_SHORTCODES[name] ?? match)
const withPlaceholders = withBuiltinGlyphs.replace(CUSTOM_EMOJI_PATTERN, (match, name) =>
customShortcodes.has(name) ? '🔹' : match,
)
const stripped = withPlaceholders.replace(/\s+/g, '')
if (!stripped || !EMOJI_ONLY_TEST.test(stripped)) return false
return [...new Intl.Segmenter().segment(stripped)].length <= MAX_EMOJI_ONLY_COUNT
}
// #71: gives every individual unicode emoji its own element (see
// MarkdownLink's `glyph:` branch) purely so the emoji-size preference can
// scale it independently of the surrounding text -- a raw emoji is just
// characters in a string otherwise, with nothing CSS can address on its
// own. Runs after convertShortcodes so a built-in `:name:` that just
// became a glyph is wrapped too ("all emoji", not just ones typed as
// literal unicode); same fence/code-span skip convention as every other
// converter here.
const EMOJI_GLYPH_PATTERN = /\p{Extended_Pictographic}(?:\p{Emoji_Modifier}|\u200D\p{Extended_Pictographic}|\uFE0F)*/gu
function wrapEmojiGlyphs(text: string): string {
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) => (i % 2 === 0 ? part.replace(EMOJI_GLYPH_PATTERN, (match) => `[${match}](glyph:)`) : part))
.join('')
})
.join('\n')
}
// Exported so MessageList's reaction pills can apply the same viewer
// preference to their own EmojiGlyph -- reactions render outside the
// markdown pipeline entirely (see EmojiGlyph's own comment above), so they
// need this looked up independently rather than inheriting --emoji-scale
// from this component's wrapper div.
export const EMOJI_SCALE_MULTIPLIER: Record<string, number> = {
small: 0.8,
normal: 1,
large: 1.5,
xlarge: 2,
}
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
const { user } = useAuth()
const { byShortcode } = useCustomEmoji()
const customShortcodes = new Set(byShortcode.keys())
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
const withCustomEmoji = convertCustomEmojiShortcodes(
convertShortcodes(withRoomRefs),
new Set(byShortcode.keys()),
const withCustomEmoji = convertCustomEmojiShortcodes(convertShortcodes(withRoomRefs), customShortcodes)
const withEmojiGlyphs = wrapEmojiGlyphs(withCustomEmoji)
const { text, headingIds } = preprocessMarkdown(withEmojiGlyphs)
const emojiOnly = isEmojiOnlyMessage(content, customShortcodes)
// #71: scoped to this element (not a :root-level variable) so it only
// ever affects emoji rendered in message text -- not the same
// .message-custom-emoji/EmojiGlyph markup reused by the emoji picker's
// grid, where a bigger image would just break its fixed-size layout
// instead of doing anything useful. Reaction pills DO scale too, but via
// their own inline --emoji-scale in MessageList.tsx, not by inheriting
// this one -- a pill isn't a descendant of this wrapper div.
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
return (
<div
className={emojiOnly ? 'message-text-emoji-only' : undefined}
style={{ '--emoji-scale': emojiScale } as CSSProperties}
>
<Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
</div>
)
const { text, headingIds } = preprocessMarkdown(withCustomEmoji)
return <Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
}
+10 -5
View File
@@ -53,8 +53,13 @@
.message-image {
display: block;
max-width: min(320px, 100%);
max-height: 240px;
/* #71: rem, not px -- scales with the text-size setting (see
lib/theme.ts's applyTextScale), same as every other size in this app.
min(...) still caps against the viewport in absolute px, since a
percentage-of-viewport constraint isn't something a root font-size
change should affect. */
max-width: min(20rem, 100%);
max-height: 15rem;
object-fit: contain;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
@@ -65,14 +70,14 @@
.message-video-wrap {
position: relative;
display: inline-block;
max-width: min(320px, 100%);
max-width: min(20rem, 100%);
margin-bottom: 4px;
}
.message-video {
display: block;
width: 100%;
max-height: 240px;
max-height: 15rem;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
background: var(--ds-void);
@@ -111,7 +116,7 @@
margin-bottom: 4px;
color: var(--ds-text);
text-decoration: none;
max-width: min(320px, 100%);
max-width: min(20rem, 100%);
}
.message-file-attachment:hover {
+10 -2
View File
@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
@@ -8,7 +9,7 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard'
import { EmojiGlyph, MessageContent } from './MessageContent'
import { EMOJI_SCALE_MULTIPLIER, EmojiGlyph, MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
import { VideoLightbox } from './VideoLightbox'
import './MessageList.css'
@@ -126,6 +127,10 @@ export function MessageList({
onDelete,
}: MessageListProps) {
const { user } = useAuth()
// #71: same viewer preference MessageContent applies to in-text emoji,
// looked up separately here since a reaction pill isn't a descendant of
// that component's wrapper div (see EmojiGlyph's own comment).
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
const containerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
// Whether the view should be pinned to the latest message -- true right
@@ -302,7 +307,10 @@ export function MessageList({
title={r.user_ids.map(displayNameForUserId).join(', ')}
onClick={() => onReact(msg.id, r.emoji)}
>
<span>
{/* No .inline-emoji here -- EmojiGlyph's own fallback branch
already applies it, and stacking it here too would double
the font-size multiplication for a custom-emoji img. */}
<span style={{ '--emoji-scale': emojiScale } as CSSProperties}>
<EmojiGlyph value={r.emoji} />
</span>
<span>{r.count}</span>
+39
View File
@@ -230,6 +230,45 @@
color: var(--ds-muted);
}
.text-scale-options {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-2);
margin-bottom: var(--sp-4);
}
.text-scale-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 10px 4px;
cursor: pointer;
}
.text-scale-option:hover {
border-color: var(--ds-accent);
}
.text-scale-option-selected {
border-color: var(--ds-accent);
box-shadow: 0 0 0 1px var(--ds-accent);
}
.text-scale-option-preview {
font-weight: 700;
color: var(--ds-text);
line-height: 1;
}
.text-scale-option-label {
font-size: 0.7rem;
color: var(--ds-muted);
}
.theme-swatch-preview-new {
background: transparent;
border-style: dashed;
+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) => {
+5 -1
View File
@@ -3,7 +3,7 @@ import * as authApi from '../api/auth'
import { ApiError, NetworkError } from '../api/client'
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
import { unsubscribeFromPush } from '../lib/push'
import { applyTheme } from '../lib/theme'
import { applyTextScale, applyTheme } from '../lib/theme'
import type { User } from '../types'
interface AuthContextValue {
@@ -26,6 +26,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null)
}, [user?.theme, user?.active_custom_theme])
useEffect(() => {
applyTextScale(user?.text_scale ?? null)
}, [user?.text_scale])
useEffect(() => {
authApi
.me()
+19 -1
View File
@@ -1,4 +1,4 @@
import type { CustomThemeColors, ThemeName } from '../types'
import type { CustomThemeColors, TextScale, ThemeName } from '../types'
// The inline custom properties a custom theme sets on :root -- must be
// removed explicitly when switching to a preset, since an inline style
@@ -76,3 +76,21 @@ export function applyTheme(theme: ThemeName | null, customColors: CustomThemeCol
for (const varName of CUSTOM_THEME_VARS) root.style.removeProperty(varName)
root.style.removeProperty('color-scheme')
}
// #71: percentages, not fixed px -- stacks on top of the browser/OS's own
// zoom or accessibility text-size setting instead of overriding it. Every
// component in this app already sizes itself in rem (see tokens.css),
// which is relative to this root value, so setting it here is the one
// change that scales text *and* the message-image/video max-size caps
// (also converted to rem -- see MessageList.css) uniformly, with no
// per-component work.
const TEXT_SCALE_PERCENT: Record<TextScale, string> = {
small: '87.5%',
normal: '100%',
large: '112.5%',
xlarge: '125%',
}
export function applyTextScale(scale: TextScale | null): void {
document.documentElement.style.fontSize = TEXT_SCALE_PERCENT[scale ?? 'normal']
}
+9
View File
@@ -1,5 +1,12 @@
export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' | 'custom'
// #71: null means "normal" -- see lib/theme.ts's TEXT_SCALE_PERCENT map.
export type TextScale = 'small' | 'normal' | 'large' | 'xlarge'
// #71: independent of TextScale -- see MessageContent.tsx's
// EMOJI_SCALE_MULTIPLIER map. Same preset shape for UI consistency.
export type EmojiScale = 'small' | 'normal' | 'large' | 'xlarge'
// Matches exactly the CSS custom properties frontend/src/styles/themes.css
// overrides per built-in preset -- kept in sync with
// backend/app/schemas/custom_theme.py's CustomThemeColors.
@@ -45,6 +52,8 @@ export interface User {
// Only non-null when theme === 'custom' -- see UserRead's model_validator
// in backend/app/schemas/user.py.
active_custom_theme: CustomTheme | null
text_scale: TextScale | null
emoji_scale: EmojiScale | null
avatar_filename: string | null
appear_offline: boolean
created_at: string