Private
Public Access
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 <input type="color"> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <input type="color"> 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)
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user