@oxyhq/bloom 0.73.1 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/lib/commonjs/design-tokens/theme.css +22 -0
  2. package/lib/commonjs/fab/Fab.js +9 -0
  3. package/lib/commonjs/fab/Fab.js.map +1 -1
  4. package/lib/commonjs/fab/Fab.web.js +11 -0
  5. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  6. package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
  7. package/lib/commonjs/theme/build-theme-from-seed.js +7 -5
  8. package/lib/commonjs/theme/build-theme-from-seed.js.map +1 -1
  9. package/lib/commonjs/theme/build-theme.js +9 -4
  10. package/lib/commonjs/theme/build-theme.js.map +1 -1
  11. package/lib/commonjs/theme/color-policy.js +315 -0
  12. package/lib/commonjs/theme/color-policy.js.map +1 -0
  13. package/lib/commonjs/theme/color-scope/seed-scope.js +13 -1
  14. package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
  15. package/lib/commonjs/theme/color-scope/style-builder.js +6 -0
  16. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  17. package/lib/commonjs/theme/preset-vars.js +9 -1
  18. package/lib/commonjs/theme/preset-vars.js.map +1 -1
  19. package/lib/commonjs/theme/token-registry.js +11 -1
  20. package/lib/commonjs/theme/token-registry.js.map +1 -1
  21. package/lib/module/design-tokens/theme.css +22 -0
  22. package/lib/module/fab/Fab.js +9 -0
  23. package/lib/module/fab/Fab.js.map +1 -1
  24. package/lib/module/fab/Fab.web.js +11 -0
  25. package/lib/module/fab/Fab.web.js.map +1 -1
  26. package/lib/module/theme/BloomThemeProvider.js.map +1 -1
  27. package/lib/module/theme/build-theme-from-seed.js +7 -5
  28. package/lib/module/theme/build-theme-from-seed.js.map +1 -1
  29. package/lib/module/theme/build-theme.js +9 -4
  30. package/lib/module/theme/build-theme.js.map +1 -1
  31. package/lib/module/theme/color-policy.js +310 -0
  32. package/lib/module/theme/color-policy.js.map +1 -0
  33. package/lib/module/theme/color-scope/seed-scope.js +13 -1
  34. package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
  35. package/lib/module/theme/color-scope/style-builder.js +6 -0
  36. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  37. package/lib/module/theme/preset-vars.js +9 -1
  38. package/lib/module/theme/preset-vars.js.map +1 -1
  39. package/lib/module/theme/token-registry.js +11 -1
  40. package/lib/module/theme/token-registry.js.map +1 -1
  41. package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/fab/types.d.ts +6 -1
  44. package/lib/typescript/commonjs/fab/types.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/icons/common.d.ts +1 -1
  46. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/theme/build-theme-from-seed.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/theme/color-policy.d.ts +48 -0
  50. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/theme/preset-vars.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/theme/token-registry.d.ts +1 -1
  55. package/lib/typescript/commonjs/theme/token-registry.d.ts.map +1 -1
  56. package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
  57. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  58. package/lib/typescript/module/fab/types.d.ts +6 -1
  59. package/lib/typescript/module/fab/types.d.ts.map +1 -1
  60. package/lib/typescript/module/icons/common.d.ts +1 -1
  61. package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
  62. package/lib/typescript/module/theme/build-theme-from-seed.d.ts.map +1 -1
  63. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -1
  64. package/lib/typescript/module/theme/color-policy.d.ts +48 -0
  65. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -0
  66. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
  67. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  68. package/lib/typescript/module/theme/preset-vars.d.ts.map +1 -1
  69. package/lib/typescript/module/theme/token-registry.d.ts +1 -1
  70. package/lib/typescript/module/theme/token-registry.d.ts.map +1 -1
  71. package/package.json +1 -1
  72. package/src/__tests__/preset-vars.test.ts +27 -15
  73. package/src/design-tokens/theme.css +22 -0
  74. package/src/fab/Fab.tsx +3 -0
  75. package/src/fab/Fab.web.tsx +3 -0
  76. package/src/fab/types.ts +6 -1
  77. package/src/theme/BloomThemeProvider.tsx +7 -4
  78. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +1300 -312
  79. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +442 -442
  80. package/src/theme/__tests__/policy-legibility.test.ts +118 -0
  81. package/src/theme/__tests__/theme-colors-parity.test.ts +11 -6
  82. package/src/theme/build-theme-from-seed.ts +7 -4
  83. package/src/theme/build-theme.ts +9 -4
  84. package/src/theme/color-policy.ts +342 -0
  85. package/src/theme/color-scope/seed-scope.ts +13 -1
  86. package/src/theme/color-scope/style-builder.ts +6 -0
  87. package/src/theme/preset-vars.ts +10 -1
  88. package/src/theme/token-registry.ts +32 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The legibility gate for the colour policy.
