@bsuite/theme 0.10.4 → 0.10.6

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.
@@ -1,5 +1,48 @@
1
- import type { ReactNode } from 'react';
1
+ /**
2
+ * BrandingProvider — runtime enterprise white-labelling
3
+ * Version 0.4.1
4
+ *
5
+ * On mount:
6
+ * 1. Calls supabase.rpc('branding_json_for_tenant') using the user's authed session.
7
+ * 2. Validates each colour value is a well-formed oklch() string.
8
+ * 3. Applies each key as a CSS custom property on document.documentElement.
9
+ * 4. Caches the result in localStorage under BRANDING_STORAGE_KEY for FOUC prevention.
10
+ * 5. Subscribes to tenant row changes via Supabase Realtime so branding updates
11
+ * propagate without a page reload (within ~1 second).
12
+ *
13
+ * Security / brand policies:
14
+ * - Only oklch() values are accepted for colour fields — hex/rgb/hsl are rejected.
15
+ * - --role-error and --role-destructive are NOT overridable by tenants.
16
+ * Colourblind policy (purple error) is a system-level requirement, not a brand pref.
17
+ * - brand="braden" short-circuits all Supabase calls — Braden corporate site uses
18
+ * static CSS tokens, not runtime tenant overrides.
19
+ * - SEC-002 / SEC-003 (added 0.4.1) — tenant-controlled URL fields
20
+ * (`logo_url`, `logo_light_url`, `logo_dark_url`, `mark_url`,
21
+ * `favicon_url`) and `font_stack` are routed through `branding-sanitize`
22
+ * at the DOM-apply sink. Dangerous schemes (`javascript:`, `data:`,
23
+ * `vbscript:`, `blob:`, `file:`) are rejected; the URL is parsed and
24
+ * re-serialised so breakout chars are percent-encoded; the `url()`
25
+ * token is emitted in the safe quoted form with `"` and `\` escaped;
26
+ * font values carrying CSS-breakout tokens (`< > ( ) { } ; @ \ /* *\/`)
27
+ * are rejected and the var is cleared so the default font applies.
28
+ *
29
+ * Environment flags:
30
+ * - VITE_ENABLE_BRANDING_OVERRIDE (default: 'true') — set 'false' as kill switch.
31
+ *
32
+ * Usage:
33
+ * <ThemeProvider>
34
+ * <BrandingProvider supabaseClient={supabase}>
35
+ * {children}
36
+ * </BrandingProvider>
37
+ * </ThemeProvider>
38
+ *
39
+ * For Braden corporate (skips all RPC/Realtime):
40
+ * <BrandingProvider brand="braden" supabaseClient={supabase}>
41
+ * {children}
42
+ * </BrandingProvider>
43
+ */
2
44
  import type { SupabaseClient } from '@supabase/supabase-js';
45
+ import type { ReactNode } from 'react';
3
46
  export declare const BRANDING_STORAGE_KEY = "bsuite_tenant_branding";
4
47
  export declare const BRANDING_OVERRIDE_FLAG = "VITE_ENABLE_BRANDING_OVERRIDE";
5
48
  /** Shape returned by the branding_json_for_tenant RPC */
