@bsuite/theme 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * BrandingProvider — runtime enterprise white-labelling
3
+ *
4
+ * On mount:
5
+ * 1. Calls supabase.rpc('branding_json_for_tenant') using the user's authed session.
6
+ * 2. Applies each key as a CSS custom property on document.documentElement.
7
+ * 3. Caches the result in localStorage under BRANDING_STORAGE_KEY for FOUC prevention.
8
+ * 4. Subscribes to tenant row changes via Supabase Realtime so branding updates
9
+ * propagate without a page reload (within ~1 second).
10
+ *
11
+ * Environment flags:
12
+ * - VITE_ENABLE_BRANDING_OVERRIDE (default: 'true') — set to 'false' to disable
13
+ * entirely without code changes (emergency kill switch).
14
+ *
15
+ * Usage:
16
+ * Wrap BrandingProvider inside ThemeProvider so branding CSS vars override theme defaults:
17
+ *
18
+ * <ThemeProvider>
19
+ * <BrandingProvider supabaseClient={supabase}>
20
+ * {children}
21
+ * </BrandingProvider>
22
+ * </ThemeProvider>
23
+ */
24
+ import type { ReactNode } from 'react';
25
+ import type { SupabaseClient } from '@supabase/supabase-js';
26
+ export declare const BRANDING_STORAGE_KEY = "bsuite_tenant_branding";
27
+ export declare const BRANDING_OVERRIDE_FLAG = "VITE_ENABLE_BRANDING_OVERRIDE";
28
+ /** Shape returned by the branding_json_for_tenant RPC */
29
+ export interface TenantBranding {
30
+ /** Primary action colour as an OKLCH string, e.g. "oklch(0.55 0.22 265)" */
31
+ primary?: string;
32
+ /** Accent / secondary colour as an OKLCH string */
33
+ accent?: string;
34
+ /** Full logo URL (SVG or raster) */
35
+ logo_url?: string;
36
+ /** Mark / icon logo URL */
37
+ mark_url?: string;
38
+ /** Optional CSS font-family stack string */
39
+ font_stack?: string | null;
40
+ }
41
+ export interface BrandingContextValue {
42
+ /** Current resolved branding, or null if no tenant override */
43
+ branding: TenantBranding | null;
44
+ /** True while the initial RPC call is in flight */
45
+ isLoading: boolean;
46
+ /** Any error from the RPC call */
47
+ error: Error | null;
48
+ /** Manually trigger a branding refresh */
49
+ refresh: () => Promise<void>;
50
+ }
51
+ export declare const BrandingContext: import("react").Context<BrandingContextValue | undefined>;
52
+ export interface BrandingProviderProps {
53
+ children: ReactNode;
54
+ /** Authenticated Supabase client from the consuming app */
55
+ supabaseClient: SupabaseClient;
56
+ /**
57
+ * If true, apply persisted branding immediately on mount (before the RPC
58
+ * resolves) to prevent FOUC. Default: true.
59
+ */
60
+ applyPersistedOnMount?: boolean;
61
+ }
62
+ export declare function BrandingProvider({ children, supabaseClient, applyPersistedOnMount, }: BrandingProviderProps): import("react/jsx-runtime").JSX.Element;
63
+ //# sourceMappingURL=BrandingProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrandingProvider.d.ts","sourceRoot":"","sources":["../../src/react/BrandingProvider.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAUH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAE3D,eAAO,MAAM,oBAAoB,2BAA2B,CAAA;AAC5D,eAAO,MAAM,sBAAsB,kCAAkC,CAAA;AAErE,yDAAyD;AACzD,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,+DAA+D;IAC/D,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAA;IAClB,kCAAkC;IAClC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,0CAA0C;IAC1C,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AAED,eAAO,MAAM,eAAe,2DAA6D,CAAA;AAqFzF,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,SAAS,CAAA;IACnB,2DAA2D;IAC3D,cAAc,EAAE,cAAc,CAAA;IAC9B;;;OAGG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,cAAc,EACd,qBAA4B,GAC7B,EAAE,qBAAqB,2CAyFvB"}
@@ -0,0 +1,183 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * BrandingProvider — runtime enterprise white-labelling
4
+ *
5
+ * On mount:
6
+ * 1. Calls supabase.rpc('branding_json_for_tenant') using the user's authed session.
7
+ * 2. Applies each key as a CSS custom property on document.documentElement.
8
+ * 3. Caches the result in localStorage under BRANDING_STORAGE_KEY for FOUC prevention.
9
+ * 4. Subscribes to tenant row changes via Supabase Realtime so branding updates
10
+ * propagate without a page reload (within ~1 second).
11
+ *
12
+ * Environment flags:
13
+ * - VITE_ENABLE_BRANDING_OVERRIDE (default: 'true') — set to 'false' to disable
14
+ * entirely without code changes (emergency kill switch).
15
+ *
16
+ * Usage:
17
+ * Wrap BrandingProvider inside ThemeProvider so branding CSS vars override theme defaults:
18
+ *
19
+ * <ThemeProvider>
20
+ * <BrandingProvider supabaseClient={supabase}>
21
+ * {children}
22
+ * </BrandingProvider>
23
+ * </ThemeProvider>
24
+ */
25
+ import { createContext, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
26
+ export const BRANDING_STORAGE_KEY = 'bsuite_tenant_branding';
27
+ export const BRANDING_OVERRIDE_FLAG = 'VITE_ENABLE_BRANDING_OVERRIDE';
28
+ export const BrandingContext = createContext(undefined);
29
+ const BRANDING_CSS_MAP = {
30
+ primary: '--primary',
31
+ accent: '--accent',
32
+ logo_url: '--logo-url',
33
+ mark_url: '--mark-url',
34
+ font_stack: '--font-stack',
35
+ };
36
+ function applyBrandingToRoot(branding) {
37
+ if (typeof document === 'undefined')
38
+ return;
39
+ const root = document.documentElement;
40
+ if (!branding) {
41
+ // Remove any previously applied overrides
42
+ Object.values(BRANDING_CSS_MAP).forEach((cssVar) => {
43
+ if (cssVar)
44
+ root.style.removeProperty(cssVar);
45
+ });
46
+ // Also remove aliased vars
47
+ root.style.removeProperty('--accent-primary');
48
+ root.style.removeProperty('--app-primary');
49
+ root.style.removeProperty('--app-accent');
50
+ root.removeAttribute('data-branding-loaded');
51
+ return;
52
+ }
53
+ if (branding.primary) {
54
+ root.style.setProperty('--primary', branding.primary);
55
+ root.style.setProperty('--accent-primary', branding.primary);
56
+ root.style.setProperty('--app-primary', branding.primary);
57
+ root.style.setProperty('--ring', branding.primary);
58
+ }
59
+ if (branding.accent) {
60
+ root.style.setProperty('--accent', branding.accent);
61
+ root.style.setProperty('--accent-secondary', branding.accent);
62
+ root.style.setProperty('--app-accent', branding.accent);
63
+ }
64
+ if (branding.logo_url) {
65
+ root.style.setProperty('--logo-url', `url(${branding.logo_url})`);
66
+ }
67
+ if (branding.mark_url) {
68
+ root.style.setProperty('--mark-url', `url(${branding.mark_url})`);
69
+ }
70
+ if (branding.font_stack) {
71
+ root.style.setProperty('--font-stack', branding.font_stack);
72
+ }
73
+ root.setAttribute('data-branding-loaded', 'true');
74
+ }
75
+ function persistBranding(branding) {
76
+ try {
77
+ if (branding) {
78
+ localStorage.setItem(BRANDING_STORAGE_KEY, JSON.stringify(branding));
79
+ }
80
+ else {
81
+ localStorage.removeItem(BRANDING_STORAGE_KEY);
82
+ }
83
+ }
84
+ catch {
85
+ // localStorage not available — silently ignore
86
+ }
87
+ }
88
+ function loadPersistedBranding() {
89
+ try {
90
+ const raw = localStorage.getItem(BRANDING_STORAGE_KEY);
91
+ if (!raw)
92
+ return null;
93
+ return JSON.parse(raw);
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ function isBrandingEnabled() {
100
+ try {
101
+ // Vite env var — only present in client bundles that set it
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ const flag = import.meta.env;
104
+ const value = flag?.VITE_ENABLE_BRANDING_OVERRIDE ?? flag?.['VITE_ENABLE_BRANDING_OVERRIDE'];
105
+ if (value === 'false' || value === '0')
106
+ return false;
107
+ }
108
+ catch {
109
+ // Not in a Vite context — default to enabled
110
+ }
111
+ return true;
112
+ }
113
+ export function BrandingProvider({ children, supabaseClient, applyPersistedOnMount = true, }) {
114
+ const [branding, setBranding] = useState(null);
115
+ const [isLoading, setIsLoading] = useState(true);
116
+ const [error, setError] = useState(null);
117
+ const channelRef = useRef(null);
118
+ const fetchBranding = useCallback(async () => {
119
+ if (!isBrandingEnabled()) {
120
+ setIsLoading(false);
121
+ return;
122
+ }
123
+ setIsLoading(true);
124
+ setError(null);
125
+ try {
126
+ const { data, error: rpcError } = await supabaseClient.rpc('branding_json_for_tenant');
127
+ if (rpcError) {
128
+ throw new Error(rpcError.message);
129
+ }
130
+ const resolved = data ?? null;
131
+ setBranding(resolved);
132
+ applyBrandingToRoot(resolved);
133
+ persistBranding(resolved);
134
+ }
135
+ catch (err) {
136
+ setError(err instanceof Error ? err : new Error(String(err)));
137
+ // On error keep any persisted branding applied (already done at mount)
138
+ }
139
+ finally {
140
+ setIsLoading(false);
141
+ }
142
+ }, [supabaseClient]);
143
+ // Apply persisted branding immediately to prevent FOUC
144
+ useEffect(() => {
145
+ if (applyPersistedOnMount && isBrandingEnabled()) {
146
+ const persisted = loadPersistedBranding();
147
+ if (persisted) {
148
+ applyBrandingToRoot(persisted);
149
+ setBranding(persisted);
150
+ }
151
+ }
152
+ }, [applyPersistedOnMount]);
153
+ // Fetch fresh branding on mount
154
+ useEffect(() => {
155
+ void fetchBranding();
156
+ }, [fetchBranding]);
157
+ // Subscribe to Realtime tenant row changes so branding propagates without reload
158
+ useEffect(() => {
159
+ if (!isBrandingEnabled())
160
+ return;
161
+ const channel = supabaseClient
162
+ .channel('tenant-branding-updates')
163
+ .on('postgres_changes', {
164
+ event: 'UPDATE',
165
+ schema: 'public',
166
+ table: 'tenants',
167
+ // filter is applied server-side via RLS — only own tenant rows are received
168
+ }, (_payload) => {
169
+ // Re-fetch to get the full branding_json_for_tenant RPC result
170
+ void fetchBranding();
171
+ })
172
+ .subscribe();
173
+ channelRef.current = channel;
174
+ return () => {
175
+ if (channelRef.current) {
176
+ void supabaseClient.removeChannel(channelRef.current);
177
+ channelRef.current = null;
178
+ }
179
+ };
180
+ }, [supabaseClient, fetchBranding]);
181
+ const value = useMemo(() => ({ branding, isLoading, error, refresh: fetchBranding }), [branding, isLoading, error, fetchBranding]);
182
+ return _jsx(BrandingContext.Provider, { value: value, children: children });
183
+ }
@@ -3,4 +3,6 @@ export type { ThemeProviderProps } from './ThemeProvider';
3
3
  export { useTheme } from './useTheme';
4
4
  export type { ThemeContextValue, ThemeMode, ResolvedTheme } from '../index';
5
5
  export { THEME_STORAGE_KEY } from '../index';
6
+ export { BrandingProvider, BrandingContext, BRANDING_STORAGE_KEY, BRANDING_OVERRIDE_FLAG } from './BrandingProvider';
7
+ export type { TenantBranding, BrandingContextValue } from './BrandingProvider';
6
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrC,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrC,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA;AAC5C,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAA;AACpH,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA"}
@@ -1,3 +1,4 @@
1
1
  export { ThemeProvider } from './ThemeProvider';
2
2
  export { useTheme } from './useTheme';
3
3
  export { THEME_STORAGE_KEY } from '../index';
4
+ export { BrandingProvider, BrandingContext, BRANDING_STORAGE_KEY, BRANDING_OVERRIDE_FLAG } from './BrandingProvider';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Access the current tenant branding values and loading state.
3
+ *
4
+ * Must be used inside a <BrandingProvider>.
5
+ *
6
+ * @example
7
+ * const { branding, isLoading } = useBranding()
8
+ * const logoUrl = branding?.logo_url ?? '/logos/default-logo.svg'
9
+ */
10
+ export declare function useBranding(): import("./BrandingProvider").BrandingContextValue;
11
+ //# sourceMappingURL=useBranding.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useBranding.d.ts","sourceRoot":"","sources":["../../src/react/useBranding.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH,wBAAgB,WAAW,sDAM1B"}
@@ -0,0 +1,18 @@
1
+ import { useContext } from 'react';
2
+ import { BrandingContext } from './BrandingProvider';
3
+ /**
4
+ * Access the current tenant branding values and loading state.
5
+ *
6
+ * Must be used inside a <BrandingProvider>.
7
+ *
8
+ * @example
9
+ * const { branding, isLoading } = useBranding()
10
+ * const logoUrl = branding?.logo_url ?? '/logos/default-logo.svg'
11
+ */
12
+ export function useBranding() {
13
+ const ctx = useContext(BrandingContext);
14
+ if (!ctx) {
15
+ throw new Error('useBranding must be used within a <BrandingProvider>');
16
+ }
17
+ return ctx;
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/theme",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -46,18 +46,24 @@
46
46
  "prepublishOnly": "pnpm build"
47
47
  },
48
48
  "peerDependencies": {
49
+ "@supabase/supabase-js": ">=2.0.0",
49
50
  "react": ">=18.0.0",
50
51
  "react-dom": ">=18.0.0",
51
52
  "tailwindcss": ">=3.4.0"
52
53
  },
53
54
  "peerDependenciesMeta": {
55
+ "@supabase/supabase-js": {
56
+ "optional": true
57
+ },
54
58
  "tailwindcss": {
55
59
  "optional": true
56
60
  }
57
61
  },
58
62
  "devDependencies": {
63
+ "@supabase/supabase-js": "^2.49.4",
59
64
  "@testing-library/jest-dom": "^6.9.1",
60
65
  "@testing-library/react": "^16.3.2",
66
+ "@types/node": "^20.0.0",
61
67
  "@types/react": "^18.3.28",
62
68
  "@vitejs/plugin-react": "^5.2.0",
63
69
  "jsdom": "^28.1.0",