@oxyhq/bloom 0.7.7 → 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.
- package/lib/commonjs/normalize-colors.d.js +2 -0
- package/lib/commonjs/normalize-colors.d.js.map +1 -0
- package/lib/commonjs/theme/apply-dark-class.js +8 -24
- package/lib/commonjs/theme/apply-dark-class.js.map +1 -1
- package/lib/commonjs/theme/build-theme.js +39 -61
- package/lib/commonjs/theme/build-theme.js.map +1 -1
- package/lib/commonjs/theme/color-presets.js +9 -9
- package/lib/commonjs/theme/color-scope/index.web.js +7 -14
- package/lib/commonjs/theme/color-scope/index.web.js.map +1 -1
- package/lib/commonjs/theme/color-scope/style-builder.js +7 -6
- package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
- package/lib/commonjs/theme/color-space.js +76 -0
- package/lib/commonjs/theme/color-space.js.map +1 -0
- package/lib/commonjs/theme/index.js +0 -6
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/native-root-vars.native.js +28 -18
- package/lib/commonjs/theme/native-root-vars.native.js.map +1 -1
- package/lib/commonjs/theme/preset-vars.js +20 -152
- package/lib/commonjs/theme/preset-vars.js.map +1 -1
- package/lib/commonjs/theme/token-registry.js +123 -0
- package/lib/commonjs/theme/token-registry.js.map +1 -0
- package/lib/module/normalize-colors.d.js +2 -0
- package/lib/module/normalize-colors.d.js.map +1 -0
- package/lib/module/theme/apply-dark-class.js +8 -19
- package/lib/module/theme/apply-dark-class.js.map +1 -1
- package/lib/module/theme/build-theme.js +39 -61
- package/lib/module/theme/build-theme.js.map +1 -1
- package/lib/module/theme/color-presets.js +9 -9
- package/lib/module/theme/color-scope/index.web.js +7 -14
- package/lib/module/theme/color-scope/index.web.js.map +1 -1
- package/lib/module/theme/color-scope/style-builder.js +7 -6
- package/lib/module/theme/color-scope/style-builder.js.map +1 -1
- package/lib/module/theme/color-space.js +69 -0
- package/lib/module/theme/color-space.js.map +1 -0
- package/lib/module/theme/index.js +1 -1
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/native-root-vars.native.js +29 -18
- package/lib/module/theme/native-root-vars.native.js.map +1 -1
- package/lib/module/theme/preset-vars.js +19 -150
- package/lib/module/theme/preset-vars.js.map +1 -1
- package/lib/module/theme/token-registry.js +113 -0
- package/lib/module/theme/token-registry.js.map +1 -0
- package/lib/typescript/commonjs/theme/apply-dark-class.d.ts +5 -10
- package/lib/typescript/commonjs/theme/apply-dark-class.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/build-theme.d.ts +1 -1
- package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts +9 -9
- package/lib/typescript/commonjs/theme/color-scope/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts +5 -2
- package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-space.d.ts +28 -0
- package/lib/typescript/commonjs/theme/color-space.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/index.d.ts +1 -2
- package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts +25 -15
- package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/preset-vars.d.ts +11 -74
- package/lib/typescript/commonjs/theme/preset-vars.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/token-registry.d.ts +53 -0
- package/lib/typescript/commonjs/theme/token-registry.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/types.d.ts +3 -0
- package/lib/typescript/commonjs/theme/types.d.ts.map +1 -1
- package/lib/typescript/module/theme/apply-dark-class.d.ts +5 -10
- package/lib/typescript/module/theme/apply-dark-class.d.ts.map +1 -1
- package/lib/typescript/module/theme/build-theme.d.ts +1 -1
- package/lib/typescript/module/theme/build-theme.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts +9 -9
- package/lib/typescript/module/theme/color-scope/index.web.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/style-builder.d.ts +5 -2
- package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-space.d.ts +28 -0
- package/lib/typescript/module/theme/color-space.d.ts.map +1 -0
- package/lib/typescript/module/theme/index.d.ts +1 -2
- package/lib/typescript/module/theme/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/native-root-vars.native.d.ts +25 -15
- package/lib/typescript/module/theme/native-root-vars.native.d.ts.map +1 -1
- package/lib/typescript/module/theme/preset-vars.d.ts +11 -74
- package/lib/typescript/module/theme/preset-vars.d.ts.map +1 -1
- package/lib/typescript/module/theme/token-registry.d.ts +53 -0
- package/lib/typescript/module/theme/token-registry.d.ts.map +1 -0
- package/lib/typescript/module/theme/types.d.ts +3 -0
- package/lib/typescript/module/theme/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/BloomColorScope.test.tsx +5 -5
- package/src/__tests__/BloomThemeProvider.test.tsx +4 -3
- package/src/__tests__/apply-dark-class.test.ts +26 -21
- package/src/__tests__/native-root-vars.test.ts +8 -7
- package/src/__tests__/preset-vars.test.ts +31 -77
- package/src/__tests__/theme.test.ts +6 -6
- package/src/normalize-colors.d.ts +22 -0
- package/src/react-native-css.d.ts +3 -3
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +574 -0
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +838 -0
- package/src/theme/__tests__/color-space.test.ts +28 -0
- package/src/theme/__tests__/golden-tokens.test.ts +33 -0
- package/src/theme/__tests__/runtime-contract.test.ts +49 -0
- package/src/theme/__tests__/token-registry.test.ts +30 -0
- package/src/theme/__tests__/visual-gallery.test.tsx +10 -0
- package/src/theme/apply-dark-class.ts +8 -17
- package/src/theme/build-theme.ts +39 -67
- package/src/theme/color-presets.ts +9 -9
- package/src/theme/color-scope/index.web.tsx +7 -14
- package/src/theme/color-scope/style-builder.ts +7 -4
- package/src/theme/color-space.ts +56 -0
- package/src/theme/index.ts +1 -6
- package/src/theme/native-root-vars.native.ts +28 -18
- package/src/theme/preset-vars.ts +17 -178
- package/src/theme/token-registry.ts +159 -0
- 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,11 +1,6 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
2
|
import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
|
|
3
|
-
import {
|
|
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.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 =
|
|
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,
|
|
36
|
+
root.setProperty(key, value);
|
|
46
37
|
}
|
|
47
38
|
}
|
package/src/theme/build-theme.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
53
|
-
const tokens = resolved === 'dark' ? config.dark : config.light;
|
|
30
|
+
const t = getResolvedTokens(preset, resolved);
|
|
54
31
|
const isDark = resolved === 'dark';
|
|
55
32
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const
|
|
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:
|
|
39
|
+
background: g('background'),
|
|
40
|
+
backgroundSecondary: g('surface'),
|
|
41
|
+
backgroundTertiary: g('muted'),
|
|
69
42
|
|
|
70
|
-
text:
|
|
71
|
-
textSecondary:
|
|
72
|
-
textTertiary:
|
|
43
|
+
text: g('foreground'),
|
|
44
|
+
textSecondary: g('muted-foreground'),
|
|
45
|
+
textTertiary: g('muted-foreground'),
|
|
73
46
|
|
|
74
|
-
border:
|
|
75
|
-
borderLight:
|
|
47
|
+
border: g('border'),
|
|
48
|
+
borderLight: g('input'),
|
|
76
49
|
|
|
77
|
-
primary:
|
|
78
|
-
primaryForeground,
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
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`
|
|
86
|
-
secondary:
|
|
57
|
+
// Corrected: `secondary` is the preset's secondary surface, NOT a primary mirror.
|
|
58
|
+
secondary: g('secondary'),
|
|
87
59
|
|
|
88
|
-
tint:
|
|
89
|
-
icon:
|
|
90
|
-
iconActive:
|
|
60
|
+
tint: g('primary'),
|
|
61
|
+
icon: g('muted-foreground'),
|
|
62
|
+
iconActive: g('primary'),
|
|
91
63
|
|
|
92
64
|
...STATUS_COLORS,
|
|
93
65
|
|
|
94
|
-
primarySubtle: isDark ?
|
|
95
|
-
primarySubtleForeground:
|
|
96
|
-
negative:
|
|
66
|
+
primarySubtle: mix('primary', isDark ? 0.16 : 0.12),
|
|
67
|
+
primarySubtleForeground: g('primary'),
|
|
68
|
+
negative: g('destructive'),
|
|
97
69
|
negativeForeground: '#FFFFFF',
|
|
98
|
-
negativeSubtle: isDark ?
|
|
99
|
-
negativeSubtleForeground:
|
|
100
|
-
contrast50:
|
|
70
|
+
negativeSubtle: mix('destructive', isDark ? 0.16 : 0.12),
|
|
71
|
+
negativeSubtleForeground: g('destructive'),
|
|
72
|
+
contrast50: mix('foreground', 0.5),
|
|
101
73
|
|
|
102
|
-
card:
|
|
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,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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
*
|
|
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 {
|
|
7
|
-
import { buildScopeVars } from './style-builder';
|
|
6
|
+
import { getResolvedTokens } from '../token-registry';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
* element's inline `style`,
|
|
13
|
-
* `var(--x)` directly
|
|
14
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
29
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/theme/index.ts
CHANGED
|
@@ -16,12 +16,7 @@ export {
|
|
|
16
16
|
HEX_TO_APP_COLOR,
|
|
17
17
|
hexToAppColorName,
|
|
18
18
|
} from './color-presets';
|
|
19
|
-
export {
|
|
20
|
-
getPresetVars,
|
|
21
|
-
applyPresetVarsToDocument,
|
|
22
|
-
toWebColorValue,
|
|
23
|
-
} from './preset-vars';
|
|
24
|
-
export type { PresetVarsOptions } from './preset-vars';
|
|
19
|
+
export { getPresetVars, applyPresetVarsToDocument } from './preset-vars';
|
|
25
20
|
export { applyDarkClass } from './apply-dark-class';
|
|
26
21
|
export { setColorSchemeSafe } from './set-color-scheme-safe';
|
|
27
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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. `
|
|
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([['
|
|
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 =
|
|
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; `
|
|
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
|
}
|