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 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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
×
|
×
|
||||||
</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,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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user