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