@oxyhq/bloom 0.41.0 → 0.42.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 (161) hide show
  1. package/lib/commonjs/theme/BloomThemeProvider.js +18 -7
  2. package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
  3. package/lib/commonjs/theme/apply-dark-class.js +2 -2
  4. package/lib/commonjs/theme/apply-dark-class.js.map +1 -1
  5. package/lib/commonjs/theme/build-theme-from-seed.js +17 -5
  6. package/lib/commonjs/theme/build-theme-from-seed.js.map +1 -1
  7. package/lib/commonjs/theme/build-theme.js +9 -6
  8. package/lib/commonjs/theme/build-theme.js.map +1 -1
  9. package/lib/commonjs/theme/color-engine/index.js +8 -1
  10. package/lib/commonjs/theme/color-engine/index.js.map +1 -1
  11. package/lib/commonjs/theme/color-engine/scheme-variants.js +42 -18
  12. package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
  13. package/lib/commonjs/theme/color-engine/seed-from-image.js +11 -1
  14. package/lib/commonjs/theme/color-engine/seed-from-image.js.map +1 -1
  15. package/lib/commonjs/theme/color-presets.js.map +1 -1
  16. package/lib/commonjs/theme/color-scope/seed-scope.js +3 -1
  17. package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
  18. package/lib/commonjs/theme/color-scope/style-builder.js +2 -2
  19. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  20. package/lib/commonjs/theme/index.js.map +1 -1
  21. package/lib/commonjs/theme/native-root-vars.js +1 -1
  22. package/lib/commonjs/theme/native-root-vars.js.map +1 -1
  23. package/lib/commonjs/theme/native-root-vars.native.js +2 -2
  24. package/lib/commonjs/theme/native-root-vars.native.js.map +1 -1
  25. package/lib/commonjs/theme/native-root-vars.web.js +1 -1
  26. package/lib/commonjs/theme/native-root-vars.web.js.map +1 -1
  27. package/lib/commonjs/theme/preset-vars.js +16 -2
  28. package/lib/commonjs/theme/preset-vars.js.map +1 -1
  29. package/lib/commonjs/theme/seed-scope/index.js +11 -4
  30. package/lib/commonjs/theme/seed-scope/index.js.map +1 -1
  31. package/lib/commonjs/theme/seed-scope/index.web.js +11 -4
  32. package/lib/commonjs/theme/seed-scope/index.web.js.map +1 -1
  33. package/lib/commonjs/theme/token-registry.js +2 -2
  34. package/lib/commonjs/theme/token-registry.js.map +1 -1
  35. package/lib/module/theme/BloomThemeProvider.js +18 -7
  36. package/lib/module/theme/BloomThemeProvider.js.map +1 -1
  37. package/lib/module/theme/apply-dark-class.js +2 -2
  38. package/lib/module/theme/apply-dark-class.js.map +1 -1
  39. package/lib/module/theme/build-theme-from-seed.js +18 -5
  40. package/lib/module/theme/build-theme-from-seed.js.map +1 -1
  41. package/lib/module/theme/build-theme.js +9 -6
  42. package/lib/module/theme/build-theme.js.map +1 -1
  43. package/lib/module/theme/color-engine/index.js +8 -1
  44. package/lib/module/theme/color-engine/index.js.map +1 -1
  45. package/lib/module/theme/color-engine/scheme-variants.js +43 -18
  46. package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
  47. package/lib/module/theme/color-engine/seed-from-image.js +11 -1
  48. package/lib/module/theme/color-engine/seed-from-image.js.map +1 -1
  49. package/lib/module/theme/color-presets.js.map +1 -1
  50. package/lib/module/theme/color-scope/seed-scope.js +3 -1
  51. package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
  52. package/lib/module/theme/color-scope/style-builder.js +2 -2
  53. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  54. package/lib/module/theme/index.js.map +1 -1
  55. package/lib/module/theme/native-root-vars.js +1 -1
  56. package/lib/module/theme/native-root-vars.js.map +1 -1
  57. package/lib/module/theme/native-root-vars.native.js +2 -2
  58. package/lib/module/theme/native-root-vars.native.js.map +1 -1
  59. package/lib/module/theme/native-root-vars.web.js +1 -1
  60. package/lib/module/theme/native-root-vars.web.js.map +1 -1
  61. package/lib/module/theme/preset-vars.js +17 -2
  62. package/lib/module/theme/preset-vars.js.map +1 -1
  63. package/lib/module/theme/seed-scope/index.js +11 -4
  64. package/lib/module/theme/seed-scope/index.js.map +1 -1
  65. package/lib/module/theme/seed-scope/index.web.js +11 -4
  66. package/lib/module/theme/seed-scope/index.web.js.map +1 -1
  67. package/lib/module/theme/token-registry.js +2 -2
  68. package/lib/module/theme/token-registry.js.map +1 -1
  69. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts +15 -1
  70. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/theme/apply-dark-class.d.ts +2 -1
  72. package/lib/typescript/commonjs/theme/apply-dark-class.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/theme/build-theme-from-seed.d.ts +13 -2
  74. package/lib/typescript/commonjs/theme/build-theme-from-seed.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/theme/build-theme.d.ts +2 -1
  76. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/theme/color-engine/index.d.ts +17 -1
  78. package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +25 -7
  80. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/theme/color-engine/seed-from-image.d.ts +11 -1
  82. package/lib/typescript/commonjs/theme/color-engine/seed-from-image.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/theme/color-presets.d.ts +14 -0
  84. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts +12 -0
  86. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts +2 -1
  88. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/theme/index.d.ts +3 -1
  90. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/theme/native-root-vars.d.ts +2 -1
  92. package/lib/typescript/commonjs/theme/native-root-vars.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts +2 -1
  94. package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/theme/native-root-vars.web.d.ts +2 -1
  96. package/lib/typescript/commonjs/theme/native-root-vars.web.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/theme/preset-vars.d.ts +12 -1
  98. package/lib/typescript/commonjs/theme/preset-vars.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/theme/seed-scope/index.d.ts +9 -1
  100. package/lib/typescript/commonjs/theme/seed-scope/index.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/theme/seed-scope/index.web.d.ts +9 -1
  102. package/lib/typescript/commonjs/theme/seed-scope/index.web.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/theme/token-registry.d.ts +2 -1
  104. package/lib/typescript/commonjs/theme/token-registry.d.ts.map +1 -1
  105. package/lib/typescript/module/theme/BloomThemeProvider.d.ts +15 -1
  106. package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
  107. package/lib/typescript/module/theme/apply-dark-class.d.ts +2 -1
  108. package/lib/typescript/module/theme/apply-dark-class.d.ts.map +1 -1
  109. package/lib/typescript/module/theme/build-theme-from-seed.d.ts +13 -2
  110. package/lib/typescript/module/theme/build-theme-from-seed.d.ts.map +1 -1
  111. package/lib/typescript/module/theme/build-theme.d.ts +2 -1
  112. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -1
  113. package/lib/typescript/module/theme/color-engine/index.d.ts +17 -1
  114. package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
  115. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +25 -7
  116. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
  117. package/lib/typescript/module/theme/color-engine/seed-from-image.d.ts +11 -1
  118. package/lib/typescript/module/theme/color-engine/seed-from-image.d.ts.map +1 -1
  119. package/lib/typescript/module/theme/color-presets.d.ts +14 -0
  120. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  121. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts +12 -0
  122. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
  123. package/lib/typescript/module/theme/color-scope/style-builder.d.ts +2 -1
  124. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  125. package/lib/typescript/module/theme/index.d.ts +3 -1
  126. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  127. package/lib/typescript/module/theme/native-root-vars.d.ts +2 -1
  128. package/lib/typescript/module/theme/native-root-vars.d.ts.map +1 -1
  129. package/lib/typescript/module/theme/native-root-vars.native.d.ts +2 -1
  130. package/lib/typescript/module/theme/native-root-vars.native.d.ts.map +1 -1
  131. package/lib/typescript/module/theme/native-root-vars.web.d.ts +2 -1
  132. package/lib/typescript/module/theme/native-root-vars.web.d.ts.map +1 -1
  133. package/lib/typescript/module/theme/preset-vars.d.ts +12 -1
  134. package/lib/typescript/module/theme/preset-vars.d.ts.map +1 -1
  135. package/lib/typescript/module/theme/seed-scope/index.d.ts +9 -1
  136. package/lib/typescript/module/theme/seed-scope/index.d.ts.map +1 -1
  137. package/lib/typescript/module/theme/seed-scope/index.web.d.ts +9 -1
  138. package/lib/typescript/module/theme/seed-scope/index.web.d.ts.map +1 -1
  139. package/lib/typescript/module/theme/token-registry.d.ts +2 -1
  140. package/lib/typescript/module/theme/token-registry.d.ts.map +1 -1
  141. package/package.json +1 -1
  142. package/src/__tests__/theme.test.ts +3 -0
  143. package/src/theme/BloomThemeProvider.tsx +38 -8
  144. package/src/theme/__tests__/explicit-accents.test.ts +129 -0
  145. package/src/theme/apply-dark-class.ts +7 -2
  146. package/src/theme/build-theme-from-seed.ts +25 -2
  147. package/src/theme/build-theme.ts +9 -3
  148. package/src/theme/color-engine/index.ts +27 -1
  149. package/src/theme/color-engine/scheme-variants.ts +108 -30
  150. package/src/theme/color-engine/seed-from-image.ts +11 -1
  151. package/src/theme/color-presets.ts +14 -0
  152. package/src/theme/color-scope/seed-scope.ts +14 -0
  153. package/src/theme/color-scope/style-builder.ts +3 -1
  154. package/src/theme/index.ts +3 -1
  155. package/src/theme/native-root-vars.native.ts +7 -2
  156. package/src/theme/native-root-vars.ts +6 -1
  157. package/src/theme/native-root-vars.web.ts +6 -1
  158. package/src/theme/preset-vars.ts +19 -0
  159. package/src/theme/seed-scope/index.tsx +17 -4
  160. package/src/theme/seed-scope/index.web.tsx +15 -3
  161. package/src/theme/token-registry.ts +3 -2
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Explicit secondary/tertiary accent pinning.
3
+ *
4
+ * A brand with a distinct accent (e.g. blue primary + yellow secondary) can PIN
5
+ * its real accent colours instead of the engine deriving secondary/tertiary as
6
+ * hue-rotations of the primary seed. These tests assert:
7
+ * 1. pinning shifts the secondary/tertiary family HUE toward the pinned colour
8
+ * (and away from the derived one), and
9
+ * 2. omitting the seeds is byte-identical to the pre-feature derived output.
10
+ */
11
+ import {
12
+ argbFromHex,
13
+ generateRoleColors,
14
+ type RoleColors,
15
+ } from '../color-engine';
16
+ import { Hct } from '../color-engine/hct';
17
+ import { buildSeedScopeVars } from '../color-scope/seed-scope';
18
+
19
+ /** Hue (deg) of an `rgb(r g b)` engine output string. */
20
+ function hueOf(rgb: string): number {
21
+ const m = rgb.match(/rgb\((\d+) (\d+) (\d+)\)/);
22
+ if (!m) throw new Error(`expected "rgb(r g b)", got: ${rgb}`);
23
+ const argb = (0xff << 24) | (Number(m[1]) << 16) | (Number(m[2]) << 8) | Number(m[3]);
24
+ return Hct.fromInt(argb >>> 0).hue;
25
+ }
26
+
27
+ /** Smallest absolute angular distance between two hues, in degrees (0–180). */
28
+ function hueDistance(a: number, b: number): number {
29
+ const d = Math.abs(a - b) % 360;
30
+ return d > 180 ? 360 - d : d;
31
+ }
32
+
33
+ const BLUE = '#1d9bf0';
34
+ const YELLOW = '#ffc300';
35
+ const PINK = '#ec4899';
36
+
37
+ describe('explicit accent pinning — generateRoleColors', () => {
38
+ const derived: RoleColors = generateRoleColors({ seed: BLUE, variant: 'vivid', isDark: false });
39
+
40
+ it('pins the secondary family to the supplied colour hue', () => {
41
+ const pinned = generateRoleColors({
42
+ seed: BLUE,
43
+ variant: 'vivid',
44
+ isDark: false,
45
+ secondarySeed: YELLOW,
46
+ });
47
+
48
+ const yellowHue = Hct.fromInt(argbFromHex(YELLOW)).hue;
49
+ // The pinned secondary family reads at the yellow hue…
50
+ expect(hueDistance(hueOf(pinned.secondary), yellowHue)).toBeLessThan(20);
51
+ // …and its container / on-colour roles share that hue family too.
52
+ expect(hueDistance(hueOf(pinned.secondaryContainer), yellowHue)).toBeLessThan(20);
53
+ expect(hueDistance(hueOf(pinned.onSecondaryContainer), yellowHue)).toBeLessThan(20);
54
+ // …which differs materially from the derived (blue-rotation) secondary.
55
+ expect(hueDistance(hueOf(pinned.secondary), hueOf(derived.secondary))).toBeGreaterThan(30);
56
+ });
57
+
58
+ it('pins the tertiary family independently of the secondary', () => {
59
+ const pinned = generateRoleColors({
60
+ seed: BLUE,
61
+ variant: 'vivid',
62
+ isDark: false,
63
+ tertiarySeed: PINK,
64
+ });
65
+
66
+ const pinkHue = Hct.fromInt(argbFromHex(PINK)).hue;
67
+ expect(hueDistance(hueOf(pinned.tertiary), pinkHue)).toBeLessThan(20);
68
+ expect(hueDistance(hueOf(pinned.tertiary), hueOf(derived.tertiary))).toBeGreaterThan(20);
69
+ // Secondary is untouched when only tertiary is pinned.
70
+ expect(pinned.secondary).toBe(derived.secondary);
71
+ });
72
+
73
+ it('pins both accents at once, leaving primary + neutrals unchanged', () => {
74
+ const pinned = generateRoleColors({
75
+ seed: BLUE,
76
+ variant: 'vivid',
77
+ isDark: false,
78
+ secondarySeed: YELLOW,
79
+ tertiarySeed: PINK,
80
+ });
81
+
82
+ expect(hueDistance(hueOf(pinned.secondary), Hct.fromInt(argbFromHex(YELLOW)).hue)).toBeLessThan(20);
83
+ expect(hueDistance(hueOf(pinned.tertiary), Hct.fromInt(argbFromHex(PINK)).hue)).toBeLessThan(20);
84
+ // Primary + surfaces are seed-driven and must not move.
85
+ expect(pinned.primary).toBe(derived.primary);
86
+ expect(pinned.background).toBe(derived.background);
87
+ expect(pinned.surfaceContainerLowest).toBe(derived.surfaceContainerLowest);
88
+ });
89
+
90
+ it('is byte-identical to the derived output when no accents are supplied', () => {
91
+ const again = generateRoleColors({ seed: BLUE, variant: 'vivid', isDark: false });
92
+ expect(again).toEqual(derived);
93
+ });
94
+
95
+ it('applies pinned accents in dark mode too', () => {
96
+ const pinnedDark = generateRoleColors({
97
+ seed: BLUE,
98
+ variant: 'vivid',
99
+ isDark: true,
100
+ secondarySeed: YELLOW,
101
+ });
102
+ expect(hueDistance(hueOf(pinnedDark.secondary), Hct.fromInt(argbFromHex(YELLOW)).hue)).toBeLessThan(20);
103
+ });
104
+ });
105
+
106
+ describe('explicit accent pinning — arbitrary-seed scope path', () => {
107
+ it('threads pinned accents through buildSeedScopeVars', () => {
108
+ const derivedVars = buildSeedScopeVars({ seed: BLUE, mode: 'light' });
109
+ const pinnedVars = buildSeedScopeVars({
110
+ seed: BLUE,
111
+ mode: 'light',
112
+ secondarySeed: YELLOW,
113
+ tertiarySeed: PINK,
114
+ });
115
+
116
+ const yellowHue = Hct.fromInt(argbFromHex(YELLOW)).hue;
117
+ const pinkHue = Hct.fromInt(argbFromHex(PINK)).hue;
118
+
119
+ expect(pinnedVars['--secondary']).toBeDefined();
120
+ expect(hueDistance(hueOf(pinnedVars['--secondary'] ?? ''), yellowHue)).toBeLessThan(20);
121
+ expect(hueDistance(hueOf(pinnedVars['--tertiary'] ?? ''), pinkHue)).toBeLessThan(20);
122
+ // The scoped `--color-*` alias tracks the pinned value too (web utility contract).
123
+ expect(pinnedVars['--color-secondary']).toBe(pinnedVars['--secondary']);
124
+ // Primary token is unchanged vs the derived scope.
125
+ expect(pinnedVars['--primary']).toBe(derivedVars['--primary']);
126
+ // Derived secondary differs from the pinned one.
127
+ expect(pinnedVars['--secondary']).not.toBe(derivedVars['--secondary']);
128
+ });
129
+ });
@@ -1,5 +1,6 @@
1
1
  import { Platform } from 'react-native';
