@bsuite/theme 0.3.2 → 0.4.1

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.
package/README.md CHANGED
@@ -1,15 +1,14 @@
1
1
  # @bsuite/theme
2
2
 
3
- Universal D2C Neon Electric theme for the BSuite monorepo. Ships the canonical palette, Tailwind tokens, CSS variables, React ThemeProvider, and a framework-agnostic FOUC-prevention script.
3
+ Universal theme package for the BSuite monorepo. Ships the D2C Neon Electric baseline, the Braden Corporate baseline, Tailwind tokens, CSS variables, React `ThemeProvider`, runtime `BrandingProvider`, and a framework-agnostic FOUC-prevention script.
4
4
 
5
5
  ## What's inside
6
6
 
7
- - **CSS variables** — 11 neon electric colours + light/dark surface tokens + WCAG AA-compliant text tokens
8
- - **Tailwind v3 preset** — drop-in `presets: [require('@bsuite/theme/tailwind-preset')]`
9
- - **Tailwind v4 `@theme` block** — `@import '@bsuite/theme/preset-v4.css'`
7
+ - **CSS variables** — OKLCH source palettes, role aliases, light/dark surfaces, shadcn bridge variables, and WCAG AA-compliant anti-glare text tokens
8
+ - **Dual brand baselines** — D2C via `@bsuite/theme/css`; Braden via `@bsuite/theme/braden-css`
9
+ - **Tailwind v4+ `@theme` block** — `@import '@bsuite/theme/preset-v4.css'`
10
10
  - **`<ThemeProvider>`** + **`useTheme()`** with localStorage persistence + system preference
11
- - **`<BrandingProvider>`** + **`useBranding()`** for runtime tenant branding
12
- - **`usePlatformLogo()`** and `resolvePlatformLogo()` for shared light/dark/mark/favicon logo fallback selection
11
+ - **`<BrandingProvider>`** enterprise runtime white-labelling for D2C apps; Braden short-circuits to static corporate tokens
13
12
  - **`getThemeInitScript()`** — stringified JS for inline `<script>` tags to prevent FOUC
14
13
 
15
14
  ## Install
@@ -20,7 +19,7 @@ pnpm add @bsuite/theme
20
19
 
21
20
  ## Consumer setup
22
21
 
23
- ### Vite (v4 Tailwind) — BSU / CRM7 / R80.3
22
+ ### Vite (Tailwind v4+) — BSU / CRM7 / R80.3 / Throughput
24
23
 
25
24
  **1.** `src/index.css` or equivalent global stylesheet:
26
25
 
@@ -32,6 +31,13 @@ pnpm add @bsuite/theme
32
31
  /* your app-specific @theme {} overrides below */
33
32
  ```
34
33
 
34
+ Braden uses the same Tailwind bridge shape but imports the corporate baseline:
35
+
36
+ ```css
37
+ @import 'tailwindcss';
38
+ @import '@bsuite/theme/braden-css';
39
+ ```
40
+
35
41
  **2.** `src/main.tsx`:
36
42
 
37
43
  ```tsx
@@ -48,7 +54,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
48
54
 
49
55
  Paste the result of `getThemeInitScript()` into `<head>` before stylesheets.
50
56
 
51
- ### Next.js 16 (v4 Tailwind) — conduit
57
+ ### Next.js 16 (Tailwind v4+) — conduit
52
58
 
53
59
  **1.** `src/app/globals.css`:
54
60
 
@@ -81,27 +87,11 @@ export default function RootLayout({ children }) {
81
87
  }
82
88
  ```
83
89
 
84
- ### Vite (v3 Tailwind) — throughput
85
-
86
- **1.** `tailwind.config.js`:
90
+ ### Tailwind floor
87
91
 
88
- ```js
89
- module.exports = {
90
- presets: [require('@bsuite/theme/tailwind-preset')],
91
- content: ['./src/**/*.{js,ts,jsx,tsx}'],
92
- }
93
- ```
94
-
95
- **2.** Global stylesheet:
96
-
97
- ```css
98
- @tailwind base;
99
- @tailwind components;
100
- @tailwind utilities;
101
- @import '@bsuite/theme/css';
102
- ```
103
-
104
- **3.** `src/main.tsx` + `index.html` — same as the v4 Vite setup above.
92
+ All consumers must use Tailwind CSS v4 or later. The legacy `./tailwind-preset`
93
+ export remains only so older published package metadata does not break import
94
+ resolution; it is not a supported BSuite consumption path.
105
95
 
106
96
  ## Using the hook
107
97
 
@@ -120,46 +110,21 @@ export function ThemeToggleButton() {
120
110
 
121
111
  The `resolvedTheme` always returns `'light'` or `'dark'` — use this when you need the _effective_ theme (it resolves `'system'` for you).
122
112
 
123
- ## Runtime branding and platform logos
124
-
125
- Wrap BSuite apps in `BrandingProvider` when tenant/platform branding should be resolved from Supabase. Braden Corporate can pass `brand="braden"` to keep using static corporate tokens.
113
+ ## Palette And Roles
126
114
 
127
- ```tsx
128
- import { BrandingProvider, usePlatformLogo } from '@bsuite/theme/react'
129
-
130
- function HeaderLogo() {
131
- const { src, alt, isLoading } = usePlatformLogo({
132
- slot: 'header',
133
- scheme: 'light',
134
- fallback: '/logos/bsuite.svg',
135
- })
115
+ All 11 canonical electric colours are available as palette tokens, but consumer UI should bind to role/shadcn tokens. Palette names are presentational; roles are the stable contract for white-labelling.
136
116
 
137
- if (isLoading || !src) return null
138
- return <img src={src} alt={alt} />
139
- }
140
- ```
141
-
142
- Use `resolvePlatformLogo()` in non-React code or tests when you already have a branding payload and need the same fallback order without mounting a provider.
143
-
144
- ## Palette
117
+ | Role / token | OKLCH source | Notes |
118
+ |---|---|---|
119
+ | `--role-primary` / Electric Blue | `oklch(0.546 0.215 262.9)` | Primary actions and focus affordances |
120
+ | `--role-accent` / Electric Cyan | `oklch(0.769 0.132 191.7)` | Accents, highlights, visible focus in dark mode |
121
+ | `--role-success` | `oklch(0.723 0.192 149.6)` | Success; never rely on colour alone |
122
+ | `--role-warning` | `oklch(0.728 0.168 22.5)` | Warning; pair with icon/text |
123
+ | `--role-error` / `--role-destructive` | `oklch(0.568 0.202 283.1)` | Purple by platform policy; coral/red must not be semantic error/destructive |
145
124
 
146
- All 11 canonical electric colours are available via Tailwind classes:
125
+ Braden keeps separate corporate identity tokens in `@bsuite/theme/braden-css`: Braden Red `oklch(0.51 0.17 19)`, Braden Gold `oklch(0.77 0.10 82)`, and Braden Navy `oklch(0.34 0.04 250)`. Red is identity only; Braden error/destructive roles still map to purple.
147
126
 
148
- | Token | Hex | Use |
149
- |---|---|---|
150
- | `neon-electric-blue` | `#2563eb` | Primary, highlights |
151
- | `neon-electric-cyan` | `#00cec9` | Accents, borders |
152
- | `neon-electric-indigo` | `#4f46e5` | Secondary actions |
153
- | `neon-electric-purple` | `#6c5ce7` | Gradients, effects |
154
- | `neon-electric-magenta` | `#fd79a8` | Interactive |
155
- | `neon-electric-pink` | `#ec4899` | Hover states |
156
- | `neon-electric-coral` | `#ff4757` | Alerts, destructive |
157
- | `neon-electric-orange` | `#ff7675` | Warnings |
158
- | `neon-electric-yellow` | `#fdcb6e` | Info |
159
- | `neon-electric-green` | `#22c55e` | Success |
160
- | `neon-electric-lavender` | `#a29bfe` | Subtle accents |
161
-
162
- WCAG AA compliance: use `text-color-accent-text` and `text-color-primary-text` for text on light backgrounds. These are the accessible equivalents of cyan and blue — same visual identity, 5:1+ contrast on `#f2f2f2`.
127
+ WCAG AA compliance: use semantic text tokens (`text-foreground`, `text-muted-foreground`, `text-text-on-primary`, `text-text-on-accent`) rather than raw `text-white`/`text-black`. Dark-surface text is capped at L=0.94 for extended-session comfort.
163
128
 
