@oxyhq/bloom 0.75.0 → 0.76.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.
@@ -94,8 +94,14 @@ const noLegibleForeground = (argb: number): boolean =>
94
94
  /** The tone a white-label fill sits at in LIGHT mode. */
95
95
  const LIGHT_FILL_TONE = 45;
96
96
 
97
- /** No dark-mode BRAND fill sits below this: under it a colour goes heavy on black. */
98
- const DARK_FLOOR = 58;
97
+ /** The tone a white-label fill sits at in DARK: the ceiling white text allows. */
98
+ const DARK_FILL_TONE = 49;
99
+
100
+ /**
101
+ * No dark-mode ACCENT sits below this. Some hues peak dark — violet at tone 33 —
102
+ * and rendering one there gives a slab rather than a highlight.
103
+ */
104
+ const DARK_ACCENT_FLOOR = 58;
99
105
 
100
106
  /**
101
107
  * Accent fills sit at their hue's PEAK tone in dark — where the hue is most
@@ -190,6 +196,23 @@ function vividHueNear(hue: number): number {
190
196
  return bestChroma - baseChroma >= VIVID_SNAP_MIN_GAIN ? bestHue : hue;
191
197
  }
192
198
 
199
+ /**
200
+ * The tone a BRAND fill sits at.
201
+ *
202
+ * Normally the ceiling white text allows, so a Follow button, an avatar and the
203
+ * compose button all carry a white label. But a seed that is ALREADY light —
204
+ * yellow at tone 82, faircoin at 90 — cannot be dragged down there without
205
+ * ceasing to be itself: yellow turns to brown and lime to moss. Those keep their
206
+ * own tone and take a black label instead, which is what a bright yellow button
207
+ * wants anyway. The 25-tone budget is what separates the two cases, and it is
208
+ * why 11 of the 13 presets carry white and exactly the two light ones do not.
209
+ */
210
+ 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);
213
+ return contrastOf(palette.tone(candidate), true) >= AA ? candidate : seedTone;
214
+ }
215
+
193
216
  /** Settle a fill tone so some foreground is legible, then pair it with that foreground. */
194
217
  function fillPair(palette: TonalPalette, startTone: number): { fill: string; foreground: string } {
195
218
  let tone = startTone;
@@ -246,19 +269,15 @@ export function buildPolicyTokens(
246
269
  ): PolicyTokens {
247
270
  const seed = Hct.fromInt(argbFromHex(seedHex));
248
271
 
249
- // LIGHT takes the max-chroma palette at a deep tone: on a near-white page the
250
- // fill has to go deep to read calm, and max chroma is what keeps it from going
251
- // pastel there. DARK takes the SEED'S OWN palette its own chroma and its own
252
- // tone, floored so a dark seed still lifts off the ground. At a fixed dark tone
253
- // every mid-tone preset read dull on black, and the ones that looked right were
254
- // exactly those whose seed tone escaped it.
255
- const brandPalette = isDark
256
- ? TonalPalette.fromInt(argbFromHex(seedHex))
257
- : TonalPalette.fromHueAndChroma(seed.hue, 200);
258
- const primary = fillPair(
259
- brandPalette,
260
- isDark ? Math.max(seed.tone, DARK_FLOOR) : LIGHT_FILL_TONE,
261
- );
272
+ // Max chroma at a tone that carries a WHITE label, in both modes.
273
+ //
274
+ // The brand fill is what a compose button, a Follow button, an avatar and the
275
+ // user's own chat bubble paint with, and those carry white text. White needs
276
+ // tone <= 49, so that is the ceiling in dark as much as in light. Letting the
277
+ // dark fill sit at the seed's own tone instead makes it brighter, but every one
278
+ // of those labels turns black, which costs more than the brightness buys.
279
+ const brandPalette = TonalPalette.fromHueAndChroma(seed.hue, 200);
280
+ const primary = fillPair(brandPalette, whiteLabelTone(brandPalette, seed.tone, isDark));
262
281
 
263
282
  const tokens: PolicyTokens = {
264
283
  '--primary': primary.fill,
@@ -309,7 +328,7 @@ export function buildPolicyTokens(
309
328
  // black. The accent chroma cap already keeps these hues out of neon territory,
310
329
  // and the hue itself is the caller's brand rather than ours to move. The
311
330
  // analyzer still runs where it was designed to, inside `color-roles`.
312
- const tone = isDark ? Math.max(peakTone(hue), DARK_FLOOR) : LIGHT_FILL_TONE;
331
+ const tone = isDark ? Math.max(peakTone(hue), DARK_ACCENT_FLOOR) : LIGHT_FILL_TONE;
313
332
  const pair = fillPair(fillPalette, tone);
314
333
  tokens[`--${role}`] = pair.fill;
315
334
  tokens[`--${role}-foreground`] = pair.foreground;
@@ -336,7 +355,7 @@ export function buildPolicyTokens(
336
355
  for (const [role, hex] of Object.entries(STATUS_SEEDS)) {
337
356
  const status = Hct.fromInt(argbFromHex(hex));
338
357
  const palette = TonalPalette.fromHueAndChroma(status.hue, status.chroma);
339
- const pair = fillPair(palette, isDark ? Math.max(status.tone, DARK_FLOOR) : LIGHT_FILL_TONE);
358
+ const pair = fillPair(palette, isDark ? DARK_FILL_TONE : LIGHT_FILL_TONE);
340
359
  tokens[`--${role}`] = pair.fill;
341
360
  tokens[`--${role}-foreground`] = pair.foreground;
342
361
  tokens[`--${role}-text`] = rgb(