Private
Public Access
1
0

Fix branding page 500 error and make theme changes instant

1. Fix 500 on hard refresh: branding page load was querying tenant
   for logoUrl/logoText fields that may not exist in DB yet. Now
   uses locals.tenant data (from resolver) instead of a separate query.

2. Instant theme application: after saving, persistThemeCSS() generates
   CSS from the draft and writes it into the <style id="tenant-theme">
   element in the DOM. This means navigating to any page after saving
   immediately shows the new theme without a hard refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-27 14:58:32 -05:00
parent b1d994c9e4
commit 795e7c14ff
3 changed files with 100 additions and 9 deletions
+93
View File
@@ -0,0 +1,93 @@
import type { TenantThemeData } from '../../app.d.js';
/**
* Client-side CSS variable mapping — mirrors server-side theme-css.ts
*/
const FIELD_MAP: Record<string, { cssVar: string; mode: 'light' | 'dark' }> = {};
const vars = [
['PageBg', '--color-page-bg'],
['NavBg', '--color-nav-bg'],
['ColumnBg', '--color-column-bg'],
['ColumnBorder', '--color-column-border'],
['ColumnText', '--color-column-text'],
['CardBg', '--color-card-bg'],
['CardBorder', '--color-card-border'],
['CardText', '--color-card-text'],
['CardRadius', '--color-card-radius'],
['Primary', '--color-primary'],
['PrimaryHover', '--color-primary-hover'],
['Danger', '--color-danger'],
['DangerHover', '--color-danger-hover'],
['Success', '--color-success'],
['Warning', '--color-warning']
] as const;
for (const [suffix, cssVar] of vars) {
FIELD_MAP[`light${suffix}`] = { cssVar, mode: 'light' };
FIELD_MAP[`dark${suffix}`] = { cssVar, mode: 'dark' };
}
function hexToRgba(hex: string, opacity: number): string {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
/**
* Generates CSS from theme data and writes it into the <style id="tenant-theme"> element.
* This mirrors what generateTenantCSS does server-side, but runs in the browser
* so theme changes take effect immediately without a full page reload.
*/
export function persistThemeCSS(theme: TenantThemeData) {
const lightVars: string[] = [];
const darkVars: string[] = [];
for (const [field, { cssVar, mode }] of Object.entries(FIELD_MAP)) {
const value = (theme as any)[field];
if (value == null) continue;
const target = mode === 'dark' ? darkVars : lightVars;
target.push(` ${cssVar}: ${value};`);
}
// Handle opacity for columns
if (theme.lightColumnOpacity != null && theme.lightColumnBg?.startsWith('#')) {
const idx = lightVars.findIndex((v) => v.includes('--color-column-bg:'));
if (idx !== -1) lightVars[idx] = ` --color-column-bg: ${hexToRgba(theme.lightColumnBg, theme.lightColumnOpacity)};`;
if (theme.lightColumnOpacity < 1) lightVars.push(` --column-backdrop-blur: blur(8px);`);
}
if (theme.lightCardOpacity != null && theme.lightCardBg?.startsWith('#')) {
const idx = lightVars.findIndex((v) => v.includes('--color-card-bg:'));
if (idx !== -1) lightVars[idx] = ` --color-card-bg: ${hexToRgba(theme.lightCardBg, theme.lightCardOpacity)};`;
if (theme.lightCardOpacity < 1) lightVars.push(` --card-backdrop-blur: blur(8px);`);
}
if (theme.darkColumnOpacity != null && theme.darkColumnBg?.startsWith('#')) {
const idx = darkVars.findIndex((v) => v.includes('--color-column-bg:'));
if (idx !== -1) darkVars[idx] = ` --color-column-bg: ${hexToRgba(theme.darkColumnBg, theme.darkColumnOpacity)};`;
if (theme.darkColumnOpacity < 1) darkVars.push(` --column-backdrop-blur: blur(8px);`);
}
if (theme.darkCardOpacity != null && theme.darkCardBg?.startsWith('#')) {
const idx = darkVars.findIndex((v) => v.includes('--color-card-bg:'));
if (idx !== -1) darkVars[idx] = ` --color-card-bg: ${hexToRgba(theme.darkCardBg, theme.darkCardOpacity)};`;
if (theme.darkCardOpacity < 1) darkVars.push(` --card-backdrop-blur: blur(8px);`);
}
let css = '';
if (lightVars.length > 0) {
css += `:root {\n${lightVars.join('\n')}\n}\n`;
}
if (darkVars.length > 0) {
css += `html.dark {\n${darkVars.join('\n')}\n}\n`;
}
// Find or create the <style id="tenant-theme"> element
let styleEl = document.getElementById('tenant-theme') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'tenant-theme';
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
}
+4 -9
View File
@@ -8,19 +8,14 @@ export const load: PageServerLoad = async ({ locals }) => {
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const [theme, tenantData] = await withTenant(tenant.id, async (tx) => {
const t = await tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } });
const td = await tx.tenant.findUnique({
where: { id: tenant.id },
select: { logoUrl: true, logoText: true }
});
return [t, td] as const;
const theme = await withTenant(tenant.id, async (tx) => {
return tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } });
});
return {
theme: theme ?? null,
tenantName: tenant.name,
logoUrl: tenantData?.logoUrl ?? null,
logoText: tenantData?.logoText ?? null
logoUrl: tenant.logoUrl ?? null,
logoText: tenant.logoText ?? null
};
};
+3
View File
@@ -5,6 +5,7 @@
import { toast } from '$lib/stores/toasts.svelte.js';
import { getTheme } from '$lib/stores/theme.svelte.js';
import { applyThemePreview, clearThemePreview } from '$lib/utils/theme-preview.js';
import { persistThemeCSS } from '$lib/utils/theme-persist.js';
import ColorPicker from '$lib/components/ColorPicker.svelte';
import ImageUploadField from '$lib/components/ImageUploadField.svelte';
import type { TenantThemeData } from '../../../app.d.js';
@@ -55,6 +56,7 @@
body: JSON.stringify({ ...draft, logoText: logoText || null })
});
if (ok) {
persistThemeCSS(draft);
toast.success('Theme saved');
await invalidateAll();
}
@@ -84,6 +86,7 @@
if (ok) {
draft = {};
clearThemePreview();
persistThemeCSS({});
toast.success('Theme reset to defaults');
await invalidateAll();
}