@oxyhq/bloom 0.7.6 → 0.8.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.
Files changed (111) hide show
  1. package/lib/commonjs/normalize-colors.d.js +2 -0
  2. package/lib/commonjs/normalize-colors.d.js.map +1 -0
  3. package/lib/commonjs/theme/apply-dark-class.js +15 -13
  4. package/lib/commonjs/theme/apply-dark-class.js.map +1 -1
  5. package/lib/commonjs/theme/build-theme.js +39 -61
  6. package/lib/commonjs/theme/build-theme.js.map +1 -1
  7. package/lib/commonjs/theme/color-presets.js +11 -5
  8. package/lib/commonjs/theme/color-presets.js.map +1 -1
  9. package/lib/commonjs/theme/color-scope/index.web.js +13 -3
  10. package/lib/commonjs/theme/color-scope/index.web.js.map +1 -1
  11. package/lib/commonjs/theme/color-scope/style-builder.js +7 -6
  12. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  13. package/lib/commonjs/theme/color-space.js +76 -0
  14. package/lib/commonjs/theme/color-space.js.map +1 -0
  15. package/lib/commonjs/theme/index.js.map +1 -1
  16. package/lib/commonjs/theme/native-root-vars.native.js +28 -18
  17. package/lib/commonjs/theme/native-root-vars.native.js.map +1 -1
  18. package/lib/commonjs/theme/preset-vars.js +20 -99
  19. package/lib/commonjs/theme/preset-vars.js.map +1 -1
  20. package/lib/commonjs/theme/token-registry.js +123 -0
  21. package/lib/commonjs/theme/token-registry.js.map +1 -0
  22. package/lib/module/normalize-colors.d.js +2 -0
  23. package/lib/module/normalize-colors.d.js.map +1 -0
  24. package/lib/module/theme/apply-dark-class.js +15 -13
  25. package/lib/module/theme/apply-dark-class.js.map +1 -1
  26. package/lib/module/theme/build-theme.js +39 -61
  27. package/lib/module/theme/build-theme.js.map +1 -1
  28. package/lib/module/theme/color-presets.js +11 -5
  29. package/lib/module/theme/color-presets.js.map +1 -1
  30. package/lib/module/theme/color-scope/index.web.js +16 -4
  31. package/lib/module/theme/color-scope/index.web.js.map +1 -1
  32. package/lib/module/theme/color-scope/style-builder.js +7 -6
  33. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  34. package/lib/module/theme/color-space.js +69 -0
  35. package/lib/module/theme/color-space.js.map +1 -0
  36. package/lib/module/theme/index.js.map +1 -1
  37. package/lib/module/theme/native-root-vars.native.js +29 -18
  38. package/lib/module/theme/native-root-vars.native.js.map +1 -1
  39. package/lib/module/theme/preset-vars.js +19 -98
  40. package/lib/module/theme/preset-vars.js.map +1 -1
  41. package/lib/module/theme/token-registry.js +113 -0
  42. package/lib/module/theme/token-registry.js.map +1 -0
  43. package/lib/typescript/commonjs/theme/apply-dark-class.d.ts +13 -9
  44. package/lib/typescript/commonjs/theme/apply-dark-class.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/theme/build-theme.d.ts +1 -1
  46. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/theme/color-presets.d.ts +11 -5
  48. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/theme/color-scope/index.web.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts +5 -2
  51. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/theme/color-space.d.ts +28 -0
  53. package/lib/typescript/commonjs/theme/color-space.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/theme/index.d.ts +1 -2
  55. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts +25 -15
  57. package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/theme/preset-vars.d.ts +12 -45
  59. package/lib/typescript/commonjs/theme/preset-vars.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/theme/token-registry.d.ts +53 -0
  61. package/lib/typescript/commonjs/theme/token-registry.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/theme/types.d.ts +3 -0
  63. package/lib/typescript/commonjs/theme/types.d.ts.map +1 -1
  64. package/lib/typescript/module/theme/apply-dark-class.d.ts +13 -9
  65. package/lib/typescript/module/theme/apply-dark-class.d.ts.map +1 -1
  66. package/lib/typescript/module/theme/build-theme.d.ts +1 -1
  67. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -1
  68. package/lib/typescript/module/theme/color-presets.d.ts +11 -5
  69. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  70. package/lib/typescript/module/theme/color-scope/index.web.d.ts.map +1 -1
  71. package/lib/typescript/module/theme/color-scope/style-builder.d.ts +5 -2
  72. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  73. package/lib/typescript/module/theme/color-space.d.ts +28 -0
  74. package/lib/typescript/module/theme/color-space.d.ts.map +1 -0
  75. package/lib/typescript/module/theme/index.d.ts +1 -2
  76. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  77. package/lib/typescript/module/theme/native-root-vars.native.d.ts +25 -15
  78. package/lib/typescript/module/theme/native-root-vars.native.d.ts.map +1 -1
  79. package/lib/typescript/module/theme/preset-vars.d.ts +12 -45
  80. package/lib/typescript/module/theme/preset-vars.d.ts.map +1 -1
  81. package/lib/typescript/module/theme/token-registry.d.ts +53 -0
  82. package/lib/typescript/module/theme/token-registry.d.ts.map +1 -0
  83. package/lib/typescript/module/theme/types.d.ts +3 -0
  84. package/lib/typescript/module/theme/types.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/__tests__/BloomColorScope.test.tsx +14 -0
  87. package/src/__tests__/BloomThemeProvider.test.tsx +4 -3
  88. package/src/__tests__/apply-dark-class.test.ts +70 -0
  89. package/src/__tests__/native-root-vars.test.ts +8 -7
  90. package/src/__tests__/preset-vars.test.ts +42 -42
  91. package/src/__tests__/theme.test.ts +6 -6
  92. package/src/normalize-colors.d.ts +22 -0
  93. package/src/react-native-css.d.ts +3 -3
  94. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +574 -0
  95. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +838 -0
  96. package/src/theme/__tests__/color-space.test.ts +28 -0
  97. package/src/theme/__tests__/golden-tokens.test.ts +33 -0
  98. package/src/theme/__tests__/runtime-contract.test.ts +49 -0
  99. package/src/theme/__tests__/token-registry.test.ts +30 -0
  100. package/src/theme/__tests__/visual-gallery.test.tsx +10 -0
  101. package/src/theme/apply-dark-class.ts +15 -11
  102. package/src/theme/build-theme.ts +39 -67
  103. package/src/theme/color-presets.ts +11 -5
  104. package/src/theme/color-scope/index.web.tsx +17 -3
  105. package/src/theme/color-scope/style-builder.ts +7 -4
  106. package/src/theme/color-space.ts +56 -0
  107. package/src/theme/index.ts +1 -5
  108. package/src/theme/native-root-vars.native.ts +28 -18
  109. package/src/theme/preset-vars.ts +17 -126
  110. package/src/theme/token-registry.ts +159 -0
  111. package/src/theme/types.ts +3 -0