@@ -1 +1 @@
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,+BA4GvB"}
1
+ {"version":3,"file":"BrandingProvider.d.ts","sourceRoot":"","sources":["../../src/react/BrandingProvider.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAC3D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAQtC,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;AAyLD,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,cAAc,EACd,KAAgB,EAChB,qBAA4B,GAC7B,EAAE,qBAAqB,+BA4GvB"}
@@ -1,47 +1,4 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * BrandingProvider — runtime enterprise white-labelling
4
- * Version 0.4.1
5
- *
6
- * On mount:
7
- * 1. Calls supabase.rpc('branding_json_for_tenant') using the user's authed session.
8
- * 2. Validates each colour value is a well-formed oklch() string.
9
- * 3. Applies each key as a CSS custom property on document.documentElement.
10
- * 4. Caches the result in localStorage under BRANDING_STORAGE_KEY for FOUC prevention.
11
- * 5. Subscribes to tenant row changes via Supabase Realtime so branding updates
12
- * propagate without a page reload (within ~1 second).
13
- *
14
- * Security / brand policies:
15
- * - Only oklch() values are accepted for colour fields — hex/rgb/hsl are rejected.
16
- * - --role-error and --role-destructive are NOT overridable by tenants.
17
- * Colourblind policy (purple error) is a system-level requirement, not a brand pref.
18
- * - brand="braden" short-circuits all Supabase calls — Braden corporate site uses
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.
29
- *
30
- * Environment flags:
31
- * - VITE_ENABLE_BRANDING_OVERRIDE (default: 'true') — set 'false' as kill switch.
32
- *
33
- * Usage:
34
- * <ThemeProvider>
35
- * <BrandingProvider supabaseClient={supabase}>
36
- * {children}
37
- * </BrandingProvider>
38
- * </ThemeProvider>
39
- *
40
- * For Braden corporate (skips all RPC/Realtime):
41
- * <BrandingProvider brand="braden" supabaseClient={supabase}>
42
- * {children}
43
- * </BrandingProvider>
44
- */
45
2
  import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
46
3
  import { sanitizeBrandingUrl, sanitizeFontFamily, toCssUrl, } from './branding-sanitize.js';
47
4
  export const BRANDING_STORAGE_KEY = 'bsuite_tenant_branding';
@@ -169,8 +126,17 @@ function warnIfProtectedKeyAttempted(branding) {
169
126
  }
170
127
  }
171
128
  function isDevEnvironment() {
172
- const env = import.meta.env;
173
- return env?.DEV === true || env?.MODE === 'development' || env?.NODE_ENV === 'development';
129
+ // SECURITY: direct static key access only — assigning `import.meta.env` to a
130
+ // variable makes consumer bundlers (Vite) inline the ENTIRE env object into
131
+ // every client bundle, leaking every VITE_* secret. try/catch keeps this
132
+ // safe in non-Vite runtimes where import.meta.env does not exist.
133
+ try {
134
+ return (import.meta.env.DEV === true ||
135
+ import.meta.env.MODE === 'development');
136
+ }
137
+ catch {
138
+ return false;
139
+ }
174
140
  }
