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:
2026-08-17 06:15:49 -06:00
co-authored by Claude Sonnet 5
parent 68e487e5ec
commit 53d16973fb
11 changed files with 443 additions and 18 deletions
+86
View File
@@ -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"))