3
+ *
4
+ * Every accent and status family is generated, not authored, so nothing else in
5
+ * the suite can notice when a tuning change makes a label unreadable. This walks
6
+ * the full matrix — 13 presets x 2 modes x 7 families — and asserts the property
7
+ * a user actually perceives.
8
+ *
9
+ * Two failure shapes it exists to catch, both of which have already happened:
10
+ *
11
+ * - A pair that lands just under AA. Contrast is decided on the QUANTIZED
12
+ * colour: an earlier version chose tones with the engine's continuous
13
+ * tone-ratio and shipped 135 pairs at a measured 4.49.
14
+ * - Black on a fill where white was legible. Foregrounds must follow the fill's
15
+ * tone rather than a preference, and a tie once handed 8 presets a black label
16
+ * on a fill picked specifically to carry white.
17
+ *
18
+ * A `-subtle` token is a translucent tint, so its text member is checked
19
+ * COMPOSITED over the page background — the surface it actually sits on. Checking
20
+ * it against the raw rgba would compare against a colour nobody ever sees.
21
+ */
22
+ import { getResolvedTokens } from '../token-registry';
23
+ import { APP_COLOR_NAMES } from '../color-presets';
24
+
25
+ const AA = 4.5;
26
+
27
+ /** Families whose fill must carry its own foreground. */
28
+ const FAMILIES = ['primary', 'secondary', 'tertiary', 'success', 'error', 'warning', 'info'] as const;
29
+
30
+ /** Families that also ship a translucent surface with a text member on it. */
31
+ const TINTED = ['primary', 'secondary', 'tertiary', 'success', 'error', 'warning', 'info'] as const;
32
+
33
+ type Rgba = { r: number; g: number; b: number; a: number };
34
+
35
+ function parse(value: string): Rgba {
36
+ const parts = (value.match(/[\d.]+/g) ?? []).map(Number);
37
+ return {
38
+ r: parts[0] ?? 0,
39
+ g: parts[1] ?? 0,
40
+ b: parts[2] ?? 0,
41
+ a: parts[3] ?? 1,
42
+ };
43
+ }
44
+
45
+ /** Flatten a translucent colour onto an opaque one. */
46
+ function over(top: Rgba, bottom: Rgba): Rgba {
47
+ return {
48
+ r: top.r * top.a + bottom.r * (1 - top.a),
49
+ g: top.g * top.a + bottom.g * (1 - top.a),
50
+ b: top.b * top.a + bottom.b * (1 - top.a),
51
+ a: 1,
52
+ };
53
+ }
54
+
55
+ function luminance({ r, g, b }: Rgba): number {
56
+ const channel = (v: number): number => {
57
+ const c = v / 255;
58
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
59
+ };
60
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
61
+ }
62
+
63
+ function contrast(a: Rgba, b: Rgba): number {
64
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
65
+ return (hi + 0.05) / (lo + 0.05);
66
+ }
67
+
68
+ const WHITE: Rgba = { r: 255, g: 255, b: 255, a: 1 };
69
+
70
+ describe('colour policy legibility', () => {
71
+ const failures: string[] = [];
72
+ const avoidableBlack: string[] = [];
73
+ let checked = 0;
74
+
75
+ for (const preset of APP_COLOR_NAMES) {
76
+ for (const mode of ['light', 'dark'] as const) {
77
+ const tokens = getResolvedTokens(preset, mode);
78
+ const background = parse(tokens['--background'] ?? 'rgb(0 0 0)');
79
+
80
+ for (const family of FAMILIES) {
81
+ const fill = parse(tokens[`--${family}`] ?? '');
82
+ const foreground = parse(tokens[`--${family}-foreground`] ?? '');
83
+ checked += 1;
84
+ const ratio = contrast(fill, foreground);
85
+ if (ratio < AA) failures.push(`${preset}/${mode} --${family} ${ratio.toFixed(2)}`);
86
+ // Black chosen where white would have been legible means the foreground
87
+ // rule stopped following the fill's tone.
88
+ if (foreground.r === 0 && contrast(fill, WHITE) >= AA) {
89
+ avoidableBlack.push(`${preset}/${mode} --${family}`);
90
+ }
91
+ }
92
+
93
+ for (const family of TINTED) {
94
+ const tint = over(parse(tokens[`--${family}-subtle`] ?? ''), background);
95
+ const text = parse(tokens[`--${family}-text`] ?? '');
96
+ checked += 1;
97
+ const ratio = contrast(tint, text);
98
+ if (ratio < AA) {
99
+ failures.push(`${preset}/${mode} --${family}-text sobre -subtle ${ratio.toFixed(2)}`);
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ // Vacuity floor: a broken traversal must not pass as "nothing failed".
106
+ it('covers the whole preset matrix', () => {
107
+ expect(checked).toBe(APP_COLOR_NAMES.length * 2 * (FAMILIES.length + TINTED.length));
108
+ expect(checked).toBeGreaterThan(300);
109
+ });
110
+
111
+ it('every fill carries its own foreground at AA', () => {
112
+ expect(failures).toEqual([]);
113
+ });
114
+
115
+ it('never picks black where white is legible', () => {
116
+ expect(avoidableBlack).toEqual([]);
117
+ });
118
+ });
@@ -44,12 +44,17 @@ describe.each(APP_COLOR_NAMES)('engine-backed theme.colors (%s)', (preset: AppCo
44
44
  }
45
45
  });
46
46
 
47
- it('card is lighter than background in light, darker (or equal) in dark', () => {
48
- const light = buildTheme(preset, 'light').colors;
49
- expect(lightness(parse(light.card))).toBeGreaterThan(lightness(parse(light.background)));
50
-
51
- const dark = buildTheme(preset, 'dark').colors;
52
- expect(lightness(parse(dark.card))).toBeLessThanOrEqual(lightness(parse(dark.background)));
47
+ // Deliberately inverted from what this asserted before. M3's
48
+ // `surfaceContainerLowest` is the RECESSED step: in dark it sits at tone 4
49
+ // against a tone-6 background, so every card, sheet and chat bubble SANK into
50
+ // the page instead of lifting off it — measured identically on all thirteen
51
+ // presets, so it was the mapping and not any one seed. A card lifts in both
52
+ // modes now, and this is the assertion that would catch a silent revert.
53
+ it('card lifts off the background in BOTH modes', () => {
54
+ for (const mode of ['light', 'dark'] as const) {
55
+ const { colors } = buildTheme(preset, mode);
56
+ expect(lightness(parse(colors.card))).toBeGreaterThan(lightness(parse(colors.background)));
57
+ }
53
58
  });
54
59
 
55
60
  it('token-backed fields read straight from the resolved token map', () => {
@@ -84,14 +84,17 @@ export function buildColorsFromSeed(
84
84
  icon: g('muted-foreground'),
85
85
  iconActive: g('primary'),
86
86
 
87
- ...STATUS_COLORS,
87
+ success: g('success'),
88
+ error: g('error'),
89
+ warning: g('warning'),
90
+ info: g('info'),
88
91
 
89
- primarySubtle: r.primaryContainer,
92
+ primarySubtle: g('primary-subtle'),
90
93
  primarySubtleForeground: r.onPrimaryContainer,
91
94
  negative: r.error,
92
95
  negativeForeground: r.onError,
93
- negativeSubtle: r.errorContainer,
94
- negativeSubtleForeground: r.onErrorContainer,
96
+ negativeSubtle: g('error-subtle'),
97
+ negativeSubtleForeground: g('error-text'),
95
98
  contrast50: g('muted'),
96
99
 
97
100
  card: g('card'),
@@ -83,14 +83,19 @@ function buildColorsFromPreset(
83
83
  icon: g('muted-foreground'),
84
84
  iconActive: g('primary'),
85
85
 
86
- ...STATUS_COLORS,
86
+ // The status family is themed per mode and legible, from the same pipeline
87
+ // as everything else — not four frozen hexes that fail AA under white text.
88
+ success: g('success'),
89
+ error: g('error'),
90
+ warning: g('warning'),
91
+ info: g('info'),
87
92
 
88
- primarySubtle: r.primaryContainer,
93
+ primarySubtle: g('primary-subtle'),
89
94
  primarySubtleForeground: r.onPrimaryContainer,
90
95
  negative: r.error,
91
96
  negativeForeground: r.onError,
92
- negativeSubtle: r.errorContainer,
93
- negativeSubtleForeground: r.onErrorContainer,
97
+ negativeSubtle: g('error-subtle'),
98
+ negativeSubtleForeground: g('error-text'),
94
99
  contrast50: g('muted'),
95
100
 
96
101
  // The FIX: `card` is the lightest surface (engine `surfaceContainerLowest`).
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Bloom colour policy — how a seed becomes the visible token set.
3
+ *
4
+ * The engine (`color-engine/`) answers "what colours exist around this seed".
5
+ * This module answers the separate question of how deep, how loud and how far
6
+ * apart they should be, which canonical Material 3 answers differently than
7
+ * Bloom needs:
8
+ *
9
+ * - M3 puts `primary` at tone 80 in dark so it can be used as TEXT. Bloom uses
10
+ * `--primary` as a FILL (buttons, FABs, the user's own chat bubble), and tone
11
+ * 80 collapses chroma — the reason the whole palette read pastel. The two jobs
12
+ * are split here: `--primary` is the fill, `--primary-text` is the accent
13
+ * legible as text on the page.
14
+ * - M3's supporting accents rotate 18–35 degrees off the seed, so the
15
+ * primary/secondary/tertiary trio lands in one hue family and cannot encode
16
+ * anything. A real split-complementary rotation fixes that.
17
+ * - Chroma has to scale inversely with the area a colour paints: a chip's
18
+ * background covers far more pixels than its label.
19
+ *
20
+ * The single rule underneath every decision here is that **light and dark want
21
+ * opposite things**. On a near-white page a fill must go deep to read calm; on a
22
+ * near-black one it must stay bright or it turns to mud. Every asymmetry below
23
+ * is that rule applied to one more role.
24
+ */
25
+ import { argbFromHex, type RoleColors } from './color-engine';
26
+ import { Hct } from './color-engine/hct';
27
+ import { TonalPalette } from './color-engine/tonal-palette';
28
+ import { fixIfDisliked } from './color-engine/dislike-analyzer';
29
+ import { blueFromArgb, greenFromArgb, redFromArgb } from './color-engine/color-utils';
30
+
31
+ /** Semantic status seeds. Only hue and chroma survive; tones come from the policy. */
32
+ export const STATUS_SEEDS = {
33
+ success: '#10B981',
34
+ error: '#EF4444',
35
+ warning: '#F59E0B',
36
+ info: '#3B82F6',
37
+ } as const;
38
+
39
+ /** Optional real accent colours — e.g. the 2nd and 3rd seeds extracted from artwork. */
40
+ export interface PinnedAccents {
41
+ secondary?: string;
42
+ tertiary?: string;
43
+ }
44
+
45
+ /** The tokens the policy owns. Everything else keeps coming from the M3 roles. */
46
+ export type PolicyTokens = Record<string, string>;
47
+
48
+ const AA = 4.5;
49
+
50
+ const rgb = (argb: number): string =>
51
+ `rgb(${redFromArgb(argb)} ${greenFromArgb(argb)} ${blueFromArgb(argb)})`;
52
+
53
+ /**
54
+ * A translucent tint, as `rgba(r, g, b, a)`.
55
+ *
56
+ * Deliberately the legacy comma form: React Native's colour parser accepts it on
57
+ * every platform, while the modern `rgb(r g b / a)` slash syntax is a web-only
58
+ * spelling.
59
+ */
60
+ const rgba = (argb: number, alpha: number): string =>
61
+ `rgba(${redFromArgb(argb)}, ${greenFromArgb(argb)}, ${blueFromArgb(argb)}, ${alpha})`;
62
+
63
+ /**
64
+ * WCAG contrast of a QUANTIZED colour against white or black.
65
+ *
66
+ * The engine's tone-based ratio is continuous, but what ships is an 8-bit colour
67
+ * whose rounding turns a nominal 4.50 into a measured 4.49 — below AA. Every
68
+ * legibility decision here reads the colour that actually renders.
69
+ */
70
+ function contrastOf(argb: number, onWhite: boolean): number {
71
+ const channel = (v: number): number => {
72
+ const c = v / 255;
73
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
74
+ };
75
+ const luminance =
76
+ 0.2126 * channel(redFromArgb(argb)) +
77
+ 0.7152 * channel(greenFromArgb(argb)) +
78
+ 0.0722 * channel(blueFromArgb(argb));
79
+ return onWhite ? 1.05 / (luminance + 0.05) : (luminance + 0.05) / 0.05;
80
+ }
81
+
82
+ /**
83
+ * White and black are never both legible on the same fill — white needs a
84
+ * light-enough one, black a dark-enough one, and they overlap in a single tone.
85
+ * So the foreground is not a preference: the fill's tone already decided it. The
86
+ * one overlapping tone goes to white, because landing there is the entire point
87
+ * of a deep fill.
88
+ */
89
+ const foregroundFor = (argb: number): string =>
90
+ contrastOf(argb, true) >= AA ? 'rgb(255 255 255)' : 'rgb(0 0 0)';
91
+
92
+ const noLegibleForeground = (argb: number): boolean =>
93
+ contrastOf(argb, true) < AA && contrastOf(argb, false) < AA;
94
+
95
+ /** The tone a white-label fill sits at in LIGHT mode. */
96
+ const LIGHT_FILL_TONE = 45;
97
+
98
+ /** No dark-mode fill sits below this: under it a colour goes heavy on black. */
99
+ const DARK_FLOOR = 58;
100
+
101
+ /** Chroma ceiling for an accent FILL — the brand palette's own maximum, not the gamut's. */
102
+ const ACCENT_CHROMA = 80;
103
+
104
+ /** The text/icon member: small enough that saturation costs nothing. */
105
+ const TEXT_TONE = { light: 40, dark: 80 } as const;
106
+ const TEXT_CHROMA = 60;
107
+
108
+ /** The `-subtle` tint is mixed from a BRIGHT tone at low alpha, never a dark one at high alpha. */
109
+ const SUBTLE_SOURCE_TONE = 60;
110
+ const SUBTLE_ALPHA = { light: 0.13, dark: 0.24 } as const;
111
+
112
+ /** Split-complementary: the two accents sit either side of the seed's complement. */
113
+ const SPLIT_ROTATIONS = [150, 210] as const;
114
+
115
+ /** How far the standout accent may move toward a more vivid hue, and the gain that earns it. */
116
+ const VIVID_SNAP_WINDOW = 25;
117
+ const VIVID_SNAP_MIN_GAIN = 15;
118
+
119
+ /** Below this the split pair stops reading as a pair, so a snap that crowds it is abandoned. */
120
+ const MIN_ACCENT_SEPARATION = 25;
121
+
122
+ /** A standout accent this close to the error hue reads as "delete", not "create". */
123
+ const DESTRUCTIVE_PROXIMITY = 30;
124
+
125
+ function hueDistance(a: number, b: number): number {
126
+ const d = Math.abs(a - b) % 360;
127
+ return Math.min(d, 360 - d);
128
+ }
129
+
130
+ /**
131
+ * The tone at which a hue reaches its highest chroma in sRGB — where it is most
132
+ * itself.
133
+ *
134
+ * It varies enormously by family: red and orange peak near tone 55, violet near
135
+ * 33, while lime, green and cyan only peak above 85. A single tone for every hue
136
+ * therefore reads right for some and as mud for others — lime at tone 49 is
137
+ * olive, and it takes tone 89 to look like lime at all.
138
+ */
139
+ function peakTone(hue: number): number {
140
+ const palette = TonalPalette.fromHueAndChroma(hue, 200);
141
+ let bestTone = 50;
142
+ let bestChroma = -1;
143
+ for (let tone = 30; tone <= 92; tone += 1) {
144
+ const chroma = Hct.fromInt(palette.tone(tone)).chroma;
145
+ if (chroma > bestChroma) {
146
+ bestChroma = chroma;
147
+ bestTone = tone;
148
+ }
149
+ }
150
+ return bestTone;
151
+ }
152
+
153
+ /** How vivid a hue can get at all — its chroma at its peak tone. */
154
+ function peakChroma(hue: number): number {
155
+ return Hct.fromInt(TonalPalette.fromHueAndChroma(hue, 200).tone(peakTone(hue))).chroma;
156
+ }
157
+
158
+ /**
159
+ * Nudge a hue to the most vivid one near it, but only when that is a real gain.
160
+ *
161
+ * The sRGB gamut is not a circle, so a rotation can land in a genuinely dull
162
+ * pocket: from a magenta seed the +150 arm falls on hue 113, which peaks at
163
+ * chroma 74 and reads as acid yellow, while hue 138 twenty-five degrees away
164
+ * peaks at 101 and is the lime it was supposed to be. The threshold keeps this
165
+ * from becoming a free-for-all — the same search would drift a well-placed pink
166
+ * to magenta for four points, which is noise.
167
+ */
168
+ function vividHueNear(hue: number): number {
169
+ const baseChroma = peakChroma(hue);
170
+ let bestHue = hue;
171
+ let bestChroma = baseChroma;
172
+ for (let delta = -VIVID_SNAP_WINDOW; delta <= VIVID_SNAP_WINDOW; delta += 1) {
173
+ const candidate = (((hue + delta) % 360) + 360) % 360;
174
+ const chroma = peakChroma(candidate);
175
+ if (chroma > bestChroma) {
176
+ bestChroma = chroma;
177
+ bestHue = candidate;
178
+ }
179
+ }
180
+ return bestChroma - baseChroma >= VIVID_SNAP_MIN_GAIN ? bestHue : hue;
181
+ }
182
+
183
+ /** Settle a fill tone so some foreground is legible, then pair it with that foreground. */
184
+ function fillPair(palette: TonalPalette, startTone: number): { fill: string; foreground: string } {
185
+ let tone = startTone;
186
+ while (noLegibleForeground(palette.tone(tone)) && tone > 1 && tone < 99) {
187
+ tone += tone < 50 ? -1 : 1;
188
+ }
189
+ return { fill: rgb(palette.tone(tone)), foreground: foregroundFor(palette.tone(tone)) };
190
+ }
191
+
192
+ /**
193
+ * Pick the two accent hues: which arm of the split becomes the STANDOUT one
194
+ * (`tertiary` — the FAB, the compose button) and which stays quiet.
195
+ *
196
+ * A split pair is symmetric about the complement, but the two arms are not
197
+ * equally vivid, because the gamut is not a circle. The louder role takes
198
+ * whichever arm can reach more chroma, then that arm alone is allowed to drift
199
+ * toward a more vivid neighbour. Only the standout moves: snapping both can pull
200
+ * them onto the same pocket, which collapses the pair and leaves the destructive
201
+ * check below with nothing to swap to.
202
+ */
203
+ function accentHues(seedHue: number): { secondary: number; tertiary: number } {
204
+ const [firstRotation, secondRotation] = SPLIT_ROTATIONS;
205
+ const geometricA = (seedHue + firstRotation) % 360;
206
+ const geometricB = (seedHue + secondRotation) % 360;
207
+
208
+ const geometricStandout =
209
+ peakChroma(geometricA) >= peakChroma(geometricB) ? geometricA : geometricB;
210
+ const quiet = geometricStandout === geometricA ? geometricB : geometricA;
211
+ const snapped = vividHueNear(geometricStandout);
212
+ const standout =
213
+ hueDistance(snapped, quiet) >= MIN_ACCENT_SEPARATION ? snapped : geometricStandout;
214
+
215
+ // A call to action must not be mistaken for a destructive one. When the loud
216
+ // arm lands near the error hue the two trade places, which keeps the split
217
+ // pair intact instead of inventing a third rotation.
218
+ const errorHue = Hct.fromInt(argbFromHex(STATUS_SEEDS.error)).hue;
219
+ return hueDistance(standout, errorHue) < DESTRUCTIVE_PROXIMITY
220
+ ? { secondary: standout, tertiary: quiet }
221
+ : { secondary: quiet, tertiary: standout };
222
+ }
223
+
224
+ /**
225
+ * Build the policy-owned tokens for a seed and mode.
226
+ *
227
+ * `roles` is the engine's `vivid` role set for the same seed and mode, passed in
228
+ * so the caller does not generate it twice — the surface scale and
229
+ * `--primary-text` come straight from it.
230
+ */
231
+ export function buildPolicyTokens(
232
+ seedHex: string,
233
+ isDark: boolean,
234
+ roles: RoleColors,
235
+ pinned: PinnedAccents = {},
236
+ ): PolicyTokens {
237
+ const seed = Hct.fromInt(argbFromHex(seedHex));
238
+
239
+ // LIGHT takes the max-chroma palette at a deep tone: on a near-white page the
240
+ // fill has to go deep to read calm, and max chroma is what keeps it from going
241
+ // pastel there. DARK takes the SEED'S OWN palette — its own chroma and its own
242
+ // tone, floored so a dark seed still lifts off the ground. At a fixed dark tone
243
+ // every mid-tone preset read dull on black, and the ones that looked right were
244
+ // exactly those whose seed tone escaped it.
245
+ const brandPalette = isDark
246
+ ? TonalPalette.fromInt(argbFromHex(seedHex))
247
+ : TonalPalette.fromHueAndChroma(seed.hue, 200);
248
+ const primary = fillPair(
249
+ brandPalette,
250
+ isDark ? Math.max(seed.tone, DARK_FLOOR) : LIGHT_FILL_TONE,
251
+ );
252
+
253
+ const tokens: PolicyTokens = {
254
+ '--primary': primary.fill,
255
+ '--primary-foreground': primary.foreground,
256
+ // The accent legible as TEXT on the page surface — the job M3's `primary`
257
+ // does correctly, and which the fill must stop trying to do at the same time.
258
+ '--primary-text': roles.primary,
259
+ '--primary-subtle': rgba(
260
+ TonalPalette.fromHueAndChroma(seed.hue, 200).tone(SUBTLE_SOURCE_TONE),
261
+ isDark ? SUBTLE_ALPHA.dark : SUBTLE_ALPHA.light,
262
+ ),
263
+ '--ring': roles.primary,
264
+ // `--card` is the one surface MAPPING the policy changes.
265
+ // `surfaceContainerLowest` is M3's RECESSED step: in dark it sits at tone 4
266
+ // against a tone-6 background, so every card, sheet and chat bubble sinks
267
+ // into the page instead of lifting off it. Light is unaffected — lowest IS
268
+ // the lightest there — so only dark moves.
269
+ '--card': isDark ? roles.surfaceContainer : roles.surfaceContainerLowest,
270
+ // A touch darker than M3's page background: near-white reads as unfinished
271
+ // next to the card, and the extra step gives the surface ramp somewhere to sit.
272
+ '--background': rgb(TonalPalette.fromHueAndChroma(seed.hue, 10).tone(isDark ? 4 : 96)),
273
+ // `--accent` stays what every consumer actually uses it for: a hover surface.
274
+ '--accent': roles.surfaceContainerHigh,
275
+ '--accent-foreground': roles.onSurfaceVariant,
276
+ };
277
+
278
+ const hues = accentHues(seed.hue);
279
+ for (const role of ['secondary', 'tertiary'] as const) {
280
+ const pin = pinned[role];
281
+ const hue = pin !== undefined ? Hct.fromInt(argbFromHex(pin)).hue : hues[role];
282
+ // One hue, three palettes. Every member shares it, so the family still reads
283
+ // as one colour; only how loudly each speaks differs.
284
+ const fillPalette = TonalPalette.fromHueAndChroma(hue, ACCENT_CHROMA);
285
+ const textPalette = TonalPalette.fromHueAndChroma(hue, TEXT_CHROMA);
286
+ const vividPalette = TonalPalette.fromHueAndChroma(hue, 200);
287
+ // DARK sits at the hue's peak tone, floored by the same rule the brand fill
288
+ // uses: some hues peak dark (violet at 35), and rendering them there gives a
289
+ // slab rather than a highlight. LIGHT goes deep like every other fill, but
290
+ // that is exactly the tone where a yellow-green turns to bile, so the
291
+ // analyzer runs there — Bloom ships it yet only reaches it on the fidelity
292
+ // path, which no Bloom variant uses, so a rotated accent could land in the
293
+ // band with nothing to lift it out.
294
+ const tone = isDark
295
+ ? Math.max(peakTone(hue), DARK_FLOOR)
296
+ : fixIfDisliked(Hct.fromInt(fillPalette.tone(LIGHT_FILL_TONE))).tone;
297
+ const pair = fillPair(fillPalette, tone);
298
+ tokens[`--${role}`] = pair.fill;
299
+ tokens[`--${role}-foreground`] = pair.foreground;
300
+ tokens[`--${role}-text`] = rgb(textPalette.tone(isDark ? TEXT_TONE.dark : TEXT_TONE.light));
301
+ tokens[`--${role}-subtle`] = rgba(
302
+ vividPalette.tone(SUBTLE_SOURCE_TONE),
303
+ isDark ? SUBTLE_ALPHA.dark : SUBTLE_ALPHA.light,
304
+ );
305
+ }
306
+
307
+ // Categorical chart ramp: five hues spread evenly from the seed at one tone.
308
+ // The old ramp drew all five from the primary/secondary/tertiary trio, which
309
+ // spans ~35 degrees, so adjacent series were indistinguishable.
310
+ for (let i = 0; i < 5; i += 1) {
311
+ tokens[`--chart-${i + 1}`] = rgb(
312
+ TonalPalette.fromHueAndChroma((seed.hue + i * 72) % 360, 60).tone(isDark ? 72 : 48),
313
+ );
314
+ }
315
+
316
+ // The status family, themed per mode. The four frozen hexes it replaces all
317
+ // FAIL AA under white text — warning at 2.15, success 2.54, info 3.68, error
318
+ // 3.76 — and had no subtle or text member, so every status pill in the
319
+ // ecosystem was either illegible or hand-rolled.
320
+ for (const [role, hex] of Object.entries(STATUS_SEEDS)) {
321
+ const status = Hct.fromInt(argbFromHex(hex));
322
+ const palette = TonalPalette.fromHueAndChroma(status.hue, status.chroma);
323
+ const pair = fillPair(palette, isDark ? Math.max(status.tone, DARK_FLOOR) : LIGHT_FILL_TONE);
324
+ tokens[`--${role}`] = pair.fill;
325
+ tokens[`--${role}-foreground`] = pair.foreground;
326
+ tokens[`--${role}-text`] = rgb(
327
+ TonalPalette.fromHueAndChroma(status.hue, TEXT_CHROMA).tone(
328
+ isDark ? TEXT_TONE.dark : TEXT_TONE.light,
329
+ ),
330
+ );
331
+ // Mixed from the seed's OWN tone and chroma — for error that reproduces
332
+ // #EF4444 exactly, the bright red consumers were hand-tinting with
333
+ // `theme.colors.error + '15'`, which only worked because that token was a
334
+ // flat hex and produced an opaque slab on any `rgb(...)` one.
335
+ tokens[`--${role}-subtle`] = rgba(
336
+ palette.tone(status.tone),
337
+ isDark ? SUBTLE_ALPHA.dark : SUBTLE_ALPHA.light,
338
+ );
339
+ }
340
+
341
+ return tokens;
342
+ }
@@ -1,4 +1,5 @@
1
1
  import { generateRoleColors, type RoleColors, type SchemeVariant } from '../color-engine';
2
+ import { buildPolicyTokens } from '../color-policy';
2
3
  import { CANONICAL_TOKENS } from '../token-registry';
3
4
 
4
5
  /**
@@ -109,9 +110,20 @@ export function buildSeedScopeVars(options: SeedScopeOptions): Record<string, st
109
110
  secondarySeed: options.secondarySeed,
110
111
  tertiarySeed: options.tertiarySeed,
111
112
  });
112
- const tokens = roleColorsToPresetTokens(roles);
113
+ const tokens: Record<string, string> = {
114
+ ...roleColorsToPresetTokens(roles),
115
+ ...buildPolicyTokens(options.seed, options.mode === 'dark', roles, {
116
+ secondary: options.secondarySeed,
117
+ tertiary: options.tertiarySeed,
118
+ }),
119
+ };
113
120
  const vars: Record<string, string> = { ...tokens };
114
121
 
122
+ // Policy tokens live outside CANONICAL_TOKENS, so alias every token present.
123
+ for (const [key, value] of Object.entries(tokens)) {
124
+ vars[`--color-${key.slice(2)}`] = value;
125
+ }
126
+
115
127
  for (const token of CANONICAL_TOKENS) {
116
128
  const value = tokens[`--${token}`];
117
129
  if (value !== undefined) {
@@ -141,6 +141,12 @@ export function buildScopeVars(
141
141
  const tokens = getResolvedTokens(colorPreset, mode, accents);
142
142
  const vars: Record<string, string> = { ...tokens };
143
143
 
144
+ // Policy tokens live outside CANONICAL_TOKENS, so give every token present a
145
+ // `--color-*` alias; NativeWind resolves its utilities through those.
146
+ for (const [key, value] of Object.entries(tokens)) {
147
+ vars[`--color-${key.slice(2)}`] = value;
148
+ }
149
+
144
150
  for (const token of CANONICAL_TOKENS) {
145
151
  const value = tokens[`--${token}`];
146
152
  if (value !== undefined) {
@@ -1,5 +1,6 @@
1
1
  import { APP_COLOR_PRESETS, type AppColorName, type PresetTokens } from './color-presets';
2
2
  import { generateRoleColors, type RoleColors } from './color-engine';
3
+ import { buildPolicyTokens } from './color-policy';
3
4
 
4
5
  /**
5
6
  * Optional explicit accent-colour overrides (`#rrggbb`) that PIN the secondary
@@ -53,7 +54,7 @@ export function getPresetVars(
53
54
  tertiarySeed: accents?.tertiaryHex ?? preset.tertiaryHex,
54
55
  });
55
56
 
56
- return {
57
+ const base: PresetTokens = {
57
58
  // --- base shadcn palette ---
58
59
  '--background': r.background,
59
60
  '--foreground': r.onBackground,
@@ -104,6 +105,14 @@ export function getPresetVars(
104
105
  '--sidebar-border': r.outlineVariant,
105
106
  '--sidebar-ring': r.primary,
106
107
  };
108
+
109
+ return {
110
+ ...base,
111
+ ...buildPolicyTokens(preset.hex, mode === 'dark', r, {
112
+ secondary: accents?.secondaryHex ?? preset.secondaryHex,
113
+ tertiary: accents?.tertiaryHex ?? preset.tertiaryHex,
114
+ }),
115
+ };
107
116
  }
108
117
 
109
118
  /**
@@ -51,6 +51,38 @@ export const CANONICAL_TOKENS = [
51
51
  'sidebar-accent-foreground',
52
52
  'sidebar-border',
53
53
  'sidebar-ring',
54
+
55
+ // --- the accent families ---
56
+ // Each accent is a FAMILY, not a colour: a fill, the label that is legible on
57
+ // it, a `-text` form for a mark on the page, and a `-subtle` translucent tint
58
+ // for the surface behind one. A single value can only ever be a fill, which is
59
+ // why consumers hand-rolled the rest with hex-alpha arithmetic.
60
+ 'primary-text',
61
+ 'primary-subtle',
62
+ 'secondary-text',
63
+ 'secondary-subtle',
64
+ 'tertiary-text',
65
+ 'tertiary-subtle',
66
+
67
+ // --- semantic status, themed per mode ---
68
+ // Replaces four frozen hexes that all FAILED AA under white text (warning at
69
+ // 2.15, success 2.54, info 3.68, error 3.76) and had no subtle or text member.
70
+ 'success',
71
+ 'success-foreground',
72
+ 'success-text',
73
+ 'success-subtle',
74
+ 'error',
75
+ 'error-foreground',
76
+ 'error-text',
77
+ 'error-subtle',
78
+ 'warning',
79
+ 'warning-foreground',
80
+ 'warning-text',
81
+ 'warning-subtle',
82
+ 'info',
83
+ 'info-foreground',
84
+ 'info-text',
85
+ 'info-subtle',
54
86
  ] as const;
55
87
 
56
88
  /** A canonical token name (no leading `--`). */