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
@@ -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 ###
+5
View File
@@ -2,6 +2,7 @@ import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -19,6 +20,10 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50)) display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20)) 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_filename: Mapped[str | None] = mapped_column(String(64))
avatar_content_type: Mapped[str | None] = mapped_column(String(50)) avatar_content_type: Mapped[str | None] = mapped_column(String(50))
# Manual override for the presence indicator -- when set, this user # Manual override for the presence indicator -- when set, this user
+2
View File
@@ -85,6 +85,8 @@ async def update_profile(
current_user.display_name = display_name or None current_user.display_name = display_name or None
if "theme" in updates: if "theme" in updates:
current_user.theme = updates["theme"] 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: if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"] current_user.appear_offline = updates["appear_offline"]
await db.commit() await db.commit()
+28 -1
View File
@@ -11,6 +11,31 @@ class UserCreate(BaseModel):
password: str = Field(min_length=8, max_length=200) 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): class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -21,6 +46,7 @@ class UserRead(BaseModel):
is_site_admin: bool is_site_admin: bool
display_name: str | None display_name: str | None
theme: str | None theme: str | None
custom_theme_colors: CustomThemeColors | None
avatar_filename: str | None avatar_filename: str | None
appear_offline: bool appear_offline: bool
created_at: datetime created_at: datetime
@@ -34,7 +60,8 @@ class ProfileUpdate(BaseModel):
# their defaults, and vice versa. # their defaults, and vice versa.
display_name: str | None = Field(default=None, max_length=50) display_name: str | None = Field(default=None, max_length=50)
# Kept in sync with frontend/src/styles/themes.css's theme blocks. # 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) appear_offline: bool | None = Field(default=None)
+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." 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): async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice")) await register_and_login(client, db_session, username=_unique("alice"))
+7 -6
View File
@@ -1,5 +1,5 @@
import { apiFetch, ApiError, NetworkError } from './client' 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 // 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 // an operator via the backend CLI (`python -m app.cli create-user`), not
@@ -27,13 +27,14 @@ export function updateProfile(displayName: string | null): Promise<User> {
}) })
} }
// Deliberately its own call sending only `theme` -- the backend only // Deliberately its own call sending only `theme` (and, for 'custom',
// applies fields actually present in the request body, so this can't // `custom_theme_colors` alongside it) -- the backend only applies fields
// clobber display_name (and updateProfile above can't clobber theme). // actually present in the request body, so this can't clobber display_name
export function updateTheme(theme: ThemeName): Promise<User> { // (and updateProfile above can't clobber theme).
export function updateTheme(theme: ThemeName, customThemeColors?: CustomThemeColors): Promise<User> {
return apiFetch<User>('/api/auth/me', { return apiFetch<User>('/api/auth/me', {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ theme }), body: JSON.stringify({ theme, custom_theme_colors: customThemeColors }),
}) })
} }
+54
View File
@@ -176,6 +176,60 @@
background: #fca050; 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 { .toggle-row {
display: flex; display: flex;
align-items: center; align-items: center;
+127 -8
View File
@@ -4,7 +4,8 @@ import { ApiError } from '../api/client'
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' 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 { UserAvatar } from './UserAvatar'
import './Modal.css' import './Modal.css'
@@ -15,6 +16,21 @@ const THEME_OPTIONS: { name: ThemeName; label: string }[] = [
{ name: 'sunset', label: 'Sunset' }, { name: 'sunset', label: 'Sunset' },
] ]
const CUSTOM_COLOR_FIELDS: { key: keyof Omit<CustomThemeColors, 'color_scheme'>; 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 { interface ProfileModalProps {
onClose: () => void onClose: () => void
} }
@@ -27,6 +43,11 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(null) const [themeError, setThemeError] = useState<string | null>(null)
const [customColors, setCustomColors] = useState<CustomThemeColors>(
user?.custom_theme_colors ?? DEFAULT_CUSTOM_COLORS,
)
const [customColorsDirty, setCustomColorsDirty] = useState(false)
const [savingColors, setSavingColors] = useState(false)
const [currentPassword, setCurrentPassword] = useState('') const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -44,7 +65,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
try { try {
const updated = await updateProfile(displayName.trim() || null) const updated = await updateProfile(displayName.trim() || null)
updateUser(updated) updateUser(updated)
onClose() handleClose()
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : String(err)) setError(err instanceof ApiError ? err.message : String(err))
setSavingName(false) setSavingName(false)
@@ -80,18 +101,55 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
async function handleSelectTheme(theme: ThemeName) { async function handleSelectTheme(theme: ThemeName) {
// Instant visual feedback, then persist -- mirrors avatar upload's // Instant visual feedback, then persist -- mirrors avatar upload's
// apply-immediately pattern rather than requiring a separate Save. // 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) setThemeError(null)
try { try {
const updated = await updateTheme(theme) const updated = await updateTheme(theme, theme === 'custom' ? customColors : undefined)
updateUser(updated) updateUser(updated)
setCustomColorsDirty(false)
} catch (err) { } catch (err) {
// Revert the optimistic DOM change if it didn't actually persist. // 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)) 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) { async function handleChangePassword(e: FormEvent) {
e.preventDefault() e.preventDefault()
setPasswordError(null) setPasswordError(null)
@@ -117,11 +175,11 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
return ( return (
<div className="modal-scrim" onClick={onClose}> <div className="modal-scrim" onClick={handleClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>Profile settings</h2> <h2>Profile settings</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close"> <button type="button" className="modal-close" onClick={handleClose} aria-label="Close">
&times; &times;
</button> </button>
</div> </div>
@@ -178,9 +236,70 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
<span className="theme-swatch-label">{option.label}</span> <span className="theme-swatch-label">{option.label}</span>
</button> </button>
))} ))}
<button
type="button"
className={`theme-swatch${user.theme === 'custom' ? ' theme-swatch-selected' : ''}`}
onClick={() => handleSelectTheme('custom')}
aria-pressed={user.theme === 'custom'}
>
<span
className="theme-swatch-preview"
aria-hidden="true"
style={{ background: customColors.void }}
>
<span className="theme-swatch-accent" style={{ background: customColors.accent }} />
</span>
<span className="theme-swatch-label">Custom</span>
</button>
</div> </div>
{themeError && <p className="modal-error">{themeError}</p>} {themeError && <p className="modal-error">{themeError}</p>}
{user.theme === 'custom' && (
<div className="custom-theme-editor">
<div className="custom-theme-grid">
{CUSTOM_COLOR_FIELDS.map((field) => (
<label key={field.key} className="custom-theme-field">
<input
type="color"
value={customColors[field.key]}
onChange={(e) => handleCustomColorChange(field.key, e.target.value)}
/>
<span>{field.label}</span>
</label>
))}
</div>
<div className="custom-theme-scheme">
<span>Native controls (scrollbars, form inputs)</span>
<div className="custom-theme-scheme-toggle">
<button
type="button"
className={`btn-secondary${customColors.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleCustomColorChange('color_scheme', 'light')}
>
Light
</button>
<button
type="button"
className={`btn-secondary${customColors.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleCustomColorChange('color_scheme', 'dark')}
>
Dark
</button>
</div>
</div>
<div className="modal-actions">
<button
type="button"
className="btn-primary"
onClick={handleSaveColors}
disabled={savingColors || !customColorsDirty}
>
{savingColors ? 'Saving…' : 'Save colors'}
</button>
</div>
</div>
)}
<hr className="modal-divider" /> <hr className="modal-divider" />
<form onSubmit={handleSaveName}> <form onSubmit={handleSaveName}>
@@ -194,7 +313,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
/> />
{error && <p className="modal-error">{error}</p>} {error && <p className="modal-error">{error}</p>}
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}> <button type="button" className="btn-secondary" onClick={handleClose}>
Close Close
</button> </button>
<button type="submit" className="btn-primary" disabled={savingName}> <button type="submit" className="btn-primary" disabled={savingName}>
+3 -2
View File
@@ -3,6 +3,7 @@ import * as authApi from '../api/auth'
import { ApiError, NetworkError } from '../api/client' import { ApiError, NetworkError } from '../api/client'
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser' import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
import { unsubscribeFromPush } from '../lib/push' import { unsubscribeFromPush } from '../lib/push'
import { applyTheme } from '../lib/theme'
import type { User } from '../types' import type { User } from '../types'
interface AuthContextValue { interface AuthContextValue {
@@ -22,8 +23,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', user?.theme ?? 'dark') applyTheme(user?.theme ?? null, user?.custom_theme_colors ?? null)
}, [user?.theme]) }, [user?.theme, user?.custom_theme_colors])
useEffect(() => { useEffect(() => {
authApi authApi
+78
View File
@@ -0,0 +1,78 @@
import type { CustomThemeColors, 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
// always wins over :root[data-theme='...']'s stylesheet rule regardless of
// cascade order, so a stale one would otherwise silently override every
// preset picked afterward.
const CUSTOM_THEME_VARS = [
'--ds-void',
'--ds-void-2',
'--ds-surface',
'--ds-surface-2',
'--ds-border',
'--ds-text',
'--ds-muted',
'--ds-accent',
'--ds-accent-2',
'--ds-accent-3',
'--ds-highlight',
'--ds-danger',
'--card-bg',
]
// Starting point for a user who's never saved a custom palette before --
// exactly tokens.css's default (Dark) values, so "Custom" begins as a copy
// of what they were already looking at rather than something jarring.
export const DEFAULT_CUSTOM_COLORS: CustomThemeColors = {
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',
}
// Applied on load/user-change (AuthContext) and live while editing
// (ProfileModal) -- the single place that knows how to turn either a preset
// name or a custom palette into what's actually on screen.
export function applyTheme(theme: ThemeName | null, customColors: CustomThemeColors | null): void {
const root = document.documentElement
if (theme === 'custom' && customColors) {
root.setAttribute('data-theme', 'custom')
root.style.setProperty('--ds-void', customColors.void)
root.style.setProperty('--ds-void-2', customColors.void_2)
root.style.setProperty('--ds-surface', customColors.surface)
root.style.setProperty('--ds-surface-2', customColors.surface_2)
root.style.setProperty('--ds-border', customColors.border)
root.style.setProperty('--ds-text', customColors.text)
root.style.setProperty('--ds-muted', customColors.muted)
root.style.setProperty('--ds-accent', customColors.accent)
root.style.setProperty('--ds-accent-2', customColors.accent_2)
root.style.setProperty('--ds-accent-3', customColors.accent_3)
root.style.setProperty('--ds-highlight', customColors.highlight)
root.style.setProperty('--ds-danger', customColors.danger)
// Same two-stop-gradient formula the built-in presets use (see
// themes.css), just built from the picked surface colors instead of a
// literal rgba() -- 8-digit hex alpha is well-supported in every
// evergreen browser and avoids a separate hex-to-rgb conversion.
root.style.setProperty(
'--card-bg',
`linear-gradient(180deg, ${customColors.surface_2}f6, ${customColors.surface}f6)`,
)
root.style.setProperty('color-scheme', customColors.color_scheme)
return
}
root.setAttribute('data-theme', theme ?? 'dark')
for (const varName of CUSTOM_THEME_VARS) root.style.removeProperty(varName)
root.style.removeProperty('color-scheme')
}
+21 -1
View File
@@ -1,4 +1,23 @@
export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' | 'custom'
// Matches exactly the CSS custom properties frontend/src/styles/themes.css
// overrides per built-in preset -- kept in sync with
// backend/app/schemas/user.py's CustomThemeColors.
export interface CustomThemeColors {
void: string
void_2: string
surface: string
surface_2: string
border: string
text: string
muted: string
accent: string
accent_2: string
accent_3: string
highlight: string
danger: string
color_scheme: 'light' | 'dark'
}
export interface User { export interface User {
id: string id: string
@@ -8,6 +27,7 @@ export interface User {
is_site_admin: boolean is_site_admin: boolean
display_name: string | null display_name: string | null
theme: ThemeName | null theme: ThemeName | null
custom_theme_colors: CustomThemeColors | null
avatar_filename: string | null avatar_filename: string | null
appear_offline: boolean appear_offline: boolean
created_at: string created_at: string