Show a live mockup mapping each custom theme field to the real UI

The 12 color pickers (Background, Sidebar background, Surface, Border,
etc.) gave no indication of what each one actually affects without
trial and error. Adds a miniature, self-contained mockup of the real
chat UI above the picker grid -- a sidebar with room rows, a message
with an avatar/mention/role badge, a composer, a danger button -- styled
from inline styles bound to the draft colors (not the --ds-* custom
properties, since those reflect whatever theme is currently active, not
necessarily the one being edited).

Hovering or focusing either a color input or its matching element in
the mockup highlights both, using a fixed amber outline that stays
visible regardless of the theme's own palette -- makes the mapping
between the 12 fields and where they actually show up immediately
obvious in either direction, without needing to activate the theme and
go look around the rest of the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:13:53 -06:00
co-authored by Claude Sonnet 5
parent a0e1565097
commit dddda19238
4 changed files with 242 additions and 1 deletions
@@ -0,0 +1,108 @@
.custom-theme-preview {
display: flex;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--ds-border);
margin-bottom: var(--sp-3);
font-size: 0.74rem;
/* Isolated from the outer picker grid's own hover/highlight rules --
each swatch's highlight ring is drawn on itself, not inherited. */
position: relative;
}
.ctp-hoverable {
cursor: default;
transition: box-shadow 0.1s ease;
}
.ctp-highlighted {
box-shadow: 0 0 0 2px #fbbf24, 0 0 8px 1px rgba(251, 191, 36, 0.6);
position: relative;
z-index: 1;
}
.ctp-sidebar {
width: 96px;
flex: none;
padding: 8px 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.ctp-room-row {
padding: 5px 7px;
border-radius: 5px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ctp-room-row-quiet {
border: 1px solid transparent;
background: transparent;
}
.ctp-main {
flex: 1;
min-width: 0;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.ctp-message {
display: flex;
align-items: flex-start;
gap: 6px;
}
.ctp-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
flex: none;
}
.ctp-message-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ctp-mention {
border-radius: 4px;
padding: 0 3px;
font-weight: 700;
}
.ctp-badge {
flex: none;
align-self: flex-start;
border-radius: var(--radius-pill);
padding: 1px 7px;
font-size: 0.68rem;
font-weight: 800;
color: #07080f;
}
.ctp-composer {
border: 1px solid transparent;
border-radius: 6px;
padding: 5px 8px;
}
.ctp-danger-btn {
align-self: flex-start;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
padding: 4px 8px;
font-size: 0.72rem;
font-weight: 700;
cursor: pointer;
}
@@ -0,0 +1,103 @@
import type { CustomThemeColors } from '../types'
import './CustomThemePreview.css'
interface CustomThemePreviewProps {
colors: CustomThemeColors
highlightedField: keyof CustomThemeColors | null
onHighlight: (field: keyof CustomThemeColors | null) => void
}
// A miniature, self-contained mockup of the real chat UI, styled entirely
// from inline styles bound to the draft colors -- deliberately not the
// --ds-* custom properties, since those reflect whatever theme is actually
// active right now, not necessarily the one being edited (see
// ProfileModal.tsx's handleEditColorChange comment). Hovering either a
// swatch here or the matching color input elsewhere highlights both, so
// it's clear at a glance which of the 12 fields controls which part of the
// real UI, in both directions.
export function CustomThemePreview({ colors, highlightedField, onHighlight }: CustomThemePreviewProps) {
function hoverClass(field: keyof CustomThemeColors, extra = ''): string {
const highlighted = highlightedField === field ? ' ctp-highlighted' : ''
return `ctp-hoverable${extra ? ` ${extra}` : ''}${highlighted}`
}
function hoverHandlers(field: keyof CustomThemeColors) {
return {
onMouseEnter: () => onHighlight(field),
onMouseLeave: () => onHighlight(null),
}
}
return (
<div
className={hoverClass('void', 'custom-theme-preview')}
style={{ background: colors.void }}
{...hoverHandlers('void')}
>
<div className={hoverClass('void_2', 'ctp-sidebar')} style={{ background: colors.void_2 }} {...hoverHandlers('void_2')}>
<div className={hoverClass('surface', 'ctp-room-row')} style={{ background: colors.surface }} {...hoverHandlers('surface')}>
<span style={{ color: colors.text }}># general</span>
</div>
<div
className={hoverClass('border', 'ctp-room-row ctp-room-row-quiet')}
style={{ borderColor: colors.border }}
{...hoverHandlers('border')}
>
<span style={{ color: colors.muted }}># random</span>
</div>
</div>
<div className="ctp-main">
<div className="ctp-message">
<div
className={hoverClass('accent', 'ctp-avatar')}
style={{ background: colors.accent }}
{...hoverHandlers('accent')}
/>
<div className="ctp-message-body">
<span>
<span className={hoverClass('accent_2')} style={{ color: colors.accent_2 }} {...hoverHandlers('accent_2')}>
Alice
</span>{' '}
<span className={hoverClass('muted')} style={{ color: colors.muted }} {...hoverHandlers('muted')}>
2:30 PM
</span>
</span>
<div className={hoverClass('text')} style={{ color: colors.text }} {...hoverHandlers('text')}>
Hey{' '}
<span
className={hoverClass('highlight', 'ctp-mention')}
style={{ background: `${colors.highlight}30`, color: colors.highlight }}
{...hoverHandlers('highlight')}
>
@bob
</span>{' '}
check this out
</div>
</div>
<span
className={hoverClass('accent_3', 'ctp-badge')}
style={{ background: colors.accent_3 }}
{...hoverHandlers('accent_3')}
>
Owner
</span>
</div>
<div
className={hoverClass('surface_2', 'ctp-composer')}
style={{ background: colors.surface_2, borderColor: colors.border }}
{...hoverHandlers('surface_2')}
>
<span style={{ color: colors.muted }}>Message</span>
</div>
<button
type="button"
className={hoverClass('danger', 'ctp-danger-btn')}
style={{ color: colors.danger, borderColor: colors.danger }}
{...hoverHandlers('danger')}
>
Leave room
</button>
</div>
</div>
)
}
+14
View File
@@ -263,6 +263,20 @@
font-size: 0.8rem; font-size: 0.8rem;
color: var(--ds-text); color: var(--ds-text);
cursor: pointer; cursor: pointer;
border-radius: 6px;
padding: 3px 4px;
margin: -3px -4px;
transition: background 0.1s ease;
}
/* Mirrors CustomThemePreview.css's .ctp-highlighted -- same fixed, theme-
independent color so it stays visible regardless of what's actually
being edited. Set when either this field's input OR the matching swatch
in the live mockup above is hovered/focused, so the mapping works in
both directions. */
.custom-theme-field-highlighted {
background: rgba(251, 191, 36, 0.15);
box-shadow: 0 0 0 1px #fbbf24;
} }
.custom-theme-field input[type='color'] { .custom-theme-field input[type='color'] {
+17 -1
View File
@@ -13,6 +13,7 @@ import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme' import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors } from '../types' import type { CustomTheme, CustomThemeColors } from '../types'
import { CustomThemePreview } from './CustomThemePreview'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import './Modal.css' import './Modal.css'
@@ -56,6 +57,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [editNameDraft, setEditNameDraft] = useState('') const [editNameDraft, setEditNameDraft] = useState('')
const [editColorsDraft, setEditColorsDraft] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS) const [editColorsDraft, setEditColorsDraft] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS)
const [savingThemeEdit, setSavingThemeEdit] = useState(false) const [savingThemeEdit, setSavingThemeEdit] = useState(false)
const [highlightedField, setHighlightedField] = useState<keyof CustomThemeColors | null>(null)
const [currentPassword, setCurrentPassword] = useState('') const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -158,6 +160,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
setEditNameDraft(theme.name) setEditNameDraft(theme.name)
setEditColorsDraft(theme.colors) setEditColorsDraft(theme.colors)
setThemeError(null) setThemeError(null)
setHighlightedField(null)
} }
function closeEditor() { function closeEditor() {
@@ -168,6 +171,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
applyTheme(user.theme, user.active_custom_theme.colors) applyTheme(user.theme, user.active_custom_theme.colors)
} }
setEditingThemeId(null) setEditingThemeId(null)
setHighlightedField(null)
} }
function handleEditColorChange(key: keyof CustomThemeColors, value: string) { function handleEditColorChange(key: keyof CustomThemeColors, value: string) {
@@ -393,13 +397,25 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
placeholder="Theme name" placeholder="Theme name"
maxLength={50} maxLength={50}
/> />
<CustomThemePreview
colors={editColorsDraft}
highlightedField={highlightedField}
onHighlight={setHighlightedField}
/>
<div className="custom-theme-grid"> <div className="custom-theme-grid">
{CUSTOM_COLOR_FIELDS.map((field) => ( {CUSTOM_COLOR_FIELDS.map((field) => (
<label key={field.key} className="custom-theme-field"> <label
key={field.key}
className={`custom-theme-field${highlightedField === field.key ? ' custom-theme-field-highlighted' : ''}`}
onMouseEnter={() => setHighlightedField(field.key)}
onMouseLeave={() => setHighlightedField(null)}
>
<input <input
type="color" type="color"
value={editColorsDraft[field.key]} value={editColorsDraft[field.key]}
onChange={(e) => handleEditColorChange(field.key, e.target.value)} onChange={(e) => handleEditColorChange(field.key, e.target.value)}
onFocus={() => setHighlightedField(field.key)}
onBlur={() => setHighlightedField(null)}
/> />
<span>{field.label}</span> <span>{field.label}</span>
</label> </label>