Private
Public Access
1
0

Fix branding: live updates on save, reset clears everything, show defaults

- Clear inline preview styles before persisting CSS on save to avoid
  specificity conflicts that required Ctrl+Shift+R
- Reset to Defaults now deletes uploaded images and clears logoText
- ColorPicker emits value on Solid/Gradient toggle for instant preview
- Show default color swatch with dashed border when field is cleared
- persistThemeCSS now handles background-image rules like server-side

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-27 17:33:34 -05:00
parent 795e7c14ff
commit 8c9a5abc5a
3 changed files with 99 additions and 2 deletions
+11 -1
View File
@@ -3,11 +3,13 @@
label, label,
value = $bindable(''), value = $bindable(''),
gradient = false, gradient = false,
defaultValue = '',
onchange onchange
}: { }: {
label: string; label: string;
value: string; value: string;
gradient?: boolean; gradient?: boolean;
defaultValue?: string;
onchange?: (value: string) => void; onchange?: (value: string) => void;
} = $props(); } = $props();
@@ -49,7 +51,11 @@
function switchMode(newMode: 'color' | 'gradient') { function switchMode(newMode: 'color' | 'gradient') {
mode = newMode; mode = newMode;
// Don't emit — let the user pick values first if (newMode === 'color') {
emitColor();
} else {
emitGradient();
}
} }
function clear() { function clear() {
@@ -63,6 +69,8 @@
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label> <label class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label>
{#if value} {#if value}
<button onclick={clear} class="text-[10px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Reset</button> <button onclick={clear} class="text-[10px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Reset</button>
{:else if defaultValue}
<span class="text-[10px] text-gray-400 dark:text-gray-500">Default</span>
{/if} {/if}
</div> </div>
@@ -134,5 +142,7 @@
{#if value && mode === 'color'} {#if value && mode === 'color'}
<div class="h-4 w-full rounded" style="background: {value}"></div> <div class="h-4 w-full rounded" style="background: {value}"></div>
{:else if !value && defaultValue}
<div class="h-4 w-full rounded border border-dashed border-gray-300 dark:border-gray-600" style="background: {defaultValue}"></div>
{/if} {/if}
</div> </div>
+18
View File
@@ -74,6 +74,21 @@ export function persistThemeCSS(theme: TenantThemeData) {
if (theme.darkCardOpacity < 1) darkVars.push(` --card-backdrop-blur: blur(8px);`); if (theme.darkCardOpacity < 1) darkVars.push(` --card-backdrop-blur: blur(8px);`);
} }
// Handle background images
const extraRules: string[] = [];
if (theme.lightPageBgImage) {
extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`);
}
if (theme.lightNavBgImage) {
extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`);
}
if (theme.darkPageBgImage) {
extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`);
}
if (theme.darkNavBgImage) {
extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`);
}
let css = ''; let css = '';
if (lightVars.length > 0) { if (lightVars.length > 0) {
css += `:root {\n${lightVars.join('\n')}\n}\n`; css += `:root {\n${lightVars.join('\n')}\n}\n`;
@@ -81,6 +96,9 @@ export function persistThemeCSS(theme: TenantThemeData) {
if (darkVars.length > 0) { if (darkVars.length > 0) {
css += `html.dark {\n${darkVars.join('\n')}\n}\n`; css += `html.dark {\n${darkVars.join('\n')}\n}\n`;
} }
if (extraRules.length > 0) {
css += extraRules.join('\n') + '\n';
}
// Find or create the <style id="tenant-theme"> element // Find or create the <style id="tenant-theme"> element
let styleEl = document.getElementById('tenant-theme') as HTMLStyleElement | null; let styleEl = document.getElementById('tenant-theme') as HTMLStyleElement | null;
+70 -1
View File
@@ -32,6 +32,46 @@
clearThemePreview(); clearThemePreview();
}); });
// CSS defaults from app.css — shown when a field is cleared
const DEFAULTS: Record<string, Record<string, string>> = {
light: {
pageBg: '#f9fafb',
navBg: '#0079bf',
columnBg: '#ebecf0',
columnBorder: 'transparent',
columnText: 'inherit',
cardBg: '#ffffff',
cardBorder: '#e5e7eb',
cardText: 'inherit',
primary: '#0079bf',
primaryHover: '#026aa7',
danger: '#eb5a46',
dangerHover: '#cf513d',
success: '#61bd4f',
warning: '#f2d600'
},
dark: {
pageBg: '#030712',
navBg: '#3b82f6',
columnBg: '#1f2937',
columnBorder: 'transparent',
columnText: 'inherit',
cardBg: '#374151',
cardBorder: '#4b5563',
cardText: 'inherit',
primary: '#3b82f6',
primaryHover: '#2563eb',
danger: '#ef4444',
dangerHover: '#dc2626',
success: '#22c55e',
warning: '#eab308'
}
};
function getDefault(name: string): string {
return DEFAULTS[activeTab]?.[name] ?? '';
}
// Helpers to get/set field values by prefix + name // Helpers to get/set field values by prefix + name
function getField(name: string): any { function getField(name: string): any {
const key = `${activeTab}${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TenantThemeData; const key = `${activeTab}${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TenantThemeData;
@@ -56,6 +96,7 @@
body: JSON.stringify({ ...draft, logoText: logoText || null }) body: JSON.stringify({ ...draft, logoText: logoText || null })
}); });
if (ok) { if (ok) {
clearThemePreview();
persistThemeCSS(draft); persistThemeCSS(draft);
toast.success('Theme saved'); toast.success('Theme saved');
await invalidateAll(); await invalidateAll();
@@ -79,12 +120,26 @@
for (const f of fields) empty[f] = null; for (const f of fields) empty[f] = null;
saving = true; saving = true;
// Delete all uploaded images
const imageSlots = ['light-page-bg', 'dark-page-bg', 'light-nav-bg', 'dark-nav-bg', 'logo'];
await Promise.all(
imageSlots.map((slot) =>
api('/api/admin/branding/images', {
method: 'DELETE',
body: JSON.stringify({ slot })
}).catch(() => {})
)
);
// Clear all color/opacity fields + logoText
const { ok } = await api('/api/admin/branding', { const { ok } = await api('/api/admin/branding', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(empty) body: JSON.stringify({ ...empty, logoText: null })
}); });
if (ok) { if (ok) {
draft = {}; draft = {};
logoText = '';
clearThemePreview(); clearThemePreview();
persistThemeCSS({}); persistThemeCSS({});
toast.success('Theme reset to defaults'); toast.success('Theme reset to defaults');
@@ -176,6 +231,7 @@
label="Background color" label="Background color"
value={getField('pageBg') || ''} value={getField('pageBg') || ''}
gradient={true} gradient={true}
defaultValue={getDefault('pageBg')}
onchange={(v) => setField('pageBg', v)} onchange={(v) => setField('pageBg', v)}
/> />
<ImageUploadField <ImageUploadField
@@ -195,6 +251,7 @@
label="Background color" label="Background color"
value={getField('navBg') || ''} value={getField('navBg') || ''}
gradient={true} gradient={true}
defaultValue={getDefault('navBg')}
onchange={(v) => setField('navBg', v)} onchange={(v) => setField('navBg', v)}
/> />
<ImageUploadField <ImageUploadField
@@ -213,16 +270,19 @@
<ColorPicker <ColorPicker
label="Background" label="Background"
value={getField('columnBg') || ''} value={getField('columnBg') || ''}
defaultValue={getDefault('columnBg')}
onchange={(v) => setField('columnBg', v)} onchange={(v) => setField('columnBg', v)}
/> />
<ColorPicker <ColorPicker
label="Border" label="Border"
value={getField('columnBorder') || ''} value={getField('columnBorder') || ''}
defaultValue={getDefault('columnBorder')}
onchange={(v) => setField('columnBorder', v)} onchange={(v) => setField('columnBorder', v)}
/> />
<ColorPicker <ColorPicker
label="Text" label="Text"
value={getField('columnText') || ''} value={getField('columnText') || ''}
defaultValue={getDefault('columnText')}
onchange={(v) => setField('columnText', v)} onchange={(v) => setField('columnText', v)}
/> />
</div> </div>
@@ -249,16 +309,19 @@
<ColorPicker <ColorPicker
label="Background" label="Background"
value={getField('cardBg') || ''} value={getField('cardBg') || ''}
defaultValue={getDefault('cardBg')}
onchange={(v) => setField('cardBg', v)} onchange={(v) => setField('cardBg', v)}
/> />
<ColorPicker <ColorPicker
label="Border" label="Border"
value={getField('cardBorder') || ''} value={getField('cardBorder') || ''}
defaultValue={getDefault('cardBorder')}
onchange={(v) => setField('cardBorder', v)} onchange={(v) => setField('cardBorder', v)}
/> />
<ColorPicker <ColorPicker
label="Text" label="Text"
value={getField('cardText') || ''} value={getField('cardText') || ''}
defaultValue={getDefault('cardText')}
onchange={(v) => setField('cardText', v)} onchange={(v) => setField('cardText', v)}
/> />
</div> </div>
@@ -299,31 +362,37 @@
<ColorPicker <ColorPicker
label="Primary" label="Primary"
value={getField('primary') || ''} value={getField('primary') || ''}
defaultValue={getDefault('primary')}
onchange={(v) => setField('primary', v)} onchange={(v) => setField('primary', v)}
/> />
<ColorPicker <ColorPicker
label="Primary hover" label="Primary hover"
value={getField('primaryHover') || ''} value={getField('primaryHover') || ''}
defaultValue={getDefault('primaryHover')}
onchange={(v) => setField('primaryHover', v)} onchange={(v) => setField('primaryHover', v)}
/> />
<ColorPicker <ColorPicker
label="Danger" label="Danger"
value={getField('danger') || ''} value={getField('danger') || ''}
defaultValue={getDefault('danger')}
onchange={(v) => setField('danger', v)} onchange={(v) => setField('danger', v)}
/> />
<ColorPicker <ColorPicker
label="Danger hover" label="Danger hover"
value={getField('dangerHover') || ''} value={getField('dangerHover') || ''}
defaultValue={getDefault('dangerHover')}
onchange={(v) => setField('dangerHover', v)} onchange={(v) => setField('dangerHover', v)}
/> />
<ColorPicker <ColorPicker
label="Success" label="Success"
value={getField('success') || ''} value={getField('success') || ''}
defaultValue={getDefault('success')}
onchange={(v) => setField('success', v)} onchange={(v) => setField('success', v)}
/> />
<ColorPicker <ColorPicker
label="Warning" label="Warning"
value={getField('warning') || ''} value={getField('warning') || ''}
defaultValue={getDefault('warning')}
onchange={(v) => setField('warning', v)} onchange={(v) => setField('warning', v)}
/> />
</div> </div>