164
129
  ## License
165
130
 
@@ -10,16 +10,16 @@ export interface TenantBranding {
10
10
  accent?: string;
11
11
  /** Full logo URL (SVG or raster) */
12
12
  logo_url?: string;
13
- /** Light-mode full logo URL (SVG or raster) */
13
+ /** Light-mode full logo URL */
14
14
  logo_light_url?: string;
15
- /** Dark-mode full logo URL (SVG or raster) */
15
+ /** Dark-mode full logo URL */
16
16
  logo_dark_url?: string;
17
17
  /** Mark / icon logo URL */
18
18
  mark_url?: string;
19
19
  /** Favicon URL */
20
20
  favicon_url?: string;
21
- /** Display name used for default image alt text */
22
- company_name?: string | null;
21
+ /** Display name used for generated logo alt text */
22
+ company_name?: string;
23
23
  /** Optional CSS font-family stack string */
24
24
  font_stack?: string | null;
25
25
  }
@@ -1 +1 @@
1
- {"version":3,"file":"BrandingProvider.d.ts","sourceRoot":"","sources":["../../src/react/BrandingProvider.tsx"],"names":[],"mappings":"AAmCA,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,wDAAwD;IACxD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kBAAkB;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AAED,eAAO,MAAM,eAAe,2DAA6D,CAAA;AAEzF,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE9C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,SAAS,CAAA;IACnB,2DAA2D;IAC3D,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AA6JD,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,cAAc,EACd,KAAgB,EAChB,qBAA4B,GAC7B,EAAE,qBAAqB,2CA+EvB"}
1
+ {"version":3,"file":"BrandingProvider.d.ts","sourceRoot":"","sources":["../../src/react/BrandingProvider.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAO3D,eAAO,MAAM,oBAAoB,2BAA2B,CAAA;AAC5D,eAAO,MAAM,sBAAsB,kCAAkC,CAAA;AAErE,yDAAyD;AACzD,MAAM,WAAW,cAAc;IAC7B,wDAAwD;IACxD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,+BAA+B;IAC/B,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kBAAkB;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,oDAAoD;IACpD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AAED,eAAO,MAAM,eAAe,2DAA6D,CAAA;AAEzF,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE9C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,SAAS,CAAA;IACnB,2DAA2D;IAC3D,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AA+KD,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,cAAc,EACd,KAAgB,EAChB,qBAA4B,GAC7B,EAAE,qBAAqB,2CA+EvB"}
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * BrandingProvider — runtime enterprise white-labelling
4
- * Version 0.3.0
4
+ * Version 0.4.1
5
5
  *
6
6
  * On mount:
7
7
  * 1. Calls supabase.rpc('branding_json_for_tenant') using the user's authed session.
@@ -17,6 +17,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
17
17
  * Colourblind policy (purple error) is a system-level requirement, not a brand pref.
18
18
  * - brand="braden" short-circuits all Supabase calls — Braden corporate site uses
19
19
  * static CSS tokens, not runtime tenant overrides.
20
+ * - SEC-002 / SEC-003 (added 0.4.1) — tenant-controlled URL fields
21
+ * (`logo_url`, `logo_light_url`, `logo_dark_url`, `mark_url`,
22
+ * `favicon_url`) and `font_stack` are routed through `branding-sanitize`
23
+ * at the DOM-apply sink. Dangerous schemes (`javascript:`, `data:`,
24
+ * `vbscript:`, `blob:`, `file:`) are rejected; the URL is parsed and
25
+ * re-serialised so breakout chars are percent-encoded; the `url()`
26
+ * token is emitted in the safe quoted form with `"` and `\` escaped;
27
+ * font values carrying CSS-breakout tokens (`< > ( ) { } ; @ \ /* *\/`)
28
+ * are rejected and the var is cleared so the default font applies.
20
29
  *
21
30
  * Environment flags:
22
31
  * - VITE_ENABLE_BRANDING_OVERRIDE (default: 'true') — set 'false' as kill switch.
@@ -34,6 +43,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
34
43
  * </BrandingProvider>
35
44
  */
36
45
  import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
46
+ import { sanitizeBrandingUrl, sanitizeFontFamily, toCssUrl, } from './branding-sanitize';
37
47
  export const BRANDING_STORAGE_KEY = 'bsuite_tenant_branding';
38
48
  export const BRANDING_OVERRIDE_FLAG = 'VITE_ENABLE_BRANDING_OVERRIDE';
39
49
  export const BrandingContext = createContext(undefined);
@@ -72,13 +82,9 @@ const BRANDING_CSS_MAP = {
72
82
  logo_dark_url: '--logo-dark-url',
73
83
  mark_url: '--mark-url',
74
84
  favicon_url: '--favicon-url',
85
+ company_name: '',
75
86
  font_stack: '--font-stack',
76
87
  };
77
- function setUrlCssVar(root, cssVar, value) {
78
- const trimmed = value?.trim();
79
- if (trimmed)
80
- root.style.setProperty(cssVar, `url(${trimmed})`);
81
- }
82
88
  function applyBrandingToRoot(branding) {
83
89
  if (typeof document === 'undefined')
84
90
  return;
@@ -97,7 +103,7 @@ function applyBrandingToRoot(branding) {
97
103
  }
98
104
  if (branding.primary) {
99
105
  if (!isValidOklch(branding.primary)) {
100
- if (process.env.NODE_ENV === 'development') {
106
+ if (isDevEnvironment()) {
101
107
  console.warn(`[BrandingProvider] Rejected primary colour "${branding.primary}" — must be oklch(). ` +
102
108
  'Only oklch() values are accepted. Hex/rgb/hsl are not permitted in the token system.');
103
109
  }
@@ -111,7 +117,7 @@ function applyBrandingToRoot(branding) {
111
117
  }
112
118
  if (branding.accent) {
113
119
  if (!isValidOklch(branding.accent)) {
114
- if (process.env.NODE_ENV === 'development') {
120
+ if (isDevEnvironment()) {
115
121
  console.warn(`[BrandingProvider] Rejected accent colour "${branding.accent}" — must be oklch().`);
116
122
  }
117
123
  }
@@ -121,19 +127,36 @@ function applyBrandingToRoot(branding) {
121
127
  root.style.setProperty('--app-accent', branding.accent);
122
128
  }
123
129
  }
124
- setUrlCssVar(root, '--logo-url', branding.logo_url);
125
- setUrlCssVar(root, '--logo-light-url', branding.logo_light_url);
126
- setUrlCssVar(root, '--logo-dark-url', branding.logo_dark_url);
127
- setUrlCssVar(root, '--mark-url', branding.mark_url);
128
- setUrlCssVar(root, '--favicon-url', branding.favicon_url);
129
- if (branding.font_stack) {
130
- root.style.setProperty('--font-stack', branding.font_stack);
130
+ // Tenant-controlled URL and font values pass through `branding-sanitize`
131
+ // (SEC-002 / SEC-003) so a crafted row cannot break out of the `url(...)`
132
+ // token or the font declaration and inject arbitrary CSS. Each setter is
133
+ // paired with a removeProperty fall-through so a malicious value clears
134
+ // the var (caller falls back to the default) rather than persisting a
135
+ // previous tenant's URL.
136
+ const setOrClearUrlVar = (cssVar, raw) => {
137
+ const safe = toCssUrl(sanitizeBrandingUrl(raw));
138
+ if (safe)
139
+ root.style.setProperty(cssVar, safe);
140
+ else
141
+ root.style.removeProperty(cssVar);
142
+ };
143
+ setOrClearUrlVar('--logo-url', branding.logo_url);
144
+ setOrClearUrlVar('--logo-light-url', branding.logo_light_url);
145
+ setOrClearUrlVar('--logo-dark-url', branding.logo_dark_url);
146
+ setOrClearUrlVar('--mark-url', branding.mark_url);
147
+ setOrClearUrlVar('--favicon-url', branding.favicon_url);
148
+ const safeFontStack = sanitizeFontFamily(branding.font_stack ?? null);
149
+ if (safeFontStack) {
150
+ root.style.setProperty('--font-stack', safeFontStack);
151
+ }
152
+ else {
153
+ root.style.removeProperty('--font-stack');
131
154
  }
132
155
  root.setAttribute('data-branding-loaded', 'true');
133
156
  }
134
157
  // Warn in dev if a caller tries to set a protected key
135
158
  function warnIfProtectedKeyAttempted(branding) {
136
- if (process.env.NODE_ENV !== 'development')
159
+ if (!isDevEnvironment())
137
160
  return;
138
161
  // Check for any attempt to set error/destructive via unexpected RPC fields
139
162
  const raw = branding;
@@ -145,6 +168,10 @@ function warnIfProtectedKeyAttempted(branding) {
145
168
  }
146
169
  }
147
170
  }
171
+ function isDevEnvironment() {
172
+ const env = import.meta.env;
173
+ return env?.DEV === true || env?.MODE === 'development' || env?.NODE_ENV === 'development';
174
+ }
148
175
  function persistBranding(branding) {
149
176
  try {
150
177
  if (branding) {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * branding-sanitize — neutralises CSS injection in tenant-controlled
3
+ * white-label values before they reach CSS custom properties.
4
+ *
5
+ * Closes two risks in the `applyBrandingToRoot` sink in `BrandingProvider`:
6
+ *
7
+ * SEC-002 — CSS injection via `font_stack`. A crafted value written to
8
+ * `--font-stack` can carry `</style>`, `expression(...)`,
9
+ * `url(...)` or a declaration breakout.
10
+ * SEC-003 — CSS injection via `logo_url` / `mark_url` / `logo_*_url` /
11
+ * `favicon_url`. An unescaped value embedded in an unquoted
12
+ * `url(...)` token can close the token early and inject
13
+ * arbitrary CSS, or smuggle a dangerous scheme
14
+ * (`javascript:`, `data:text/html`, …).
15
+ *
16
+ * The three white-label tables backing `branding_json_for_tenant`
17
+ * (`platform_branding`, `tenant_branding`, `tenant_app_branding`) are
18
+ * writable by semi-trusted platform/tenant admins, so the sanitiser runs
19
+ * at the DOM-apply boundary — every write path is covered, not just the
20
+ * admin UI.
21
+ *
22
+ * Mirrors the canonical sanitiser shipped on
23
+ * business-suite-unified/src/lib/branding-sanitize.ts (BSU#485, merged
24
+ * 2026-05-25)
25
+ * crm7/src/lib/branding-sanitize.ts (crm7#857, open draft)
26
+ * — DRY-merged into `@bsuite/theme` here so every consumer of
27
+ * `<BrandingProvider>` is covered with a single version bump.
28
+ *
29
+ * All helpers are pure and regex-free (per the BSuite No-Regex-by-Default
30
+ * rule); they are exercised directly in `./branding-sanitize.test.ts`.
31
+ */
32
+ /**
33
+ * Validate a branding image URL (logo / mark / favicon).
34
+ *
35
+ * Accepts and returns:
36
+ * - absolute `http(s)` URLs — parsed and normalised via the URL API
37
+ * - root-relative paths (`/logo.svg`) — same-origin, no scheme to abuse
38
+ *
39
+ * Returns `null` for everything else — dangerous schemes, protocol-relative
40
+ * `//host` forms, control characters, unparseable input, over-long input.
41
+ * Callers treat `null` as "no logo" and fall back to the default.
42
+ */
43
+ export declare function sanitizeBrandingUrl(raw: string | null | undefined): string | null;
44
+ /**
45
+ * Wrap a validated URL in a `url("...")` token, escaping `"` and `\` so the
46
+ * string cannot be closed early. Always pair with {@link sanitizeBrandingUrl}
47
+ * — never call this on un-validated input. Returns `null` for `null` input
48
+ * so call sites can pass the result straight to a set-or-clear helper.
49
+ */
50
+ export declare function toCssUrl(safeUrl: string | null | undefined): string | null;
51
+ /**
52
+ * Validate a CSS `font-family` value. Legitimate font stacks are
53
+ * comma-separated family names (`"Inter", "Helvetica Neue", sans-serif`)
54
+ * and never need braces, semicolons, parentheses, at-rules or comment
55
+ * markers. The presence of any forbidden token means an injection attempt,
56
+ * so the whole value is rejected (returns `null` → caller falls back to the
57
+ * default font).
58
+ */
59
+ export declare function sanitizeFontFamily(raw: string | null | undefined): string | null;
60
+ //# sourceMappingURL=branding-sanitize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branding-sanitize.d.ts","sourceRoot":"","sources":["../../src/react/branding-sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AA0CH;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAoBjF;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAG1E;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAShF"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * branding-sanitize — neutralises CSS injection in tenant-controlled
3
+ * white-label values before they reach CSS custom properties.
4
+ *
5
+ * Closes two risks in the `applyBrandingToRoot` sink in `BrandingProvider`:
6
+ *
7
+ * SEC-002 — CSS injection via `font_stack`. A crafted value written to
8
+ * `--font-stack` can carry `</style>`, `expression(...)`,
9
+ * `url(...)` or a declaration breakout.
10
+ * SEC-003 — CSS injection via `logo_url` / `mark_url` / `logo_*_url` /
11
+ * `favicon_url`. An unescaped value embedded in an unquoted
12
+ * `url(...)` token can close the token early and inject
13
+ * arbitrary CSS, or smuggle a dangerous scheme
14
+ * (`javascript:`, `data:text/html`, …).
15
+ *
16
+ * The three white-label tables backing `branding_json_for_tenant`
17
+ * (`platform_branding`, `tenant_branding`, `tenant_app_branding`) are
18
+ * writable by semi-trusted platform/tenant admins, so the sanitiser runs
19
+ * at the DOM-apply boundary — every write path is covered, not just the
20
+ * admin UI.
21
+ *
22
+ * Mirrors the canonical sanitiser shipped on
23
+ * business-suite-unified/src/lib/branding-sanitize.ts (BSU#485, merged
24
+ * 2026-05-25)
25
+ * crm7/src/lib/branding-sanitize.ts (crm7#857, open draft)
26
+ * — DRY-merged into `@bsuite/theme` here so every consumer of
27
+ * `<BrandingProvider>` is covered with a single version bump.
28
+ *
29
+ * All helpers are pure and regex-free (per the BSuite No-Regex-by-Default
30
+ * rule); they are exercised directly in `./branding-sanitize.test.ts`.
31
+ */
32
+ /** Schemes permitted in a branding image URL. Everything else
33
+ * (`javascript:`, `data:`, `vbscript:`, `blob:`, `file:`, …) is rejected. */
34
+ const SAFE_URL_SCHEMES = new Set(['http:', 'https:']);
35
+ /** Upper bound on a branding URL — guards against pathological input. */
36
+ const MAX_URL_LENGTH = 2048;
37
+ /** Upper bound on a font-family value — real font stacks are short. */
38
+ const MAX_FONT_LENGTH = 200;
39
+ /**
40
+ * Substrings that must never appear in a font-family value. Each can
41
+ * break out of the declaration or open an injection vector:
42
+ * - angle brackets → closing-tag breakout
43
+ * - parentheses → url() / expression() invocation
44
+ * - braces, semicolon → declaration / ruleset breakout
45
+ * - at-sign → at-rules such as the import rule
46
+ * - backslash → CSS escape sequences
47
+ * - the comment-open / comment-close digraphs
48
+ * Real font stacks are comma-separated family names and need none of these.
49
+ */
50
+ const FONT_FORBIDDEN_TOKENS = [
51
+ '<', '>', '(', ')', '{', '}', ';', '@', '\\', '/*', '*/',
52
+ ];
53
+ /** True when the string contains an ASCII control character (incl. newlines),
54
+ * any of which can terminate a CSS token early. Regex-free by design. */
55
+ function hasControlChar(value) {
56
+ for (let i = 0; i < value.length; i += 1) {
57
+ const code = value.charCodeAt(i);
58
+ if (code < 0x20 || code === 0x7f)
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+ /** Escape the characters that could close a double-quoted CSS string early. */
64
+ function escapeCssString(value) {
65
+ return value.split('\\').join('\\\\').split('"').join('\\"');
66
+ }
67
+ /**
68
+ * Validate a branding image URL (logo / mark / favicon).
69
+ *
70
+ * Accepts and returns:
71
+ * - absolute `http(s)` URLs — parsed and normalised via the URL API
72
+ * - root-relative paths (`/logo.svg`) — same-origin, no scheme to abuse
73
+ *
74
+ * Returns `null` for everything else — dangerous schemes, protocol-relative
75
+ * `//host` forms, control characters, unparseable input, over-long input.
76
+ * Callers treat `null` as "no logo" and fall back to the default.
77
+ */
78
+ export function sanitizeBrandingUrl(raw) {
79
+ if (typeof raw !== 'string')
80
+ return null;
81
+ const value = raw.trim();
82
+ if (value === '' || value.length > MAX_URL_LENGTH)
83
+ return null;
84
+ if (hasControlChar(value))
85
+ return null;
86
+ // Root-relative path: same-origin, no scheme. Reject the protocol-relative
87
+ // `//host` form, which resolves to an arbitrary (attacker-chosen) origin.
88
+ if (value.startsWith('/')) {
89
+ return value.startsWith('//') ? null : value;
90
+ }
91
+ let parsed;
92
+ try {
93
+ parsed = new URL(value);
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ if (!SAFE_URL_SCHEMES.has(parsed.protocol))
99
+ return null;
100
+ return parsed.href;
101
+ }
102
+ /**
103
+ * Wrap a validated URL in a `url("...")` token, escaping `"` and `\` so the
104
+ * string cannot be closed early. Always pair with {@link sanitizeBrandingUrl}
105
+ * — never call this on un-validated input. Returns `null` for `null` input
106
+ * so call sites can pass the result straight to a set-or-clear helper.
107
+ */
108
+ export function toCssUrl(safeUrl) {
109
+ if (!safeUrl)
110
+ return null;
111
+ return `url("${escapeCssString(safeUrl)}")`;
112
+ }
113
+ /**
114
+ * Validate a CSS `font-family` value. Legitimate font stacks are
115
+ * comma-separated family names (`"Inter", "Helvetica Neue", sans-serif`)
116
+ * and never need braces, semicolons, parentheses, at-rules or comment
117
+ * markers. The presence of any forbidden token means an injection attempt,
118
+ * so the whole value is rejected (returns `null` → caller falls back to the
119
+ * default font).
120
+ */
121
+ export function sanitizeFontFamily(raw) {
122
+ if (typeof raw !== 'string')
123
+ return null;
124
+ const value = raw.trim();
125
+ if (value === '' || value.length > MAX_FONT_LENGTH)
126
+ return null;
127
+ if (hasControlChar(value))
128
+ return null;
129
+ for (const token of FONT_FORBIDDEN_TOKENS) {
130
+ if (value.includes(token))
131
+ return null;
132
+ }
133
+ return value;
134
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * StatusBadge — canonical status pill across BSuite apps.
3
+ *
4
+ * Brand-Token Strategy A (operator-approved 2026-05-13): consolidates the
5
+ * 4 parallel implementations from crm7/conduit/BSU/R80.3 into one published
6
+ * primitive. Apps replace their local StatusBadge with `import { StatusBadge }
7
+ * from '@bsuite/theme/react'`.
8
+ *
9
+ * Self-contained (no router, no className util peer-dep):
10
+ * - Visual-only by default. Wrap in your app's `<Link>` / `<button>` for
11
+ * navigation/click behaviour. Keeps this package router-agnostic.
12
+ * - Includes `variant` (6 colours) + auto-resolver `getStatusVariant()` +
13
+ * `formatStatusLabel()`. Exports `AutoStatusBadge` for the common
14
+ * "give me a badge from a status string" case.
15
+ *
16
+ * Visual contract (D2C Neon Electric + Braden Corporate compatible):
17
+ * - Tailwind v4 classes against the role tokens consumers ship via
18
+ * `@bsuite/theme/css` or `@bsuite/theme/braden-css`.
19
+ * - Dark-mode handled via `dark:` prefix on each variant.
20
+ *
21
+ * Sizes: 'sm' (default — table cells), 'md' (cards), 'lg' (heroes).
22
+ */
23
+ import { type ReactNode, type CSSProperties } from 'react';
24
+ export type BadgeVariant =
25
+ /** Green — Approved, Active, Complete */
26
+ 'success'
27
+ /** Amber — Pending, In Progress */
28
+ | 'warning'
29
+ /** Red — Rejected, Failed, Overdue */
30
+ | 'error'
31
+ /** Blue — Draft, New, Scheduled */
32
+ | 'info'
33
+ /** Purple — Converted, Promoted */
34
+ | 'purple'
35
+ /** Gray — Inactive, Archived */
36
+ | 'neutral';
37
+ export interface StatusBadgeProps {
38
+ variant: BadgeVariant;
39
+ label: string;
40
+ /** sm = table cells (default), md = cards, lg = heroes */
41
+ size?: 'sm' | 'md' | 'lg';
42
+ className?: string;
43
+ style?: CSSProperties;
44
+ /** Optional id/data attributes for testing */
45
+ 'data-testid'?: string;
46
+ }
47
+ export declare function StatusBadge({ variant, label, size, className, style, 'data-testid': testId, }: StatusBadgeProps): ReactNode;
48
+ /**
49
+ * Map a status string to a semantic BadgeVariant.
50
+ *
51
+ * Normalizes lowercase + trim + underscores/hyphens → spaces, then matches
52
+ * against the canonical synonym sets. Pass `overrides` to remap a domain-
53
+ * specific status without forking the function.
54
+ */
55
+ export declare function getStatusVariant(status: string, overrides?: Partial<Record<string, BadgeVariant>>): BadgeVariant;
56
+ /**
57
+ * Format a raw status string into a human-readable label.
58
+ * Examples: 'in_progress' → 'In Progress', 'not_yet_competent' → 'Not Yet Competent'.
59
+ */
60
+ export declare function formatStatusLabel(status: string): string;
61
+ export interface AutoStatusBadgeProps extends Omit<StatusBadgeProps, 'variant' | 'label'> {
62
+ status: string;
63
+ /** Optional label override; auto-formatted from status when omitted. */
64
+ label?: string;
65
+ /** Override specific status→variant mappings for domain-specific needs. */
66
+ variantOverrides?: Partial<Record<string, BadgeVariant>>;
67
+ }
68
+ /**
69
+ * Convenience wrapper: derives variant + label from a raw status string.
70
+ *
71
+ * @example
72
+ * <AutoStatusBadge status="in_progress" />
73
+ * // renders <StatusBadge variant="warning" label="In Progress" />
74
+ */
75
+ export declare function AutoStatusBadge({ status, label, variantOverrides, ...rest }: AutoStatusBadgeProps): ReactNode;
76
+ //# sourceMappingURL=StatusBadge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StatusBadge.d.ts","sourceRoot":"","sources":["../../../src/react/components/StatusBadge.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,OAAO,CAAC;AAE3D,MAAM,MAAM,YAAY;AACtB,yCAAyC;AACvC,SAAS;AACX,mCAAmC;GACjC,SAAS;AACX,sCAAsC;GACpC,OAAO;AACT,mCAAmC;GACjC,MAAM;AACR,mCAAmC;GACjC,QAAQ;AACV,gCAAgC;GAC9B,SAAS,CAAC;AAEd,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAqBD,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,KAAK,EACL,IAAW,EACX,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,GACtB,EAAE,gBAAgB,GAAG,SAAS,CAc9B;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,GAChD,YAAY,CAoCd;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CASxD;AAED,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,OAAO,CAAC;IACvF,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC;CAC1D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,EAC9B,MAAM,EACN,KAAK,EACL,gBAAgB,EAChB,GAAG,IAAI,EACR,EAAE,oBAAoB,GAAG,SAAS,CAIlC"}
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ const variantClasses = {
3
+ success: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-300 dark:border-emerald-800',
4
+ warning: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800',
5
+ error: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800',
6
+ info: 'bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300 dark:border-sky-800',
7
+ purple: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800',
8
+ neutral: 'bg-muted text-muted-foreground border-border',
9
+ };
10
+ const sizeClasses = {
11
+ sm: 'text-xs px-2 py-0.5',
12
+ md: 'text-sm px-2.5 py-1',
13
+ lg: 'text-base px-3 py-1.5',
14
+ };
15
+ export function StatusBadge({ variant, label, size = 'sm', className, style, 'data-testid': testId, }) {
16
+ const classes = [
17
+ 'inline-flex items-center font-medium rounded-full border',
18
+ variantClasses[variant],
19
+ sizeClasses[size],
20
+ className,
21
+ ]
22
+ .filter(Boolean)
23
+ .join(' ');
24
+ return (_jsx("span", { className: classes, style: style, "data-testid": testId, children: label }));
25
+ }
26
+ /**
27
+ * Map a status string to a semantic BadgeVariant.
28
+ *
29
+ * Normalizes lowercase + trim + underscores/hyphens → spaces, then matches
30
+ * against the canonical synonym sets. Pass `overrides` to remap a domain-
31
+ * specific status without forking the function.
32
+ */
33
+ export function getStatusVariant(status, overrides) {
34
+ const normalized = status.toLowerCase().trim().replace(/_/g, ' ').replace(/-/g, ' ');
35
+ if (overrides && normalized in overrides) {
36
+ const override = overrides[normalized];
37
+ if (override)
38
+ return override;
39
+ }
40
+ const SUCCESS = [
41
+ 'approved', 'active', 'complete', 'completed', 'won',
42
+ 'current', 'on track', 'competent', 'pass', 'passed',
43
+ 'filled', 'resolved', 'closed won',
44
+ ];
45
+ const WARNING = [
46
+ 'pending', 'in progress', 'review', 'under review',
47
+ 'at risk', 'on leave', 'offered', 'onboarding',
48
+ 'probation', 'investigating', 'action required',
49
+ ];
50
+ const ERROR = [
51
+ 'rejected', 'failed', 'lost', 'overdue', 'fail',
52
+ 'cancelled', 'canceled', 'terminated', 'suspended',
53
+ 'behind', 'unavailable', 'expired', 'not yet competent',
54
+ 'closed lost',
55
+ ];
56
+ const INFO = [
57
+ 'draft', 'new', 'scheduled', 'open',
58
+ 'applicant', 'interviewing', 'reported',
59
+ ];
60
+ const PURPLE = ['converted', 'promoted', 'shortlisted'];
61
+ if (SUCCESS.includes(normalized))
62
+ return 'success';
63
+ if (WARNING.includes(normalized))
64
+ return 'warning';
65
+ if (ERROR.includes(normalized))
66
+ return 'error';
67
+ if (INFO.includes(normalized))
68
+ return 'info';
69
+ if (PURPLE.includes(normalized))
70
+ return 'purple';
71
+ return 'neutral';
72
+ }
73
+ /**
74
+ * Format a raw status string into a human-readable label.
75
+ * Examples: 'in_progress' → 'In Progress', 'not_yet_competent' → 'Not Yet Competent'.
76
+ */
77
+ export function formatStatusLabel(status) {
78
+ return status
79
+ .replace(/_/g, ' ')
80
+ .replace(/-/g, ' ')
81
+ .trim()
82
+ .split(' ')
83
+ .filter(Boolean)
84
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
85
+ .join(' ');
86
+ }
87
+ /**
88
+ * Convenience wrapper: derives variant + label from a raw status string.
89
+ *
90
+ * @example
91
+ * <AutoStatusBadge status="in_progress" />
92
+ * // renders <StatusBadge variant="warning" label="In Progress" />
93
+ */
94
+ export function AutoStatusBadge({ status, label, variantOverrides, ...rest }) {
95
+ const variant = getStatusVariant(status, variantOverrides);
96
+ const displayLabel = label ?? formatStatusLabel(status);
97
+ return _jsx(StatusBadge, { variant: variant, label: displayLabel, ...rest });
98
+ }
@@ -6,6 +6,8 @@ export { THEME_STORAGE_KEY } from '../index';
6
6
  export { BrandingProvider, BrandingContext, BRANDING_STORAGE_KEY, BRANDING_OVERRIDE_FLAG, } from './BrandingProvider';
7
7
  export type { TenantBranding, BrandingContextValue } from './BrandingProvider';
8
8
  export { useBranding } from './useBranding';
9
- export { resolvePlatformLogo, usePlatformLogo, } from './usePlatformLogo';
9
+ export { resolvePlatformLogo, usePlatformLogo } from './usePlatformLogo';
10
10
  export type { PlatformLogoHookResult, PlatformLogoOptions, PlatformLogoScheme, PlatformLogoSlot, PlatformLogoSource, ResolvedPlatformLogo, } from './usePlatformLogo';
11
+ export { StatusBadge, AutoStatusBadge, getStatusVariant, formatStatusLabel, } from './components/StatusBadge';
12
+ export type { BadgeVariant, StatusBadgeProps, AutoStatusBadgeProps, } from './components/StatusBadge';
11
13
  //# 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;AAC5C,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,oBAAoB,CAAA;AAC3B,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AAC9E,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EACL,mBAAmB,EACnB,eAAe,GAChB,MAAM,mBAAmB,CAAA;AAC1B,YAAY,EACV,sBAAsB,EACtB,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,mBAAmB,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,EACL,gBAAgB,EAChB,eAAe,EACf,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,oBAAoB,CAAA;AAC3B,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AAC9E,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACxE,YAAY,EACV,sBAAsB,EACtB,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EACL,WAAW,EACX,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,0BAA0B,CAAA;AACjC,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,0BAA0B,CAAA"}
@@ -3,4 +3,6 @@ export { useTheme } from './useTheme';
3
3
  export { THEME_STORAGE_KEY } from '../index';
4
4
  export { BrandingProvider, BrandingContext, BRANDING_STORAGE_KEY, BRANDING_OVERRIDE_FLAG, } from './BrandingProvider';
5
5
  export { useBranding } from './useBranding';
6
- export { resolvePlatformLogo, usePlatformLogo, } from './usePlatformLogo';
6
+ export { resolvePlatformLogo, usePlatformLogo } from './usePlatformLogo';
7
+ // Components — see ./components/<Name>.tsx
8
+ export { StatusBadge, AutoStatusBadge, getStatusVariant, formatStatusLabel, } from './components/StatusBadge';
@@ -1 +1 @@
1
- {"version":3,"file":"usePlatformLogo.d.ts","sourceRoot":"","sources":["../../src/react/usePlatformLogo.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAGxD,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AACjF,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,MAAM,CAAA;AACjD,MAAM,MAAM,kBAAkB,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,CAAA;AAEjE,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,gBAAgB,CAAA;IACvB,MAAM,CAAC,EAAE,kBAAkB,CAAA;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAA;IACjE,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,gBAAgB,CAAA;IACtB,MAAM,EAAE,kBAAkB,CAAA;IAC1B,MAAM,EAAE,kBAAkB,CAAA;IAC1B,YAAY,EAAE,OAAO,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,sBAAuB,SAAQ,oBAAoB;IAClE,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AA+CD,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,EAC3C,OAAO,GAAE,mBAAwB,GAChC,oBAAoB,CAiBtB;AAED,wBAAgB,eAAe,CAAC,OAAO,GAAE,mBAAwB,GAAG,sBAAsB,CAczF"}
1
+ {"version":3,"file":"usePlatformLogo.d.ts","sourceRoot":"","sources":["../../src/react/usePlatformLogo.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAGxD,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AACjF,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,MAAM,CAAA;AACjD,MAAM,MAAM,kBAAkB,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,CAAA;AAEjE,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,gBAAgB,CAAA;IACvB,MAAM,CAAC,EAAE,kBAAkB,CAAA;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAA;IACjE,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,gBAAgB,CAAA;IACtB,MAAM,EAAE,kBAAkB,CAAA;IAC1B,MAAM,EAAE,kBAAkB,CAAA;IAC1B,YAAY,EAAE,OAAO,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,sBAAuB,SAAQ,oBAAoB;IAClE,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AAqDD,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,EAC3C,OAAO,GAAE,mBAAwB,GAChC,oBAAoB,CAiBtB;AAED,wBAAgB,eAAe,CAAC,OAAO,GAAE,mBAAwB,GAAG,sBAAsB,CAczF"}
@@ -25,7 +25,13 @@ function getLogoCandidates(branding, slot, scheme) {
25
25
  if (!branding)
26
26
  return [];
27
27
  if (slot === 'favicon') {
28
- return [branding.favicon_url, branding.mark_url, branding.logo_url, branding.logo_light_url, branding.logo_dark_url];
28
+ return [
29
+ branding.favicon_url,
30
+ branding.mark_url,
31
+ branding.logo_url,
32
+ branding.logo_light_url,
33
+ branding.logo_dark_url,
34
+ ];
29
35
  }
30
36
  if (slot === 'mark') {
31
37
  return [branding.mark_url, branding.logo_url, branding.logo_light_url, branding.logo_dark_url];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/theme",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -11,7 +11,7 @@
11
11
  "url": "https://github.com/GaryOcean428/bsuite.git",
12
12
  "directory": "packages/theme"
13
13
  },
14
- "description": "BSuite Universal theme D2C Neon Electric + Braden Corporate baselines. Tailwind v4 preset, CSS vars (5-layer), role aliases, colourblind-safe tokens, eye-strain-safe text scale, BrandingProvider (runtime white-label). Used by BSU, CRM7, conduit, R80.3, throughput (D2C) and braden.com.au (Corporate).",
14
+ "description": "BSuite Universal theme \u2014 D2C Neon Electric + Braden Corporate baselines. Tailwind v4 preset, CSS vars (5-layer), role aliases, colourblind-safe tokens, eye-strain-safe text scale, BrandingProvider (runtime white-label). Used by BSU, CRM7, conduit, R80.3, throughput (D2C) and braden.com.au (Corporate).",
15
15
  "files": [
16
16
  "dist",
17
17
  "src/css",
@@ -50,7 +50,7 @@
50
50
  "@supabase/supabase-js": ">=2.0.0",
51
51
  "react": ">=18.0.0",
52
52
  "react-dom": ">=18.0.0",
53
- "tailwindcss": ">=3.4.0"
53
+ "tailwindcss": ">=4.0.0"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@supabase/supabase-js": {
@@ -61,16 +61,18 @@
61
61
  }
62
62
  },
63
63
  "devDependencies": {
64
- "@supabase/supabase-js": "^2.49.4",
64
+ "@supabase/supabase-js": "^2.105.3",
65
65
  "@testing-library/jest-dom": "^6.9.1",
66
66
  "@testing-library/react": "^16.3.2",
67
- "@types/node": "^20.0.0",
68
- "@types/react": "^18.3.28",
69
- "@vitejs/plugin-react": "^5.2.0",
70
- "jsdom": "^28.1.0",
71
- "react": "^18.3.1",
72
- "react-dom": "^18.3.1",
73
- "typescript": "~5.7.3",
74
- "vitest": "^3.2.4"
67
+ "@types/node": "^24.12.2",
68
+ "@types/react": "^19.2.14",
69
+ "@types/react-dom": "^19.2.3",
70
+ "@vitejs/plugin-react": "^6.0.1",
71
+ "jsdom": "^29.1.1",
72
+ "react": "^19.2.5",
73
+ "react-dom": "^19.2.5",
74
+ "typescript": "~5.9.3",
75
+ "vite": "^8.0.10",
76
+ "vitest": "^4.1.5"
75
77
  }
76
78
  }
@@ -23,8 +23,8 @@
23
23
  * Eye-strain policy is PRESERVED:
24
24
  * Dark text capped at L=0.94. Light text capped at L=0.22 (navy hue 250).
25
25
  *
26
- * OKLCH format mandatory for all new tokens. HSL values below are for
27
- * legacy Tailwind v3 interop only, marked with LEGACY comments.
26
+ * OKLCH format mandatory for all new tokens. Tailwind v4+ is the only
27
+ * supported Tailwind engine for BSuite consumers.
28
28
  */
29
29
 
30
30
  /* ============================================================
@@ -0,0 +1,22 @@
1
+ /*
2
+ * runtime-branding.css — populated at runtime by <BrandingProvider>
3
+ *
4
+ * DO NOT add static values here. BrandingProvider reads the tenant's
5
+ * branding JSON from Supabase and calls
6
+ * document.documentElement.style.setProperty('--primary', value)
7
+ * for each key. This file is a documentation placeholder only.
8
+ *
9
+ * Keys populated at runtime (if tenant has branding configured):
10
+ * --primary (oklch string)
11
+ * --accent (oklch string)
12
+ * --accent-primary (oklch string)
13
+ * --app-primary (oklch string)
14
+ * --app-accent (oklch string)
15
+ * Logo URLs are injected via JS into CSS vars:
16
+ * --logo-url (url() string)
17
+ * --mark-url (url() string)
18
+ */
19
+
20
+ :root[data-branding-loaded="true"] {
21
+ /* intentionally empty — BrandingProvider injects vars directly via JS */
22
+ }
@@ -0,0 +1,43 @@
1
+ /*
2
+ * @bsuite/theme — tokens-brand-corporate-braden.css
3
+ *
4
+ * Braden corporate brand deviation.
5
+ * Applied via .theme-braden or data-brand="braden" — NEVER auto-applied.
6
+ *
7
+ * ⚠️ This file is EXEMPT from the D2C oklch-only rule (BRADEN-EXEMPT).
8
+ * Do NOT import this file from index.css or any D2C app entry point.
9
+ * Braden's own stylesheet opts in explicitly.
10
+ *
11
+ * Source of truth: TOKEN-MAPPING.md §4 + §6
12
+ */
13
+
14
+ .theme-braden,
15
+ [data-brand="braden"] {
16
+
17
+ /* ===== Braden brand primitives ===== */
18
+ --app-primary: oklch(0.488 0.170 17.6); /* Braden Red */
19
+ --app-accent: oklch(0.769 0.096 90.9); /* Braden Gold */
20
+ --app-primary-dark: oklch(0.400 0.137 16.8); /* Braden Red — pressed/hover state */
21
+
22
+ /* ===== Background ===== */
23
+ --bg-body: oklch(1 0 0);
24
+ --bg-panel: oklch(1 0 0);
25
+ --bg-surface: oklch(0.975 0 0);
26
+
27
+ /* ===== Text ===== */
28
+ --text-primary: oklch(0.156 0 0);
29
+
30
+ /* ===== Border ===== */
31
+ --border-color: oklch(0.916 0 0);
32
+
33
+ /* ===== Accent / brand ===== */
34
+ --accent-primary: oklch(0.488 0.170 17.6); /* Braden Red */
35
+ --accent-secondary: oklch(0.769 0.096 90.9); /* Braden Gold */
36
+
37
+ /* ===== shadcn/ui bridge tokens ===== */
38
+ --primary: oklch(0.488 0.170 17.6); /* Braden Red */
39
+ --primary-foreground: oklch(1 0 0);
40
+ --accent: oklch(0.769 0.096 90.9); /* Braden Gold */
41
+ --accent-foreground: oklch(0.156 0 0);
42
+ --ring: oklch(0.488 0.170 17.6); /* Braden Red ring */
43
+ }
@@ -0,0 +1,86 @@
1
+ /*
2
+ * @bsuite/theme — tokens-dark.css
3
+ *
4
+ * Full semantic token set for the dark theme.
5
+ * Applied to .dark.
6
+ *
7
+ * All values are in OKLCH — no hex, no hsl() wrappers.
8
+ * Source of truth: TOKEN-MAPPING.md v0.2.0 + vars.css
9
+ */
10
+
11
+ .dark {
12
+
13
+ /* ===== Text ===== */
14
+ --text-primary: oklch(0.94 0.012 260);
15
+ --text-secondary: oklch(0.82 0.015 260);
16
+ --text-muted: oklch(0.68 0.018 260);
17
+ --text-subtle: oklch(0.56 0.015 260);
18
+ --text-disabled: oklch(0.44 0.010 260);
19
+ --text-on-primary: oklch(0.99 0 0);
20
+ --text-on-surface: oklch(0.94 0.012 260);
21
+ --color-primary-text: oklch(0.623 0.188 259.8); /* WCAG AA blue text on dark bg */
22
+ --color-accent-text: oklch(0.769 0.132 191.7); /* WCAG AA cyan text on dark bg */
23
+
24
+ /* ===== Background ===== */
25
+ --bg-body: oklch(0.166 0.026 269.4); /* deep navy */
26
+ --bg-surface: oklch(0.19 0.02 260);
27
+ --bg-panel: oklch(0.242 0.03 269.9);
28
+ --bg-tertiary: oklch(0.326 0.036 266.7);
29
+ --bg-elevated: oklch(0.292 0.034 270);
30
+ --bg-input: oklch(0.242 0.03 269.9 / 0.6);
31
+ --bg-hover: oklch(0.39 0.035 265);
32
+ --bg-selected: oklch(0.546 0.215 262.9 / 0.15);
33
+
34
+ /* ===== Border ===== */
35
+ --border-color: oklch(0.769 0.132 191.7 / 0.15);
36
+ --border-color-strong: oklch(0.769 0.132 191.7 / 0.3);
37
+ --border-shell: oklch(0.769 0.132 191.7 / 0.18);
38
+
39
+ /* ===== Accent / brand ===== */
40
+ --accent-primary: oklch(0.546 0.215 262.9); /* Electric Blue */
41
+ --accent-secondary: oklch(0.769 0.132 191.7); /* Electric Cyan */
42
+
43
+ /* ===== Status (same values in both themes) ===== */
44
+ --color-success: oklch(0.697 0.135 172.1);
45
+ --color-warning: oklch(0.868 0.125 81.4);
46
+ --color-error: oklch(0.568 0.202 283.1);
47
+ --color-info: oklch(0.769 0.132 191.7);
48
+
49
+ /* ===== shadcn/ui bridge tokens ===== */
50
+ --background: oklch(0.166 0.026 269.4);
51
+ --foreground: var(--text-primary);
52
+ --card: oklch(0.242 0.03 269.9);
53
+ --card-foreground: var(--text-primary);
54
+ --popover: oklch(0.292 0.034 270);
55
+ --popover-foreground: var(--text-primary);
56
+ --primary: oklch(0.769 0.132 191.7); /* Cyan as primary in dark */
57
+ --primary-foreground: oklch(0.166 0.026 269.4);
58
+ --secondary: oklch(0.242 0.03 269.9);
59
+ --secondary-foreground: var(--text-primary);
60
+ --muted: oklch(0.242 0.03 269.9);
61
+ --muted-foreground: var(--text-muted);
62
+ --accent: oklch(0.546 0.215 262.9); /* Blue as accent in dark */
63
+ --accent-foreground: var(--text-primary);
64
+ --destructive: var(--color-error);
65
+ --destructive-foreground: oklch(0.99 0 0);
66
+ --border: oklch(0.769 0.132 191.7 / 0.15);
67
+ --input: oklch(0.769 0.132 191.7 / 0.15);
68
+ --ring: oklch(0.769 0.132 191.7); /* Cyan ring in dark */
69
+
70
+ /* ===== Box shadows — deep blacks + neon cyan glow ===== */
71
+ --shadow-elev-0: none;
72
+ --shadow-elev-1: 0 1px 3px oklch(0 0 0 / 0.3);
73
+ --shadow-elev-2: 0 4px 8px oklch(0 0 0 / 0.4), 0 1px 3px oklch(0 0 0 / 0.3);
74
+ --shadow-elev-3: 0 8px 16px oklch(0 0 0 / 0.5), 0 4px 8px oklch(0 0 0 / 0.3);
75
+ --shadow-elev-4: 0 16px 32px oklch(0 0 0 / 0.6), 0 8px 16px oklch(0 0 0 / 0.4);
76
+
77
+ /* ===== Neon glow helpers ===== */
78
+ --glow-button: 0 0 20px oklch(0.769 0.132 191.7 / 0.3);
79
+ --glow-button-hover: 0 0 30px oklch(0.769 0.132 191.7 / 0.5);
80
+ --glow-card: 0 0 15px oklch(0.769 0.132 191.7 / 0.08);
81
+ --glow-card-hover: 0 0 25px oklch(0.769 0.132 191.7 / 0.15);
82
+
83
+ /* ===== Button shadow ===== */
84
+ --button-shadow: 0 0 20px oklch(0.769 0.132 191.7 / 0.3);
85
+ --button-hover-shadow: 0 0 30px oklch(0.769 0.132 191.7 / 0.5);
86
+ }
@@ -0,0 +1,34 @@
1
+ /*
2
+ * @bsuite/theme — tokens-high-contrast.css
3
+ *
4
+ * AAA accessibility overrides for users who have opted into
5
+ * "More Contrast" in their OS/browser settings.
6
+ *
7
+ * Kept intentionally minimal — only tokens that fail AAA at default
8
+ * values are overridden. All other tokens inherit from tokens-light.css
9
+ * or tokens-dark.css.
10
+ *
11
+ * Source of truth: TOKEN-MAPPING.md v0.2.0
12
+ */
13
+
14
+ @media (prefers-contrast: more) {
15
+
16
+ /* ----- Light mode overrides (default / :root) ----- */
17
+ :root,
18
+ .light {
19
+ --text-primary: oklch(0 0 0); /* pure black */
20
+ --text-secondary: oklch(0.1 0 0);
21
+ --text-muted: oklch(0.2 0 0);
22
+ --border-color: oklch(0 0 0);
23
+ --border-color-strong: oklch(0 0 0);
24
+ }
25
+
26
+ /* ----- Dark mode overrides ----- */
27
+ .dark {
28
+ --text-primary: oklch(1 0 0); /* pure white */
29
+ --text-secondary: oklch(0.9 0 0);
30
+ --text-muted: oklch(0.8 0 0);
31
+ --border-color: oklch(1 0 0);
32
+ --border-color-strong: oklch(1 0 0);
33
+ }
34
+ }
@@ -0,0 +1,89 @@
1
+ /*
2
+ * @bsuite/theme — tokens-light.css
3
+ *
4
+ * Full semantic token set for the light theme.
5
+ * Applied to :root and .light.
6
+ *
7
+ * All values are in OKLCH — no hex, no hsl() wrappers.
8
+ * Source of truth: TOKEN-MAPPING.md v0.2.0
9
+ */
10
+
11
+ :root,
12
+ .light {
13
+
14
+ /* ===== Text ===== */
15
+ --text-primary: oklch(0.22 0.015 260);
16
+ --text-secondary: oklch(0.38 0.018 260);
17
+ --text-muted: oklch(0.52 0.018 260);
18
+ --text-subtle: oklch(0.60 0.012 260);
19
+ --text-disabled: oklch(0.72 0.010 260);
20
+ --text-on-primary: oklch(0.99 0 0);
21
+ --text-on-surface: oklch(0.22 0.015 260);
22
+ --color-primary-text: oklch(0.485 0.243 263.6); /* WCAG AA blue text on light bg */
23
+ --color-accent-text: oklch(0.486 0.084 191.5); /* WCAG AA cyan text on light bg */
24
+
25
+ /* ===== Background ===== */
26
+ --bg-body: oklch(0.961 0 0.5);
27
+ --bg-surface: oklch(0.982 0.002 248);
28
+ --bg-panel: oklch(1 0 0);
29
+ --bg-tertiary: oklch(0.963 0.003 228.9);
30
+ --bg-elevated: oklch(1 0 0);
31
+ --bg-input: oklch(0.982 0.002 248);
32
+ --bg-hover: oklch(0.963 0.003 228.9);
33
+ --bg-selected: oklch(0.546 0.215 262.9 / 0.08);
34
+
35
+ /* ===== Border ===== */
36
+ --border-color: oklch(0.916 0.006 248);
37
+ --border-color-strong: oklch(0.741 0.022 250 / 0.5);
38
+ --border-shell: oklch(0.741 0.022 250 / 0.2);
39
+
40
+ /* ===== Accent / brand ===== */
41
+ --accent-primary: oklch(0.546 0.215 262.9); /* Electric Blue */
42
+ --accent-secondary: oklch(0.769 0.132 191.7); /* Electric Cyan */
43
+
44
+ /* ===== Status ===== */
45
+ --color-success: oklch(0.697 0.135 172.1);
46
+ --color-warning: oklch(0.868 0.125 81.4);
47
+ --color-error: oklch(0.568 0.202 283.1);
48
+ --color-info: oklch(0.769 0.132 191.7);
49
+
50
+ /* ===== shadcn/ui bridge tokens ===== */
51
+ --background: oklch(0.961 0 0.5);
52
+ --foreground: var(--text-primary);
53
+ --card: oklch(1 0 0);
54
+ --card-foreground: var(--text-primary);
55
+ --popover: oklch(1 0 0);
56
+ --popover-foreground: var(--text-primary);
57
+ --primary: oklch(0.546 0.215 262.9); /* Electric Blue */
58
+ --primary-foreground: var(--text-on-primary);
59
+ --secondary: oklch(0.961 0 0.5);
60
+ --secondary-foreground: var(--text-primary);
61
+ --muted: oklch(0.961 0 0.5);
62
+ --muted-foreground: var(--text-muted);
63
+ --accent: oklch(0.769 0.132 191.7); /* Electric Cyan */
64
+ --accent-foreground: oklch(0.17 0.02 260);
65
+ --destructive: var(--color-error);
66
+ --destructive-foreground: oklch(0.99 0 0);
67
+ --border: oklch(0.916 0.006 248); /* from hsl(214.3 31.8% 91.4%) */
68
+ --input: oklch(0.916 0.006 248);
69
+ --ring: oklch(0.546 0.215 262.9); /* Electric Blue */
70
+
71
+ /* ===== Border-radius ===== */
72
+ --radius: 0.5rem;
73
+
74
+ /* ===== Box shadows — soft neutral, no neon glow in light mode ===== */
75
+ --shadow-elev-0: none;
76
+ --shadow-elev-1: 0 1px 2px oklch(0 0 0 / 0.05);
77
+ --shadow-elev-2: 0 4px 6px oklch(0 0 0 / 0.07), 0 2px 4px oklch(0 0 0 / 0.06);
78
+ --shadow-elev-3: 0 10px 15px oklch(0 0 0 / 0.1), 0 4px 6px oklch(0 0 0 / 0.05);
79
+ --shadow-elev-4: 0 20px 25px oklch(0 0 0 / 0.1), 0 10px 10px oklch(0 0 0 / 0.04);
80
+
81
+ /* ===== Glow helpers — disabled in light mode ===== */
82
+ --glow-button: none;
83
+ --glow-button-hover: none;
84
+ --glow-card: none;
85
+ --glow-card-hover: none;
86
+
87
+ /* ===== Button shadow — transparent/none in light mode ===== */
88
+ --button-shadow: none;
89
+ }
@@ -2,7 +2,7 @@
2
2
  * @bsuite/theme — utility classes
3
3
  *
4
4
  * Pure CSS (no Tailwind @apply) so this file is safe to import from any
5
- * consumer regardless of their Tailwind version (v3 or v4).
5
+ * consumer on Tailwind v4 or later.
6
6
  */
7
7
 
8
8
  /* ===== Glow effects (oklch with alpha scaled by glow-strength) ===== */