2
2
  import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
3
+ import type { ExplicitAccents } from './preset-vars';
3
4
  import { getResolvedTokens } from './token-registry';
4
5
 
5
6
  export function applyDarkClass(resolved: 'light' | 'dark') {
@@ -25,11 +26,15 @@ export function applyDarkClass(resolved: 'light' | 'dark') {
25
26
  * Includes extended tokens (card, chart-*, content-area, sidebar-*) so consumer
26
27
  * apps don't need to synthesize them.
27
28
  */
28
- export function applyColorPresetVars(preset: AppColorName, resolved: 'light' | 'dark') {
29
+ export function applyColorPresetVars(
30
+ preset: AppColorName,
31
+ resolved: 'light' | 'dark',
32
+ accents?: ExplicitAccents,
33
+ ) {
29
34
  if (Platform.OS !== 'web' || typeof document === 'undefined') return;
30
35
  if (!APP_COLOR_PRESETS[preset]) return;
31
36
 
32
- const vars = getResolvedTokens(preset, resolved);
37
+ const vars = getResolvedTokens(preset, resolved, accents);
33
38
  const root = document.documentElement.style;
34
39
 
35
40
  for (const [key, value] of Object.entries(vars)) {
@@ -4,6 +4,18 @@ import { STATUS_COLORS } from './build-theme';
4
4
  import { THEME_GRADIENTS } from './gradients';
5
5
  import type { Theme, ThemeColors } from './types';
6
6
 
7
+ /**
8
+ * Optional explicit accent seeds for the arbitrary-seed theme path. Pinning a
9
+ * `secondarySeed` / `tertiarySeed` (`#rrggbb`) forces that accent family to the
10
+ * given brand colour instead of the derived hue-rotation — mirroring
11
+ * {@link GenerateOptions.secondarySeed}. For an artwork-derived theme, pass the
12
+ * ranked `seedsFromImagePixels` results (`seeds[1]` / `seeds[2]`).
13
+ */
14
+ export interface SeedAccents {
15
+ secondarySeed?: string;
16
+ tertiarySeed?: string;
17
+ }
18
+
7
19
  /**
8
20
  * Build the JS `theme.colors` object from an ARBITRARY seed colour — the dynamic
9
21
  * counterpart of `buildTheme(preset, …)`.
@@ -22,8 +34,16 @@ export function buildColorsFromSeed(
22
34
  resolved: 'light' | 'dark',
23
35
  variant?: SchemeVariant,
24
36
  contrastLevel?: number,
37
+ accents?: SeedAccents,
25
38
  ): ThemeColors {
26
- const t = buildSeedScopeVars({ seed, mode: resolved, variant, contrastLevel });
39
+ const t = buildSeedScopeVars({
40
+ seed,
41
+ mode: resolved,
42
+ variant,
43
+ contrastLevel,
44
+ secondarySeed: accents?.secondarySeed,
45
+ tertiarySeed: accents?.tertiarySeed,
46
+ });
27
47
  const isDark = resolved === 'dark';
28
48
 
29
49
  // Read a resolved `rgb(...)` token by its bare name (no leading `--`).
@@ -34,6 +54,8 @@ export function buildColorsFromSeed(
34
54
  variant,
35
55
  isDark,
36
56
  contrastLevel,
57
+ secondarySeed: accents?.secondarySeed,
58
+ tertiarySeed: accents?.tertiarySeed,
37
59
  });
38
60
 
39
61
  return {
@@ -88,10 +110,11 @@ export function buildThemeFromSeed(
88
110
  resolved: 'light' | 'dark',
89
111
  variant?: SchemeVariant,
90
112
  contrastLevel?: number,
113
+ accents?: SeedAccents,
91
114
  ): Theme {
92
115
  return {
93
116
  mode: resolved,
94
- colors: buildColorsFromSeed(seed, resolved, variant, contrastLevel),
117
+ colors: buildColorsFromSeed(seed, resolved, variant, contrastLevel, accents),
95
118
  gradients: THEME_GRADIENTS,
96
119
  isDark: resolved === 'dark',
97
120
  isLight: resolved === 'light',
@@ -1,6 +1,7 @@
1
1
  import { Platform } from 'react-native';
2
2
  import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
3
3
  import { generateRoleColors, type RoleColors } from './color-engine';
4
+ import type { ExplicitAccents } from './preset-vars';
4
5
  import { getAdaptiveColors } from './adaptive-colors';
5
6
  import { getResolvedTokens } from './token-registry';
6
7
  import { THEME_GRADIENTS } from './gradients';
@@ -32,20 +33,24 @@ export const STATUS_COLORS = {
32
33
  function buildColorsFromPreset(
33
34
  preset: AppColorName,
34
35
  resolved: 'light' | 'dark',
36
+ accents?: ExplicitAccents,
35
37
  ): ThemeColors {
36
- const t = getResolvedTokens(preset, resolved);
38
+ const t = getResolvedTokens(preset, resolved, accents);
37
39
  const isDark = resolved === 'dark';
38
40
 
39
41
  // Read a resolved `rgb(...)` token by its bare name (no leading `--`).
40
42
  const g = (k: string): string => t[`--${k}`] ?? 'rgb(0 0 0)';
41
43
 
42
44
  // The full engine role set — for fields that map to roles Bloom's canonical
43
- // token set does not surface (containers, error family).
45
+ // token set does not surface (containers, error family). An app-wide accent
46
+ // override wins over the preset's own declared accent (else derived).
44
47
  const config = APP_COLOR_PRESETS[preset];
45
48
  const r: RoleColors = generateRoleColors({
46
49
  seed: config.hex,
47
50
  variant: config.variant,
48
51
  isDark,
52
+ secondarySeed: accents?.secondaryHex ?? config.secondaryHex,
53
+ tertiarySeed: accents?.tertiaryHex ?? config.tertiaryHex,
49
54
  });
50
55
 
51
56
  return {
@@ -105,9 +110,10 @@ export function buildTheme(
105
110
  preset: AppColorName,
106
111
  resolved: 'light' | 'dark',
107
112
  isAdaptive: boolean = false,
113
+ accents?: ExplicitAccents,
108
114
  ): Theme {
109
115
  const adaptive = isAdaptive && Platform.OS !== 'web' ? getAdaptiveColors() : undefined;
110
- const colors = adaptive ?? buildColorsFromPreset(preset, resolved);
116
+ const colors = adaptive ?? buildColorsFromPreset(preset, resolved, accents);
111
117
 
112
118
  return {
113
119
  mode: resolved,
@@ -11,7 +11,7 @@ export { Hct } from './hct';
11
11
  export { TonalPalette } from './tonal-palette';
12
12
  export { DynamicScheme } from './dynamic-scheme';
13
13
  export { buildScheme, schemeVibrant, schemeExpressive, schemeTonalSpot, schemeNeutral } from './scheme-variants';
14
- export type { SchemeVariant } from './scheme-variants';
14
+ export type { SchemeVariant, AccentSources } from './scheme-variants';
15
15
  export { Roles } from './color-roles';
16
16
  export type { RoleName } from './color-roles';
17
17
  export { ColorRole } from './color-role';
@@ -51,11 +51,27 @@ export interface GenerateOptions {
51
51
  isDark?: boolean;
52
52
  /** −1 (low) … 0 (normal) … 1 (high). */
53
53
  contrastLevel?: number;
54
+ /**
55
+ * Optional explicit secondary-accent seed, `#rrggbb`. When set, the secondary
56
+ * palette is pinned to THIS colour's hue + chroma (M3 role tones still apply)
57
+ * instead of the variant's derived hue-rotation of the primary seed. Omit for
58
+ * the current derived behaviour.
59
+ */
60
+ secondarySeed?: string;
61
+ /**
62
+ * Optional explicit tertiary-accent seed, `#rrggbb`. Same semantics as
63
+ * {@link GenerateOptions.secondarySeed} for the tertiary palette.
64
+ */
65
+ tertiarySeed?: string;
54
66
  }
55
67
 
56
68
  /**
57
69
  * Generate the full role set from ANY seed colour — the dynamic-theming entry
58
70
  * point. Presets are just fixed seeds; a user-picked colour works identically.
71
+ *
72
+ * `secondarySeed` / `tertiarySeed` optionally PIN those accent families to real
73
+ * brand colours (e.g. a blue-primary brand with a yellow secondary); omitted, the
74
+ * accents are derived as the variant's hue-rotations — byte-identical to before.
59
75
  */
60
76
  export function generateRoleColors(opts: GenerateOptions): RoleColors {
61
77
  const scheme = buildScheme(
@@ -63,6 +79,16 @@ export function generateRoleColors(opts: GenerateOptions): RoleColors {
63
79
  Hct.fromInt(argbFromHex(opts.seed)),
64
80
  opts.isDark ?? false,
65
81
  opts.contrastLevel ?? 0,
82
+ {
83
+ secondarySource:
84
+ opts.secondarySeed !== undefined
85
+ ? Hct.fromInt(argbFromHex(opts.secondarySeed))
86
+ : undefined,
87
+ tertiarySource:
88
+ opts.tertiarySeed !== undefined
89
+ ? Hct.fromInt(argbFromHex(opts.tertiarySeed))
90
+ : undefined,
91
+ },
66
92
  );
67
93
  const out = {} as RoleColors;
68
94
  for (const name of Object.keys(Roles) as RoleName[]) {
@@ -15,6 +15,29 @@ import { Variant } from './variant';
15
15
 
16
16
  export type SchemeVariant = 'vivid' | 'vibrant' | 'expressive' | 'tonalSpot' | 'neutral';
17
17
 
18
+ /**
19
+ * Optional explicit accent seeds for a scheme. When a source HCT is supplied, its
20
+ * palette is built from that colour itself (keeping the brand hue + chroma) instead
21
+ * of the derived hue-rotation — so a brand with a distinct secondary/tertiary accent
22
+ * (e.g. blue primary + yellow secondary) can PIN its real accent family. The M3 role
23
+ * tones still apply on top (a pinned yellow yields a yellow-hued secondary family:
24
+ * light container, dark on-colour) — that is correct M3 behaviour.
25
+ */
26
+ export interface AccentSources {
27
+ secondarySource?: Hct;
28
+ tertiarySource?: Hct;
29
+ }
30
+
31
+ /**
32
+ * Build the accent tonal palette for a role: from the pinned brand colour when a
33
+ * `source` is provided (preserving its hue + chroma), otherwise from the caller's
34
+ * derived rotation. Uses `TonalPalette.fromInt` so the palette keeps the exact
35
+ * brand key colour; the role tones apply the same way for both.
36
+ */
37
+ function accentPalette(source: Hct | undefined, derived: () => TonalPalette): TonalPalette {
38
+ return source !== undefined ? TonalPalette.fromInt(source.toInt()) : derived();
39
+ }
40
+
18
41
  const VIBRANT_HUES = [0.0, 41.0, 61.0, 101.0, 131.0, 181.0, 251.0, 301.0, 360.0];
19
42
  const VIBRANT_SECONDARY_ROTATIONS = [18.0, 15.0, 10.0, 12.0, 15.0, 18.0, 15.0, 12.0, 12.0];
20
43
  const VIBRANT_TERTIARY_ROTATIONS = [35.0, 30.0, 20.0, 25.0, 30.0, 35.0, 30.0, 25.0, 25.0];
@@ -24,20 +47,29 @@ const EXPRESSIVE_SECONDARY_ROTATIONS = [45.0, 95.0, 45.0, 20.0, 45.0, 90.0, 45.0
24
47
  const EXPRESSIVE_TERTIARY_ROTATIONS = [120.0, 120.0, 20.0, 45.0, 20.0, 15.0, 20.0, 120.0, 120.0];
25
48
 
26
49
  /** Maxed-out colourfulness at each position of the primary palette. */
27
- export function schemeVibrant(source: Hct, isDark: boolean, contrastLevel: number): DynamicScheme {
50
+ export function schemeVibrant(
51
+ source: Hct,
52
+ isDark: boolean,
53
+ contrastLevel: number,
54
+ accents: AccentSources = {},
55
+ ): DynamicScheme {
28
56
  return new DynamicScheme({
29
57
  sourceColorArgb: source.toInt(),
30
58
  variant: Variant.VIBRANT,
31
59
  contrastLevel,
32
60
  isDark,
33
61
  primaryPalette: TonalPalette.fromHueAndChroma(source.hue, 200.0),
34
- secondaryPalette: TonalPalette.fromHueAndChroma(
35
- DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_SECONDARY_ROTATIONS),
36
- 24.0,
62
+ secondaryPalette: accentPalette(accents.secondarySource, () =>
63
+ TonalPalette.fromHueAndChroma(
64
+ DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_SECONDARY_ROTATIONS),
65
+ 24.0,
66
+ ),
37
67
  ),
38
- tertiaryPalette: TonalPalette.fromHueAndChroma(
39
- DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_TERTIARY_ROTATIONS),
40
- 32.0,
68
+ tertiaryPalette: accentPalette(accents.tertiarySource, () =>
69
+ TonalPalette.fromHueAndChroma(
70
+ DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_TERTIARY_ROTATIONS),
71
+ 32.0,
72
+ ),
41
73
  ),
42
74
  neutralPalette: TonalPalette.fromHueAndChroma(source.hue, 10.0),
43
75
  neutralVariantPalette: TonalPalette.fromHueAndChroma(source.hue, 12.0),
@@ -53,40 +85,58 @@ export function schemeVibrant(source: Hct, isDark: boolean, contrastLevel: numbe
53
85
  * old hand-authored palette felt more vivid than pure M3, and this restores that
54
86
  * across the whole primary/secondary/tertiary trio.
55
87
  */
56
- export function schemeVivid(source: Hct, isDark: boolean, contrastLevel: number): DynamicScheme {
88
+ export function schemeVivid(
89
+ source: Hct,
90
+ isDark: boolean,
91
+ contrastLevel: number,
92
+ accents: AccentSources = {},
93
+ ): DynamicScheme {
57
94
  return new DynamicScheme({
58
95
  sourceColorArgb: source.toInt(),
59
96
  variant: Variant.VIBRANT,
60
97
  contrastLevel,
61
98
  isDark,
62
99
  primaryPalette: TonalPalette.fromHueAndChroma(source.hue, 200.0),
63
- secondaryPalette: TonalPalette.fromHueAndChroma(
64
- DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_SECONDARY_ROTATIONS),
65
- 64.0,
100
+ secondaryPalette: accentPalette(accents.secondarySource, () =>
101
+ TonalPalette.fromHueAndChroma(
102
+ DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_SECONDARY_ROTATIONS),
103
+ 64.0,
104
+ ),
66
105
  ),
67
- tertiaryPalette: TonalPalette.fromHueAndChroma(
68
- DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_TERTIARY_ROTATIONS),
69
- 72.0,
106
+ tertiaryPalette: accentPalette(accents.tertiarySource, () =>
107
+ TonalPalette.fromHueAndChroma(
108
+ DynamicScheme.getRotatedHue(source, VIBRANT_HUES, VIBRANT_TERTIARY_ROTATIONS),
109
+ 72.0,
110
+ ),
70
111
  ),
71
112
  neutralPalette: TonalPalette.fromHueAndChroma(source.hue, 10.0),
72
113
  neutralVariantPalette: TonalPalette.fromHueAndChroma(source.hue, 12.0),
73
114
  });
74
115
  }
75
116
 
76
- export function schemeExpressive(source: Hct, isDark: boolean, contrastLevel: number): DynamicScheme {
117
+ export function schemeExpressive(
118
+ source: Hct,
119
+ isDark: boolean,
120
+ contrastLevel: number,
121
+ accents: AccentSources = {},
122
+ ): DynamicScheme {
77
123
  return new DynamicScheme({
78
124
  sourceColorArgb: source.toInt(),
79
125
  variant: Variant.EXPRESSIVE,
80
126
  contrastLevel,
81
127
  isDark,
82
128
  primaryPalette: TonalPalette.fromHueAndChroma(sanitizeDegreesDouble(source.hue + 240.0), 40.0),
83
- secondaryPalette: TonalPalette.fromHueAndChroma(
84
- DynamicScheme.getRotatedHue(source, EXPRESSIVE_HUES, EXPRESSIVE_SECONDARY_ROTATIONS),
85
- 24.0,
129
+ secondaryPalette: accentPalette(accents.secondarySource, () =>
130
+ TonalPalette.fromHueAndChroma(
131
+ DynamicScheme.getRotatedHue(source, EXPRESSIVE_HUES, EXPRESSIVE_SECONDARY_ROTATIONS),
132
+ 24.0,
133
+ ),
86
134
  ),
87
- tertiaryPalette: TonalPalette.fromHueAndChroma(
88
- DynamicScheme.getRotatedHue(source, EXPRESSIVE_HUES, EXPRESSIVE_TERTIARY_ROTATIONS),
89
- 32.0,
135
+ tertiaryPalette: accentPalette(accents.tertiarySource, () =>
136
+ TonalPalette.fromHueAndChroma(
137
+ DynamicScheme.getRotatedHue(source, EXPRESSIVE_HUES, EXPRESSIVE_TERTIARY_ROTATIONS),
138
+ 32.0,
139
+ ),
90
140
  ),
91
141
  neutralPalette: TonalPalette.fromHueAndChroma(source.hue + 15, 8.0),
92
142
  neutralVariantPalette: TonalPalette.fromHueAndChroma(source.hue + 15, 12.0),
@@ -94,36 +144,57 @@ export function schemeExpressive(source: Hct, isDark: boolean, contrastLevel: nu
94
144
  }
95
145
 
96
146
  /** The Material You default — a calm, balanced treatment of the seed. */
97
- export function schemeTonalSpot(source: Hct, isDark: boolean, contrastLevel: number): DynamicScheme {
147
+ export function schemeTonalSpot(
148
+ source: Hct,
149
+ isDark: boolean,
150
+ contrastLevel: number,
151
+ accents: AccentSources = {},
152
+ ): DynamicScheme {
98
153
  return new DynamicScheme({
99
154
  sourceColorArgb: source.toInt(),
100
155
  variant: Variant.TONAL_SPOT,
101
156
  contrastLevel,
102
157
  isDark,
103
158
  primaryPalette: TonalPalette.fromHueAndChroma(source.hue, 36.0),
104
- secondaryPalette: TonalPalette.fromHueAndChroma(source.hue, 16.0),
105
- tertiaryPalette: TonalPalette.fromHueAndChroma(sanitizeDegreesDouble(source.hue + 60.0), 24.0),
159
+ secondaryPalette: accentPalette(accents.secondarySource, () =>
160
+ TonalPalette.fromHueAndChroma(source.hue, 16.0),
161
+ ),
162
+ tertiaryPalette: accentPalette(accents.tertiarySource, () =>
163
+ TonalPalette.fromHueAndChroma(sanitizeDegreesDouble(source.hue + 60.0), 24.0),
164
+ ),
106
165
  neutralPalette: TonalPalette.fromHueAndChroma(source.hue, 6.0),
107
166
  neutralVariantPalette: TonalPalette.fromHueAndChroma(source.hue, 8.0),
108
167
  });
109
168
  }
110
169
 
111
170
  /** Near-greyscale — the seed hue is present only as a whisper. */
112
- export function schemeNeutral(source: Hct, isDark: boolean, contrastLevel: number): DynamicScheme {
171
+ export function schemeNeutral(
172
+ source: Hct,
173
+ isDark: boolean,
174
+ contrastLevel: number,
175
+ accents: AccentSources = {},
176
+ ): DynamicScheme {
113
177
  return new DynamicScheme({
114
178
  sourceColorArgb: source.toInt(),
115
179
  variant: Variant.NEUTRAL,
116
180
  contrastLevel,
117
181
  isDark,
118
182
  primaryPalette: TonalPalette.fromHueAndChroma(source.hue, 12.0),
119
- secondaryPalette: TonalPalette.fromHueAndChroma(source.hue, 8.0),
120
- tertiaryPalette: TonalPalette.fromHueAndChroma(source.hue, 16.0),
183
+ secondaryPalette: accentPalette(accents.secondarySource, () =>
184
+ TonalPalette.fromHueAndChroma(source.hue, 8.0),
185
+ ),
186
+ tertiaryPalette: accentPalette(accents.tertiarySource, () =>
187
+ TonalPalette.fromHueAndChroma(source.hue, 16.0),
188
+ ),
121
189
  neutralPalette: TonalPalette.fromHueAndChroma(source.hue, 2.0),
122
190
  neutralVariantPalette: TonalPalette.fromHueAndChroma(source.hue, 2.0),
123
191
  });
124
192
  }
125
193
 
126
- const BUILDERS: Record<SchemeVariant, (s: Hct, d: boolean, c: number) => DynamicScheme> = {
194
+ const BUILDERS: Record<
195
+ SchemeVariant,
196
+ (s: Hct, d: boolean, c: number, accents: AccentSources) => DynamicScheme
197
+ > = {
127
198
  vivid: schemeVivid,
128
199
  vibrant: schemeVibrant,
129
200
  expressive: schemeExpressive,
@@ -131,12 +202,19 @@ const BUILDERS: Record<SchemeVariant, (s: Hct, d: boolean, c: number) => Dynamic
131
202
  neutral: schemeNeutral,
132
203
  };
133
204
 
134
- /** Build a scheme by variant name. */
205
+ /**
206
+ * Build a scheme by variant name.
207
+ *
208
+ * `accents` optionally pins the secondary and/or tertiary palette to an explicit
209
+ * brand colour (see {@link AccentSources}); omitted accents keep the variant's
210
+ * derived hue-rotation, so existing callers get byte-identical output.
211
+ */
135
212
  export function buildScheme(
136
213
  variant: SchemeVariant,
137
214
  source: Hct,
138
215
  isDark: boolean,
139
216
  contrastLevel = 0,
217
+ accents: AccentSources = {},
140
218
  ): DynamicScheme {
141
- return BUILDERS[variant](source, isDark, contrastLevel);
219
+ return BUILDERS[variant](source, isDark, contrastLevel, accents);
142
220
  }
@@ -51,7 +51,17 @@ export function quantizeImage(pixels: number[], maxColors = 128): Map<number, nu
51
51
  return result;
52
52
  }
53
53
 
54
- /** Ranked seed candidates (best first) extracted from an image's pixels (ARGB). */
54
+ /**
55
+ * Ranked seed candidates (best first) extracted from an image's pixels (ARGB).
56
+ *
57
+ * The top result (`seeds[0]`) is the primary seed for `generateRoleColors({ seed })`.
58
+ * To derive a richer, brand-accurate theme from artwork, a caller can additionally
59
+ * pin distinct accents from the ranked list: pass `hexFromArgb(seeds[1])` as
60
+ * `secondarySeed` and `hexFromArgb(seeds[2])` as `tertiarySeed` to
61
+ * `generateRoleColors` / `buildSeedScopeVars` / `buildThemeFromSeed` so the
62
+ * secondary and tertiary families reflect the artwork's real supporting colours
63
+ * instead of hue-rotations of the primary.
64
+ */
55
65
  export function seedsFromImagePixels(pixels: number[], options?: ScoreOptions): number[] {
56
66
  return score(quantizeImage(pixels), options);
57
67
  }
@@ -32,6 +32,20 @@ export interface AppColorPreset {
32
32
  hex: string;
33
33
  /** The tonal scheme variant the engine derives roles with. */
34
34
  variant: SchemeVariant;
35
+ /**
36
+ * Optional explicit secondary-accent seed, `#rrggbb`. When set, the secondary
37
+ * palette is PINNED to this brand colour (hue + chroma preserved; M3 role tones
38
+ * still apply) instead of the derived hue-rotation of {@link AppColorPreset.hex}.
39
+ * Bloom's built-in presets do NOT set this, so their generated output is
40
+ * unchanged; a consumer brand with a distinct accent (blue primary + yellow
41
+ * secondary) sets it to pin its real accent family.
42
+ */
43
+ secondaryHex?: string;
44
+ /**
45
+ * Optional explicit tertiary-accent seed, `#rrggbb`. Same semantics as
46
+ * {@link AppColorPreset.secondaryHex} for the tertiary palette.
47
+ */
48
+ tertiaryHex?: string;
35
49
  }
36
50
 
37
51
  /**
@@ -72,6 +72,18 @@ export interface SeedScopeOptions {
72
72
  variant?: SchemeVariant;
73
73
  /** −1 (low) … 0 (normal) … 1 (high). Defaults to 0. */
74
74
  contrastLevel?: number;
75
+ /**
76
+ * Optional explicit secondary-accent seed, `#rrggbb`. Pins the secondary palette
77
+ * to this brand colour instead of the derived rotation (M3 role tones still
78
+ * apply). Handy for an artwork-extracted theme: pass the second-ranked seed from
79
+ * `seedsFromImagePixels` (`seeds[1]`) as a distinct secondary accent.
80
+ */
81
+ secondarySeed?: string;
82
+ /**
83
+ * Optional explicit tertiary-accent seed, `#rrggbb`. Same semantics; pass
84
+ * `seedsFromImagePixels`' `seeds[2]` for a third artwork-derived accent.
85
+ */
86
+ tertiarySeed?: string;
75
87
  }
76
88
 
77
89
  /**
@@ -92,6 +104,8 @@ export function buildSeedScopeVars(options: SeedScopeOptions): Record<string, st
92
104
  variant: options.variant ?? 'vivid',
93
105
  isDark: options.mode === 'dark',
94
106
  contrastLevel: options.contrastLevel ?? 0,
107
+ secondarySeed: options.secondarySeed,
108
+ tertiarySeed: options.tertiarySeed,
95
109
  });
96
110
  const tokens = roleColorsToPresetTokens(roles);
97
111
  const vars: Record<string, string> = { ...tokens };
@@ -3,6 +3,7 @@ import { Platform, type StyleProp, type ViewStyle } from 'react-native';
3
3
 
4
4
  import { CANONICAL_TOKENS, getResolvedTokens } from '../token-registry';
5
5
  import type { AppColorName } from '../color-presets';
6
+ import type { ExplicitAccents } from '../preset-vars';
6
7
  import { lazyRequire } from '../../utils/lazy-require';
7
8
 
8
9
  /**
@@ -36,8 +37,9 @@ const getNativeWindVars = lazyRequire<NativeWindVarsModule>('nativewind');
36
37
  export function buildScopeVars(
37
38
  colorPreset: AppColorName,
38
39
  mode: 'light' | 'dark',
40
+ accents?: ExplicitAccents,
39
41
  ): Record<string, string> {
40
- const tokens = getResolvedTokens(colorPreset, mode);
42
+ const tokens = getResolvedTokens(colorPreset, mode, accents);
41
43
  const vars: Record<string, string> = { ...tokens };
42
44
 
43
45
  for (const token of CANONICAL_TOKENS) {
@@ -24,9 +24,10 @@ export {
24
24
  greenFromArgb,
25
25
  blueFromArgb,
26
26
  } from './color-engine';
27
- export type { RoleColors, GenerateOptions, RoleName, SchemeVariant } from './color-engine';
27
+ export type { RoleColors, GenerateOptions, RoleName, SchemeVariant, AccentSources } from './color-engine';
28
28
  export { buildTheme, STATUS_COLORS } from './build-theme';
29
29
  export { buildThemeFromSeed, buildColorsFromSeed } from './build-theme-from-seed';
30
+ export type { SeedAccents } from './build-theme-from-seed';
30
31
  export { THEME_GRADIENTS } from './gradients';
31
32
  export { useTheme, useThemeColor, useBloomTheme } from './use-theme';
32
33
  export { useNavigationTheme } from './use-navigation-theme';
@@ -43,6 +44,7 @@ export {
43
44
  export { parseRgb, withAlpha } from './color-utils';
44
45
  export type { RgbChannels } from './color-utils';
45
46
  export { getPresetVars, applyPresetVarsToDocument } from './preset-vars';
47
+ export type { ExplicitAccents } from './preset-vars';
46
48
  export { applyDarkClass } from './apply-dark-class';
47
49
  export { setColorSchemeSafe } from './set-color-scheme-safe';
48
50
  export { initCssInteropDarkMode } from './init-css-interop';