@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.
- package/lib/commonjs/theme/color-policy.js +77 -11
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/commonjs/theme/color-presets.js +49 -19
- package/lib/commonjs/theme/color-presets.js.map +1 -1
- package/lib/module/theme/color-policy.js +77 -11
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/module/theme/color-presets.js +49 -19
- package/lib/module/theme/color-presets.js.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/BloomThemeProvider.test.tsx +1 -1
- package/src/__tests__/theme.test.ts +2 -2
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +1160 -680
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +712 -432
- package/src/theme/__tests__/policy-legibility.test.ts +46 -12
- package/src/theme/color-policy.ts +82 -13
- package/src/theme/color-presets.ts +34 -16
|
@@ -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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
335
|
-
//
|
|
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
|
-
:
|
|
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(
|
|
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', '
|
|
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
|
-
'#
|
|
71
|
+
'#0085fe': 'blue',
|
|
68
72
|
'#10b981': 'green',
|
|
69
|
-
'#
|
|
70
|
-
'#ffc300': 'yellow',
|
|
73
|
+
'#fcdc00': 'yellow',
|
|
71
74
|
'#ef4444': 'red',
|
|
72
|
-
'#
|
|
73
|
-
'#
|
|
74
|
-
'#
|
|
75
|
-
'#
|
|
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: '#
|
|
102
|
+
blue: { name: 'blue', hex: '#0085fe', variant: 'vivid' },
|
|
95
103
|
green: { name: 'green', hex: '#10b981', variant: 'vivid' },
|
|
96
|
-
|
|
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: '#
|
|
100
|
-
pink: { name: 'pink', hex: '#
|
|
101
|
-
sky: { name: 'sky', hex: '#
|
|
102
|
-
|
|
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
|