@@ -0,0 +1,28 @@
1
+ import { oklchToSrgb, srgbToRgbString, parseRgbString, deltaE } from '../color-space';
2
+
3
+ describe('color-space', () => {
4
+ it('converts oklch white/black correctly', () => {
5
+ expect(oklchToSrgb({ l: 1, c: 0, h: 0 })).toEqual({ r: 255, g: 255, b: 255 });
6
+ expect(oklchToSrgb({ l: 0, c: 0, h: 0 })).toEqual({ r: 0, g: 0, b: 0 });
7
+ });
8
+ it('round-trips a known oxy purple within ΔE 1', () => {
9
+ // hsl(277 66% 56%) === rgb(160 69 217) (current oracle)
10
+ const rgb = oklchToSrgb({ l: 0.5783, c: 0.22, h: 309.31 });
11
+ expect(deltaE(rgb, { r: 160, g: 69, b: 217 })).toBeLessThanOrEqual(1.0);
12
+ });
13
+ it('formats rgb as space-separated modern syntax', () => {
14
+ expect(srgbToRgbString({ r: 31, g: 153, b: 239 })).toBe('rgb(31 153 239)');
15
+ expect(srgbToRgbString({ r: 31, g: 153, b: 239 }, 0.1)).toBe('rgb(31 153 239 / 0.1)');
16
+ });
17
+ it('parses modern + legacy rgb strings', () => {
18
+ expect(parseRgbString('rgb(31 153 239)')).toEqual({ r: 31, g: 153, b: 239 });
19
+ expect(parseRgbString('rgb(31, 153, 239)')).toEqual({ r: 31, g: 153, b: 239 });
20
+ });
21
+ it('throws loudly on non-rgb input instead of silently returning black', () => {
22
+ expect(() => parseRgbString('277 50% 5%')).toThrow(
23
+ 'parseRgbString: expected an rgb() string, got: 277 50% 5%',
24
+ );
25
+ expect(() => parseRgbString('hsl(277 50% 5%)')).toThrow(/expected an rgb\(\) string/);
26
+ expect(() => parseRgbString('')).toThrow(/expected an rgb\(\) string/);
27
+ });
28
+ });
@@ -0,0 +1,33 @@
1
+ import goldenResolvedTokens from './__fixtures__/golden-resolved-tokens.json';
2
+ import { APP_COLOR_NAMES } from '../color-presets';
3
+ import { getResolvedTokens } from '../token-registry';
4
+
5
+ // `golden-resolved-tokens.json` is the FROZEN parity oracle: the exact resolved
6
+ // rgb for every preset×mode×token that the legacy resolved-color pipeline
7
+ // produced (captured once, each resolved-color key remapped to its base `--X`).
8
+ // It is IMMUTABLE — never regenerate it to chase new output. These tests prove
9
+ // the canonical `getResolvedTokens` pipeline reproduces that palette EXACTLY
10
+ // (byte-for-byte rgb), so the refactor to a single rgb token pipeline does not
11
+ // shift a single color.
12
+ const fixture = goldenResolvedTokens as Record<string, Record<string, string>>;
13
+
14
+ describe('golden token parity (frozen oracle)', () => {
15
+ for (const name of APP_COLOR_NAMES) {
16
+ for (const mode of ['light', 'dark'] as const) {
17
+ it(`${name}/${mode} resolves to the frozen rgb palette exactly`, () => {
18
+ const expected = fixture[`${name}/${mode}`];
19
+ expect(expected).toBeDefined();
20
+
21
+ const resolved = getResolvedTokens(name, mode);
22
+ // For every token the oracle ever resolved, the canonical pipeline must
23
+ // produce the identical rgb string. (The fixture covers exactly the keys
24
+ // that had a resolved-color companion; extended-only tokens —
25
+ // chart-*/sidebar-*/content-area — were never in the rgb oracle and are
26
+ // covered by the canonical-token coverage test instead.)
27
+ const actual: Record<string, string | undefined> = {};
28
+ for (const key of Object.keys(expected ?? {})) actual[key] = resolved[key];
29
+ expect(actual).toEqual(expected);
30
+ });
31
+ }
32
+ }
33
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import normalizeColor from '@react-native/normalize-colors';
6
+ import { Platform } from 'react-native';
7
+
8
+ import { buildTheme } from '../build-theme';
9
+ import { applyColorPresetVars } from '../apply-dark-class';
10
+
11
+ // `@react-native/normalize-colors` is the exact parser React Native (native) and
12
+ // react-native-web use behind `StyleSheet`/`processColor`. If it returns `null`
13
+ // for a value, that color is UNPARSEABLE and the element renders nothing. These
14
+ // tests pin the runtime contract for the canonical rgb token pipeline: every
15
+ // JS `theme.colors` value must parse, the web document vars must be full colors
16
+ // (not bare HSL triples), and the historically mislabeled aliases must be fixed.
17
+
18
+ it('every theme.colors value parses via normalize-colors (native/RNW StyleSheet)', () => {
19
+ const { colors } = buildTheme('oxy', 'dark');
20
+ for (const v of Object.values(colors)) {
21
+ expect(normalizeColor(v)).not.toBeNull(); // null = unparseable -> renders nothing
22
+ }
23
+ });
24
+
25
+ describe('web var(--primary) resolves to a real color (not a bare triple)', () => {
26
+ const originalOS = Platform.OS;
27
+
28
+ beforeEach(() => {
29
+ Platform.OS = 'web';
30
+ document.documentElement.removeAttribute('style');
31
+ });
32
+
33
+ afterEach(() => {
34
+ Platform.OS = originalOS;
35
+ document.documentElement.removeAttribute('style');
36
+ });
37
+
38
+ it('writes --primary as a resolved rgb() color', () => {
39
+ applyColorPresetVars('oxy', 'dark');
40
+ const v = document.documentElement.style.getPropertyValue('--primary').trim();
41
+ expect(v).toMatch(/^rgb\(/);
42
+ expect(normalizeColor(v)).not.toBeNull();
43
+ });
44
+ });
45
+
46
+ it('mislabeled aliases are fixed: secondary !== primary', () => {
47
+ const { colors } = buildTheme('oxy', 'light');
48
+ expect(colors.secondary).not.toBe(colors.primary);
49
+ });
@@ -0,0 +1,30 @@
1
+ import goldenResolvedTokens from './__fixtures__/golden-resolved-tokens.json';
2
+ import { CANONICAL_TOKENS, getResolvedTokens } from '../token-registry';
3
+ import { APP_COLOR_NAMES } from '../color-presets';
4
+ import { getPresetVars } from '../preset-vars';
5
+
6
+ const fixture = goldenResolvedTokens as Record<string, Record<string, string>>;
7
+
8
+ it('canonical token list covers every base+extended preset var', () => {
9
+ const fromPreset = Object.keys(getPresetVars('oxy', 'light'));
10
+ for (const k of fromPreset) expect(CANONICAL_TOKENS).toContain(k.replace(/^--/, ''));
11
+ });
12
+
13
+ describe('getResolvedTokens parity vs frozen oracle (exact rgb)', () => {
14
+ for (const name of APP_COLOR_NAMES) {
15
+ for (const mode of ['light', 'dark'] as const) {
16
+ it(`${name}/${mode}`, () => {
17
+ const oracle = fixture[`${name}/${mode}`];
18
+ expect(oracle).toBeDefined();
19
+
20
+ const resolved = getResolvedTokens(name, mode); // Record<'--x','rgb(...)'>
21
+ for (const [base, oracleRgb] of Object.entries(oracle ?? {})) {
22
+ expect(resolved[base]).toBeDefined();
23
+ // Exact match — the canonical pipeline reproduces the legacy
24
+ // resolved-color rgb byte-for-byte, so there is no palette drift.
25
+ expect(resolved[base]).toBe(oracleRgb);
26
+ }
27
+ });
28
+ }
29
+ }
30
+ });
@@ -0,0 +1,10 @@
1
+ import { buildTheme } from '../build-theme';
2
+ import { APP_COLOR_NAMES } from '../color-presets';
3
+
4
+ it('theme.colors snapshot for all presets (catches unintended palette shifts)', () => {
5
+ const all = Object.fromEntries(
6
+ APP_COLOR_NAMES.flatMap((n) => (['light', 'dark'] as const).map(
7
+ (m) => [`${n}/${m}`, buildTheme(n, m).colors] as const)),
8
+ );
9
+ expect(all).toMatchSnapshot();
10
+ });
@@ -1,6 +1,6 @@
1
1
  import { Platform } from 'react-native';
2
2
  import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
3
- import { getPresetVars } from './preset-vars';
3
+ import { getResolvedTokens } from './token-registry';
4
4
 
5
5
  export function applyDarkClass(resolved: 'light' | 'dark') {
6
6
  if (Platform.OS === 'web' && typeof document !== 'undefined') {
@@ -10,22 +10,26 @@ export function applyDarkClass(resolved: 'light' | 'dark') {
10
10
 
11
11
  /**
12
12
  * Apply a color preset's CSS custom properties to the document root.
13
- * No-op on native — only affects web.
13
+ * No-op on native — only affects web (early-returns on `Platform.OS !== 'web'`).
14
14
  *
15
- * Writes both the raw HSL triples (e.g. `--primary: 185 100% 20%`) and the
16
- * resolved `--color-*` vars (`--color-primary: rgb(0 94 102)`) so both
17
- * shadcn-style `hsl(var(--primary))` plumbing and Tailwind v4 `@theme`
18
- * utilities resolve consistently. The resolved vars are emitted as `rgb(...)`
19
- * (not `hsl(...)`) so the alpha utilities (`bg-primary/10`) resolve on native
20
- * see `getPresetVars` / `hslTripletToRgb`. Includes extended tokens (card,
21
- * chart-*, content-area, sidebar-*) so consumer apps don't need to synthesize
22
- * them.
15
+ * Web var contract (the form Tailwind v4 `@theme inline` compiles to)
16
+ * -------------------------------------------------------------------
17
+ * The shadcn/Tailwind-v4 web apps compile their color utilities to reference the
18
+ * BASE token directly (`.bg-background { background-color: var(--background) }`),
19
+ * so on web the base `--x` tokens MUST be FULL CSS colors. `getResolvedTokens`
20
+ * returns every canonical token already resolved to an sRGB `rgb(...)` string,
21
+ * so `var(--x)` resolves to a valid color directly — no per-value wrapping. The
22
+ * same rgb values feed native (`color-mix` alpha utilities resolve on sRGB), so
23
+ * web and native share one canonical token pipeline.
24
+ *
25
+ * Includes extended tokens (card, chart-*, content-area, sidebar-*) so consumer
26
+ * apps don't need to synthesize them.
23
27
  */
24
28
  export function applyColorPresetVars(preset: AppColorName, resolved: 'light' | 'dark') {
25
29
  if (Platform.OS !== 'web' || typeof document === 'undefined') return;
26
30
  if (!APP_COLOR_PRESETS[preset]) return;
27
31
 
28
- const vars = getPresetVars(preset, resolved, { includeResolvedColorVars: true });
32
+ const vars = getResolvedTokens(preset, resolved);
29
33
  const root = document.documentElement.style;
30
34
 
31
35
  for (const [key, value] of Object.entries(vars)) {
@@ -1,6 +1,8 @@
1
1
  import { Platform } from 'react-native';
2
- import { APP_COLOR_PRESETS, type AppColorName, type PresetTokens } from './color-presets';
2
+ import type { AppColorName } from './color-presets';
3
3
  import { getAdaptiveColors } from './adaptive-colors';
4
+ import { getResolvedTokens } from './token-registry';
5
+ import { parseRgbString, srgbToRgbString } from './color-space';
4
6
  import type { Theme, ThemeColors } from './types';
5
7
 
6
8
  /**
@@ -15,91 +17,61 @@ export const STATUS_COLORS = {
15
17
  } as const;
16
18
 
17
19
  /**
18
- * Convert a shadcn-style HSL CSS variable (`'H S% L%'` or `'H S% L% / A'`)
19
- * into a fully-resolved `hsl()` / `hsla()` color string consumable by both
20
- * web `style` and React Native.
20
+ * Build the JS `theme.colors` object from the SAME canonical rgb token source
21
+ * the web/native CSS-var writes use (`getResolvedTokens`). JS styles and the
22
+ * `var(--x)` document tokens therefore share one rgb pipeline — no second,
23
+ * drift-prone HSL conversion lives here. Subtle/alpha mixes derive from the
24
+ * resolved rgb via `parseRgbString` + `srgbToRgbString`.
21
25
  */
22
- function hslVarToColor(hslVar: string): string {
23
- const parts = hslVar.split('/').map((p) => p.trim());
24
- const triple = parts[0] ?? '0 0% 0%';
25
- const components = triple.replace(/\s+/g, ', ');
26
-
27
- if (parts.length === 2) {
28
- const alpha = parseFloat(parts[1] ?? '100') / 100;
29
- return `hsla(${components}, ${alpha})`;
30
- }
31
- return `hsl(${components})`;
32
- }
33
-
34
- function extractHue(hslVar: string): number {
35
- const first = hslVar.split(/\s+/)[0] ?? '0';
36
- const hue = parseInt(first, 10);
37
- return Number.isFinite(hue) ? hue : 0;
38
- }
39
-
40
- function hsl(h: number, s: number, l: number): string {
41
- return `hsl(${h}, ${s}%, ${l}%)`;
42
- }
43
-
44
- function readToken(tokens: PresetTokens, key: string, fallback = '0 0% 0%'): string {
45
- return hslVarToColor(tokens[key] ?? fallback);
46
- }
47
-
48
26
  function buildColorsFromPreset(
49
27
  preset: AppColorName,
50
28
  resolved: 'light' | 'dark',
51
29
  ): ThemeColors {
52
- const config = APP_COLOR_PRESETS[preset];
53
- const tokens = resolved === 'dark' ? config.dark : config.light;
30
+ const t = getResolvedTokens(preset, resolved);
54
31
  const isDark = resolved === 'dark';
55
32
 
56
- const primaryHue = extractHue(tokens['--primary'] ?? '0 0% 50%');
57
- const destructiveHue = extractHue(tokens['--destructive'] ?? '0 0% 0%');
58
-
59
- const background = readToken(tokens, '--background');
60
- const surface = readToken(tokens, '--surface');
61
- const mutedForeground = readToken(tokens, '--muted-foreground', '0 0% 50%');
62
- const primaryColor = readToken(tokens, '--primary', '0 0% 50%');
63
- const primaryForeground = readToken(tokens, '--primary-foreground', '0 0% 100%');
33
+ // Read a resolved `rgb(...)` token by its bare name (no leading `--`).
34
+ const g = (k: string): string => t[`--${k}`] ?? 'rgb(0 0 0)';
35
+ // Re-emit a resolved token at a given alpha (sRGB rgb-with-alpha).
36
+ const mix = (k: string, a: number): string => srgbToRgbString(parseRgbString(g(k)), a);
64
37
 
65
38
  return {
66
- background,
67
- backgroundSecondary: surface,
68
- backgroundTertiary: readToken(tokens, '--muted'),
39
+ background: g('background'),
40
+ backgroundSecondary: g('surface'),
41
+ backgroundTertiary: g('muted'),
69
42
 
70
- text: readToken(tokens, '--foreground', '0 0% 100%'),
71
- textSecondary: mutedForeground,
72
- textTertiary: mutedForeground,
43
+ text: g('foreground'),
44
+ textSecondary: g('muted-foreground'),
45
+ textTertiary: g('muted-foreground'),
73
46
 
74
- border: readToken(tokens, '--border', '0 0% 20%'),
75
- borderLight: readToken(tokens, '--input', '0 0% 20%'),
47
+ border: g('border'),
48
+ borderLight: g('input'),
76
49
 
77
- primary: primaryColor,
78
- primaryForeground,
79
- // Legacy aliases retained for backwards compatibility with downstream
80
- // consumers. `primaryLight` should be a brand tint, not the surface, but
81
- // changing this is a breaking change handled in a separate major.
82
- primaryLight: surface,
83
- primaryDark: background,
50
+ primary: g('primary'),
51
+ primaryForeground: g('primary-foreground'),
52
+ // Corrected aliases (see types.ts): `primaryLight` is the preset accent
53
+ // tint, `primaryDark` is the focus-ring shade NOT the surface/background.
54
+ primaryLight: g('accent'),
55
+ primaryDark: g('ring'),
84
56
 
85
- // `secondary` historically mirrored `primary`. Retained for compatibility.
86
- secondary: primaryColor,
57
+ // Corrected: `secondary` is the preset's secondary surface, NOT a primary mirror.
58
+ secondary: g('secondary'),
87
59
 
88
- tint: primaryColor,
89
- icon: mutedForeground,
90
- iconActive: primaryColor,
60
+ tint: g('primary'),
61
+ icon: g('muted-foreground'),
62
+ iconActive: g('primary'),
91
63
 
92
64
  ...STATUS_COLORS,
93
65
 
94
- primarySubtle: isDark ? hsl(primaryHue, 50, 10) : hsl(primaryHue, 70, 93),
95
- primarySubtleForeground: isDark ? hsl(primaryHue, 70, 65) : hsl(primaryHue, 90, 25),
96
- negative: hsl(destructiveHue, 84, 45),
66
+ primarySubtle: mix('primary', isDark ? 0.16 : 0.12),
67
+ primarySubtleForeground: g('primary'),
68
+ negative: g('destructive'),
97
69
  negativeForeground: '#FFFFFF',
98
- negativeSubtle: isDark ? hsl(destructiveHue, 50, 10) : hsl(destructiveHue, 90, 95),
99
- negativeSubtleForeground: isDark ? hsl(destructiveHue, 70, 65) : hsl(destructiveHue, 80, 40),
100
- contrast50: isDark ? hsl(primaryHue, 15, 12) : hsl(primaryHue, 10, 93),
70
+ negativeSubtle: mix('destructive', isDark ? 0.16 : 0.12),
71
+ negativeSubtleForeground: g('destructive'),
72
+ contrast50: mix('foreground', 0.5),
101
73
 
102
- card: surface,
74
+ card: g('card'),
103
75
  shadow: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
104
76
  overlay: 'rgba(0, 0, 0, 0.5)',
105
77
  };
@@ -6,11 +6,17 @@ export type AppColorName = 'teal' | 'blue' | 'green' | 'amber' | 'yellow' | 'red
6
6
  * Keys carry a leading `--` for historical reasons (shadcn-style CSS variable
7
7
  * names) — keep in mind the values are platform-agnostic **raw HSL triples**
8
8
  * (e.g. `'185 100% 20%'` or `'185 100% 20% / 0.5'`), not CSS-resolved colors.
9
- * The same map drives both:
10
- * - the web layer (written verbatim into `document.documentElement.style`
11
- * so Tailwind's `hsl(var(--primary))` plumbing picks them up), and
12
- * - the native layer (`buildTheme` resolves them into `hsl(...)` strings
13
- * consumable by React Native styles).
9
+ * The single canonical resolver `getResolvedTokens` (in `token-registry.ts`)
10
+ * converts this raw map to sRGB `rgb(...)` for every runtime write — the same
11
+ * values drive BOTH layers:
12
+ * - the web layer: `getResolvedTokens`'s `rgb(...)` strings are written to
13
+ * `document.documentElement` / a `BloomColorScope` element, so `var(--x)` —
14
+ * the form Tailwind v4 `@theme inline` compiles its color utilities to —
15
+ * resolves to a valid color directly, and
16
+ * - the native layer (`buildTheme` resolves the raw triples into `hsl(...)`
17
+ * strings consumable by React Native styles; `native-root-vars.native.ts`
18
+ * writes the same canonical `rgb(...)` values into react-native-css's
19
+ * `rootVariables` family).
14
20
  *
15
21
  * The `--` prefix is an implementation detail we will drop in a future major.
16
22
  */
@@ -3,7 +3,21 @@ import React, { Children, cloneElement, isValidElement, useContext, useMemo } fr
3
3
  import { BloomThemeContext, type BloomThemeContextValue } from '../BloomThemeProvider';
4
4
  import { buildTheme } from '../build-theme';
5
5
  import type { AppColorName } from '../color-presets';
6
- import { buildScopeVars } from './style-builder';
6
+ import { getResolvedTokens } from '../token-registry';
7
+
8
+ /**
9
+ * `getResolvedTokens` returns every canonical token already resolved to an sRGB
10
+ * `rgb(...)` string — the SAME pipeline the document-root web writer and the
11
+ * native writer use. Scoped onto an element's inline `style`, Tailwind v4's
12
+ * compiled utilities read these as `var(--x)` directly, so each base token is a
13
+ * full CSS color with no per-value wrapping needed.
14
+ */
15
+ function buildWebScopeVars(
16
+ colorPreset: AppColorName,
17
+ mode: 'light' | 'dark',
18
+ ): React.CSSProperties {
19
+ return getResolvedTokens(colorPreset, mode) as React.CSSProperties;
20
+ }
7
21
 
8
22
  export interface BloomColorScopeProps {
9
23
  /**
@@ -63,7 +77,7 @@ export function BloomColorScope({
63
77
  }, [colorPreset, resolvedMode, parent]);
64
78
 
65
79
  const varsStyle = useMemo(
66
- () => buildScopeVars(colorPreset, resolvedMode) as React.CSSProperties,
80
+ () => buildWebScopeVars(colorPreset, resolvedMode),
67
81
  [colorPreset, resolvedMode],
68
82
  );
69
83
 
@@ -104,7 +118,7 @@ export function useColorScopeStyle(colorPreset: AppColorName): React.CSSProperti
104
118
  }
105
119
  const resolvedMode = parent.theme.mode;
106
120
  return useMemo(
107
- () => buildScopeVars(colorPreset, resolvedMode) as React.CSSProperties,
121
+ () => buildWebScopeVars(colorPreset, resolvedMode),
108
122
  [colorPreset, resolvedMode],
109
123
  );
110
124
  }
@@ -1,7 +1,7 @@
1
1
  import type React from 'react';
2
2
  import { Platform, type StyleProp, type ViewStyle } from 'react-native';
3
3
 
4
- import { getPresetVars } from '../preset-vars';
4
+ import { getResolvedTokens } from '../token-registry';
5
5
  import type { AppColorName } from '../color-presets';
6
6
  import { lazyRequire } from '../../utils/lazy-require';
7
7
 
@@ -25,14 +25,17 @@ const getNativeWindVars = lazyRequire<NativeWindVarsModule>('nativewind');
25
25
 
26
26
  /**
27
27
  * Build the CSS custom-property map for a preset, ready to be applied to a
28
- * subtree. Always includes the resolved `--color-*` vars so Tailwind v4
29
- * `@theme` utilities (e.g. `bg-background`) honour the scope.
28
+ * subtree. Every token is resolved to an sRGB `rgb(...)` string via
29
+ * `getResolvedTokens` the single canonical token pipeline shared by web and
30
+ * native — so Tailwind v4 `@theme` utilities (e.g. `bg-background`) and
31
+ * `color-mix`-based alpha utilities (`bg-primary/10`) both resolve against the
32
+ * same color values. Keys retain the leading `--`.
30
33
  */
31
34
  export function buildScopeVars(
32
35
  colorPreset: AppColorName,
33
36
  mode: 'light' | 'dark',
34
37
  ): Record<string, string> {
35
- return getPresetVars(colorPreset, mode, { includeResolvedColorVars: true });
38
+ return getResolvedTokens(colorPreset, mode);
36
39
  }
37
40
 
38
41
  /**
@@ -0,0 +1,56 @@
1
+ export interface Oklch { l: number; c: number; h: number } // l 0..1, c 0..~0.4, h deg
2
+ export interface Rgb { r: number; g: number; b: number } // 0..255 integers
3
+
4
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
5
+ const srgbGamma = (x: number) =>
6
+ x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
7
+
8
+ /** OKLCH → linear OKLab → linear sRGB → gamma sRGB → 0..255. Pure. */
9
+ export function oklchToSrgb({ l, c, h }: Oklch): Rgb {
10
+ const hr = (h * Math.PI) / 180;
11
+ const a = c * Math.cos(hr);
12
+ const b2 = c * Math.sin(hr);
13
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b2;
14
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b2;
15
+ const s_ = l - 0.0894841775 * a - 1.291485548 * b2;
16
+ const lc = l_ ** 3, mc = m_ ** 3, sc = s_ ** 3;
17
+ const lr = +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
18
+ const lg = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
19
+ const lb = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc;
20
+ return {
21
+ r: Math.round(clamp(srgbGamma(lr), 0, 1) * 255),
22
+ g: Math.round(clamp(srgbGamma(lg), 0, 1) * 255),
23
+ b: Math.round(clamp(srgbGamma(lb), 0, 1) * 255),
24
+ };
25
+ }
26
+
27
+ export function srgbToRgbString({ r, g, b }: Rgb, alpha?: number): string {
28
+ return alpha === undefined || alpha >= 1
29
+ ? `rgb(${r} ${g} ${b})`
30
+ : `rgb(${r} ${g} ${b} / ${alpha})`;
31
+ }
32
+
33
+ /**
34
+ * Parse an `rgb(...)` / `rgba(...)` string into an {@link Rgb}.
35
+ *
36
+ * Contract: input MUST be an `rgb(...)` / `rgba(...)` string (e.g. a resolved
37
+ * token from `getResolvedTokens`). Both modern (`rgb(r g b)`) and legacy
38
+ * (`rgb(r, g, b)`) syntaxes are accepted. Anything else throws — this is a
39
+ * canonical-pipeline primitive and a non-rgb input signals a real bug at the
40
+ * call site, never a value to silently coerce to black.
41
+ *
42
+ * @throws {Error} if `s` is not an `rgb()` / `rgba()` string.
43
+ */
44
+ export function parseRgbString(s: string): Rgb {
45
+ const inner = s.match(/rgba?\(([^)]+)\)/i)?.[1];
46
+ if (inner === undefined) {
47
+ throw new Error(`parseRgbString: expected an rgb() string, got: ${s}`);
48
+ }
49
+ const [r, g, b] = inner.split(/[ ,/]+/).map((n) => parseInt(n, 10));
50
+ return { r: r ?? 0, g: g ?? 0, b: b ?? 0 };
51
+ }
52
+
53
+ /** Cheap perceptual-ish distance in sRGB (sufficient for a ≤1 rounding gate). */
54
+ export function deltaE(a: Rgb, b: Rgb): number {
55
+ return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2) / Math.sqrt(3);
56
+ }
@@ -16,11 +16,7 @@ export {
16
16
  HEX_TO_APP_COLOR,
17
17
  hexToAppColorName,
18
18
  } from './color-presets';
19
- export {
20
- getPresetVars,
21
- applyPresetVarsToDocument,
22
- } from './preset-vars';
23
- export type { PresetVarsOptions } from './preset-vars';
19
+ export { getPresetVars, applyPresetVarsToDocument } from './preset-vars';
24
20
  export { applyDarkClass } from './apply-dark-class';
25
21
  export { setColorSchemeSafe } from './set-color-scheme-safe';
26
22
  export { initCssInteropDarkMode } from './init-css-interop';
@@ -16,8 +16,8 @@
16
16
  // directive ships raw in `src/` (Bloom publishes `files: ["src", "lib"]`).
17
17
  import { rootVariables } from 'react-native-css/native-internal';
18
18
 
19
- import { buildScopeVars } from './color-scope/style-builder';
20
19
  import { type AppColorName } from './color-presets';
20
+ import { getResolvedTokens } from './token-registry';
21
21
 
22
22
  /**
23
23
  * Publish the active color preset's CSS custom properties into react-native-css's
@@ -41,18 +41,28 @@ import { type AppColorName } from './color-presets';
41
41
  * `BloomThemeProvider`'s `VariableContextProvider` only feeds path #1, so the navigator
42
42
  * host and every portal/modal — which render outside that subtree — never see the
43
43
  * preset vars and fall through to `rootVariables`/`universalVariables`. By default those
44
- * families hold only react-native-css's own `__rn-css-rem`/`__rn-css-color` entries
45
- * (Bloom's `global.css` defines the `hsl(var(--primary))` indirection but not the HSL
46
- * triples themselves those are injected at runtime), so the whole tree below the
47
- * navigator renders monochrome. Writing the vars into `rootVariables` here is the
48
- * exact same mechanism react-native-css uses for compiled `:root {}` / `@theme` vars
49
- * (`StyleCollection.inject` does `rootVariables(name).set(valueArray)`), so portaled
50
- * content resolves the palette identically regardless of React-tree position.
44
+ * families hold only react-native-css's own `__rn-css-rem`/`__rn-css-color` entries, so
45
+ * the whole tree below the navigator renders monochrome until the palette is injected at
46
+ * runtime. Writing the vars into `rootVariables` here is the exact same mechanism
47
+ * react-native-css uses for compiled `:root {}` / `@theme` vars (`StyleCollection.inject`
48
+ * does `rootVariables(name).set(valueArray)`), so portaled content resolves the palette
49
+ * identically regardless of React-tree position.
51
50
  *
52
- * The raw tokens are written as HSL triples (`primary: '205 87% 53%'`) and the
53
- * resolved Tailwind v4 `--color-*` vars as sRGB `rgb(...)` (`color-primary:
54
- * 'rgb(31 153 239)'`) the latter so `color-mix`-based alpha utilities resolve
55
- * on native (see `getPresetVars` / `hslTripletToRgb`).
51
+ * Consumer contract (CRITICAL single canonical color pipeline)
52
+ * -------------------------------------------------------------
53
+ * Each base `--x` token is now written as a FULL `rgb(...)` color (not a raw HSL
54
+ * triple). A consuming app's Tailwind `@theme` must therefore reference these
55
+ * base tokens DIRECTLY (its Tailwind color var equals `var(--x)`) and must NOT
56
+ * wrap them in `hsl(...)`. The legacy `hsl(var(--x))` indirection (which assumed
57
+ * a raw triple) would produce the invalid `hsl(rgb(...))` and must be removed in
58
+ * every consumer. Web and native now share this one contract.
59
+ *
60
+ * Every token is written as an sRGB `rgb(...)` string from the single canonical
61
+ * token pipeline (`getResolvedTokens`) — `primary: 'rgb(31 153 239)'` — the SAME
62
+ * values web writes to `document.documentElement`. sRGB is the space
63
+ * react-native-css's `color-mix` resolver registers, so `color-mix`-based alpha
64
+ * utilities (`bg-primary/10`) resolve on native; full (non-alpha) utilities
65
+ * resolve directly through bloom's `global.css` `var(--x)` indirection.
56
66
  *
57
67
  * Why a STATIC `import`, not a runtime `require` (critical)
58
68
  * ---------------------------------------------------------
@@ -82,15 +92,15 @@ import { type AppColorName } from './color-presets';
82
92
  * `shared.rootVariables[name]`, and `@property --x` goes through
83
93
  * `name.startsWith("--") ? name.slice(2) : name`. The lookup side matches: a `var(--x)`
84
94
  * reference compiles to `[{}, "var", ident.slice(2)]`, so `varResolver` looks up the
85
- * bare name. `buildScopeVars` returns keys WITH the `--` prefix, so we strip it before
86
- * writing each entry.
95
+ * bare name. `getResolvedTokens` returns keys WITH the `--` prefix, so we strip it
96
+ * before writing each entry.
87
97
  *
88
98
  * Value shape
89
99
  * -----------
90
100
  * Each entry is a `VariableValue[]` — an array of `[value, mediaCondition?]` tuples.
91
101
  * Bloom's preset vars are unconditional, so each is a single-element `[[value]]`, e.g.
92
- * `rootVariables('primary').set([['205 87% 53%']])`. This is the same shape the compiler
93
- * emits for unconditional `:root` vars.
102
+ * `rootVariables('primary').set([['rgb(31 153 239)']])`. This is the same shape the
103
+ * compiler emits for unconditional `:root` vars.
94
104
  *
95
105
  * This is native-only and additive: the `VariableContextProvider` wrapper stays (it is
96
106
  * the correct, scoped mechanism for `BloomColorScope` subtree overrides and harmlessly
@@ -114,9 +124,9 @@ import { type AppColorName } from './color-presets';
114
124
  export function applyNativeRootVars(colorPreset: AppColorName, mode: 'light' | 'dark'): void {
115
125
  if (typeof rootVariables !== 'function') return;
116
126
 
117
- const vars = buildScopeVars(colorPreset, mode);
127
+ const vars = getResolvedTokens(colorPreset, mode);
118
128
  for (const [key, value] of Object.entries(vars)) {
119
- // The family is keyed by the bare name; `buildScopeVars` keys include `--`.
129
+ // The family is keyed by the bare name; `getResolvedTokens` keys include `--`.
120
130
  const name = key.startsWith('--') ? key.slice(2) : key;
121
131
  rootVariables(name).set([[value]]);
122
132
  }