@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 +29 -64
- package/dist/react/BrandingProvider.d.ts +4 -4
- package/dist/react/BrandingProvider.d.ts.map +1 -1
- package/dist/react/BrandingProvider.js +43 -16
- package/dist/react/branding-sanitize.d.ts +60 -0
- package/dist/react/branding-sanitize.d.ts.map +1 -0
- package/dist/react/branding-sanitize.js +134 -0
- package/dist/react/components/StatusBadge.d.ts +76 -0
- package/dist/react/components/StatusBadge.d.ts.map +1 -0
- package/dist/react/components/StatusBadge.js +98 -0
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -1
- package/dist/react/usePlatformLogo.d.ts.map +1 -1
- package/dist/react/usePlatformLogo.js +7 -1
- package/package.json +14 -12
- package/src/css/braden.css +2 -2
- package/src/css/runtime-branding.css +22 -0
- package/src/css/tokens-brand-corporate-braden.css +43 -0
- package/src/css/tokens-dark.css +86 -0
- package/src/css/tokens-high-contrast.css +34 -0
- package/src/css/tokens-light.css +89 -0
- package/src/css/utilities.css +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# @bsuite/theme
|
|
2
2
|
|
|
3
|
-
Universal
|
|
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** —
|
|
8
|
-
- **
|
|
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>`**
|
|
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
|
|
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
|
|
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
|
-
###
|
|
85
|
-
|
|
86
|
-
**1.** `tailwind.config.js`:
|
|
90
|
+
### Tailwind floor
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
+
/** Light-mode full logo URL */
|
|
14
14
|
logo_light_url?: string;
|
|
15
|
-
/** Dark-mode full logo URL
|
|
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
|
|
22
|
-
company_name?: string
|
|
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":"
|
|
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.
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 (
|
|
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
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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"}
|
package/dist/react/index.js
CHANGED
|
@@ -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
|
|
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;
|
|
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 [
|
|
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
|
+
"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
|
|
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": ">=
|
|
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.
|
|
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": "^
|
|
68
|
-
"@types/react": "^
|
|
69
|
-
"@
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"react
|
|
73
|
-
"
|
|
74
|
-
"
|
|
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
|
}
|
package/src/css/braden.css
CHANGED
|
@@ -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.
|
|
27
|
-
*
|
|
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
|
+
}
|
package/src/css/utilities.css
CHANGED
|
@@ -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
|
|
5
|
+
* consumer on Tailwind v4 or later.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/* ===== Glow effects (oklch with alpha scaled by glow-strength) ===== */
|