Skip to content

Commit b5ae569

Browse files
authored
feat: implement CSS variable override system for tenant themes (#1395)
* feat: implement CSS variable override system for tenant themes Adds theme-utils.ts with applyTenantTheme, resetTheme, and updateFavicon functions. Integrates into TenantProvider via useEffect so theme is applied on tenant config change and cleaned up on unmount or tenant switch. * fix: clear stale theme properties on switch, reset only on unmount applyTenantTheme now removes previously applied CSS variables before setting new ones, preventing stale properties when switching between themes with different property sets. TenantProvider cleanup now runs resetTheme only on unmount to avoid a visual flash during theme switches. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent bef2e24 commit b5ae569

3 files changed

Lines changed: 267 additions & 2 deletions

File tree

frontend/src/contexts/tenant-context.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
1+
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
22
import { useQueryClient } from '@tanstack/react-query'
33
import { useAuth } from '@/contexts/auth-context'
4-
import { DEFAULT_UI_CONFIG, type TenantUIConfig } from '@/lib/tenant-ui-config'
4+
import { DEFAULT_UI_CONFIG, type TenantThemeConfig, type TenantUIConfig } from '@/lib/tenant-ui-config'
5+
import { applyTenantTheme, resetTheme } from '@/lib/theme-utils'
56

67
export interface Tenant {
78
id: string
@@ -15,6 +16,7 @@ export interface TenantContextValue {
1516
isPlatformAdmin: boolean
1617
switchTenant: (tenant: Tenant) => void
1718
clearTenant: () => void
19+
applyTheme: (theme: TenantThemeConfig) => void
1820
tenantConfig?: TenantUIConfig
1921
}
2022

@@ -24,9 +26,23 @@ export function TenantProvider({ children }: { children: ReactNode }) {
2426
const { claims, lens } = useAuth()
2527
const queryClient = useQueryClient()
2628
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null)
29+
const [tenantTheme, setTenantTheme] = useState<TenantThemeConfig | null>(null)
2730

2831
const isPlatformAdmin = lens === 'platform'
2932

33+
useEffect(() => {
34+
if (tenantTheme) {
35+
applyTenantTheme(tenantTheme)
36+
} else {
37+
resetTheme()
38+
}
39+
}, [tenantTheme])
40+
41+
// Reset theme only on unmount to avoid a visual flash during theme switches.
42+
useEffect(() => {
43+
return () => resetTheme()
44+
}, [])
45+
3046
const switchTenant = useCallback(
3147
(tenant: Tenant) => {
3248
if (!isPlatformAdmin) return
@@ -50,8 +66,13 @@ export function TenantProvider({ children }: { children: ReactNode }) {
5066
const clearTenant = useCallback(() => {
5167
if (!isPlatformAdmin) return
5268
setSelectedTenant(null)
69+
setTenantTheme(null)
5370
}, [isPlatformAdmin])
5471

72+
const applyTheme = useCallback((theme: TenantThemeConfig) => {
73+
setTenantTheme(theme)
74+
}, [])
75+
5576
// For tenant admins, tenant slug is fixed from JWT claims
5677
const tenantSlug = isPlatformAdmin ? selectedTenant?.slug ?? null : claims?.tenantId ?? null
5778

@@ -61,6 +82,7 @@ export function TenantProvider({ children }: { children: ReactNode }) {
6182
isPlatformAdmin,
6283
switchTenant,
6384
clearTenant,
85+
applyTheme,
6486
tenantConfig: DEFAULT_UI_CONFIG,
6587
}
6688

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
2+
import { applyTenantTheme, resetTheme, updateFavicon } from "../theme-utils"
3+
4+
describe("theme-utils", () => {
5+
beforeEach(() => {
6+
// Clean slate for each test
7+
document.documentElement.removeAttribute("data-tenant-theme")
8+
document.documentElement.style.cssText = ""
9+
// Remove any favicon links added during tests
10+
document.querySelectorAll("link[rel~='icon']").forEach((el) => el.remove())
11+
})
12+
13+
afterEach(() => {
14+
resetTheme()
15+
})
16+
17+
describe("applyTenantTheme", () => {
18+
it("applies primaryColor as --primary CSS variable", () => {
19+
applyTenantTheme({ primaryColor: "#007bff" })
20+
expect(document.documentElement.style.getPropertyValue("--primary")).toBe("#007bff")
21+
})
22+
23+
it("applies fontFamily as --font-family CSS variable", () => {
24+
applyTenantTheme({ primaryColor: "#000", fontFamily: "Inter" })
25+
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("Inter")
26+
})
27+
28+
it("does not set --font-family when fontFamily is absent", () => {
29+
applyTenantTheme({ primaryColor: "#000" })
30+
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("")
31+
})
32+
33+
it("records applied properties in data attribute", () => {
34+
applyTenantTheme({ primaryColor: "#ff0000", fontFamily: "Roboto" })
35+
const attr = document.documentElement.getAttribute("data-tenant-theme")
36+
expect(attr).toContain("--primary")
37+
expect(attr).toContain("--font-family")
38+
})
39+
40+
it("sets favicon when faviconUrl is provided", () => {
41+
applyTenantTheme({ primaryColor: "#000", faviconUrl: "https://example.com/favicon.ico" })
42+
const link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
43+
expect(link?.href).toBe("https://example.com/favicon.ico")
44+
})
45+
46+
it("does not create favicon element when faviconUrl is absent", () => {
47+
applyTenantTheme({ primaryColor: "#000" })
48+
const link = document.querySelector("link[rel~='icon']")
49+
expect(link).toBeNull()
50+
})
51+
52+
it("overwrites a previous theme application", () => {
53+
applyTenantTheme({ primaryColor: "#ff0000" })
54+
applyTenantTheme({ primaryColor: "#00ff00" })
55+
expect(document.documentElement.style.getPropertyValue("--primary")).toBe("#00ff00")
56+
})
57+
58+
it("clears stale properties when switching to a theme with fewer properties", () => {
59+
applyTenantTheme({ primaryColor: "#ff0000", fontFamily: "Inter" })
60+
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("Inter")
61+
62+
// Switch to a theme without fontFamily — stale --font-family should be removed
63+
applyTenantTheme({ primaryColor: "#00ff00" })
64+
expect(document.documentElement.style.getPropertyValue("--primary")).toBe("#00ff00")
65+
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("")
66+
})
67+
})
68+
69+
describe("resetTheme", () => {
70+
it("removes --primary CSS variable", () => {
71+
applyTenantTheme({ primaryColor: "#007bff" })
72+
resetTheme()
73+
expect(document.documentElement.style.getPropertyValue("--primary")).toBe("")
74+
})
75+
76+
it("removes --font-family CSS variable", () => {
77+
applyTenantTheme({ primaryColor: "#000", fontFamily: "Inter" })
78+
resetTheme()
79+
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("")
80+
})
81+
82+
it("removes the data-tenant-theme attribute", () => {
83+
applyTenantTheme({ primaryColor: "#007bff" })
84+
resetTheme()
85+
expect(document.documentElement.getAttribute("data-tenant-theme")).toBeNull()
86+
})
87+
88+
it("is a no-op when no theme was applied", () => {
89+
expect(() => resetTheme()).not.toThrow()
90+
expect(document.documentElement.getAttribute("data-tenant-theme")).toBeNull()
91+
})
92+
93+
it("restores the original favicon href", () => {
94+
// Set up a starting favicon
95+
const link = document.createElement("link")
96+
link.rel = "icon"
97+
link.href = "https://example.com/original.ico"
98+
document.head.appendChild(link)
99+
100+
applyTenantTheme({ primaryColor: "#000", faviconUrl: "https://example.com/tenant.ico" })
101+
expect(link.href).toBe("https://example.com/tenant.ico")
102+
103+
resetTheme()
104+
expect(link.href).toBe("https://example.com/original.ico")
105+
})
106+
})
107+
108+
describe("updateFavicon", () => {
109+
it("creates a favicon link element if none exists", () => {
110+
updateFavicon("https://example.com/favicon.ico")
111+
const link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
112+
expect(link).not.toBeNull()
113+
expect(link?.href).toBe("https://example.com/favicon.ico")
114+
})
115+
116+
it("updates an existing favicon link element", () => {
117+
const link = document.createElement("link")
118+
link.rel = "icon"
119+
link.href = "https://example.com/old.ico"
120+
document.head.appendChild(link)
121+
122+
updateFavicon("https://example.com/new.ico")
123+
expect(link.href).toBe("https://example.com/new.ico")
124+
})
125+
126+
it("records the original href in data-default-href", () => {
127+
const link = document.createElement("link")
128+
link.rel = "icon"
129+
link.href = "https://example.com/original.ico"
130+
document.head.appendChild(link)
131+
132+
updateFavicon("https://example.com/new.ico")
133+
expect(link.dataset.defaultHref).toBe("https://example.com/original.ico")
134+
})
135+
136+
it("does not overwrite data-default-href on subsequent calls", () => {
137+
const link = document.createElement("link")
138+
link.rel = "icon"
139+
link.href = "https://example.com/original.ico"
140+
document.head.appendChild(link)
141+
142+
updateFavicon("https://example.com/first.ico")
143+
updateFavicon("https://example.com/second.ico")
144+
145+
// defaultHref should still point to the original
146+
expect(link.dataset.defaultHref).toBe("https://example.com/original.ico")
147+
expect(link.href).toBe("https://example.com/second.ico")
148+
})
149+
})
150+
})

frontend/src/lib/theme-utils.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { TenantThemeConfig } from "@/lib/tenant-ui-config"
2+
3+
const TENANT_THEME_ATTR = "data-tenant-theme"
4+
5+
// Maps TenantThemeConfig properties to CSS custom property names used by shadcn/ui
6+
const COLOR_PROPERTY_MAP: Record<string, string> = {
7+
primaryColor: "--primary",
8+
fontFamily: "--font-family",
9+
}
10+
11+
/**
12+
* Applies tenant theme CSS variables to the document root element.
13+
* Marks overridden variables with a data attribute for cleanup.
14+
*/
15+
export function applyTenantTheme(theme: TenantThemeConfig): void {
16+
const root = document.documentElement
17+
18+
// Clear any previously applied variables so stale properties don't linger
19+
// when switching between themes that have different sets of properties.
20+
const previouslyApplied = root.getAttribute(TENANT_THEME_ATTR)
21+
if (previouslyApplied) {
22+
for (const prop of previouslyApplied.split(",")) {
23+
if (prop) {
24+
root.style.removeProperty(prop)
25+
}
26+
}
27+
}
28+
29+
const applied: string[] = []
30+
31+
if (theme.primaryColor) {
32+
root.style.setProperty(COLOR_PROPERTY_MAP.primaryColor, theme.primaryColor)
33+
applied.push(COLOR_PROPERTY_MAP.primaryColor)
34+
}
35+
36+
if (theme.fontFamily) {
37+
root.style.setProperty(COLOR_PROPERTY_MAP.fontFamily, theme.fontFamily)
38+
applied.push(COLOR_PROPERTY_MAP.fontFamily)
39+
}
40+
41+
// Record which variables were applied so resetTheme knows what to remove
42+
root.setAttribute(TENANT_THEME_ATTR, applied.join(","))
43+
44+
if (theme.faviconUrl) {
45+
updateFavicon(theme.faviconUrl)
46+
}
47+
}
48+
49+
/**
50+
* Removes all CSS variable overrides applied by applyTenantTheme and
51+
* restores the default favicon.
52+
*/
53+
export function resetTheme(): void {
54+
const root = document.documentElement
55+
56+
const applied = root.getAttribute(TENANT_THEME_ATTR)
57+
if (applied) {
58+
for (const prop of applied.split(",")) {
59+
if (prop) {
60+
root.style.removeProperty(prop)
61+
}
62+
}
63+
root.removeAttribute(TENANT_THEME_ATTR)
64+
}
65+
66+
restoreDefaultFavicon()
67+
}
68+
69+
/**
70+
* Updates the favicon link element to point to the provided URL.
71+
*/
72+
export function updateFavicon(url: string): void {
73+
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
74+
if (!link) {
75+
link = document.createElement("link")
76+
link.rel = "icon"
77+
document.head.appendChild(link)
78+
}
79+
80+
if (!link.dataset.defaultHref) {
81+
link.dataset.defaultHref = link.href
82+
}
83+
84+
link.href = url
85+
}
86+
87+
function restoreDefaultFavicon(): void {
88+
const link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
89+
if (link && link.dataset.defaultHref !== undefined) {
90+
link.href = link.dataset.defaultHref
91+
delete link.dataset.defaultHref
92+
}
93+
}

0 commit comments

Comments
 (0)