diff --git a/backend/alembic/versions/339b78011a4f_add_user_text_scale_preference.py b/backend/alembic/versions/339b78011a4f_add_user_text_scale_preference.py new file mode 100644 index 0000000..94c077e --- /dev/null +++ b/backend/alembic/versions/339b78011a4f_add_user_text_scale_preference.py @@ -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 ### diff --git a/backend/alembic/versions/e81c9bcc82b9_add_user_emoji_scale_preference.py b/backend/alembic/versions/e81c9bcc82b9_add_user_emoji_scale_preference.py new file mode 100644 index 0000000..12311a1 --- /dev/null +++ b/backend/alembic/versions/e81c9bcc82b9_add_user_emoji_scale_preference.py @@ -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 ### diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4a85958..d65b85e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index af16b08..3533ecd 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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() diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f3acb32..481e278 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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) diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 1647d02..6f611d1 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -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")) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a195473..2d679b3 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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 { + return apiFetch('/api/auth/me', { + method: 'PATCH', + body: JSON.stringify({ text_scale: textScale }), + }) +} + +export function updateEmojiScale(emojiScale: EmojiScale): Promise { + return apiFetch('/api/auth/me', { + method: 'PATCH', + body: JSON.stringify({ emoji_scale: emojiScale }), + }) +} + export function removeAvatar(): Promise { return apiFetch('/api/auth/me/avatar', { method: 'DELETE' }) } diff --git a/frontend/src/components/MessageContent.css b/frontend/src/components/MessageContent.css index 2a806fb..98bc3aa 100644 --- a/frontend/src/components/MessageContent.css +++ b/frontend/src/components/MessageContent.css @@ -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; +} diff --git a/frontend/src/components/MessageContent.tsx b/frontend/src/components/MessageContent.tsx index 0531401..7236b0a 100644 --- a/frontend/src/components/MessageContent.tsx +++ b/frontend/src/components/MessageContent.tsx @@ -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 . + if (href === 'glyph:') { + return {children} + } return ( {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 {value} } 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): 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 = { + 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 ( +
+ {preserveLineBreaks(text)} +
) - const { text, headingIds } = preprocessMarkdown(withCustomEmoji) - return {preserveLineBreaks(text)} } diff --git a/frontend/src/components/MessageList.css b/frontend/src/components/MessageList.css index 01cd062..6bbf712 100644 --- a/frontend/src/components/MessageList.css +++ b/frontend/src/components/MessageList.css @@ -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 { diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 3bf058a..5e6825b 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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(null) const bottomRef = useRef(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)} > - + {/* 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. */} + {r.count} diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index c7d2cf6..5e271b4 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -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; diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index 9c3ebd8..3de4cb3 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -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; 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(null) const [themeError, setThemeError] = useState(null) + const [textScaleError, setTextScaleError] = useState(null) + const [emojiScaleError, setEmojiScaleError] = useState(null) const [customThemes, setCustomThemes] = useState([]) const [editingThemeId, setEditingThemeId] = useState(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) { {themeError &&

{themeError}

} +
Text size
+
+ {TEXT_SCALE_OPTIONS.map((option) => ( + + ))} +
+ {textScaleError &&

{textScaleError}

} + +
Emoji size
+
+ {EMOJI_SCALE_OPTIONS.map((option) => ( + + ))} +
+ {emojiScaleError &&

{emojiScaleError}

} +
My custom themes
{customThemes.map((theme) => { diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 0c02d4c..52ef026 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -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() diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index 137c57d..3c19ab2 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -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 = { + 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'] +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 08392ff..cb82490 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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