@bsuite/theme 0.1.1 → 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.1",
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",
@@ -5,37 +5,37 @@
5
5
  * consumer regardless of their Tailwind version (v3 or v4).
6
6
  */
7
7
 
8
- /* ===== Glow effects (use box-shadow with alpha scaled by glow-strength) ===== */
8
+ /* ===== Glow effects (oklch with alpha scaled by glow-strength) ===== */
9
9
  .glow-electric-blue {
10
- box-shadow: 0 0 20px rgb(var(--neon-electric-blue-rgb) / var(--glow-strength));
10
+ box-shadow: 0 0 20px oklch(0.546 0.215 262.9 / var(--glow-strength));
11
11
  }
12
12
  .glow-electric-cyan {
13
- box-shadow: 0 0 20px rgb(var(--neon-electric-cyan-rgb) / var(--glow-strength));
13
+ box-shadow: 0 0 20px oklch(0.769 0.132 191.7 / var(--glow-strength));
14
14
  }
15
15
  .glow-electric-indigo {
16
- box-shadow: 0 0 20px rgb(var(--neon-electric-indigo-rgb) / var(--glow-strength));
16
+ box-shadow: 0 0 20px oklch(0.511 0.23 277 / var(--glow-strength));
17
17
  }
18
18
  .glow-electric-purple {
19
- box-shadow: 0 0 20px rgb(var(--neon-electric-purple-rgb) / var(--glow-strength));
19
+ box-shadow: 0 0 20px oklch(0.568 0.202 283.1 / var(--glow-strength));
20
20
  }
21
21
  .glow-electric-pink {
22
- box-shadow: 0 0 20px rgb(var(--neon-electric-pink-rgb) / var(--glow-strength));
22
+ box-shadow: 0 0 20px oklch(0.656 0.212 354.3 / var(--glow-strength));
23
23
  }
24
24
  .glow-electric-coral {
25
- box-shadow: 0 0 20px rgb(var(--neon-electric-coral-rgb) / var(--glow-strength));
25
+ box-shadow: 0 0 20px oklch(0.669 0.219 20.9 / var(--glow-strength));
26
26
  }
27
27
  .glow-electric-magenta {
28
- box-shadow: 0 0 20px rgb(var(--neon-electric-magenta-rgb) / var(--glow-strength));
28
+ box-shadow: 0 0 20px oklch(0.742 0.167 359.5 / var(--glow-strength));
29
29
  }
30
30
 
31
31
  /* ===== Neon text shadows ===== */
32
32
  .neon-text-cyan {
33
- text-shadow: 0 0 10px rgb(var(--neon-electric-cyan-rgb) / 0.5),
34
- 0 0 20px rgb(var(--neon-electric-cyan-rgb) / 0.3);
33
+ text-shadow: 0 0 10px oklch(0.769 0.132 191.7 / 0.5),
34
+ 0 0 20px oklch(0.769 0.132 191.7 / 0.3);
35
35
  }
36
36
  .neon-text-electric {
37
- text-shadow: 0 0 10px rgb(var(--neon-electric-blue-rgb) / 0.5),
38
- 0 0 20px rgb(var(--neon-electric-blue-rgb) / 0.3);
37
+ text-shadow: 0 0 10px oklch(0.546 0.215 262.9 / 0.5),
38
+ 0 0 20px oklch(0.546 0.215 262.9 / 0.3);
39
39
  }
40
40
 
41
41
  /* ===== Smooth theme transition ===== */
package/src/css/vars.css CHANGED
@@ -1,84 +1,71 @@
1
1
  /*
2
2
  * @bsuite/theme — CSS custom properties (:root + .dark)
3
3
  *
4
- * Canonical D2C Neon Electric palette. Both hex values and RGB triplets are
5
- * exported so consumers can use either pattern:
4
+ * Canonical D2C Neon Electric palette in oklch. Tailwind v4 supports
5
+ * native alpha modifiers on oklch values, so RGB triplets are no longer
6
+ * needed:
6
7
  *
7
8
  * color: var(--neon-electric-blue);
8
- * background: rgb(var(--neon-electric-blue-rgb) / 0.4);
9
+ * background: oklch(from var(--neon-electric-blue) l c h / 0.4);
9
10
  *
10
- * The RGB-triplet form is what the Tailwind preset uses under the hood so
11
- * `<alpha-value>` modifiers in Tailwind classes (e.g. bg-neon-electric-blue/40)
12
- * resolve correctly.
11
+ * In Tailwind v4 @theme blocks, oklch values automatically support
12
+ * alpha modifiers: bg-neon-electric-blue/40 works natively.
13
13
  */
