Open the custom theme builder in a wider dedicated dialog (#46)

The theme editor used to expand inline inside ProfileModal, whose .modal
is capped at min(380px, 100%) -- too narrow to comfortably see the live
CustomThemePreview mockup it's built around. Pulled the editor out into
a new ThemeBuilderModal (min(820px, 95vw), two-column layout above 680px)
opened on top of the profile modal, same stacked-dialog pattern already
used by ImageLightbox/FilePreviewModal. No changes to the theme data
model, activation, save, or delete behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 13:06:28 -06:00
co-authored by Claude Sonnet 5
parent 58fa38610f
commit 4cfe230c82
4 changed files with 155 additions and 75 deletions
-8
View File
@@ -242,14 +242,6 @@
margin-bottom: var(--sp-3) !important;
}
.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));
+10 -63
View File
@@ -13,7 +13,7 @@ import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors } from '../types'
import { CustomThemePreview } from './CustomThemePreview'
import { ThemeBuilderModal } from './ThemeBuilderModal'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
@@ -388,72 +388,19 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</div>
{editingTheme && (
<div className="custom-theme-editor">
<input
type="text"
className="custom-theme-name-input"
value={editNameDraft}
onChange={(e) => setEditNameDraft(e.target.value)}
placeholder="Theme name"
maxLength={50}
/>
<CustomThemePreview
<ThemeBuilderModal
colorFields={CUSTOM_COLOR_FIELDS}
name={editNameDraft}
onNameChange={setEditNameDraft}
colors={editColorsDraft}
onColorChange={handleEditColorChange}
highlightedField={highlightedField}
onHighlight={setHighlightedField}
error={themeError}
saving={savingThemeEdit}
onSave={handleSaveThemeEdit}
onCancel={closeEditor}
/>
<div className="custom-theme-grid">
{CUSTOM_COLOR_FIELDS.map((field) => (
<label
key={field.key}
className={`custom-theme-field${highlightedField === field.key ? ' custom-theme-field-highlighted' : ''}`}
onMouseEnter={() => setHighlightedField(field.key)}
onMouseLeave={() => setHighlightedField(null)}
>
<input
type="color"
value={editColorsDraft[field.key]}
onChange={(e) => handleEditColorChange(field.key, e.target.value)}
onFocus={() => setHighlightedField(field.key)}
onBlur={() => setHighlightedField(null)}
/>
<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${editColorsDraft.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleEditColorChange('color_scheme', 'light')}
>
Light
</button>
<button
type="button"
className={`btn-secondary${editColorsDraft.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleEditColorChange('color_scheme', 'dark')}
>
Dark
</button>
</div>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={closeEditor}>
Cancel
</button>
<button
type="button"
className="btn-primary"
onClick={handleSaveThemeEdit}
disabled={savingThemeEdit}
>
{savingThemeEdit ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)}
<hr className="modal-divider" />
@@ -0,0 +1,22 @@
.theme-builder-modal {
width: min(820px, 95vw);
max-height: 88vh;
}
.theme-builder-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--sp-5);
align-items: start;
margin-bottom: var(--sp-2);
}
.theme-builder-preview-col .custom-theme-preview {
margin-bottom: var(--sp-4);
}
@media (max-width: 680px) {
.theme-builder-layout {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,119 @@
import type { CustomThemeColors } from '../types'
import { CustomThemePreview } from './CustomThemePreview'
import './Modal.css'
import './ThemeBuilderModal.css'
interface ColorField {
key: keyof Omit<CustomThemeColors, 'color_scheme'>
label: string
}
interface ThemeBuilderModalProps {
colorFields: ColorField[]
name: string
onNameChange: (name: string) => void
colors: CustomThemeColors
onColorChange: (key: keyof CustomThemeColors, value: string) => void
highlightedField: keyof CustomThemeColors | null
onHighlight: (field: keyof CustomThemeColors | null) => void
error: string | null
saving: boolean
onSave: () => void
onCancel: () => void
}
// Same editor as ProfileModal.tsx used to render inline, pulled out into its
// own wider dialog (#46) -- the profile modal's .modal is capped at
// min(380px, 100%), which cramped the CustomThemePreview mockup that's
// supposed to make the color-to-UI mapping easy to see. Nested on top of
// ProfileModal rather than replacing it, same stacked-dialog pattern as
// ImageLightbox/FilePreviewModal opening over MessageList.
export function ThemeBuilderModal({
colorFields,
name,
onNameChange,
colors,
onColorChange,
highlightedField,
onHighlight,
error,
saving,
onSave,
onCancel,
}: ThemeBuilderModalProps) {
return (
<div className="modal-scrim" onClick={onCancel}>
<div className="modal theme-builder-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Edit theme</h2>
<button type="button" className="modal-close" onClick={onCancel} aria-label="Close">
&times;
</button>
</div>
<div className="theme-builder-layout">
<div className="theme-builder-preview-col">
<input
type="text"
className="custom-theme-name-input"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Theme name"
maxLength={50}
/>
<CustomThemePreview colors={colors} highlightedField={highlightedField} onHighlight={onHighlight} />
<div className="custom-theme-scheme">
<span>Native controls (scrollbars, form inputs)</span>
<div className="custom-theme-scheme-toggle">
<button
type="button"
className={`btn-secondary${colors.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => onColorChange('color_scheme', 'light')}
>
Light
</button>
<button
type="button"
className={`btn-secondary${colors.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => onColorChange('color_scheme', 'dark')}
>
Dark
</button>
</div>
</div>
</div>
<div className="custom-theme-grid theme-builder-grid">
{colorFields.map((field) => (
<label
key={field.key}
className={`custom-theme-field${highlightedField === field.key ? ' custom-theme-field-highlighted' : ''}`}
onMouseEnter={() => onHighlight(field.key)}
onMouseLeave={() => onHighlight(null)}
>
<input
type="color"
value={colors[field.key]}
onChange={(e) => onColorChange(field.key, e.target.value)}
onFocus={() => onHighlight(field.key)}
onBlur={() => onHighlight(null)}
/>
<span>{field.label}</span>
</label>
))}
</div>
</div>
{error && <p className="modal-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onCancel}>
Cancel
</button>
<button type="button" className="btn-primary" onClick={onSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)
}