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) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50)) display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20)) 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 # Only meaningful when theme == "custom" -- which of this user's saved
# CustomTheme rows (app/models/custom_theme.py) is currently active. # CustomTheme rows (app/models/custom_theme.py) is currently active.
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme # 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 current_user.display_name = display_name or None
if "theme" in updates: if "theme" in updates:
current_user.theme = updates["theme"] 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: if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"] current_user.appear_offline = updates["appear_offline"]
await db.commit() await db.commit()
+6
View File
@@ -23,6 +23,8 @@ class UserRead(BaseModel):
is_site_admin: bool is_site_admin: bool
display_name: str | None display_name: str | None
theme: 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 # Resolved, not just an id -- the frontend needs the actual palette to
# paint on load without a second round trip (see lib/theme.ts). # paint on load without a second round trip (see lib/theme.ts).
active_custom_theme: CustomThemeRead | None active_custom_theme: CustomThemeRead | None
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
# ownership check; that's POST /api/custom-themes/{id}/activate, not a # ownership check; that's POST /api/custom-themes/{id}/activate, not a
# bare theme name with nothing to point it at. # bare theme name with nothing to point it at.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None) 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) 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 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): async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice")) 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 { 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 // 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 // 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> { export function removeAvatar(): Promise<User> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' }) 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 own font-size and this just tracks it. Kept in this file (imported
directly by MessageContent.tsx) rather than MessageList.css so it's directly by MessageContent.tsx) rather than MessageList.css so it's
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage 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 { .message-custom-emoji {
height: 1.2em; height: calc(1.2em * var(--emoji-scale, 1));
width: 1.2em; width: calc(1.2em * var(--emoji-scale, 1));
object-fit: contain; object-fit: contain;
vertical-align: -0.25em; 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 Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react' import type { CSSProperties, ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { getCustomEmojiUrl } from '../api/customEmoji' import { getCustomEmojiUrl } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext' import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes' import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './MessageContent.css' 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 ( return (
<a href={href} target="_blank" rel="noopener noreferrer"> <a href={href} target="_blank" rel="noopener noreferrer">
{children} {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 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)) 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) { export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
const { user } = useAuth()
const { byShortcode } = useCustomEmoji() const { byShortcode } = useCustomEmoji()
const customShortcodes = new Set(byShortcode.keys())
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
const withCustomEmoji = convertCustomEmojiShortcodes( const withCustomEmoji = convertCustomEmojiShortcodes(convertShortcodes(withRoomRefs), customShortcodes)
convertShortcodes(withRoomRefs), const withEmojiGlyphs = wrapEmojiGlyphs(withCustomEmoji)
new Set(byShortcode.keys()), 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 { .message-image {
display: block; display: block;
max-width: min(320px, 100%); /* #71: rem, not px -- scales with the text-size setting (see
max-height: 240px; 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; object-fit: contain;
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid var(--ds-border); border: 1px solid var(--ds-border);
@@ -65,14 +70,14 @@
.message-video-wrap { .message-video-wrap {
position: relative; position: relative;
display: inline-block; display: inline-block;
max-width: min(320px, 100%); max-width: min(20rem, 100%);
margin-bottom: 4px; margin-bottom: 4px;
} }
.message-video { .message-video {
display: block; display: block;
width: 100%; width: 100%;
max-height: 240px; max-height: 15rem;
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid var(--ds-border); border: 1px solid var(--ds-border);
background: var(--ds-void); background: var(--ds-void);
@@ -111,7 +116,7 @@
margin-bottom: 4px; margin-bottom: 4px;
color: var(--ds-text); color: var(--ds-text);
text-decoration: none; text-decoration: none;
max-width: min(320px, 100%); max-width: min(20rem, 100%);
} }
.message-file-attachment:hover { .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 { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@@ -8,7 +9,7 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal' import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox' import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard' import { LinkPreviewCard } from './LinkPreviewCard'
import { EmojiGlyph, MessageContent } from './MessageContent' import { EMOJI_SCALE_MULTIPLIER, EmojiGlyph, MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import { VideoLightbox } from './VideoLightbox' import { VideoLightbox } from './VideoLightbox'
import './MessageList.css' import './MessageList.css'
@@ -126,6 +127,10 @@ export function MessageList({
onDelete, onDelete,
}: MessageListProps) { }: MessageListProps) {
const { user } = useAuth() 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 containerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
// Whether the view should be pinned to the latest message -- true right // 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(', ')} title={r.user_ids.map(displayNameForUserId).join(', ')}
onClick={() => onReact(msg.id, r.emoji)} 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} /> <EmojiGlyph value={r.emoji} />
</span> </span>
<span>{r.count}</span> <span>{r.count}</span>
+39
View File
@@ -230,6 +230,45 @@
color: var(--ds-muted); 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 { .theme-swatch-preview-new {
background: transparent; background: transparent;
border-style: dashed; border-style: dashed;
+94 -2
View File
@@ -5,7 +5,9 @@ import {
me, me,
removeAvatar, removeAvatar,
revokeSession, revokeSession,
updateEmojiScale,
updateProfile, updateProfile,
updateTextScale,
updateTheme, updateTheme,
uploadAvatar, uploadAvatar,
} from '../api/auth' } from '../api/auth'
@@ -20,8 +22,8 @@ import {
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme' import { applyTextScale, applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors, UserSession } from '../types' import type { CustomTheme, CustomThemeColors, EmojiScale, TextScale, UserSession } from '../types'
import { ThemeBuilderModal } from './ThemeBuilderModal' import { ThemeBuilderModal } from './ThemeBuilderModal'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import './Modal.css' import './Modal.css'
@@ -33,6 +35,26 @@ const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: st
{ name: 'sunset', label: 'Sunset' }, { 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 }[] = [ const CUSTOM_COLOR_FIELDS: { key: keyof Omit<CustomThemeColors, 'color_scheme'>; label: string }[] = [
{ key: 'void', label: 'Background' }, { key: 'void', label: 'Background' },
{ key: 'void_2', label: 'Sidebar background' }, { key: 'void_2', label: 'Sidebar background' },
@@ -60,6 +82,8 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(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 [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
const [editingThemeId, setEditingThemeId] = useState<string | null>(null) 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) { async function handleActivateCustomTheme(theme: CustomTheme) {
applyTheme('custom', theme.colors) applyTheme('custom', theme.colors)
setThemeError(null) setThemeError(null)
@@ -360,6 +410,48 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</div> </div>
{themeError && <p className="modal-error">{themeError}</p>} {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="modal-field-label">My custom themes</div>
<div className="theme-swatch-grid"> <div className="theme-swatch-grid">
{customThemes.map((theme) => { {customThemes.map((theme) => {
+5 -1
View File
@@ -3,7 +3,7 @@ import * as authApi from '../api/auth'
import { ApiError, NetworkError } from '../api/client' import { ApiError, NetworkError } from '../api/client'
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser' import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
import { unsubscribeFromPush } from '../lib/push' import { unsubscribeFromPush } from '../lib/push'
import { applyTheme } from '../lib/theme' import { applyTextScale, applyTheme } from '../lib/theme'
import type { User } from '../types' import type { User } from '../types'
interface AuthContextValue { interface AuthContextValue {
@@ -26,6 +26,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null) applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null)
}, [user?.theme, user?.active_custom_theme]) }, [user?.theme, user?.active_custom_theme])
useEffect(() => {
applyTextScale(user?.text_scale ?? null)
}, [user?.text_scale])
useEffect(() => { useEffect(() => {
authApi authApi
.me() .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 // The inline custom properties a custom theme sets on :root -- must be
// removed explicitly when switching to a preset, since an inline style // 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) for (const varName of CUSTOM_THEME_VARS) root.style.removeProperty(varName)
root.style.removeProperty('color-scheme') 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' 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 // Matches exactly the CSS custom properties frontend/src/styles/themes.css
// overrides per built-in preset -- kept in sync with // overrides per built-in preset -- kept in sync with
// backend/app/schemas/custom_theme.py's CustomThemeColors. // 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 // Only non-null when theme === 'custom' -- see UserRead's model_validator
// in backend/app/schemas/user.py. // in backend/app/schemas/user.py.
active_custom_theme: CustomTheme | null active_custom_theme: CustomTheme | null
text_scale: TextScale | null
emoji_scale: EmojiScale | null
avatar_filename: string | null avatar_filename: string | null
appear_offline: boolean appear_offline: boolean
created_at: string created_at: string