@oxyhq/bloom 0.7.7 → 0.8.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.
Files changed (108) 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 +8 -24
  4. package/lib/commonjs/theme/apply-dark-class.js.map +1 -1
  5. package/lib/commonjs/theme/build-theme.js +61 -57
  6. package/lib/commonjs/theme/build-theme.js.map +1 -1
  7. package/lib/commonjs/theme/color-presets.js +9 -9
  8. package/lib/commonjs/theme/color-scope/index.web.js +7 -14
  9. package/lib/commonjs/theme/color-scope/index.web.js.map +1 -1
  10. package/lib/commonjs/theme/color-scope/style-builder.js +7 -6
  11. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  12. package/lib/commonjs/theme/color-space.js +76 -0
  13. package/lib/commonjs/theme/color-space.js.map +1 -0
  14. package/lib/commonjs/theme/index.js +0 -6
  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 -152
  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 +8 -19
  25. package/lib/module/theme/apply-dark-class.js.map +1 -1
  26. package/lib/module/theme/build-theme.js +61 -57
  27. package/lib/module/theme/build-theme.js.map +1 -1
  28. package/lib/module/theme/color-presets.js +9 -9
  29. package/lib/module/theme/color-scope/index.web.js +7 -14
  30. package/lib/module/theme/color-scope/index.web.js.map +1 -1
  31. package/lib/module/theme/color-scope/style-builder.js +7 -6
  32. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  33. package/lib/module/theme/color-space.js +69 -0
  34. package/lib/module/theme/color-space.js.map +1 -0
  35. package/lib/module/theme/index.js +1 -1
  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 -150
  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 +5 -10
  44. package/lib/typescript/commonjs/theme/apply-dark-class.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/theme/color-presets.d.ts +9 -9
  47. package/lib/typescript/commonjs/theme/color-scope/index.web.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts +5 -2
  49. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/theme/color-space.d.ts +28 -0
  51. package/lib/typescript/commonjs/theme/color-space.d.ts.map +1 -0
  52. package/lib/typescript/commonjs/theme/index.d.ts +1 -2
  53. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts +25 -15
  55. package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/theme/preset-vars.d.ts +11 -74
  57. package/lib/typescript/commonjs/theme/preset-vars.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/theme/token-registry.d.ts +53 -0
  59. package/lib/typescript/commonjs/theme/token-registry.d.ts.map +1 -0
  60. package/lib/typescript/commonjs/theme/types.d.ts +3 -0
  61. package/lib/typescript/commonjs/theme/types.d.ts.map +1 -1
  62. package/lib/typescript/module/theme/apply-dark-class.d.ts +5 -10
  63. package/lib/typescript/module/theme/apply-dark-class.d.ts.map +1 -1
  64. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -1
  65. package/lib/typescript/module/theme/color-presets.d.ts +9 -9
  66. package/lib/typescript/module/theme/color-scope/index.web.d.ts.map +1 -1
  67. package/lib/typescript/module/theme/color-scope/style-builder.d.ts +5 -2
  68. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  69. package/lib/typescript/module/theme/color-space.d.ts +28 -0
  70. package/lib/typescript/module/theme/color-space.d.ts.map +1 -0
  71. package/lib/typescript/module/theme/index.d.ts +1 -2
  72. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  73. package/lib/typescript/module/theme/native-root-vars.native.d.ts +25 -15
  74. package/lib/typescript/module/theme/native-root-vars.native.d.ts.map +1 -1
  75. package/lib/typescript/module/theme/preset-vars.d.ts +11 -74
  76. package/lib/typescript/module/theme/preset-vars.d.ts.map +1 -1
  77. package/lib/typescript/module/theme/token-registry.d.ts +53 -0
  78. package/lib/typescript/module/theme/token-registry.d.ts.map +1 -0
  79. package/lib/typescript/module/theme/types.d.ts +3 -0
  80. package/lib/typescript/module/theme/types.d.ts.map +1 -1
  81. package/package.json +1 -1
  82. package/src/__tests__/BloomColorScope.test.tsx +5 -5
  83. package/src/__tests__/BloomThemeProvider.test.tsx +4 -3
  84. package/src/__tests__/apply-dark-class.test.ts +26 -21
  85. package/src/__tests__/native-root-vars.test.ts +8 -7
  86. package/src/__tests__/preset-vars.test.ts +31 -77
  87. package/src/__tests__/theme.test.ts +6 -6
  88. package/src/normalize-colors.d.ts +22 -0
  89. package/src/react-native-css.d.ts +3 -3
  90. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +574 -0
  91. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +838 -0
  92. package/src/theme/__tests__/color-space.test.ts +28 -0
  93. package/src/theme/__tests__/golden-tokens.test.ts +33 -0
  94. package/src/theme/__tests__/runtime-contract.test.ts +63 -0
  95. package/src/theme/__tests__/theme-colors-parity.test.ts +115 -0
  96. package/src/theme/__tests__/token-registry.test.ts +30 -0
  97. package/src/theme/__tests__/visual-gallery.test.tsx +10 -0
  98. package/src/theme/apply-dark-class.ts +8 -17
  99. package/src/theme/build-theme.ts +59 -62
  100. package/src/theme/color-presets.ts +9 -9
  101. package/src/theme/color-scope/index.web.tsx +7 -14
  102. package/src/theme/color-scope/style-builder.ts +7 -4
  103. package/src/theme/color-space.ts +56 -0
  104. package/src/theme/index.ts +1 -6
  105. package/src/theme/native-root-vars.native.ts +28 -18
  106. package/src/theme/preset-vars.ts +17 -178
  107. package/src/theme/token-registry.ts +159 -0
  108. 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,63 @@
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
+ import { getResolvedTokens } from '../token-registry';
11
+
12
+ // `@react-native/normalize-colors` is the exact parser React Native (native) and
13
+ // react-native-web use behind `StyleSheet`/`processColor`. If it returns `null`
14
+ // for a value, that color is UNPARSEABLE and the element renders nothing. These
15
+ // tests pin the runtime contract for the canonical rgb token pipeline: every
16
+ // JS `theme.colors` value must parse, the web document vars must be full colors
17
+ // (not bare HSL triples), and the historically mislabeled aliases must be fixed.
18
+
19
+ it('every theme.colors value parses via normalize-colors (native/RNW StyleSheet)', () => {
20
+ const { colors } = buildTheme('oxy', 'dark');
21
+ for (const v of Object.values(colors)) {
22
+ expect(normalizeColor(v)).not.toBeNull(); // null = unparseable -> renders nothing
23
+ }
24
+ });
25
+
26
+ describe('web var(--primary) resolves to a real color (not a bare triple)', () => {
27
+ const originalOS = Platform.OS;
28
+
29
+ beforeEach(() => {
30
+ Platform.OS = 'web';
31
+ document.documentElement.removeAttribute('style');
32
+ });
33
+
34
+ afterEach(() => {
35
+ Platform.OS = originalOS;
36
+ document.documentElement.removeAttribute('style');
37
+ });
38
+
39
+ it('writes --primary as a resolved rgb() color', () => {
40
+ applyColorPresetVars('oxy', 'dark');
41
+ const v = document.documentElement.style.getPropertyValue('--primary').trim();
42
+ expect(v).toMatch(/^rgb\(/);
43
+ expect(normalizeColor(v)).not.toBeNull();
44
+ });
45
+ });
46
+
47
+ it('restored legacy alias invariants hold', () => {
48
+ // Pre-0.8.0 semantics, restored in 0.8.1: `secondary` mirrors `primary`,
49
+ // `card` and `primaryLight` are the page surface, `primaryDark` is the page
50
+ // background. All read straight from the same resolved rgb token map, so the
51
+ // alias values are byte-identical to their source tokens.
52
+ for (const preset of ['oxy', 'blue'] as const) {
53
+ for (const mode of ['light', 'dark'] as const) {
54
+ const { colors } = buildTheme(preset, mode);
55
+ const t = getResolvedTokens(preset, mode);
56
+ expect(colors.secondary).toBe(colors.primary);
57
+ expect(colors.secondary).toBe(t['--primary']);
58
+ expect(colors.card).toBe(t['--surface']);
59
+ expect(colors.primaryLight).toBe(t['--surface']);
60
+ expect(colors.primaryDark).toBe(t['--background']);
61
+ }
62
+ }
63
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import normalizeColor from '@react-native/normalize-colors';
6
+
7
+ import { buildTheme } from '../build-theme';
8
+ import type { AppColorName } from '../color-presets';
9
+ import type { ThemeColors } from '../types';
10
+
11
+ // Parity guard for the 0.8.1 restoration of the pre-0.8.0 `theme.colors`
12
+ // derived/alias values. 0.8.0 changed several derived/alias mappings, visibly
13
+ // altering dark (and light) mode for consuming components. 0.8.1 restores the
14
+ // EXACT pre-0.8.0 colors while keeping the single rgb pipeline (the old logic
15
+ // emitted `hsl(h,s%,l%)` strings; the new logic emits the `rgb(...)` of the same
16
+ // color). These oracles are the VERBATIM pre-0.8.0 outputs (commit 00a4cf3:
17
+ // `git show 00a4cf3:src/theme/build-theme.ts`). We assert visual equality via
18
+ // ΔE on the parsed sRGB — `hsl(...)` and the matching `rgb(...)` parse to the
19
+ // same bytes under the RN/RNW/browser `normalizeColor`, so ΔE is 0.
20
+
21
+ type Rgb = { r: number; g: number; b: number };
22
+
23
+ function parse(color: string): Rgb {
24
+ const n = normalizeColor(color);
25
+ if (n === null || n === undefined) {
26
+ throw new Error(`unparseable color: ${color}`);
27
+ }
28
+ // normalizeColor returns 0xRRGGBBAA.
29
+ return { r: (n >>> 24) & 0xff, g: (n >>> 16) & 0xff, b: (n >>> 8) & 0xff };
30
+ }
31
+
32
+ function deltaE(a: Rgb, b: Rgb): number {
33
+ return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2) / Math.sqrt(3);
34
+ }
35
+
36
+ /**
37
+ * The pre-0.8.0 `theme.colors` for a (preset, mode) pair, expressed in the EXACT
38
+ * source forms commit 00a4cf3 produced — token-backed values as `hsl(H, S%, L%)`
39
+ * (from the preset's raw triples) and computed tints via `hsl(hue, s%, l%)`.
40
+ *
41
+ * We only assert the fields the restoration touches plus the load-bearing
42
+ * aliases (primaryLight/primaryDark/secondary/card). Status colors and
43
+ * shadow/overlay were never changed, so they are intentionally omitted.
44
+ */
45
+ type Oracle = Partial<Record<keyof ThemeColors, string>>;
46
+
47
+ const ORACLES: Array<{ preset: AppColorName; mode: 'light' | 'dark'; old: Oracle }> = [
48
+ {
49
+ // blue: primary 205 87% 53%, surface dark 205 20% 18%, background dark 205 50% 5%, destructive hue 0.
50
+ preset: 'blue',
51
+ mode: 'dark',
52
+ old: {
53
+ secondary: 'hsl(205, 87%, 53%)', // == primary
54
+ primary: 'hsl(205, 87%, 53%)',
55
+ card: 'hsl(205, 20%, 18%)', // == surface
56
+ primaryLight: 'hsl(205, 20%, 18%)', // == surface
57
+ primaryDark: 'hsl(205, 50%, 5%)', // == background
58
+ primarySubtle: 'hsl(205, 50%, 10%)',
59
+ primarySubtleForeground: 'hsl(205, 70%, 65%)',
60
+ negative: 'hsl(0, 84%, 45%)',
61
+ negativeForeground: '#FFFFFF',
62
+ negativeSubtle: 'hsl(0, 50%, 10%)',
63
+ negativeSubtleForeground: 'hsl(0, 70%, 65%)',
64
+ contrast50: 'hsl(205, 15%, 12%)',
65
+ },
66
+ },
67
+ {
68
+ // oxy: primary 277 66% 56%, surface dark 277 20% 18%, background dark 277 50% 5%, destructive hue 0.
69
+ preset: 'oxy',
70
+ mode: 'dark',
71
+ old: {
72
+ secondary: 'hsl(277, 66%, 56%)',
73
+ primary: 'hsl(277, 66%, 56%)',
74
+ card: 'hsl(277, 20%, 18%)',
75
+ primaryLight: 'hsl(277, 20%, 18%)',
76
+ primaryDark: 'hsl(277, 50%, 5%)',
77
+ primarySubtle: 'hsl(277, 50%, 10%)',
78
+ primarySubtleForeground: 'hsl(277, 70%, 65%)',
79
+ negative: 'hsl(0, 84%, 45%)',
80
+ negativeForeground: '#FFFFFF',
81
+ negativeSubtle: 'hsl(0, 50%, 10%)',
82
+ negativeSubtleForeground: 'hsl(0, 70%, 65%)',
83
+ contrast50: 'hsl(277, 15%, 12%)',
84
+ },
85
+ },
86
+ {
87
+ // blue light: surface 205 58% 94%, background 205 55% 96%.
88
+ preset: 'blue',
89
+ mode: 'light',
90
+ old: {
91
+ secondary: 'hsl(205, 87%, 53%)',
92
+ primary: 'hsl(205, 87%, 53%)',
93
+ card: 'hsl(205, 58%, 94%)',
94
+ primaryLight: 'hsl(205, 58%, 94%)',
95
+ primaryDark: 'hsl(205, 55%, 96%)',
96
+ primarySubtle: 'hsl(205, 70%, 93%)',
97
+ primarySubtleForeground: 'hsl(205, 90%, 25%)',
98
+ negative: 'hsl(0, 84%, 45%)',
99
+ negativeForeground: '#FFFFFF',
100
+ negativeSubtle: 'hsl(0, 90%, 95%)',
101
+ negativeSubtleForeground: 'hsl(0, 80%, 40%)',
102
+ contrast50: 'hsl(205, 10%, 93%)',
103
+ },
104
+ },
105
+ ];
106
+
107
+ describe.each(ORACLES)('theme.colors parity with pre-0.8.0 ($preset/$mode)', ({ preset, mode, old }) => {
108
+ const { colors } = buildTheme(preset, mode);
109
+
110
+ it.each(Object.entries(old))('%s matches the pre-0.8.0 color (ΔE ≤ 1)', (key, oldColor) => {
111
+ const newColor = colors[key as keyof ThemeColors];
112
+ const dE = deltaE(parse(oldColor), parse(newColor));
113
+ expect(dE).toBeLessThanOrEqual(1);
114
+ });
115
+ });
@@ -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,11 +1,6 @@
1
1
  import { Platform } from 'react-native';
2
2
  import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
3
- import { getPresetVars, toWebColorValue } from './preset-vars';
4
-
5
- // Re-exported so the web var-contract helper is reachable from this file (the
6
- // home of the web write path). Defined in `preset-vars.ts` next to the related
7
- // HSL parsing so native (which never imports this module) doesn't pull it in.
8
- export { toWebColorValue } from './preset-vars';
3
+ import { getResolvedTokens } from './token-registry';
9
4
 
10
5
  export function applyDarkClass(resolved: 'light' | 'dark') {
11
6
  if (Platform.OS === 'web' && typeof document !== 'undefined') {
@@ -21,16 +16,12 @@ export function applyDarkClass(resolved: 'light' | 'dark') {
21
16
  * -------------------------------------------------------------------
22
17
  * The shadcn/Tailwind-v4 web apps compile their color utilities to reference the
23
18
  * BASE token directly (`.bg-background { background-color: var(--background) }`),
24
- * so on web the base `--x` tokens MUST be FULL CSS colors. We therefore write
25
- * them as `hsl(...)` (e.g. `--primary: hsl(185 100% 20%)`) `var(--primary)`
26
- * then resolves to a valid color. The resolved `--color-*` vars are written
27
- * verbatim as `rgb(...)` (already full colors, used by native `color-mix` alpha
28
- * utilities; harmless on web). Non-color tokens (`--radius`, etc.) pass through
29
- * unchanged. See `toWebColorValue`.
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.
30
24
  *
31
- * NATIVE writes the SAME tokens as RAW HSL triples (no `hsl()` wrapper) via
32
- * `rootVariables` (`native-root-vars.native.ts`), consumed through bloom's
33
- * native `global.css` `hsl(var(--x))` indirection — that path is untouched here.
34
25
  * Includes extended tokens (card, chart-*, content-area, sidebar-*) so consumer
35
26
  * apps don't need to synthesize them.
36
27
  */
@@ -38,10 +29,10 @@ export function applyColorPresetVars(preset: AppColorName, resolved: 'light' | '
38
29
  if (Platform.OS !== 'web' || typeof document === 'undefined') return;
39
30
  if (!APP_COLOR_PRESETS[preset]) return;
40
31
 
41
- const vars = getPresetVars(preset, resolved, { includeResolvedColorVars: true });
32
+ const vars = getResolvedTokens(preset, resolved);
42
33
  const root = document.documentElement.style;
43
34
 
44
35
  for (const [key, value] of Object.entries(vars)) {
45
- root.setProperty(key, toWebColorValue(key, value));
36
+ root.setProperty(key, value);
46
37
  }
47
38
  }
@@ -1,6 +1,7 @@
1
1
  import { Platform } from 'react-native';
2
- import { APP_COLOR_PRESETS, type AppColorName, type PresetTokens } from './color-presets';
2
+ import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
3
3
  import { getAdaptiveColors } from './adaptive-colors';
4
+ import { getResolvedTokens, hslToSrgb } from './token-registry';
4
5
  import type { Theme, ThemeColors } from './types';
5
6
 
6
7
  /**
@@ -15,91 +16,87 @@ export const STATUS_COLORS = {
15
16
  } as const;
16
17
 
17
18
  /**
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.
19
+ * Extract the integer hue from a shadcn-style raw HSL triple (`'H S% L%'`,
20
+ * optionally with an alpha tail). Used to seed the computed brand tints below
21
+ * from the preset's primary/destructive hue, exactly as pre-0.8.0.
21
22
  */
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';
23
+ function extractHue(triple: string): number {
24
+ const first = (triple.split(/\s+/)[0] ?? '0').replace(/deg$/i, '');
36
25
  const hue = parseInt(first, 10);
37
26
  return Number.isFinite(hue) ? hue : 0;
38
27
  }
39
28
 
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
-
29
+ /**
30
+ * Build the JS `theme.colors` object from the SAME canonical rgb token source
31
+ * the web/native CSS-var writes use (`getResolvedTokens`). JS styles and the
32
+ * `var(--x)` document tokens therefore share one rgb pipeline — no second,
33
+ * drift-prone HSL conversion lives here.
34
+ *
35
+ * Token-backed values read straight from the resolved rgb map via `g(...)`.
36
+ * The brand tints (primarySubtle/negative/contrast50/...) reproduce the EXACT
37
+ * pre-0.8.0 colors — computed from the preset's primary/destructive HUE with
38
+ * the historical `hsl(h, s%, l%)` math — but are emitted as `rgb(...)` by
39
+ * routing the triple through the registry's `hslToSrgb`, so the single-format
40
+ * pipeline is preserved. (`hsl(h,s%,l%)` and the `rgb(...)` of the same color
41
+ * are byte-identical under the RN/RNW/browser parser.)
42
+ */
48
43
  function buildColorsFromPreset(
49
44
  preset: AppColorName,
50
45
  resolved: 'light' | 'dark',
51
46
  ): ThemeColors {
52
- const config = APP_COLOR_PRESETS[preset];
53
- const tokens = resolved === 'dark' ? config.dark : config.light;
47
+ const t = getResolvedTokens(preset, resolved);
54
48
  const isDark = resolved === 'dark';
55
49
 
56
- const primaryHue = extractHue(tokens['--primary'] ?? '0 0% 50%');
57
- const destructiveHue = extractHue(tokens['--destructive'] ?? '0 0% 0%');
50
+ // Read a resolved `rgb(...)` token by its bare name (no leading `--`).
51
+ const g = (k: string): string => t[`--${k}`] ?? 'rgb(0 0 0)';
52
+
53
+ // Convert a computed `H S% L%` triple to the canonical `rgb(...)` via the
54
+ // registry's single HSL→sRGB conversion (matches the old `hsl(h,s%,l%)`).
55
+ const hslRgb = (h: number, s: number, l: number): string => hslToSrgb(`${h} ${s}% ${l}%`);
58
56
 
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%');
57
+ // Brand hues seed the computed tints from the preset's raw HSL triples,
58
+ // exactly as the pre-0.8.0 logic did.
59
+ const presetTokens = isDark ? APP_COLOR_PRESETS[preset].dark : APP_COLOR_PRESETS[preset].light;
60
+ const pHue = extractHue(presetTokens['--primary'] ?? '0 0% 50%');
61
+ const dHue = extractHue(presetTokens['--destructive'] ?? '0 0% 0%');
64
62
 
65
63
  return {
66
- background,
67
- backgroundSecondary: surface,
68
- backgroundTertiary: readToken(tokens, '--muted'),
64
+ background: g('background'),
65
+ backgroundSecondary: g('surface'),
66
+ backgroundTertiary: g('muted'),
69
67
 
70
- text: readToken(tokens, '--foreground', '0 0% 100%'),
71
- textSecondary: mutedForeground,
72
- textTertiary: mutedForeground,
68
+ text: g('foreground'),
69
+ textSecondary: g('muted-foreground'),
70
+ textTertiary: g('muted-foreground'),
73
71
 
74
- border: readToken(tokens, '--border', '0 0% 20%'),
75
- borderLight: readToken(tokens, '--input', '0 0% 20%'),
72
+ border: g('border'),
73
+ borderLight: g('input'),
76
74
 
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,
75
+ primary: g('primary'),
76
+ primaryForeground: g('primary-foreground'),
77
+ // Legacy aliases (see types.ts): `primaryLight` is the page surface tint,
78
+ // `primaryDark` is the page background retained for downstream consumers.
79
+ primaryLight: g('surface'),
80
+ primaryDark: g('background'),
84
81
 
85
- // `secondary` historically mirrored `primary`. Retained for compatibility.
86
- secondary: primaryColor,
82
+ // `secondary` historically mirrors `primary`. Retained for compatibility.
83
+ secondary: g('primary'),
87
84
 
88
- tint: primaryColor,
89
- icon: mutedForeground,
90
- iconActive: primaryColor,
85
+ tint: g('primary'),
86
+ icon: g('muted-foreground'),
87
+ iconActive: g('primary'),
91
88
 
92
89
  ...STATUS_COLORS,
93
90
 
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),
91
+ primarySubtle: isDark ? hslRgb(pHue, 50, 10) : hslRgb(pHue, 70, 93),
92
+ primarySubtleForeground: isDark ? hslRgb(pHue, 70, 65) : hslRgb(pHue, 90, 25),
93
+ negative: hslRgb(dHue, 84, 45),
97
94
  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),
95
+ negativeSubtle: isDark ? hslRgb(dHue, 50, 10) : hslRgb(dHue, 90, 95),
96
+ negativeSubtleForeground: isDark ? hslRgb(dHue, 70, 65) : hslRgb(dHue, 80, 40),
97
+ contrast50: isDark ? hslRgb(pHue, 15, 12) : hslRgb(pHue, 10, 93),
101
98
 
102
- card: surface,
99
+ card: g('surface'),
103
100
  shadow: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
104
101
  overlay: 'rgba(0, 0, 0, 0.5)',
105
102
  };
@@ -6,17 +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: the base `--x` tokens are wrapped to full `hsl(...)` colors
11
- * (via `toWebColorValue`) before being written to `document.documentElement`
12
- * / a `BloomColorScope` element, so `var(--x)` the form Tailwind v4
13
- * `@theme inline` compiles its color utilities to resolves to a valid
14
- * color directly; the resolved `--color-*` companions are written as
15
- * `rgb(...)`, and
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
16
  * - the native layer (`buildTheme` resolves the raw triples into `hsl(...)`
17
17
  * strings consumable by React Native styles; `native-root-vars.native.ts`
18
- * writes the raw triples through bloom's `hsl(var(--x))` `global.css`
19
- * indirection).
18
+ * writes the same canonical `rgb(...)` values into react-native-css's
19
+ * `rootVariables` family).
20
20
  *
21
21
  * The `--` prefix is an implementation detail we will drop in a future major.
22
22
  */
@@ -3,27 +3,20 @@ 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 { toWebColorValue } from '../preset-vars';
7
- import { buildScopeVars } from './style-builder';
6
+ import { getResolvedTokens } from '../token-registry';
8
7
 
9
8
  /**
10
- * `buildScopeVars` returns the preset's tokens as platform-agnostic raw HSL
11
- * triples (shared with the native write path). On web they are scoped onto an
12
- * element's inline `style`, where Tailwind v4's compiled utilities read them as
13
- * `var(--x)` directly so the base tokens MUST be full CSS colors. Wrap each
14
- * raw triple in `hsl(...)` via `toWebColorValue`; `--color-*` rgb vars and
15
- * non-color tokens pass through. See `toWebColorValue` in `../preset-vars`.
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.
16
14
  */
17
15
  function buildWebScopeVars(
18
16
  colorPreset: AppColorName,
19
17
  mode: 'light' | 'dark',
20
18
  ): React.CSSProperties {
21
- const raw = buildScopeVars(colorPreset, mode);
22
- const out: Record<string, string> = {};
23
- for (const [key, value] of Object.entries(raw)) {
24
- out[key] = toWebColorValue(key, value);
25
- }
26
- return out as React.CSSProperties;
19
+ return getResolvedTokens(colorPreset, mode) as React.CSSProperties;
27
20
  }
28
21
 
29
22
  export interface BloomColorScopeProps {
@@ -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
+ }