14
14
 
15
15
  :root {
16
16
  /* ===== Neon Electric (11 colours — FROZEN per D2C spec v1.00A) ===== */
17
- --neon-electric-blue: #2563eb;
18
- --neon-electric-cyan: #00cec9;
19
- --neon-electric-indigo: #4f46e5;
20
- --neon-electric-purple: #6c5ce7;
21
- --neon-electric-magenta: #fd79a8;
22
- --neon-electric-pink: #ec4899;
23
- --neon-electric-coral: #ff4757;
24
- --neon-electric-orange: #ff7675;
25
- --neon-electric-yellow: #fdcb6e;
26
- --neon-electric-green: #22c55e;
27
- --neon-electric-lavender: #a29bfe;
28
-
29
- /* RGB triplets for rgba() use and Tailwind alpha-value substitution */
30
- --neon-electric-blue-rgb: 37 99 235;
31
- --neon-electric-cyan-rgb: 0 206 201;
32
- --neon-electric-indigo-rgb: 79 70 229;
33
- --neon-electric-purple-rgb: 108 92 231;
34
- --neon-electric-magenta-rgb: 253 121 168;
35
- --neon-electric-pink-rgb: 236 72 153;
36
- --neon-electric-coral-rgb: 255 71 87;
37
- --neon-electric-orange-rgb: 255 118 117;
38
- --neon-electric-yellow-rgb: 253 203 110;
39
- --neon-electric-green-rgb: 34 197 94;
40
- --neon-electric-lavender-rgb: 162 155 254;
17
+ --neon-electric-blue: oklch(0.546 0.215 262.9);
18
+ --neon-electric-cyan: oklch(0.769 0.132 191.7);
19
+ --neon-electric-indigo: oklch(0.511 0.23 277);
20
+ --neon-electric-purple: oklch(0.568 0.202 283.1);
21
+ --neon-electric-magenta: oklch(0.742 0.167 359.5);
22
+ --neon-electric-pink: oklch(0.656 0.212 354.3);
23
+ --neon-electric-coral: oklch(0.669 0.219 20.9);
24
+ --neon-electric-orange: oklch(0.728 0.168 22.5);
25
+ --neon-electric-yellow: oklch(0.868 0.125 81.4);
26
+ --neon-electric-green: oklch(0.723 0.192 149.6);
27
+ --neon-electric-lavender: oklch(0.736 0.141 285.6);
41
28
 
42
29
  /* ===== Light theme surfaces ===== */
43
- --light-bg-primary: #f2f2f2;
44
- --light-bg-secondary: #f8f9fa;
45
- --light-bg-tertiary: #f1f3f4;
46
- --light-bg-accent: #ffffff;
47
- --light-text-primary: #2d3436;
48
- --light-text-secondary: #636e72;
49
- --light-text-tertiary: #74b9ff;
50
- --light-text-quaternary: #a4afb7;
51
- --light-border: #e9ecef;
52
- --light-hover: #f1f3f4;
30
+ --light-bg-primary: oklch(0.961 0 0.5);
31
+ --light-bg-secondary: oklch(0.982 0.002 248);
32
+ --light-bg-tertiary: oklch(0.963 0.003 228.9);
33
+ --light-bg-accent: oklch(1 0 0.5);
34
+ --light-text-primary: oklch(0.319 0.01 216.8);
35
+ --light-text-secondary: oklch(0.53 0.015 221.6);
36
+ --light-text-tertiary: oklch(0.768 0.123 250);
37
+ --light-text-quaternary: oklch(0.748 0.017 239.2);
38
+ --light-border: oklch(0.942 0.005 247.9);
39
+ --light-hover: oklch(0.963 0.003 228.9);
53
40
 
54
41
  /* ===== Dark theme surfaces ===== */
55
- --dark-bg-primary: #0a0e1a;
56
- --dark-bg-secondary: #1a1f2e;
57
- --dark-bg-tertiary: #2c3447;
58
- --dark-bg-quaternary: #3c4558;
59
- --dark-bg-accent: #252b3d;
60
- --dark-text-primary: #f8f9fa;
61
- --dark-text-secondary: #adb5bd;
62
- --dark-text-tertiary: #6c757d;
63
- --dark-text-quaternary: #495057;
64
- --dark-border: #495057;
65
- --dark-hover: #3c4558;
42
+ --dark-bg-primary: oklch(0.166 0.026 269.4);
43
+ --dark-bg-secondary: oklch(0.242 0.03 269.9);
44
+ --dark-bg-tertiary: oklch(0.326 0.036 266.7);
45
+ --dark-bg-quaternary: oklch(0.39 0.035 265);
46
+ --dark-bg-accent: oklch(0.292 0.034 270);
47
+ --dark-text-primary: oklch(0.982 0.002 248);
48
+ --dark-text-secondary: oklch(0.769 0.015 248);
49
+ --dark-text-tertiary: oklch(0.558 0.016 244.9);
50
+ --dark-text-quaternary: oklch(0.428 0.015 248.2);
51
+ --dark-border: oklch(0.428 0.015 248.2);
52
+ --dark-hover: oklch(0.39 0.035 265);
66
53
 
67
54
  /* ===== Semantic status colours ===== */
68
- --status-success: #00b894;
69
- --status-warning: #fdcb6e;
70
- --status-error: #ff4757;
71
- --status-info: #00cec9;
55
+ --status-success: oklch(0.697 0.135 172.1);
56
+ --status-warning: oklch(0.868 0.125 81.4);
57
+ --status-error: oklch(0.669 0.219 20.9);
58
+ --status-info: oklch(0.769 0.132 191.7);
72
59
 
73
60
  /* ===== WCAG AA compliant text pairs =====
74
61
  *
75
- * Raw neon cyan #00cec9 on light #f2f2f2 is 1.76:1 — fails WCAG AA 4.5:1.
62
+ * Raw neon cyan on light bg is 1.76:1 — fails WCAG AA 4.5:1.
76
63
  * These tokens are the accessible text equivalents for the same visual
77
64
  * identity. Use these for any text, border, or icon that needs to pass
78
65
  * contrast checks; use raw --neon-electric-cyan for backgrounds and
79
66
  * decorative elements only. */
80
- --color-accent-text: #006e6b; /* Dark cyan — passes AA on light bg */
81
- --color-primary-text: #0a47e5; /* Saturated blue — passes AA on light bg */
67
+ --color-accent-text: oklch(0.486 0.084 191.5); /* Dark cyan — passes AA on light bg */
68
+ --color-primary-text: oklch(0.485 0.243 263.6); /* Saturated blue — passes AA on light bg */
82
69
 
83
70
  /* ===== Glow + shadow strength multipliers ===== */
84
71
  --glow-strength: 0.4;
@@ -86,14 +73,14 @@
86
73
  }