175
141
  function persistBranding(branding) {
176
142
  try {
@@ -197,10 +163,10 @@ function loadPersistedBranding() {
197
163
  }
198
164
  }
199
165
  function isBrandingEnabled() {
166
+ // SECURITY: direct static key access only (see isDevEnvironment) — reading
167
+ // `import.meta.env` wholesale leaks every VITE_* secret into client bundles.
200
168
  try {
201
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
- const flag = import.meta.env;
203
- const value = flag?.VITE_ENABLE_BRANDING_OVERRIDE ?? flag?.['VITE_ENABLE_BRANDING_OVERRIDE'];
169
+ const value = import.meta.env.VITE_ENABLE_BRANDING_OVERRIDE;
204
170
  if (value === 'false' || value === '0')
205
171
  return false;
206
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/theme",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
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 \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).",
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).",
15
15
  "files": [
16
16
  "dist",
17
17
  "src/css",
@@ -24,10 +24,32 @@
24
24
  * already false twice over: the 0.7.0 separation-first contract retired purple
25
25
  * for semantics, and the Corporate contract binds error to --braden-indigo.
26
26
  *
27
- * ERROR IS INDIGO HERE, NOT RED deliberately, and for the opposite reason to
28
- * D2C. D2C's primary is blue, so error must be red to separate from it.
29
- * Corporate's primary IS red, so a red error would collide with the brand's
30
- * own primary action colour. Same rule, inverted inputs.
27
+ * ERROR IS RED HERE OPERATOR RULING 2026-08-10, superseding the indigo rule.
28
+ * Braden: "use standard error red not braden corporate red."
29
+ *
30
+ * What this overturns. 0.4.0 bound error to --braden-indigo on a
31
+ * separation-first argument: D2C's primary is blue so error must be red;
32
+ * Corporate's primary IS red so error must not be. That reasoning was sound
33
+ * and it is no longer the rule. Error now uses --error-red, the SAME standard
34
+ * error red the D2C brand uses (oklch(0.580 0.230 25)) — one error colour
35
+ * across the estate. Recognition beats palette separation: a person reads red
36
+ * as "danger" before they read it as "Braden".
37
+ *
38
+ * --error-red is NOT a Braden brand colour and must never be used as one. It
39
+ * is permitted here for the error/destructive roles only. It stays inside the
40
+ * palette whitelist because it is a value the D2C source-of-truth document
41
+ * already ships; the whitelist pools both contracts.
42
+ *
43
+ * THE COST, MEASURED, SO NOBODY REDISCOVERS IT AS A BUG:
44
+ * ΔE_oklch(error red, --braden-red) = 0.113 ← now the palette's tightest pair
45
+ * ΔE_oklch(old indigo, --braden-red) = 0.312
46
+ * The Corporate contract's previously-worst pair was 0.143. A destructive
47
+ * button and a primary button are now both red, separated by lightness (0.092)
48
+ * and chroma rather than hue, and under deuteranopia lightness is nearly all
49
+ * that survives. THEREFORE: destructive actions on Corporate must carry an
50
+ * icon or an explicit verb ("Delete"), never colour alone. The contract
51
+ * already said shape and label do more work on this brand than on D2C; after
52
+ * this ruling that is load-bearing rather than advisory.
31
53
  */
32
54
 
33
55
 
@@ -52,6 +74,12 @@
52
74
  --braden-teal-deep: oklch(0.500 0.140 220);
53
75
  --braden-aqua: oklch(0.800 0.130 198);
54
76
 
77
+ /* NOT A BRAND COLOUR. The estate's single standard error red, identical to
78
+ D2C's, bound to error/destructive only — operator ruling 2026-08-10.
79
+ Never bind this to primary, accent, a gradient stop or any decorative
80
+ surface: doing so puts a non-Braden red on a Braden page. */
81
+ --error-red: oklch(0.580 0.230 25);
82
+
55
83
  /* ============================================================
56
84
  LAYER 2 — SURFACES AND BORDERS
57
85
  ============================================================ */
@@ -78,7 +106,14 @@
78
106
  ============================================================ */
79
107
  --role-text-heading: var(--role-h1);
80
108
  --role-text-body: oklch(0.22 0.020 250);
81
- --role-text-secondary: oklch(0.38 0.018 250);
109
+ /* -base is what a LIGHT panel nested inside a dark section resets to. A
110
+ dark brand fill re-scopes the surface tokens for everything inside it,
111
+ which is right until the section contains a white card — then the card
112
+ inherits on-dark type and renders light-on-white. The reset cannot
113
+ hardcode a value because the correct one differs per theme, so it reads
114
+ this alias instead. */
115
+ --role-text-secondary-base: oklch(0.38 0.018 250);
116
+ --role-text-secondary: var(--role-text-secondary-base);
82
117
  --role-text-muted: oklch(0.52 0.018 250);
83
118
  --role-text-subtle: oklch(0.60 0.012 250);
84
119
  --role-text-disabled: oklch(0.72 0.010 250);
@@ -102,13 +137,16 @@
102
137
 
103
138
  /* ============================================================
104
139
  LAYER 3 — SEMANTIC ROLES
105
- error = --braden-indigo: Corporate primary is RED, so error cannot be.
140
+ error = --error-red, the estate's standard error red operator ruling
141
+ 2026-08-10. It is deliberately NOT --braden-red: the brand primary and the
142
+ destructive colour must still be two different values, even now that both
143
+ are red. See the header for the measured separation and what it obliges.
106
144
  ============================================================ */
107
145
  --role-primary: var(--braden-red);
108
146
  --role-secondary: var(--braden-navy);
109
147
  --role-accent: var(--braden-gold);
110
- --role-error: var(--braden-indigo);
111
- --role-destructive: var(--braden-indigo);
148
+ --role-error: var(--error-red);
149
+ --role-destructive: var(--error-red);
112
150
  --role-warning: var(--braden-gold);
113
151
  --role-success: var(--braden-aqua);
114
152
  --role-info: var(--braden-teal-deep);
@@ -176,8 +214,12 @@
176
214
  A saturated fill colour is not a legible text colour.
177
215
  ============================================================ */
178
216
  --role-primary-text: oklch(0.566 0.170 17.6);
179
- --role-accent-text: oklch(0.544 0.092 89.9);
180
- --role-error-text: oklch(0.562 0.200 270);
217
+ --role-accent-text-base: oklch(0.544 0.092 89.9);
218
+ --role-accent-text: var(--role-accent-text-base);
219
+ /* Red, per the 2026-08-10 ruling. Measured on Corporate's own light
220
+ surfaces, not inherited from D2C's: 6.18:1 on --role-bg-body,
221
+ 6.44:1 on --role-bg-panel. Both ✓ AA. */
222
+ --role-error-text: oklch(0.482 0.230 25);
181
223
  --role-warning-text: oklch(0.544 0.092 89.9);
182
224
  --role-success-text: oklch(0.515 0.130 198);
183
225
  --role-info-text: oklch(0.519 0.140 220);
@@ -188,6 +230,10 @@
188
230
  --text-on-light-fill: oklch(0.980 0.006 50);
189
231
  --text-on-dark-fill: oklch(0.190 0.020 250);
190
232
  --text-on-primary: var(--text-on-light-fill);
233
+ /* On the new error red (L 0.58) this measures 4.54:1 — ✓ AA, but with no
234
+ headroom. It was 9.35:1 on the old indigo fill (L 0.40). If the error
235
+ fill is ever lightened, re-measure before shipping: a tenth of a point of
236
+ lightness drops it below AA. */
191
237
  --text-on-error: var(--text-on-light-fill);
192
238
  --text-on-accent: var(--text-on-dark-fill);
193
239
  --text-on-warning: var(--text-on-dark-fill);
@@ -260,7 +306,8 @@
260
306
  --role-h5: oklch(0.80 0.040 228);
261
307
  --role-h6: oklch(0.75 0.048 220);
262
308
  --role-text-body: oklch(0.93 0.014 250);
263
- --role-text-secondary: oklch(0.80 0.016 250);
309
+ --role-text-secondary-base: oklch(0.80 0.016 250);
310
+ --role-text-secondary: var(--role-text-secondary-base);
264
311
  --role-text-muted: oklch(0.66 0.018 250);
265
312
  --role-text-subtle: oklch(0.55 0.015 250);
266
313
  --role-text-disabled: oklch(0.43 0.010 250);
@@ -278,8 +325,11 @@
278
325
  --role-kbd-bg: oklch(0.250 0.020 250);
279
326
  --role-kbd-border: oklch(0.400 0.022 250);
280
327
  --role-primary-text: oklch(0.704 0.170 17.6);
281
- --role-accent-text: oklch(0.683 0.092 89.9);
282
- --role-error-text: oklch(0.711 0.200 270);
328
+ --role-accent-text-base: oklch(0.683 0.092 89.9);
329
+ --role-accent-text: var(--role-accent-text-base);
330
+ /* Red, per the 2026-08-10 ruling. 7.24:1 on --role-bg-body,
331
+ 6.51:1 on --role-bg-panel. Both ✓ AA. */
332
+ --role-error-text: oklch(0.786 0.230 25);
283
333
  --role-warning-text: oklch(0.683 0.092 89.9);
284
334
  --role-success-text: oklch(0.662 0.130 198);
285
335
  --role-info-text: oklch(0.667 0.140 220);
package/src/css/vars.css CHANGED
@@ -200,15 +200,30 @@
200
200
 
201
201
  /* AA-safe text renderings of the saturated roles on light surfaces.
202
202
  A saturated fill colour is not a legible text colour; these are the
203
- text variants and are the ONLY correct token for coloured type. */
204
- --role-primary-text: oklch(0.541 0.215 262.9); /* 4.71:1 ✓ AA */
205
- --role-accent-text: oklch(0.499 0.132 191.7); /* 4.70:1 AA */
206
- --role-error-text: oklch(0.556 0.230 25); /* 4.69:1 AA */
207
- --role-warning-text: oklch(0.535 0.150 75); /* 4.70:1 AA */
208
- --role-success-text: oklch(0.501 0.130 195); /* 4.70:1 AA */
203
+ text variants and are the ONLY correct token for coloured type.
204
+
205
+ VERIFIED AGAINST THE TINTED PILL, NOT JUST THE PAGE BACKGROUND. These
206
+ previously sat at ~4.70:1 measured against --role-bg-body, which is the
207
+ right floor for a bare coloured word on the page and the wrong one for
208
+ where they are actually used most: `bg-success/10 text-success-text`, a
209
+ status pill captioned on a 10% wash of its own hue. That wash pulls the
210
+ backdrop toward the text, and every one of these measured BELOW 4.5:1 in
211
+ that setting — error worst at 3.64:1 live. A token can be AA in the
212
+ context it was measured in and fail in the context it ships in.
213
+
214
+ Each is now dropped in lightness until it clears 4.5:1 on its own /10
215
+ tint with margin. Darkening only ever RAISES contrast against a light
216
+ backdrop, so the original page-background figures improve rather than
217
+ regress; both are quoted below. Dark mode is untouched — its variants sit
218
+ at L 0.67-0.79 and already measure clean on tint (7:1+ AAA). */
219
+ --role-primary-text: oklch(0.499 0.215 262.9); /* 4.96:1 tint · 5.66:1 body ✓ AA */
220
+ --role-accent-text: oklch(0.468 0.132 191.7); /* 4.96:1 tint · 5.32:1 body ✓ AA */
221
+ --role-error-text: oklch(0.482 0.230 25); /* 4.96:1 tint · 5.83:1 body ✓ AA */
222
+ --role-warning-text: oklch(0.509 0.150 75); /* 4.96:1 tint · 5.24:1 body ✓ AA */
223
+ --role-success-text: oklch(0.463 0.130 195); /* 4.97:1 tint · 5.46:1 body ✓ AA */
209
224
  /* Pair for --role-info. Every other semantic role had a -text token; info
210
225
  was the only gap, which forced apps to invent a local copy (R80.4). */
211
- --role-info-text: oklch(0.480 0.120 192); /* 4.70:1 ✓ AA on panel */
226
+ --role-info-text: oklch(0.468 0.120 192); /* 4.96:1 tint · 5.44:1 body ✓ AA */
212
227
 
213
228
  /* Text roles — six-tier scale (W3 §3.1 contract). Every tier below is
214
229
  measured against --role-bg-body (the conservative floor); all tiers
package/src/preset-v4.css CHANGED
@@ -169,6 +169,31 @@
169
169
  --color-text-on-success: var(--text-on-success);
170
170
  --color-text-on-warning: var(--text-on-warning);
171
171
 
172
+ /* The two BASE inverse tones, exposed because the semantic roles above do
173
+ not cover every coloured fill in the system. The stage ramp is the case
174
+ that forced this: --stage-1..6 is a FIXED light-to-dark scale that does
175
+ NOT flip with the theme, so a chip painted `bg-stage-1` (L 0.86) needs
176
+ dark type in BOTH modes. Captioning it `text-foreground` — which DOES
177
+ flip — measured 1.34:1 in dark mode on conduit's candidate list, the
178
+ worst reading anywhere in the estate.
179
+
180
+ Read the name as describing the TEXT, not the fill: --text-on-dark-fill
181
+ is the DARK tone, and it is what goes on a LIGHT fill (see
182
+ --text-on-accent, cyan at L 0.77, which uses it).
183
+
184
+ For the stage ramp specifically, the crossover is between 4 and 5:
185
+
186
+ stage-1 L 0.86 light 1.52:1 dark 11.49:1
187
+ stage-2 L 0.78 light 1.93:1 dark 9.01:1
188
+ stage-3 L 0.70 light 2.55:1 dark 6.84:1
189
+ stage-4 L 0.62 light 3.48:1 dark 5.01:1 <- still takes DARK
190
+ stage-5 L 0.54 light 4.85:1 dark 3.59:1 <- flips to LIGHT
191
+ stage-6 L 0.46 light 6.86:1 dark 2.54:1
192
+
193
+ stage-4 is the trap: it reads as "mid, so white type is fine" and is not. */
194
+ --color-text-on-light-fill: var(--text-on-light-fill);
195
+ --color-text-on-dark-fill: var(--text-on-dark-fill);
196
+
172
197
  /* ============================================================
173
198
  TYPE — the contract's stack, bridged to Tailwind's font-* utilities.
174
199
  `font-sans` and `font-mono` are what components actually write, so