@oxyhq/bloom 0.81.0 → 0.82.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.
@@ -20,7 +20,9 @@
20
20
  * it against the raw rgba would compare against a colour nobody ever sees.
21
21
  */
22
22
  import { getResolvedTokens } from '../token-registry';
23
- import { APP_COLOR_NAMES } from '../color-presets';
23
+ import { APP_COLOR_NAMES, APP_COLOR_PRESETS } from '../color-presets';
24
+ import { Hct } from '../color-engine/hct';
25
+ import { argbFromHex } from '../color-engine';
24
26
 
25
27
  const AA = 4.5;
26
28
 
@@ -117,28 +119,60 @@ describe('colour policy legibility', () => {
117
119
  });
118
120
 
119
121
 
122
+ // The regression this exists for has landed twice, both times reported by the
123
+ // user rather than by the suite: a preset rendering the IDENTICAL brand fill in
124
+ // both modes, which is not a theme, just one palette shown twice. It is easy to
125
+ // reintroduce because every individual token stays legible and every other
126
+ // assertion here keeps passing — nothing in a per-mode check can see that the
127
+ // two modes agree.
128
+ //
129
+ // Two distinct mechanisms produced it, which is why the gate is on the OUTPUT
130
+ // rather than on either cause: a tone search that degenerated to its own floor
131
+ // for any seed whose chroma is flat across the search range (pink, purple), and
132
+ // a light floor sharing that same bound, which voided the depth step for a seed
133
+ // whose dark fill already sat on it (pink again, for the opposite reason).
134
+ it('every preset renders a different brand fill in each mode', () => {
135
+ const identical = APP_COLOR_NAMES.filter(
136
+ (preset) =>
137
+ getResolvedTokens(preset, 'light')['--primary'] ===
138
+ getResolvedTokens(preset, 'dark')['--primary'],
139
+ );
140
+ expect(identical).toEqual([]);
141
+ });
142
+
120
143
  // The two modes want opposite things and the suite has to say which. Applying
121
144
  // the budget in LIGHT let a light seed keep its own tone there, so faircoin
122
145
  // rendered the same pale lime in both modes — no theme at all. Skipping it in
123
146
  // DARK left every Follow button, avatar and chat bubble with a black label.
124
147
  // Each half was individually legible, so nothing else could catch either.
