@reflagged/shell 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,6 +132,32 @@ import { NotificationBell } from '@reflagged/shell/components/NotificationBell'
132
132
  <NotificationBell />
133
133
  ```
134
134
 
135
+ ## Tenant branding
136
+
137
+ `BrandSwitcher` shows the tenant's logo mark and loads the tenant palette from
138
+ the platform (`org.themeCssUrl` in `/api/shell-info`) as
139
+ `<link id="rflgd-tenant-theme">`. Modules without the switcher render
140
+ `<TenantTheme />` from `@reflagged/shell/components/TenantTheme` once in the root
141
+ layout.
142
+
143
+ The stylesheet defines, on `:root`:
144
+
145
+ - `--brass-50` … `--brass-900` — RGB channels (`125 85 26`), the Reflagged
146
+ brass scale re-tinted to the tenant colour. Every step keeps brass's
147
+ luminance, so contrast pairs that work with brass keep working.
148
+ - `--rflgd-brand-primary` — the raw tenant colour (decorative use only).
149
+
150
+ Point the module's own tokens at it, always with the brass fallback:
151
+
152
+ ```css
153
+ :root { --primary: rgb(var(--brass-700, 125 85 26)); }
154
+ .dark { --primary: rgb(var(--brass-400, 214 166 76)); }
155
+ ```
156
+
157
+ or in Tailwind 3: `primary: 'rgb(var(--brass-700, 125 85 26) / <alpha-value>)'`.
158
+ Text on a filled brand surface stays white on `700` (light) and dark on `400`
159
+ (dark mode). Status and destructive colours do not follow the palette.
160
+
135
161
  ## Local development (live-edit against a consumer)
136
162
 
137
163
  A published package is frozen in `node_modules`. To iterate on the shell and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -17,6 +17,7 @@
17
17
  "./middleware": "./src/middleware.ts",
18
18
  "./config": "./src/config.ts",
19
19
  "./components/BrandSwitcher": "./src/components/BrandSwitcher.tsx",
20
+ "./components/TenantTheme": "./src/components/TenantTheme.tsx",
20
21
  "./auth/oidc-config": "./src/lib/auth/oidc-config.ts",
21
22
  "./auth/oidc-cookie": "./src/lib/auth/oidc-cookie.ts",
22
23
  "./auth/can-access": "./src/lib/auth/can-access.ts",
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
4
4
  import { createPortal } from 'react-dom'
5
5
  import { loadAppConfig } from '../config'
6
6
  import { ServiceIcon } from './ServiceIcon'
7
+ import { useTenantTheme } from './TenantTheme'
7
8
 
8
9
  type Booking = {
9
10
  id: string
@@ -25,6 +26,11 @@ type Org = {
25
26
  slug: string
26
27
  name: string
27
28
  logoUrl: string | null
29
+ /** Tenant branding from rflgd-base ≥ Oct 2026; older platforms omit them. */
30
+ logoDarkUrl?: string | null
31
+ logoMarkUrl?: string | null
32
+ /** Tenant palette stylesheet (`--brass-50…900`, `--rflgd-brand-primary`). */
33
+ themeCssUrl?: string | null
28
34
  }
29
35
 
30
36
  type ShellInfo = {
@@ -36,6 +42,15 @@ type ShellInfo = {
36
42
  baseUrl: string
37
43
  }
38
44
 
45
+ /**
46
+ * Colours follow the tenant palette when the platform's theme.css is loaded
47
+ * (`--rflgd-brand-primary`, `--brass-700`), and fall back to Reflagged brass.
48
+ */
49
+ const BRAND = 'var(--rflgd-brand-primary, #B09A6A)'
50
+ const BRAND_TEXT = 'rgb(var(--brass-700, 125 85 26))'
51
+ const tint = (percent: number) =>
52
+ `color-mix(in srgb, var(--rflgd-brand-primary, #B09A6A) ${percent}%, transparent)`
53
+
39
54
  const StackIcon = ({ size = 22 }: { size?: number }) => (
40
55
  <svg
41
56
  width={size}
@@ -45,16 +60,33 @@ const StackIcon = ({ size = 22 }: { size?: number }) => (
45
60
  aria-hidden
46
61
  style={{ flexShrink: 0 }}
47
62
  >
48
- <path d="M32,0 H23 A3,3 0 0,0 20,3 A3,3 0 0,0 23,6 H32 Z" fill="#B09A6A" />
49
- <path d="M32,11 H12 A2,2 0 0,0 10,13 A2,2 0 0,0 12,15 H32 Z" fill="#B09A6A" opacity="0.45" />
63
+ <path d="M32,0 H23 A3,3 0 0,0 20,3 A3,3 0 0,0 23,6 H32 Z" style={{ fill: BRAND }} />
64
+ <path d="M32,11 H12 A2,2 0 0,0 10,13 A2,2 0 0,0 12,15 H32 Z" style={{ fill: BRAND }} opacity="0.45" />
50
65
  <path
51
66
  d="M32,23 H1.5 A1.5,1.5 0 0,0 0,24.5 A1.5,1.5 0 0,0 1.5,26 H32 Z"
52
- fill="#B09A6A"
67
+ style={{ fill: BRAND }}
53
68
  opacity="0.25"
54
69
  />
55
70
  </svg>
56
71
  )
57
72
 
73
+ /** The tenant's logo mark (square) when it has one, else the Reflagged stack. */
74
+ function BrandMark({ org, size }: { org: Org | null | undefined; size: number }) {
75
+ const [failed, setFailed] = useState(false)
76
+ const src = org?.logoMarkUrl ?? null
77
+ if (!src || failed) return <StackIcon size={size} />
78
+ return (
79
+ <img
80
+ src={src}
81
+ alt=""
82
+ width={size}
83
+ height={size}
84
+ onError={() => setFailed(true)}
85
+ style={{ width: size, height: size, objectFit: 'contain', borderRadius: 4, flexShrink: 0 }}
86
+ />
87
+ )
88
+ }
89
+
58
90
  export function BrandSwitcher() {
59
91
  const config = loadAppConfig()
60
92
  const [shell, setShell] = useState<ShellInfo | null>(null)
@@ -120,6 +152,8 @@ export function BrandSwitcher() {
120
152
  return () => document.removeEventListener('mousedown', onClick)
121
153
  }, [open])
122
154
 
155
+ useTenantTheme(shell?.org?.themeCssUrl)
156
+
123
157
  const ready = (shell?.bookings ?? []).filter((b) => b.status === 'ready' && b.url)
124
158
  const orgName = shell?.org?.name ?? null
125
159
  const baseUrl = shell?.baseUrl ?? 'https://app.rfl.gd'
@@ -147,7 +181,7 @@ export function BrandSwitcher() {
147
181
  color: 'inherit',
148
182
  }}
149
183
  >
150
- <StackIcon size={22} />
184
+ <BrandMark org={shell?.org} size={22} />
151
185
  <div style={{ flex: 1, minWidth: 0, lineHeight: 1.15 }}>
152
186
  <div
153
187
  style={{
@@ -223,10 +257,10 @@ export function BrandSwitcher() {
223
257
  marginTop: pos ? 0 : 8,
224
258
  width: 280,
225
259
  borderRadius: 14,
226
- border: '1px solid rgba(176,154,106,0.16)',
260
+ border: `1px solid ${tint(16)}`,
227
261
  background: '#FFFFFF',
228
262
  padding: 8,
229
- boxShadow: '0 1px 0 rgba(176,154,106,0.08), 0 12px 40px rgba(0,0,0,0.15)',
263
+ boxShadow: `0 1px 0 ${tint(8)}, 0 12px 40px rgba(0,0,0,0.15)`,
230
264
  }}
231
265
  >
232
266
  {showWorkspaceSection ? (
@@ -243,7 +277,7 @@ export function BrandSwitcher() {
243
277
  gap: 12,
244
278
  padding: '8px 12px',
245
279
  borderRadius: 8,
246
- background: isCurrent ? 'rgba(176,154,106,0.15)' : 'transparent',
280
+ background: isCurrent ? tint(15) : 'transparent',
247
281
  }}
248
282
  >
249
283
  <div
@@ -251,8 +285,8 @@ export function BrandSwitcher() {
251
285
  width: 32,
252
286
  height: 32,
253
287
  borderRadius: 8,
254
- background: 'rgba(176,154,106,0.15)',
255
- color: '#B09A6A',
288
+ background: tint(15),
289
+ color: BRAND_TEXT,
256
290
  display: 'flex',
257
291
  alignItems: 'center',
258
292
  justifyContent: 'center',
@@ -275,9 +309,9 @@ export function BrandSwitcher() {
275
309
  style={{
276
310
  fontFamily: "'JetBrains Mono', monospace",
277
311
  fontSize: 9.5,
278
- color: '#B09A6A',
312
+ color: BRAND_TEXT,
279
313
  padding: '2px 6px',
280
- background: 'rgba(176,154,106,0.15)',
314
+ background: tint(15),
281
315
  borderRadius: 4,
282
316
  textTransform: 'uppercase',
283
317
  letterSpacing: '0.1em',
@@ -292,7 +326,7 @@ export function BrandSwitcher() {
292
326
  <hr
293
327
  style={{
294
328
  border: 'none',
295
- borderTop: '1px solid rgba(176,154,106,0.12)',
329
+ borderTop: `1px solid ${tint(12)}`,
296
330
  margin: '6px 0',
297
331
  }}
298
332
  />
@@ -305,7 +339,7 @@ export function BrandSwitcher() {
305
339
  href={`/api/oidc/signin?callbackUrl=${encodeURIComponent(
306
340
  typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/',
307
341
  )}`}
308
- style={{ display: 'block', padding: '8px 12px 12px', fontSize: 13, color: '#B09A6A', textDecoration: 'none' }}
342
+ style={{ display: 'block', padding: '8px 12px 12px', fontSize: 13, color: BRAND_TEXT, textDecoration: 'none' }}
309
343
  >
310
344
  Anmelden, um Services zu sehen →
311
345
  </a>
@@ -331,7 +365,7 @@ export function BrandSwitcher() {
331
365
  width: 32,
332
366
  height: 32,
333
367
  borderRadius: 8,
334
- background: 'rgba(176,154,106,0.1)',
368
+ background: tint(10),
335
369
  display: 'flex',
336
370
  alignItems: 'center',
337
371
  justifyContent: 'center',
@@ -351,7 +385,7 @@ export function BrandSwitcher() {
351
385
  fontSize: 9.5,
352
386
  color: '#7A6A58',
353
387
  padding: '2px 6px',
354
- background: 'rgba(176,154,106,0.08)',
388
+ background: tint(8),
355
389
  borderRadius: 4,
356
390
  textTransform: 'uppercase',
357
391
  letterSpacing: '0.1em',
@@ -381,7 +415,7 @@ export function BrandSwitcher() {
381
415
  borderRadius: 8,
382
416
  textDecoration: 'none',
383
417
  color: '#2C1E14',
384
- background: active ? 'rgba(176,154,106,0.15)' : 'transparent',
418
+ background: active ? tint(15) : 'transparent',
385
419
  }}
386
420
  >
387
421
  <div
@@ -389,8 +423,8 @@ export function BrandSwitcher() {
389
423
  width: 32,
390
424
  height: 32,
391
425
  borderRadius: 8,
392
- background: active ? 'rgba(176,154,106,0.25)' : 'rgba(176,138,122,0.25)',
393
- color: active ? '#B09A6A' : '#B08A7A',
426
+ background: active ? tint(25) : 'rgba(176,138,122,0.25)',
427
+ color: active ? BRAND_TEXT : '#B08A7A',
394
428
  display: 'flex',
395
429
  alignItems: 'center',
396
430
  justifyContent: 'center',
@@ -411,9 +445,9 @@ export function BrandSwitcher() {
411
445
  style={{
412
446
  fontFamily: "'JetBrains Mono', monospace",
413
447
  fontSize: 9.5,
414
- color: '#B09A6A',
448
+ color: BRAND_TEXT,
415
449
  padding: '2px 6px',
416
- background: 'rgba(176,154,106,0.15)',
450
+ background: tint(15),
417
451
  borderRadius: 4,
418
452
  textTransform: 'uppercase',
419
453
  letterSpacing: '0.1em',
@@ -448,7 +482,7 @@ export function BrandSwitcher() {
448
482
  <hr
449
483
  style={{
450
484
  border: 'none',
451
- borderTop: '1px solid rgba(176,154,106,0.12)',
485
+ borderTop: `1px solid ${tint(12)}`,
452
486
  margin: '6px 0',
453
487
  }}
454
488
  />
@@ -0,0 +1,49 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ const THEME_LINK_ID = 'rflgd-tenant-theme'
6
+
7
+ /**
8
+ * Loads the tenant palette stylesheet from the platform once per page:
9
+ * `--brass-50` … `--brass-900` (RGB channels) and `--rflgd-brand-primary`.
10
+ * Modules point their own tokens at it with a brass fallback, e.g.
11
+ * `--primary: rgb(var(--brass-700, 125 85 26))`.
12
+ */
13
+ export function useTenantTheme(href: string | null | undefined): void {
14
+ useEffect(() => {
15
+ if (!href) return
16
+ const existing = document.getElementById(THEME_LINK_ID) as HTMLLinkElement | null
17
+ if (existing) {
18
+ if (existing.href !== href) existing.href = href
19
+ return
20
+ }
21
+ const link = document.createElement('link')
22
+ link.id = THEME_LINK_ID
23
+ link.rel = 'stylesheet'
24
+ link.href = href
25
+ document.head.appendChild(link)
26
+ }, [href])
27
+ }
28
+
29
+ /**
30
+ * For modules that do not mount `BrandSwitcher` (which loads the palette
31
+ * itself): render once in the root layout.
32
+ */
33
+ export function TenantTheme(): null {
34
+ const [href, setHref] = useState<string | null>(null)
35
+ useEffect(() => {
36
+ let cancel = false
37
+ fetch('/api/shell-info', { credentials: 'include' })
38
+ .then((r) => (r.ok ? r.json() : null))
39
+ .then((d: { org?: { themeCssUrl?: string | null } | null } | null) => {
40
+ if (!cancel) setHref(d?.org?.themeCssUrl ?? null)
41
+ })
42
+ .catch(() => undefined)
43
+ return () => {
44
+ cancel = true
45
+ }
46
+ }, [])
47
+ useTenantTheme(href)
48
+ return null
49
+ }