@oxyhq/bloom 0.76.1 → 0.78.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.
@@ -97,6 +97,12 @@ const LIGHT_FILL_TONE = 45;
97
97
  /** The tone a white-label fill sits at in DARK: the ceiling white text allows. */
98
98
  const DARK_FILL_TONE = 49;
99
99
 
100
+ /** How far a fill may be dragged from its natural tone before the colour is lost. */
101
+ const TONE_BUDGET = 25;
102
+
103
+ /** No dark-mode BRAND fill sits below this: under it a colour goes heavy on black. */
104
+ const DARK_SEED_FLOOR = 58;
105
+
100
106
  /**
101
107
  * No dark-mode ACCENT sits below this. Some hues peak dark — violet at tone 33 —
102
108
  * and rendering one there gives a slab rather than a highlight.
@@ -208,11 +214,31 @@ function vividHueNear(hue: number): number {
208
214
  * why 11 of the 13 presets carry white and exactly the two light ones do not.
209
215
  */
210
216
  function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean): number {
211
- const target = isDark ? DARK_FILL_TONE : LIGHT_FILL_TONE;
212
- const candidate = Math.max(target, seedTone - 25);
217
+ // DARK keeps the brand's OWN tone, floored so a dark seed still lifts off the
218
+ // ground. On a near-black page the fill IS the brand — oxy at tone 49 is a
219
+ // different, electric purple, not #c46ede — so fidelity wins and the label
220
+ // follows. LIGHT is the opposite: a bright fill on a white page reads thin, so
221
+ // it comes down to the ceiling white allows unless that costs more than the
222
+ // budget, which is what keeps yellow yellow and faircoin lime.
223
+ if (isDark) return Math.max(seedTone, DARK_SEED_FLOOR);
224
+ const candidate = Math.max(LIGHT_FILL_TONE, seedTone - TONE_BUDGET);
213
225
  return contrastOf(palette.tone(candidate), true) >= AA ? candidate : seedTone;
214
226
  }
215
227
 
228
+ /**
229
+ * The same budget governs the ACCENT: come down to where white fits, unless the
230
+ * descent would cost more than this and destroy the hue. An accent that peaks
231
+ * bright — a lime at tone 88 — keeps its peak and takes a black label; one that
232
+ * peaks dark comes down and carries white.
233
+ */
234
+ function accentTone(hue: number, isDark: boolean): number {
235
+ if (!isDark) return LIGHT_FILL_TONE;
236
+ const peak = Math.max(peakTone(hue), DARK_ACCENT_FLOOR);
237
+ const palette = TonalPalette.fromHueAndChroma(hue, ACCENT_CHROMA);
238
+ const candidate = Math.max(DARK_FILL_TONE, peak - TONE_BUDGET);
239
+ return contrastOf(palette.tone(candidate), true) >= AA ? candidate : peak;
240
+ }
241
+
216
242
  /** Settle a fill tone so some foreground is legible, then pair it with that foreground. */
217
243
  function fillPair(palette: TonalPalette, startTone: number): { fill: string; foreground: string } {
218
244
  let tone = startTone;
@@ -276,7 +302,11 @@ export function buildPolicyTokens(
276
302
  // tone <= 49, so that is the ceiling — in dark as much as in light. Letting the
277
303
  // dark fill sit at the seed's own tone instead makes it brighter, but every one
278
304
  // of those labels turns black, which costs more than the brightness buys.
279
- const brandPalette = TonalPalette.fromHueAndChroma(seed.hue, 200);
305
+ // DARK keys off the seed itself so the fill IS the brand hex; LIGHT uses the
306
+ // max-chroma palette, which is what stops a deepened fill from going pastel.
307
+ const brandPalette = isDark
308
+ ? TonalPalette.fromInt(argbFromHex(seedHex))
309
+ : TonalPalette.fromHueAndChroma(seed.hue, 200);
280
310
  const primary = fillPair(brandPalette, whiteLabelTone(brandPalette, seed.tone, isDark));
281
311
 
282
312
  const tokens: PolicyTokens = {
@@ -328,7 +358,7 @@ export function buildPolicyTokens(
328
358
  // black. The accent chroma cap already keeps these hues out of neon territory,
329
359
  // and the hue itself is the caller's brand rather than ours to move. The
330
360
  // analyzer still runs where it was designed to, inside `color-roles`.
331
- const tone = isDark ? Math.max(peakTone(hue), DARK_ACCENT_FLOOR) : LIGHT_FILL_TONE;
361
+ const tone = accentTone(hue, isDark);
332
362
  const pair = fillPair(fillPalette, tone);
333
363
  tokens[`--${role}`] = pair.fill;
334
364
  tokens[`--${role}-foreground`] = pair.foreground;