125
148
  it('the brand fill keeps light exemption-free and dark budgeted', () => {
126
- const white = { light: 0, dark: 0 };
127
149
  // `mono` is not governed by the budget — it has no chroma to preserve, and its
128
150
  // dark fill is near-WHITE by design, so it takes a black label on purpose.
129
- // Counting it here would let a real regression hide inside its slack.
130
151
  const chromatic = APP_COLOR_NAMES.filter((name) => name !== 'mono');
152
+ const black: number[] = [];
153
+ const white: number[] = [];
131
154
  for (const preset of chromatic) {
132
- for (const mode of ['light', 'dark'] as const) {
133
- if (getResolvedTokens(preset, mode)['--primary-foreground'] === 'rgb(255 255 255)') {
134
- white[mode] += 1;
135
- }
136
- }
155
+ // Light admits no exemption: every chromatic preset comes down far enough
156
+ // to carry white, which is what makes faircoin a deep green there and a
157
+ // bright lime in dark rather than the same pale smear twice.
158
+ expect(getResolvedTokens(preset, 'light')['--primary-foreground']).toBe('rgb(255 255 255)');
159
+ const seedTone = Hct.fromInt(argbFromHex(APP_COLOR_PRESETS[preset].hex)).tone;
160
+ (getResolvedTokens(preset, 'dark')['--primary-foreground'] === 'rgb(255 255 255)'
161
+ ? white
162
+ : black
163
+ ).push(seedTone);
137
164
  }
138
- // Light admits no exemption, so every preset carries white there. Dark keeps
139
- // the budget, so the seeds that are already light keep their colour instead.
140
- expect(white.light).toBe(chromatic.length);
141
- expect(white.dark).toBeGreaterThan(chromatic.length - 4);
165
+
166
+ // Dark's split is decided by the SEED'S OWN LIGHTNESS and nothing else: a seed
167
+ // already too light to come down within the budget keeps its tone and takes a
168
+ // black label. So the partition must be ordered — every black-label seed
169
+ // lighter than every white-label one. Counting them instead (an "at most N
170
+ // take black" slack) says nothing about WHICH, passes while the rule inverts,
171
+ // and has to be re-tuned by hand every time a preset is added.
172
+ expect(black.length).toBeGreaterThan(0);
173
+ expect(white.length).toBeGreaterThan(0);
174
+ expect(Math.min(...black)).toBeGreaterThan(Math.max(...white));
175
+
142
176
  // And the monochrome exception itself, stated rather than tolerated: a fill at
143
177
  // each end of the scale, carrying the opposite label.
144
178
  expect(getResolvedTokens('mono', 'light')['--primary-foreground']).toBe('rgb(255 255 255)');
@@ -92,10 +92,31 @@ const noLegibleForeground = (argb: number): boolean =>
92
92
  contrastOf(argb, true) < AA && contrastOf(argb, false) < AA;
93
93
 
94
94
  /** The tone a white-label fill sits at in LIGHT mode. */
95
- const LIGHT_FILL_TONE = 45;
95
+ const FILL_TONE_FLOOR = 45;
96
96
 
97
97
  /** The tone a white-label fill sits at in DARK: the ceiling white text allows. */
98
- const DARK_FILL_TONE = 49;
98
+ const FILL_TONE_SEARCH_CEILING = 56;
99
+
100
+ /**
101
+ * How much DEEPER the same brand sits on a light page than on a dark one.
102
+ *
103
+ * Not decoration: a colour on a near-white page has nothing to read against, so
104
+ * the mode difference is what makes a theme a theme rather than one palette shown
105
+ * twice. Dropping it is a regression that looks like a simplification — light and
106
+ * dark collapse onto the same fill for every seed that is not already bright, and
107
+ * the two modes stop being distinguishable at all.
108
+ */
109
+ const LIGHT_DEPTH_STEP = 5;
110
+
111
+ /**
112
+ * How deep a light-mode fill may go. Deliberately BELOW the search's lower bound,
113
+ * which those two used to share — and sharing them silently voided the depth step
114
+ * for any seed whose dark fill already sits at that bound. A high-chroma seed is
115
+ * exactly that case: pink loses its white label above tone 45, so dark cannot rise
116
+ * to make room and light must descend instead, or the two modes render the
117
+ * identical fill.
118
+ */
119
+ const LIGHT_FILL_FLOOR = 40;
99
120
 
100
121
  /** How far a fill may be dragged from its natural tone before the colour is lost. */
101
122
  const TONE_BUDGET = 25;
@@ -225,6 +246,47 @@ function vividHueNear(hue: number): number {
225
246
  return bestChroma - baseChroma >= VIVID_SNAP_MIN_GAIN ? bestHue : hue;
226
247
  }
227
248
 
249
+ /**
250
+ * The most vivid tone a palette reaches while a WHITE label still clears AA on
251
+ * it — searched per HUE instead of assumed.
252
+ *
253
+ * A single tone for every hue leaves chroma on the table for some and takes it
254
+ * from others, because the sRGB gamut is not a cylinder: from tone 45 upward an
255
+ * orange keeps gaining chroma until white runs out at ~49.5 (59 -> 84), while a
256
+ * violet has already passed its peak and LOSES chroma over the same interval
257
+ * (91 -> 86). The old flat 45 spent none of the contrast headroom it had — every
258
+ * preset sat at 5.38 when 4.5 was the requirement — and orange paid for that
259
+ * twice, since its hue is also one of the gamut's narrow ones.
260
+ *
261
+ * The floor is what keeps this from trading lightness for chroma: a hue that
262
+ * peaks far below 45 stays at 45 rather than descending into a near-black slab
263
+ * that happens to be saturated. So the search can only ever improve a fill or
264
+ * leave it exactly where it was.
265
+ *
266
+ * Contrast is measured on the QUANTIZED colour, not the engine's continuous
267
+ * tone-ratio: an earlier iteration chose tones by the latter and shipped 135
268
+ * pairs at a measured 4.49.
269
+ */
270
+ function vividLegibleTone(palette: TonalPalette): number {
271
+ let bestTone = FILL_TONE_FLOOR;
272
+ let bestChroma = -1;
273
+ for (let tone = FILL_TONE_FLOOR; tone <= FILL_TONE_SEARCH_CEILING; tone += 0.5) {
274
+ const argb = palette.tone(tone);
275
+ if (contrastOf(argb, true) < AA) continue;
276
+ const chroma = Hct.fromInt(argb).chroma;
277
+ // A tie keeps the DEEPEST tone. Ties are common rather than exotic — any seed
278
+ // whose chroma sits below the gamut ceiling across this whole range has a flat
279
+ // curve here — and breaking them toward the lightest end instead changes no
280
+ // preset's output, because the light-mode floor below already supplies the
281
+ // separation that would have bought. Mutation-checked, not assumed.
282
+ if (chroma > bestChroma) {
283
+ bestChroma = chroma;
284
+ bestTone = tone;
285
+ }
286
+ }
287
+ return bestTone;
288
+ }
289
+
228
290
  /**
229
291
  * The tone a BRAND fill sits at.
230
292
  *
@@ -243,11 +305,11 @@ function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean
243
305
  // smear there, indistinguishable from the same colour in dark, which is not a
244
306
  // theme at all. Coming down is what makes faircoin a deep green in light and a
245
307
  // bright lime in dark.
246
- if (!isDark) return LIGHT_FILL_TONE;
308
+ if (!isDark) return Math.max(LIGHT_FILL_FLOOR, vividLegibleTone(palette) - LIGHT_DEPTH_STEP);
247
309
  // DARK: the budget applies. Most brands come down to where white fits, but a
248
310
  // seed that is already light cannot without ceasing to be itself, so it keeps
249
311
  // its own tone and takes a black label. That is the eleven-and-two pattern.
250
- const candidate = Math.max(DARK_FILL_TONE, seedTone - TONE_BUDGET);
312
+ const candidate = Math.max(vividLegibleTone(palette), seedTone - TONE_BUDGET);
251
313
  return contrastOf(palette.tone(candidate), true) >= AA
252
314
  ? candidate
253
315
  : Math.max(seedTone, DARK_SEED_FLOOR);
@@ -260,10 +322,10 @@ function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean
260
322
  * peaks dark comes down and carries white.
261
323
  */
262
324
  function accentTone(hue: number, isDark: boolean): number {
263
- if (!isDark) return LIGHT_FILL_TONE;
264
- const peak = Math.max(peakTone(hue), DARK_ACCENT_FLOOR);
265
325
  const palette = TonalPalette.fromHueAndChroma(hue, ACCENT_CHROMA);
266
- const candidate = Math.max(DARK_FILL_TONE, peak - TONE_BUDGET);
326
+ if (!isDark) return Math.max(LIGHT_FILL_FLOOR, vividLegibleTone(palette) - LIGHT_DEPTH_STEP);
327
+ const peak = Math.max(peakTone(hue), DARK_ACCENT_FLOOR);
328
+ const candidate = Math.max(vividLegibleTone(palette), peak - TONE_BUDGET);
267
329
  return contrastOf(palette.tone(candidate), true) >= AA ? candidate : peak;
268
330
  }
269
331
 
@@ -331,17 +393,19 @@ export function buildPolicyTokens(
331
393
  // dark fill sit at the seed's own tone instead makes it brighter, but every one
332
394
  // of those labels turns black, which costs more than the brightness buys.
333
395
  const monochrome = seed.chroma <= MONOCHROME_MAX_CHROMA;
334
- // DARK keys off the seed itself so the fill IS the brand hex; LIGHT uses the
335
- // max-chroma palette, which is what stops a deepened fill from going pastel.
396
+ // BOTH modes key off the seed itself, so the fill carries the brand's SATURATION
397
+ // as well as its hue. Light used to force the hue to maximum chroma, which reads
398
+ // as "the seed only chose a hue" — and that silently renames a colour: a muted
399
+ // blue-grey seed resolved to a vivid cyan, and a brown seed to an orange, since
400
+ // both differ from a saturated neighbour by chroma alone. A deliberately soft
401
+ // brand now stays soft instead of being argued with.
336
402
  // A monochrome seed has no chroma to preserve either way, and its fill goes to
337
403
  // the far end of the scale rather than the white-label ceiling: a mid-grey
338
404
  // button reads as disabled, where near-black on white reads as the primary
339
405
  // action.
340
406
  const brandPalette = monochrome
341
407
  ? TonalPalette.fromHueAndChroma(0, 0)
342
- : isDark
343
- ? TonalPalette.fromInt(argbFromHex(seedHex))
344
- : TonalPalette.fromHueAndChroma(seed.hue, 200);
408
+ : TonalPalette.fromInt(argbFromHex(seedHex));
345
409
  const primary = fillPair(
346
410
  brandPalette,
347
411
  monochrome
@@ -443,7 +507,12 @@ export function buildPolicyTokens(
443
507
  for (const [role, hex] of Object.entries(STATUS_SEEDS)) {
444
508
  const status = Hct.fromInt(argbFromHex(hex));
445
509
  const palette = TonalPalette.fromHueAndChroma(status.hue, status.chroma);
446
- const pair = fillPair(palette, isDark ? DARK_FILL_TONE : LIGHT_FILL_TONE);
510
+ const pair = fillPair(
511
+ palette,
512
+ isDark
513
+ ? vividLegibleTone(palette)
514
+ : Math.max(LIGHT_FILL_FLOOR, vividLegibleTone(palette) - LIGHT_DEPTH_STEP),
515
+ );
447
516
  tokens[`--${role}`] = pair.fill;
448
517
  tokens[`--${role}-foreground`] = pair.foreground;
449
518
  tokens[`--${role}-text`] = rgb(
@@ -4,7 +4,6 @@ export type AppColorName =
4
4
  | 'teal'
5
5
  | 'blue'
6
6
  | 'green'
7
- | 'amber'
8
7
  | 'yellow'
9
8
  | 'red'
10
9
  | 'purple'
@@ -14,6 +13,11 @@ export type AppColorName =
14
13
  | 'mint'
15
14
  | 'oxy'
16
15
  | 'faircoin'
16
+ | 'pumpkin'
17
+ | 'gray'
18
+ | 'brown'
19
+ | 'peach'
20
+ | 'rose'
17
21
  | 'mono';
18
22
 
19
23
  /**
@@ -57,25 +61,29 @@ export interface AppColorPreset {
57
61
  */
58
62
  export type PresetTokens = Record<string, string>;
59
63
 
60
- export const APP_COLOR_NAMES: readonly AppColorName[] = ['teal', 'blue', 'green', 'amber', 'yellow', 'red', 'purple', 'pink', 'sky', 'orange', 'mint', 'oxy', 'faircoin', 'mono'];
64
+ export const APP_COLOR_NAMES: readonly AppColorName[] = ['teal', 'blue', 'green', 'yellow', 'red', 'purple', 'pink', 'sky', 'orange', 'mint', 'oxy', 'faircoin', 'pumpkin', 'gray', 'brown', 'peach', 'rose', 'mono'];
61
65
 
62
66
  /** Premium-exclusive presets, hidden from the standard color picker. */
63
67
  export const PREMIUM_COLOR_NAMES: readonly AppColorName[] = ['oxy', 'faircoin'];
64
68
 
65
69
  export const HEX_TO_APP_COLOR: Record<string, AppColorName> = {
66
70
  '#005c67': 'teal',
67
- '#1d9bf0': 'blue',
71
+ '#0085fe': 'blue',
68
72
  '#10b981': 'green',
69
- '#f59e0b': 'amber',
70
- '#ffc300': 'yellow',
73
+ '#fcdc00': 'yellow',
71
74
  '#ef4444': 'red',
72
- '#8b5cf6': 'purple',
73
- '#ec4899': 'pink',
74
- '#0ea5e9': 'sky',
75
- '#f97316': 'orange',
75
+ '#b866ff': 'purple',
76
+ '#ff3c7f': 'pink',
77
+ '#03a9f4': 'sky',
78
+ '#ff5722': 'orange',
76
79
  '#14b8a6': 'mint',
77
80
  '#c46ede': 'oxy',
78
81
  '#9ffb50': 'faircoin',
82
+ '#ff9800': 'pumpkin',
83
+ '#607d8b': 'gray',
84
+ '#813519': 'brown',
85
+ '#ffb28d': 'peach',
86
+ '#fcaffe': 'rose',
79
87
  '#000000': 'mono',
80
88
  };
81
89
 
@@ -91,18 +99,28 @@ export function hexToAppColorName(hex: string): AppColorName {
91
99
  */
92
100
  export const APP_COLOR_PRESETS: Record<AppColorName, AppColorPreset> = {
93
101
  teal: { name: 'teal', hex: '#005c67', variant: 'vivid' },
94
- blue: { name: 'blue', hex: '#1d9bf0', variant: 'vivid' },
102
+ blue: { name: 'blue', hex: '#0085fe', variant: 'vivid' },
95
103
  green: { name: 'green', hex: '#10b981', variant: 'vivid' },
96
- amber: { name: 'amber', hex: '#f59e0b', variant: 'vivid' },
97
- yellow: { name: 'yellow', hex: '#ffc300', variant: 'vivid' },
104
+ yellow: { name: 'yellow', hex: '#fcdc00', variant: 'vivid' },
98
105
  red: { name: 'red', hex: '#ef4444', variant: 'vivid' },
99
- purple: { name: 'purple', hex: '#8b5cf6', variant: 'vivid' },
100
- pink: { name: 'pink', hex: '#ec4899', variant: 'vivid' },
101
- sky: { name: 'sky', hex: '#0ea5e9', variant: 'vivid' },
102
- orange: { name: 'orange', hex: '#f97316', variant: 'vivid' },
106
+ purple: { name: 'purple', hex: '#b866ff', variant: 'vivid' },
107
+ pink: { name: 'pink', hex: '#ff3c7f', variant: 'vivid' },
108
+ sky: { name: 'sky', hex: '#03a9f4', variant: 'vivid' },
109
+ // Deliberately NOT Tailwind's orange-500 (`#f97316`). That seed sits at HCT hue
110
+ // 46, which reads as orange only because it is also light: at hue 46 the sRGB
111
+ // gamut allows a chroma of just 62 at the tones a white label needs (<= 49), so
112
+ // the brand fill resolved to `rgb(177 76 0)` — a brown. Hue 34 carries a chroma
113
+ // of 84 at that same tone, with the same white label and the same contrast
114
+ // headroom, so the fill reads as orange instead of paying for the hue twice.
115
+ orange: { name: 'orange', hex: '#ff5722', variant: 'vivid' },
103
116
  mint: { name: 'mint', hex: '#14b8a6', variant: 'vivid' },
104
117
  oxy: { name: 'oxy', hex: '#c46ede', variant: 'vivid' },
105
118
  faircoin: { name: 'faircoin', hex: '#9ffb50', variant: 'vivid' },
119
+ pumpkin: { name: 'pumpkin', hex: '#ff9800', variant: 'vivid' },
120
+ gray: { name: 'gray', hex: '#607d8b', variant: 'vivid' },
121
+ brown: { name: 'brown', hex: '#813519', variant: 'vivid' },
122
+ peach: { name: 'peach', hex: '#ffb28d', variant: 'vivid' },
123
+ rose: { name: 'rose', hex: '#fcaffe', variant: 'vivid' },
106
124
  /**
107
125
  * No colour at all — the black-and-white theme. Its seed carries zero chroma,
108
126
  * which is the whole mechanism: the policy derives a greyscale palette from any