From 53d16973fb43019caca433ba5fcf3c3d0ad31ed5 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Mon, 17 Aug 2026 06:15:49 -0600 Subject: [PATCH] Add custom theme colors, full per-token control (#30) Adds a 5th "Custom" option to the theme swatch grid alongside the existing 4 presets, opening a picker for all ~12 CSS custom properties (backgrounds, borders, text, three accent tiers, highlight, danger) plus a light/dark toggle for native control rendering. Persisted as a new users.custom_theme_colors JSONB column, validated server-side against exactly what a native can ever produce. Colors survive switching to a preset and back, since there's no reason picking a preset for a moment should force redoing every color pick. Applied at runtime as inline custom properties on :root (presets stay static CSS) via a shared applyTheme() helper, which is also responsible for clearing those inline overrides when switching away -- otherwise they'd silently keep winning over whatever preset's own stylesheet values should apply next. Live preview on every color change; persists only on explicit "Save colors" (not per keystroke, since a color input fires continuously while dragging), and closing the modal without saving reverts the preview back to whatever's actually persisted. Co-Authored-By: Claude Sonnet 5 --- ...4_custom_theme_colors_for_user_profiles.py | 32 +++++ backend/app/models/user.py | 5 + backend/app/routers/auth.py | 2 + backend/app/schemas/user.py | 29 +++- backend/tests/test_profile.py | 86 +++++++++++ frontend/src/api/auth.ts | 13 +- frontend/src/components/Modal.css | 54 +++++++ frontend/src/components/ProfileModal.tsx | 135 ++++++++++++++++-- frontend/src/context/AuthContext.tsx | 5 +- frontend/src/lib/theme.ts | 78 ++++++++++ frontend/src/types.ts | 22 ++- 11 files changed, 443 insertions(+), 18 deletions(-) create mode 100644 backend/alembic/versions/3c04cf48f4b4_custom_theme_colors_for_user_profiles.py create mode 100644 frontend/src/lib/theme.ts diff --git a/backend/alembic/versions/3c04cf48f4b4_custom_theme_colors_for_user_profiles.py b/backend/alembic/versions/3c04cf48f4b4_custom_theme_colors_for_user_profiles.py new file mode 100644 index 0000000..9dccc31 --- /dev/null +++ b/backend/alembic/versions/3c04cf48f4b4_custom_theme_colors_for_user_profiles.py @@ -0,0 +1,32 @@ +"""custom theme colors for user profiles + +Revision ID: 3c04cf48f4b4 +Revises: f9eff917e5b3 +Create Date: 2026-08-16 20:59:51.468679 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '3c04cf48f4b4' +down_revision: Union[str, Sequence[str], None] = 'f9eff917e5b3' +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('custom_theme_colors', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'custom_theme_colors') + # ### end Alembic commands ### diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0ac10a0..fc16fd8 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -2,6 +2,7 @@ import uuid from datetime import datetime from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base @@ -19,6 +20,10 @@ 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)) + # Only meaningful when theme == "custom" -- kept even if the user + # switches to a preset and back, so switching away from custom is never + # destructive. Shape is CustomThemeColors (backend/app/schemas/user.py). + custom_theme_colors: Mapped[dict | None] = mapped_column(JSONB) avatar_filename: Mapped[str | None] = mapped_column(String(64)) avatar_content_type: Mapped[str | None] = mapped_column(String(50)) # Manual override for the presence indicator -- when set, this user diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9d3f470..76af7a8 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -85,6 +85,8 @@ async def update_profile( current_user.display_name = display_name or None if "theme" in updates: current_user.theme = updates["theme"] + if "custom_theme_colors" in updates: + current_user.custom_theme_colors = updates["custom_theme_colors"] 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 27a4501..f9d6809 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -11,6 +11,31 @@ class UserCreate(BaseModel): password: str = Field(min_length=8, max_length=200) +# Matches exactly the CSS custom properties frontend/src/styles/themes.css +# overrides per built-in preset -- a "custom" theme is applied the same way, +# just as inline styles on :root instead of a static stylesheet block (see +# frontend/src/lib/theme.ts). Hex-only (`#rrggbb`) since that's exactly what +# a native always produces -- no alpha, no shorthand -- +# so the pattern constraint can't reject anything the picker UI itself sends. +_HEX_COLOR = Field(pattern=r"^#[0-9a-fA-F]{6}$") + + +class CustomThemeColors(BaseModel): + void: str = _HEX_COLOR + void_2: str = _HEX_COLOR + surface: str = _HEX_COLOR + surface_2: str = _HEX_COLOR + border: str = _HEX_COLOR + text: str = _HEX_COLOR + muted: str = _HEX_COLOR + accent: str = _HEX_COLOR + accent_2: str = _HEX_COLOR + accent_3: str = _HEX_COLOR + highlight: str = _HEX_COLOR + danger: str = _HEX_COLOR + color_scheme: Literal["light", "dark"] + + class UserRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -21,6 +46,7 @@ class UserRead(BaseModel): is_site_admin: bool display_name: str | None theme: str | None + custom_theme_colors: CustomThemeColors | None avatar_filename: str | None appear_offline: bool created_at: datetime @@ -34,7 +60,8 @@ class ProfileUpdate(BaseModel): # their defaults, 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) + theme: Literal["dark", "light", "midnight", "sunset", "custom"] | None = Field(default=None) + custom_theme_colors: CustomThemeColors | 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 4882b3a..840c20c 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -91,6 +91,92 @@ async def test_updating_display_name_does_not_clobber_theme(client, db_session): assert resp.json()["display_name"] == "Alice A." +def _sample_custom_colors(**overrides) -> dict: + colors = { + "void": "#07080f", + "void_2": "#0b0c1a", + "surface": "#101030", + "surface_2": "#181848", + "border": "#242478", + "text": "#fce4fc", + "muted": "#c0ccd8", + "accent": "#60d8fc", + "accent_2": "#6c60fc", + "accent_3": "#7848fc", + "highlight": "#f060fc", + "danger": "#fc6060", + "color_scheme": "dark", + } + colors.update(overrides) + return colors + + +async def test_custom_theme_colors_persist(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + + resp = await client.patch( + "/api/auth/me", json={"theme": "custom", "custom_theme_colors": _sample_custom_colors()} + ) + assert resp.status_code == 200, resp.text + assert resp.json()["theme"] == "custom" + assert resp.json()["custom_theme_colors"] == _sample_custom_colors() + + me = await client.get("/api/auth/me") + assert me.json()["custom_theme_colors"] == _sample_custom_colors() + + +async def test_custom_theme_colors_rejects_bad_hex(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + + resp = await client.patch( + "/api/auth/me", + json={ + "theme": "custom", + "custom_theme_colors": _sample_custom_colors(accent="not-a-color"), + }, + ) + assert resp.status_code == 422 + + +async def test_custom_theme_colors_rejects_missing_field(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + + colors = _sample_custom_colors() + del colors["danger"] + resp = await client.patch( + "/api/auth/me", json={"theme": "custom", "custom_theme_colors": colors} + ) + assert resp.status_code == 422 + + +async def test_custom_theme_colors_rejects_invalid_color_scheme(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + + resp = await client.patch( + "/api/auth/me", + json={ + "theme": "custom", + "custom_theme_colors": _sample_custom_colors(color_scheme="sepia"), + }, + ) + assert resp.status_code == 422 + + +async def test_switching_away_from_custom_preserves_saved_colors(client, db_session): + # Switching to a preset and back must not lose previously-saved custom + # colors -- there's no reason picking "Dark" for a moment should force + # you to redo all 12 color picks if you switch back to Custom later. + await register_and_login(client, db_session, username=_unique("alice")) + await client.patch( + "/api/auth/me", json={"theme": "custom", "custom_theme_colors": _sample_custom_colors()} + ) + + resp = await client.patch("/api/auth/me", json={"theme": "dark"}) + assert resp.status_code == 200 + assert resp.json()["theme"] == "dark" + assert resp.json()["custom_theme_colors"] == _sample_custom_colors() + + 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 3b5f523..ee0f0af 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 { ThemeName, User } from '../types' +import type { CustomThemeColors, 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,13 +27,14 @@ 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 { +// Deliberately its own call sending only `theme` (and, for 'custom', +// `custom_theme_colors` alongside it) -- 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, customThemeColors?: CustomThemeColors): Promise { return apiFetch('/api/auth/me', { method: 'PATCH', - body: JSON.stringify({ theme }), + body: JSON.stringify({ theme, custom_theme_colors: customThemeColors }), }) } diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index 998b521..a97260f 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -176,6 +176,60 @@ background: #fca050; } +.custom-theme-editor { + background: var(--ds-surface-2); + border: 1px solid var(--ds-border); + border-radius: var(--radius); + padding: var(--sp-3); + margin-bottom: var(--sp-4); +} + +.custom-theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--sp-3); +} + +.custom-theme-field { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: 0.8rem; + color: var(--ds-text); + cursor: pointer; +} + +.custom-theme-field input[type='color'] { + flex: none; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--ds-border); + border-radius: 6px; + background: none; + cursor: pointer; +} + +.custom-theme-scheme { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-top: var(--sp-4); + font-size: 0.8rem; + color: var(--ds-muted); +} + +.custom-theme-scheme-toggle { + display: flex; + gap: var(--sp-2); +} + +.custom-theme-scheme-active { + border-color: var(--ds-accent); + color: var(--ds-accent); +} + .toggle-row { display: flex; align-items: center; diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index 1b639c0..69676ea 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -4,7 +4,8 @@ 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 { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme' +import type { CustomThemeColors, ThemeName } from '../types' import { UserAvatar } from './UserAvatar' import './Modal.css' @@ -15,6 +16,21 @@ const THEME_OPTIONS: { name: ThemeName; label: string }[] = [ { name: 'sunset', label: 'Sunset' }, ] +const CUSTOM_COLOR_FIELDS: { key: keyof Omit; label: string }[] = [ + { key: 'void', label: 'Background' }, + { key: 'void_2', label: 'Sidebar background' }, + { key: 'surface', label: 'Surface' }, + { key: 'surface_2', label: 'Surface (secondary)' }, + { key: 'border', label: 'Border' }, + { key: 'text', label: 'Text' }, + { key: 'muted', label: 'Muted text' }, + { key: 'accent', label: 'Accent' }, + { key: 'accent_2', label: 'Accent (secondary)' }, + { key: 'accent_3', label: 'Accent (tertiary)' }, + { key: 'highlight', label: 'Highlight' }, + { key: 'danger', label: 'Danger' }, +] + interface ProfileModalProps { onClose: () => void } @@ -27,6 +43,11 @@ export function ProfileModal({ onClose }: ProfileModalProps) { const [uploadingAvatar, setUploadingAvatar] = useState(false) const fileInputRef = useRef(null) const [themeError, setThemeError] = useState(null) + const [customColors, setCustomColors] = useState( + user?.custom_theme_colors ?? DEFAULT_CUSTOM_COLORS, + ) + const [customColorsDirty, setCustomColorsDirty] = useState(false) + const [savingColors, setSavingColors] = useState(false) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') @@ -44,7 +65,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) { try { const updated = await updateProfile(displayName.trim() || null) updateUser(updated) - onClose() + handleClose() } catch (err) { setError(err instanceof ApiError ? err.message : String(err)) setSavingName(false) @@ -80,18 +101,55 @@ 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) + // For 'custom', this always sends the current draft palette alongside + // the theme name (previously-saved colors if any, else the defaults), + // so selecting Custom never leaves theme='custom' persisted with no + // palette behind it. + applyTheme(theme, theme === 'custom' ? customColors : null) setThemeError(null) try { - const updated = await updateTheme(theme) + const updated = await updateTheme(theme, theme === 'custom' ? customColors : undefined) updateUser(updated) + setCustomColorsDirty(false) } catch (err) { // Revert the optimistic DOM change if it didn't actually persist. - document.documentElement.setAttribute('data-theme', user?.theme ?? 'dark') + applyTheme(user?.theme ?? 'dark', user?.custom_theme_colors ?? null) setThemeError(err instanceof ApiError ? err.message : String(err)) } } + function handleCustomColorChange(key: keyof CustomThemeColors, value: string) { + const next = { ...customColors, [key]: value } + setCustomColors(next) + setCustomColorsDirty(true) + // Live preview only -- deliberately not persisted per keystroke (a + // native color input fires continuously while dragging), see + // handleSaveColors for the actual persist step. + applyTheme('custom', next) + } + + async function handleSaveColors() { + setSavingColors(true) + setThemeError(null) + try { + const updated = await updateTheme('custom', customColors) + updateUser(updated) + setCustomColorsDirty(false) + } catch (err) { + setThemeError(err instanceof ApiError ? err.message : String(err)) + } finally { + setSavingColors(false) + } + } + + function handleClose() { + // Unsaved color edits were only ever a live preview -- revert to + // whatever's actually persisted so closing without saving doesn't leave + // the app visually stuck on a draft. + if (customColorsDirty) applyTheme(user?.theme ?? 'dark', user?.custom_theme_colors ?? null) + onClose() + } + async function handleChangePassword(e: FormEvent) { e.preventDefault() setPasswordError(null) @@ -117,11 +175,11 @@ export function ProfileModal({ onClose }: ProfileModalProps) { const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null return ( -
+
e.stopPropagation()}>

Profile settings

-
@@ -178,9 +236,70 @@ export function ProfileModal({ onClose }: ProfileModalProps) { {option.label} ))} +
{themeError &&

{themeError}

} + {user.theme === 'custom' && ( +
+
+ {CUSTOM_COLOR_FIELDS.map((field) => ( + + ))} +
+
+ Native controls (scrollbars, form inputs) +
+ + +
+
+
+ +
+
+ )} +
@@ -194,7 +313,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) { /> {error &&

{error}

}
-