87
74
 
88
75
  .dark {
89
- /* Neon palette is identical in dark mode — same raw colours, same RGB
90
- * triplets. What changes is the intensity of glow/shadow effects, which
76
+ /* Neon palette is identical in dark mode — same oklch values.
77
+ * What changes is the intensity of glow/shadow effects, which
91
78
  * we scale down to suit dark surfaces. */
92
79
  --glow-strength: 0.6;
93
80
  --shadow-strength: 0.8;
94
81
 
95
82
  /* Accent/primary text tokens flip to the raw neons in dark mode because
96
- * contrast on #0a0e1a navy is already ≥ 9:1 for those values. */
97
- --color-accent-text: #00cec9;
98
- --color-primary-text: #3b82f6;
83
+ * contrast on navy bg is already ≥ 9:1 for those values. */
84
+ --color-accent-text: oklch(0.769 0.132 191.7);
85
+ --color-primary-text: oklch(0.623 0.188 259.8);
99
86
  }
package/src/preset-v4.css CHANGED
@@ -20,39 +20,39 @@
20
20
 
21
21
  @theme {
22
22
  /* ===== Neon Electric palette (11 colours, canonical D2C) ===== */
23
- --color-neon-electric-blue: #2563eb;
24
- --color-neon-electric-cyan: #00cec9;
25
- --color-neon-electric-indigo: #4f46e5;
26
- --color-neon-electric-purple: #6c5ce7;
27
- --color-neon-electric-magenta: #fd79a8;
28
- --color-neon-electric-pink: #ec4899;
29
- --color-neon-electric-coral: #ff4757;
30
- --color-neon-electric-orange: #ff7675;
31
- --color-neon-electric-yellow: #fdcb6e;
32
- --color-neon-electric-green: #22c55e;
33
- --color-neon-electric-lavender: #a29bfe;
23
+ --color-neon-electric-blue: oklch(0.546 0.215 262.9);
24
+ --color-neon-electric-cyan: oklch(0.769 0.132 191.7);
25
+ --color-neon-electric-indigo: oklch(0.511 0.23 277);
26
+ --color-neon-electric-purple: oklch(0.568 0.202 283.1);
27
+ --color-neon-electric-magenta: oklch(0.742 0.167 359.5);
28
+ --color-neon-electric-pink: oklch(0.656 0.212 354.3);
29
+ --color-neon-electric-coral: oklch(0.669 0.219 20.9);
30
+ --color-neon-electric-orange: oklch(0.728 0.168 22.5);
31
+ --color-neon-electric-yellow: oklch(0.868 0.125 81.4);
32
+ --color-neon-electric-green: oklch(0.723 0.192 149.6);
33
+ --color-neon-electric-lavender: oklch(0.736 0.141 285.6);
34
34
 
35
35
  /* ===== Light surface tokens ===== */
36
- --color-light-bg-primary: #f2f2f2;
37
- --color-light-bg-secondary: #f8f9fa;
38
- --color-light-bg-tertiary: #f1f3f4;
39
- --color-light-bg-accent: #ffffff;
40
- --color-light-text-primary: #2d3436;
41
- --color-light-text-secondary: #636e72;
36
+ --color-light-bg-primary: oklch(0.961 0 0.5);
37
+ --color-light-bg-secondary: oklch(0.982 0.002 248);
38
+ --color-light-bg-tertiary: oklch(0.963 0.003 228.9);
39
+ --color-light-bg-accent: oklch(1 0 0.5);
40
+ --color-light-text-primary: oklch(0.319 0.01 216.8);
41
+ --color-light-text-secondary: oklch(0.53 0.015 221.6);
42
42
 
43
43
  /* ===== Dark surface tokens ===== */
44
- --color-dark-bg-primary: #0a0e1a;
45
- --color-dark-bg-secondary: #1a1f2e;
46
- --color-dark-bg-tertiary: #2c3447;
47
- --color-dark-bg-accent: #252b3d;
48
- --color-dark-text-primary: #f8f9fa;
49
- --color-dark-text-secondary: #adb5bd;
44
+ --color-dark-bg-primary: oklch(0.166 0.026 269.4);
45
+ --color-dark-bg-secondary: oklch(0.242 0.03 269.9);
46
+ --color-dark-bg-tertiary: oklch(0.326 0.036 266.7);
47
+ --color-dark-bg-accent: oklch(0.292 0.034 270);
48
+ --color-dark-text-primary: oklch(0.982 0.002 248);
49
+ --color-dark-text-secondary: oklch(0.769 0.015 248);
50
50
 
51
51
  /* ===== Semantic status ===== */
52
- --color-status-success: #00b894;
53
- --color-status-warning: #fdcb6e;
54
- --color-status-error: #ff4757;
55
- --color-status-info: #00cec9;
52
+ --color-status-success: oklch(0.697 0.135 172.1);
53
+ --color-status-warning: oklch(0.868 0.125 81.4);
54
+ --color-status-error: oklch(0.669 0.219 20.9);
55
+ --color-status-info: oklch(0.769 0.132 191.7);
56
56
 
57
57
  /* ===== Animations (Tailwind v4 exposes via --animate-*) ===== */
58
58
  --animate-pulse-soft: pulse-soft 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @bsuite/theme — Tailwind v3 preset
2
+ * @bsuite/theme — Tailwind v3 preset (oklch edition)
3
3
  *
4
4
  * Consume via:
5
5
  * // tailwind.config.js (CommonJS)
@@ -12,9 +12,15 @@
12
12
  * surface tokens, semantic status colours, gradients, glow shadows, and
13
13
  * the six canonical animations. Does not override Tailwind's default scale.
14
14
  *
15
- * The `rgb(var(--*-rgb) / <alpha-value>)` pattern makes Tailwind alpha-value
16
- * modifiers work (e.g. `bg-neon-electric-blue/40`). Requires the companion
17
- * CSS vars to be loaded via `@import '@bsuite/theme/css'`.
15
+ * Colour values are inline oklch so the preset is self-contained — no
16
+ * dependency on any `--*-rgb` CSS custom property. Tailwind v3 injects
17
+ * `<alpha-value>` into any valid CSS colour function, and `oklch(L C H / A)`
18
+ * is valid CSS Color 4 syntax (supported in all evergreen browsers:
19
+ * Chrome ≥111, Safari ≥15.4, Firefox ≥113). Keeps bg-neon-electric-blue/40
20
+ * and friends working without the RGB-triplet variable indirection.
21
+ *
22
+ * Values are kept in sync with `preset-v4.css` — editing one without the
23
+ * other will cause v3 and v4 consumers to diverge. See README.
18
24
  *
19
25
  * NOTE: Tailwind v4 apps should import `@bsuite/theme/preset-v4.css` instead.
20
26
  * v4 removed the preset system — presets exist only for v3.
@@ -27,17 +33,17 @@ module.exports = {
27
33
  extend: {
28
34
  colors: {
29
35
  neon: {
30
- 'electric-blue': 'rgb(var(--neon-electric-blue-rgb) / <alpha-value>)',
31
- 'electric-cyan': 'rgb(var(--neon-electric-cyan-rgb) / <alpha-value>)',
32
- 'electric-indigo': 'rgb(var(--neon-electric-indigo-rgb) / <alpha-value>)',
33
- 'electric-purple': 'rgb(var(--neon-electric-purple-rgb) / <alpha-value>)',
34
- 'electric-magenta': 'rgb(var(--neon-electric-magenta-rgb) / <alpha-value>)',
35
- 'electric-pink': 'rgb(var(--neon-electric-pink-rgb) / <alpha-value>)',
36
- 'electric-coral': 'rgb(var(--neon-electric-coral-rgb) / <alpha-value>)',
37
- 'electric-orange': 'rgb(var(--neon-electric-orange-rgb) / <alpha-value>)',
38
- 'electric-yellow': 'rgb(var(--neon-electric-yellow-rgb) / <alpha-value>)',
39
- 'electric-green': 'rgb(var(--neon-electric-green-rgb) / <alpha-value>)',
40
- 'electric-lavender': 'rgb(var(--neon-electric-lavender-rgb) / <alpha-value>)',
36
+ 'electric-blue': 'oklch(0.546 0.215 262.9 / <alpha-value>)',
37
+ 'electric-cyan': 'oklch(0.769 0.132 191.7 / <alpha-value>)',
38
+ 'electric-indigo': 'oklch(0.511 0.23 277 / <alpha-value>)',
39
+ 'electric-purple': 'oklch(0.568 0.202 283.1 / <alpha-value>)',
40
+ 'electric-magenta': 'oklch(0.742 0.167 359.5 / <alpha-value>)',
41
+ 'electric-pink': 'oklch(0.656 0.212 354.3 / <alpha-value>)',
42
+ 'electric-coral': 'oklch(0.669 0.219 20.9 / <alpha-value>)',
43
+ 'electric-orange': 'oklch(0.728 0.168 22.5 / <alpha-value>)',
44
+ 'electric-yellow': 'oklch(0.868 0.125 81.4 / <alpha-value>)',
45
+ 'electric-green': 'oklch(0.726 0.197 145.5 / <alpha-value>)',
46
+ 'electric-lavender': 'oklch(0.749 0.115 288.4 / <alpha-value>)',
41
47
  },
42
48
  light: {
43
49
  bg: {
@@ -82,26 +88,26 @@ module.exports = {
82
88
 
83
89
  backgroundImage: {
84
90
  'gradient-brand':
85
- 'linear-gradient(135deg, #ff4757 0%, #ff7675 25%, #fdcb6e 50%, #00cec9 75%, #a29bfe 100%)',
91
+ 'linear-gradient(135deg, oklch(0.669 0.219 20.9) 0%, oklch(0.728 0.168 22.5) 25%, oklch(0.868 0.125 81.4) 50%, oklch(0.769 0.132 191.7) 75%, oklch(0.749 0.115 288.4) 100%)',
86
92
  'gradient-electric':
87
- 'linear-gradient(135deg, #2563eb 0%, #00cec9 50%, #ec4899 100%)',
93
+ 'linear-gradient(135deg, oklch(0.546 0.215 262.9) 0%, oklch(0.769 0.132 191.7) 50%, oklch(0.656 0.212 354.3) 100%)',
88
94
  'gradient-neon':
89
- 'linear-gradient(90deg, #00cec9 0%, #6c5ce7 50%, #ff4757 100%)',
95
+ 'linear-gradient(90deg, oklch(0.769 0.132 191.7) 0%, oklch(0.568 0.202 283.1) 50%, oklch(0.669 0.219 20.9) 100%)',
90
96
  'gradient-chat-user':
91
- 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
97
+ 'linear-gradient(135deg, oklch(0.636 0.153 271.3) 0%, oklch(0.466 0.168 296.3) 100%)',
92
98
  'gradient-chat-agent':
93
- 'linear-gradient(135deg, #2563eb 0%, #ec4899 100%)',
99
+ 'linear-gradient(135deg, oklch(0.546 0.215 262.9) 0%, oklch(0.656 0.212 354.3) 100%)',
94
100
  'gradient-neural':
95
- 'radial-gradient(circle at center, rgba(0, 206, 201, 0.1) 0%, transparent 50%)',
101
+ 'radial-gradient(circle at center, oklch(0.769 0.132 191.7 / 0.1) 0%, transparent 50%)',
96
102
  },
97
103
 
98
104
  boxShadow: {
99
- 'glow-electric-blue': '0 0 20px rgb(var(--neon-electric-blue-rgb) / var(--glow-strength))',
100
- 'glow-electric-cyan': '0 0 20px rgb(var(--neon-electric-cyan-rgb) / var(--glow-strength))',
101
- 'glow-electric-purple': '0 0 20px rgb(var(--neon-electric-purple-rgb) / var(--glow-strength))',
102
- 'glow-electric-pink': '0 0 20px rgb(var(--neon-electric-pink-rgb) / var(--glow-strength))',
103
- 'glow-electric-coral': '0 0 20px rgb(var(--neon-electric-coral-rgb) / var(--glow-strength))',
104
- 'glow-electric-magenta': '0 0 20px rgb(var(--neon-electric-magenta-rgb) / var(--glow-strength))',
105
+ 'glow-electric-blue': '0 0 20px oklch(0.546 0.215 262.9 / var(--glow-strength))',
106
+ 'glow-electric-cyan': '0 0 20px oklch(0.769 0.132 191.7 / var(--glow-strength))',
107
+ 'glow-electric-purple': '0 0 20px oklch(0.568 0.202 283.1 / var(--glow-strength))',
108
+ 'glow-electric-pink': '0 0 20px oklch(0.656 0.212 354.3 / var(--glow-strength))',
109
+ 'glow-electric-coral': '0 0 20px oklch(0.669 0.219 20.9 / var(--glow-strength))',
110
+ 'glow-electric-magenta': '0 0 20px oklch(0.742 0.167 359.5 / var(--glow-strength))',
105
111
  },
106
112
 
107
113
  animation: {
@@ -119,8 +125,8 @@ module.exports = {
119
125
  '50%': { opacity: '0.7' },
120
126
  },
121
127
  glow: {
122
- '0%': { boxShadow: '0 0 5px rgba(0, 206, 201, 0.2)' },
123
- '100%': { boxShadow: '0 0 20px rgba(0, 206, 201, 0.6)' },
128
+ '0%': { boxShadow: '0 0 5px oklch(0.769 0.132 191.7 / 0.2)' },
129
+ '100%': { boxShadow: '0 0 20px oklch(0.769 0.132 191.7 / 0.6)' },
124
130
  },
125
131
  typing: {
126
132
  '0%, 60%': { opacity: '1' },
@@ -135,8 +141,8 @@ module.exports = {
135
141
  '50%': { transform: 'translateY(-10px)' },
136
142
  },
137
143
  'neon-pulse': {
138
- '0%, 100%': { textShadow: '0 0 10px rgba(0, 206, 201, 0.3)' },
139
- '50%': { textShadow: '0 0 20px rgba(0, 206, 201, 0.8)' },
144
+ '0%, 100%': { textShadow: '0 0 10px oklch(0.769 0.132 191.7 / 0.3)' },
145
+ '50%': { textShadow: '0 0 20px oklch(0.769 0.132 191.7 / 0.8)' },
140
146
  },
141
147
  },
142
148
  },