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
+7 -6
View File
@@ -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<User> {
})
}
// 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<User> {
// 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<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ theme }),
body: JSON.stringify({ theme, custom_theme_colors: customThemeColors }),
})
}
+54
View File
@@ -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;
+127 -8
View File
@@ -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<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 {
onClose: () => void
}
@@ -27,6 +43,11 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(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 [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 (
<div className="modal-scrim" onClick={onClose}>
<div className="modal-scrim" onClick={handleClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<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;
</button>
</div>
@@ -178,9 +236,70 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
<span className="theme-swatch-label">{option.label}</span>
</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>
{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" />
<form onSubmit={handleSaveName}>
@@ -194,7 +313,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
/>
{error && <p className="modal-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
<button type="button" className="btn-secondary" onClick={handleClose}>
Close
</button>
<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 { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
import { unsubscribeFromPush } from '../lib/push'
import { applyTheme } from '../lib/theme'
import type { User } from '../types'
interface AuthContextValue {
@@ -22,8 +23,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [offline, setOffline] = useState(false)
useEffect(() => {
document.documentElement.setAttribute('data-theme', user?.theme ?? 'dark')
}, [user?.theme])
applyTheme(user?.theme ?? null, user?.custom_theme_colors ?? null)
}, [user?.theme, user?.custom_theme_colors])
useEffect(() => {
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 {
id: string
@@ -8,6 +27,7 @@ export interface User {
is_site_admin: boolean
display_name: string | null
theme: ThemeName | null
custom_theme_colors: CustomThemeColors | null
avatar_filename: string | null
appear_offline: boolean
created_at: string