From afdd5731828bbd057708e0d090bc40e6efac0e0a Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sat, 15 Aug 2026 22:02:38 -0600 Subject: [PATCH] Add UI themes (Dark, Light, Midnight, Sunset) Four preset color themes, switchable from Profile settings with instant preview and server-side persistence (User.theme, applied via a data-theme attribute the CSS custom-property overrides in themes.css key off). Dark stays the existing DarkSingularity palette and default. Light is a genuine new light-mode design; Midnight is a higher-contrast OLED-friendly dark variant; Sunset swaps in a warm amber/coral accent family. Custom theme building (pick-your-own-colors) is out of scope for this pass -- presets only. Also: fixed the PATCH /api/auth/me handler to only apply fields actually present in the request body. It previously always overwrote display_name unconditionally, which happened to be harmless when it was the only field on ProfileUpdate but would have silently cleared it on any theme-only update. And switched two hardcoded hex colors (.btn-primary:hover, .role-badge-admin) to token-derived color-mix() values so they adapt across themes instead of staying fixed to the original cyan/violet palette. Co-Authored-By: Claude Sonnet 5 --- .../e849b2efb79b_user_theme_preference.py | 32 ++++++++ backend/app/models/user.py | 1 + backend/app/routers/auth.py | 11 ++- backend/app/schemas/user.py | 8 ++ backend/tests/test_profile.py | 38 +++++++++ frontend/src/api/auth.ts | 12 ++- frontend/src/components/Modal.css | 82 +++++++++++++++++++ frontend/src/components/ProfileModal.tsx | 51 +++++++++++- frontend/src/components/RoomInfoPanel.css | 2 +- frontend/src/context/AuthContext.tsx | 4 + frontend/src/index.css | 1 + frontend/src/styles/themes.css | 72 ++++++++++++++++ frontend/src/styles/tokens.css | 5 +- frontend/src/types.ts | 3 + 14 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/e849b2efb79b_user_theme_preference.py create mode 100644 frontend/src/styles/themes.css diff --git a/backend/alembic/versions/e849b2efb79b_user_theme_preference.py b/backend/alembic/versions/e849b2efb79b_user_theme_preference.py new file mode 100644 index 0000000..0d7696c --- /dev/null +++ b/backend/alembic/versions/e849b2efb79b_user_theme_preference.py @@ -0,0 +1,32 @@ +"""user theme preference + +Revision ID: e849b2efb79b +Revises: 880f080648de +Create Date: 2026-08-15 21:53:44.914731 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e849b2efb79b' +down_revision: Union[str, Sequence[str], None] = '880f080648de' +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('theme', 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', 'theme') + # ### end Alembic commands ### diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 6f2c7d9..6754509 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -18,6 +18,7 @@ class User(Base): is_site_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 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)) avatar_filename: Mapped[str | None] = mapped_column(String(64)) avatar_content_type: Mapped[str | None] = mapped_column(String(50)) created_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c0c6932..bbd355e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -74,8 +74,15 @@ async def update_profile( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> User: - display_name = data.display_name.strip() if data.display_name else None - current_user.display_name = display_name or None + # Only apply fields actually present in the request body -- a call that + # only wants to change the theme must not clobber display_name back to + # None, and vice versa. + updates = data.model_dump(exclude_unset=True) + if "display_name" in updates: + display_name = updates["display_name"].strip() if updates["display_name"] else None + current_user.display_name = display_name or None + if "theme" in updates: + current_user.theme = updates["theme"] await db.commit() await db.refresh(current_user) return current_user diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index a6d3ba8..cd71bcf 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, EmailStr, Field @@ -19,12 +20,19 @@ class UserRead(BaseModel): is_bot: bool is_site_admin: bool display_name: str | None + theme: str | None avatar_filename: str | None created_at: datetime class ProfileUpdate(BaseModel): + # Both fields are independently optional-and-settable -- the router + # only applies keys actually present in the request body + # (model_dump(exclude_unset=True)), so a call that only wants to change + # the theme doesn't clobber display_name back to None, and vice versa. display_name: str | None = Field(default=None, max_length=50) + # Kept in sync with frontend/src/styles/themes.css's theme blocks. + theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None) class UserDirectoryRead(BaseModel): diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 048eb08..4882b3a 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -53,6 +53,44 @@ async def test_display_name_too_long_rejected(client, db_session): assert resp.status_code == 422 +async def test_update_theme_persists(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + + resp = await client.patch("/api/auth/me", json={"theme": "midnight"}) + assert resp.status_code == 200, resp.text + assert resp.json()["theme"] == "midnight" + + me = await client.get("/api/auth/me") + assert me.json()["theme"] == "midnight" + + +async def test_invalid_theme_rejected(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + + resp = await client.patch("/api/auth/me", json={"theme": "not-a-real-theme"}) + assert resp.status_code == 422 + + +async def test_updating_theme_does_not_clobber_display_name(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + await client.patch("/api/auth/me", json={"display_name": "Alice A."}) + + resp = await client.patch("/api/auth/me", json={"theme": "light"}) + assert resp.status_code == 200 + assert resp.json()["display_name"] == "Alice A." + assert resp.json()["theme"] == "light" + + +async def test_updating_display_name_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={"display_name": "Alice A."}) + assert resp.status_code == 200 + assert resp.json()["theme"] == "sunset" + assert resp.json()["display_name"] == "Alice A." + + 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 8203e3d..16544fc 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 } from '../types' +import type { ThemeName, User } 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 @@ -27,6 +27,16 @@ export function updateProfile(displayName: string | null): Promise { }) } +// Deliberately its own call sending only `theme` -- the backend only +// applies fields actually present in the request body, so this can't +// clobber display_name (and updateProfile above can't clobber theme). +export function updateTheme(theme: ThemeName): Promise { + return apiFetch('/api/auth/me', { + method: 'PATCH', + body: JSON.stringify({ theme }), + }) +} + export function removeAvatar(): Promise { return apiFetch('/api/auth/me/avatar', { method: 'DELETE' }) } diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index 1eb6212..998b521 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -94,6 +94,88 @@ margin: var(--sp-5) 0 var(--sp-4); } +.theme-swatch-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--sp-2); + margin-bottom: var(--sp-4); +} + +.theme-swatch { + display: flex; + align-items: center; + gap: var(--sp-2); + background: var(--ds-surface-2); + border: 1px solid var(--ds-border); + border-radius: var(--radius); + padding: 8px 10px; + cursor: pointer; + text-align: left; +} + +.theme-swatch:hover { + border-color: var(--ds-accent); +} + +.theme-swatch-selected { + border-color: var(--ds-accent); + box-shadow: 0 0 0 1px var(--ds-accent); +} + +.theme-swatch-label { + font-size: 0.82rem; + font-weight: 600; + color: var(--ds-text); +} + +/* Each swatch's preview always shows its OWN theme's colors, not whatever + theme is currently active -- literal per-theme values on purpose, so a + user can see what an option looks like before picking it. */ +.theme-swatch-preview { + width: 22px; + height: 22px; + border-radius: 50%; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(0, 0, 0, 0.15); +} + +.theme-swatch-accent { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.theme-swatch-dark .theme-swatch-preview { + background: #101030; +} +.theme-swatch-dark .theme-swatch-accent { + background: #60d8fc; +} + +.theme-swatch-light .theme-swatch-preview { + background: #ffffff; +} +.theme-swatch-light .theme-swatch-accent { + background: #0891b2; +} + +.theme-swatch-midnight .theme-swatch-preview { + background: #000000; +} +.theme-swatch-midnight .theme-swatch-accent { + background: #00f0ff; +} + +.theme-swatch-sunset .theme-swatch-preview { + background: #241408; +} +.theme-swatch-sunset .theme-swatch-accent { + background: #fca050; +} + .toggle-row { display: flex; align-items: center; diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index aa21615..9c3491f 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -1,12 +1,20 @@ import { useRef, useState, type ChangeEvent, type FormEvent } from 'react' -import { changePassword, removeAvatar, updateProfile, uploadAvatar } from '../api/auth' +import { changePassword, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth' import { ApiError } from '../api/client' import { getUserAvatarUrl } from '../api/users' import { useAuth } from '../context/AuthContext' import { hashIndex } from '../lib/avatar' +import type { ThemeName } from '../types' import { UserAvatar } from './UserAvatar' import './Modal.css' +const THEME_OPTIONS: { name: ThemeName; label: string }[] = [ + { name: 'dark', label: 'Dark' }, + { name: 'light', label: 'Light' }, + { name: 'midnight', label: 'Midnight' }, + { name: 'sunset', label: 'Sunset' }, +] + interface ProfileModalProps { onClose: () => void } @@ -18,6 +26,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) { const [savingName, setSavingName] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) const fileInputRef = useRef(null) + const [themeError, setThemeError] = useState(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') @@ -68,6 +77,21 @@ export function ProfileModal({ onClose }: ProfileModalProps) { } } + async function handleSelectTheme(theme: ThemeName) { + // Instant visual feedback, then persist -- mirrors avatar upload's + // apply-immediately pattern rather than requiring a separate Save. + document.documentElement.setAttribute('data-theme', theme) + setThemeError(null) + try { + const updated = await updateTheme(theme) + updateUser(updated) + } catch (err) { + // Revert the optimistic DOM change if it didn't actually persist. + document.documentElement.setAttribute('data-theme', user?.theme ?? 'dark') + setThemeError(err instanceof ApiError ? err.message : String(err)) + } + } + async function handleChangePassword(e: FormEvent) { e.preventDefault() setPasswordError(null) @@ -133,6 +157,31 @@ export function ProfileModal({ onClose }: ProfileModalProps) { +
+ +
Theme
+
+ {THEME_OPTIONS.map((option) => ( + + ))} +
+ {themeError &&

{themeError}

} + +
+
Display name
{ + document.documentElement.setAttribute('data-theme', user?.theme ?? 'dark') + }, [user?.theme]) + useEffect(() => { authApi .me() diff --git a/frontend/src/index.css b/frontend/src/index.css index a9e2e6d..2271ec1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,2 @@ @import './styles/tokens.css'; +@import './styles/themes.css'; diff --git a/frontend/src/styles/themes.css b/frontend/src/styles/themes.css new file mode 100644 index 0000000..1c43364 --- /dev/null +++ b/frontend/src/styles/themes.css @@ -0,0 +1,72 @@ +/* Alternate palettes, layered on top of tokens.css's base (unscoped :root) + DarkSingularity theme, which stays the default. Each block only + overrides the literal color tokens -- everything derived from them + (--accent-cycle-*, .btn-primary, role badges, etc. via color-mix()) picks + up the change automatically through the normal CSS custom-property + cascade, no need to re-declare those separately per theme. + + Applied via a `data-theme` attribute on (see AuthContext.tsx), + kept in sync with the Literal in backend/app/schemas/user.py's + ProfileUpdate.theme. */ + +:root[data-theme='light'] { + color-scheme: light; + + --ds-void: #f5f3fb; + --ds-void-2: #ece8f6; + --ds-surface: #ffffff; + --ds-surface-2: #f0edf9; + --ds-border: #d8d2ee; + --ds-text: #1a1030; + --ds-muted: #675f80; + --ds-accent: #0891b2; + --ds-accent-2: #4c3fd6; + --ds-accent-3: #6c2fd6; + --ds-highlight: #c026a3; + --ds-danger: #dc2626; + + --card-bg: linear-gradient(180deg, rgba(240, 237, 249, 0.96), rgba(255, 255, 255, 0.96)); +} + +/* Higher-contrast, OLED-friendly dark variant -- same cool cyan/violet + family as the default Dark theme, pushed further: true-black void, + brighter accents. */ +:root[data-theme='midnight'] { + color-scheme: dark; + + --ds-void: #000000; + --ds-void-2: #050508; + --ds-surface: #0a0a14; + --ds-surface-2: #12121e; + --ds-border: #262640; + --ds-text: #ffffff; + --ds-muted: #a8b0c0; + --ds-accent: #00f0ff; + --ds-accent-2: #7c6cff; + --ds-accent-3: #9060ff; + --ds-highlight: #ff5cf0; + --ds-danger: #ff4444; + + --card-bg: linear-gradient(180deg, rgba(18, 18, 30, 0.96), rgba(10, 10, 20, 0.96)); +} + +/* Warm accent family (amber/coral/rose) instead of Dark's cool cyan/violet + -- still dark-based, matching the app's overall dark-first identity. */ +:root[data-theme='sunset'] { + color-scheme: dark; + + --ds-void: #120a07; + --ds-void-2: #0e0805; + --ds-surface: #241408; + --ds-surface-2: #2e1c0c; + --ds-border: #4a2c14; + --ds-text: #fce8d8; + --ds-muted: #c8b0a0; + --ds-accent: #fca050; + --ds-accent-2: #fc6048; + --ds-accent-3: #fc4878; + --ds-highlight: #fcc048; + --ds-danger: #fc4848; + + --card-bg: linear-gradient(180deg, rgba(46, 28, 12, 0.96), rgba(36, 20, 8, 0.96)); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 01869ea..f2dc0eb 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -96,7 +96,10 @@ button { cursor: pointer; } .btn-primary:hover { - background: #8ae4fc; + /* color-mix so the hover tint follows whatever --ds-accent the active + theme defines, instead of a fixed cyan that would clash with other + themes' accent colors. */ + background: color-mix(in srgb, var(--ds-accent) 85%, white); } .btn-primary:disabled { opacity: 0.5; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5f9c9b7..434f9dc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,5 @@ +export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' + export interface User { id: string username: string @@ -5,6 +7,7 @@ export interface User { is_bot: boolean is_site_admin: boolean display_name: string | null + theme: ThemeName | null avatar_filename: string | null created_at: string }