@oxyhq/bloom 0.80.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/tabs/Tabs.js +153 -15
- package/lib/commonjs/tabs/Tabs.js.map +1 -1
- package/lib/commonjs/theme/color-engine/index.js +6 -0
- package/lib/commonjs/theme/color-engine/index.js.map +1 -1
- package/lib/commonjs/theme/color-engine/scheme-variants.js +29 -1
- package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
- package/lib/commonjs/theme/color-policy.js +130 -20
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/commonjs/theme/color-presets.js +62 -20
- package/lib/commonjs/theme/color-presets.js.map +1 -1
- package/lib/commonjs/theme/color-scope/seed-scope.js +5 -1
- package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/module/tabs/Tabs.js +153 -15
- package/lib/module/tabs/Tabs.js.map +1 -1
- package/lib/module/theme/color-engine/index.js +1 -1
- package/lib/module/theme/color-engine/index.js.map +1 -1
- package/lib/module/theme/color-engine/scheme-variants.js +28 -1
- package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
- package/lib/module/theme/color-policy.js +129 -20
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/module/theme/color-presets.js +62 -20
- package/lib/module/theme/color-presets.js.map +1 -1
- package/lib/module/theme/color-scope/seed-scope.js +6 -2
- package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-engine/index.d.ts +1 -1
- package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +13 -1
- package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts +6 -0
- 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/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-engine/index.d.ts +1 -1
- package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +13 -1
- package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts +6 -0
- 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/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/BloomThemeProvider.test.tsx +1 -1
- package/src/__tests__/Tabs.test.tsx +286 -10
- package/src/__tests__/theme.test.ts +4 -3
- package/src/tabs/Tabs.tsx +172 -12
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +1273 -673
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +775 -425
- package/src/theme/__tests__/policy-legibility.test.ts +55 -13
- package/src/theme/color-engine/index.ts +1 -1
- package/src/theme/color-engine/scheme-variants.ts +38 -1
- package/src/theme/color-policy.ts +147 -24
- package/src/theme/color-presets.ts +44 -17
- package/src/theme/color-scope/seed-scope.ts +6 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every accent and status family is generated, not authored, so nothing else in
|
|
5
5
|
* the suite can notice when a tuning change makes a label unreadable. This walks
|
|
6
|
-
* the full matrix —
|
|
6
|
+
* the full matrix — every preset x 2 modes x 7 families — and asserts the property
|
|
7
7
|
* a user actually perceives.
|
|
8
8
|
*
|
|
9
9
|
* Two failure shapes it exists to catch, both of which have already happened:
|
|
@@ -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,23 +119,63 @@ 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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
// `mono` is not governed by the budget — it has no chroma to preserve, and its
|
|
150
|
+
// dark fill is near-WHITE by design, so it takes a black label on purpose.
|
|
151
|
+
const chromatic = APP_COLOR_NAMES.filter((name) => name !== 'mono');
|
|
152
|
+
const black: number[] = [];
|
|
153
|
+
const white: number[] = [];
|
|
154
|
+
for (const preset of chromatic) {
|
|
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);
|
|
133
164
|
}
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
|
|
176
|
+
// And the monochrome exception itself, stated rather than tolerated: a fill at
|
|
177
|
+
// each end of the scale, carrying the opposite label.
|
|
178
|
+
expect(getResolvedTokens('mono', 'light')['--primary-foreground']).toBe('rgb(255 255 255)');
|
|
179
|
+
expect(getResolvedTokens('mono', 'dark')['--primary-foreground']).toBe('rgb(0 0 0)');
|
|
138
180
|
});
|
|
139
181
|
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
export { Hct } from './hct';
|
|
11
11
|
export { TonalPalette } from './tonal-palette';
|
|
12
12
|
export { DynamicScheme } from './dynamic-scheme';
|
|
13
|
-
export { buildScheme, schemeVibrant, schemeExpressive, schemeTonalSpot, schemeNeutral } from './scheme-variants';
|
|
13
|
+
export { buildScheme, schemeVibrant, schemeExpressive, schemeTonalSpot, schemeNeutral, schemeMonochrome } from './scheme-variants';
|
|
14
14
|
export type { SchemeVariant, AccentSources } from './scheme-variants';
|
|
15
15
|
export { Roles } from './color-roles';
|
|
16
16
|
export type { RoleName } from './color-roles';
|
|
@@ -13,7 +13,13 @@ import { sanitizeDegreesDouble } from './math-utils';
|
|
|
13
13
|
import { TonalPalette } from './tonal-palette';
|
|
14
14
|
import { Variant } from './variant';
|
|
15
15
|
|
|
16
|
-
export type SchemeVariant =
|
|
16
|
+
export type SchemeVariant =
|
|
17
|
+
| 'vivid'
|
|
18
|
+
| 'vibrant'
|
|
19
|
+
| 'expressive'
|
|
20
|
+
| 'tonalSpot'
|
|
21
|
+
| 'neutral'
|
|
22
|
+
| 'monochrome';
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Optional explicit accent seeds for a scheme. When a source HCT is supplied, its
|
|
@@ -191,6 +197,36 @@ export function schemeNeutral(
|
|
|
191
197
|
});
|
|
192
198
|
}
|
|
193
199
|
|
|
200
|
+
/**
|
|
201
|
+
* No colour at all: every palette is greyscale, so the seed's hue is irrelevant
|
|
202
|
+
* and only its tone survives. This is the variant the `isMonochrome` role
|
|
203
|
+
* branches in `color-roles` were written for — they push `primary` to the far end
|
|
204
|
+
* of the scale (tone 0 on a light page, 100 on a dark one) rather than the
|
|
205
|
+
* mid-tone the chromatic curves would pick, which is what keeps a black-and-white
|
|
206
|
+
* theme from reading as a grey one.
|
|
207
|
+
*
|
|
208
|
+
* A pinned accent is ignored on purpose: honouring it would put one coloured
|
|
209
|
+
* family back into a scheme whose entire point is that nothing is coloured.
|
|
210
|
+
*/
|
|
211
|
+
export function schemeMonochrome(
|
|
212
|
+
source: Hct,
|
|
213
|
+
isDark: boolean,
|
|
214
|
+
contrastLevel: number,
|
|
215
|
+
): DynamicScheme {
|
|
216
|
+
const grey = (): TonalPalette => TonalPalette.fromHueAndChroma(source.hue, 0);
|
|
217
|
+
return new DynamicScheme({
|
|
218
|
+
sourceColorArgb: source.toInt(),
|
|
219
|
+
variant: Variant.MONOCHROME,
|
|
220
|
+
contrastLevel,
|
|
221
|
+
isDark,
|
|
222
|
+
primaryPalette: grey(),
|
|
223
|
+
secondaryPalette: grey(),
|
|
224
|
+
tertiaryPalette: grey(),
|
|
225
|
+
neutralPalette: grey(),
|
|
226
|
+
neutralVariantPalette: grey(),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
194
230
|
const BUILDERS: Record<
|
|
195
231
|
SchemeVariant,
|
|
196
232
|
(s: Hct, d: boolean, c: number, accents: AccentSources) => DynamicScheme
|
|
@@ -200,6 +236,7 @@ const BUILDERS: Record<
|
|
|
200
236
|
expressive: schemeExpressive,
|
|
201
237
|
tonalSpot: schemeTonalSpot,
|
|
202
238
|
neutral: schemeNeutral,
|
|
239
|
+
monochrome: schemeMonochrome,
|
|
203
240
|
};
|
|
204
241
|
|
|
205
242
|
/**
|
|
@@ -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;
|
|
@@ -131,6 +152,29 @@ const TEXT_CHROMA = 60;
|
|
|
131
152
|
const SUBTLE_SOURCE_TONE = 60;
|
|
132
153
|
const SUBTLE_ALPHA = { light: 0.13, dark: 0.24 } as const;
|
|
133
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A seed at or below this chroma has no colour to derive from, so the whole
|
|
157
|
+
* palette goes greyscale — the black-and-white theme. Keyed on the seed rather
|
|
158
|
+
* than on a flag, so picking any grey in a colour wheel lands there too instead
|
|
159
|
+
* of on a nearly-grey approximation of it.
|
|
160
|
+
*/
|
|
161
|
+
const MONOCHROME_MAX_CHROMA = 6;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether a seed carries no colour to derive a theme from — the one authority for
|
|
165
|
+
* that question, so the policy and the scheme choice can never disagree about a
|
|
166
|
+
* given seed and produce a grey palette wearing chromatic surfaces.
|
|
167
|
+
*/
|
|
168
|
+
export function isColourlessSeed(seedHex: string): boolean {
|
|
169
|
+
return Hct.fromInt(argbFromHex(seedHex)).chroma <= MONOCHROME_MAX_CHROMA;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Where a monochrome brand fill sits: near-black on a white page, near-white on a black one. */
|
|
173
|
+
const MONOCHROME_FILL_TONE = { light: 12, dark: 94 } as const;
|
|
174
|
+
|
|
175
|
+
/** The quiet member of a monochrome trio — the only one that is actually grey. */
|
|
176
|
+
const MONOCHROME_SECONDARY_TONE = { light: 36, dark: 72 } as const;
|
|
177
|
+
|
|
134
178
|
/** Split-complementary: the two accents sit either side of the seed's complement. */
|
|
135
179
|
const SPLIT_ROTATIONS = [150, 210] as const;
|
|
136
180
|
|
|
@@ -202,6 +246,47 @@ function vividHueNear(hue: number): number {
|
|
|
202
246
|
return bestChroma - baseChroma >= VIVID_SNAP_MIN_GAIN ? bestHue : hue;
|
|
203
247
|
}
|
|
204
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
|
+
|
|
205
290
|
/**
|
|
206
291
|
* The tone a BRAND fill sits at.
|
|
207
292
|
*
|
|
@@ -211,7 +296,8 @@ function vividHueNear(hue: number): number {
|
|
|
211
296
|
* ceasing to be itself: yellow turns to brown and lime to moss. Those keep their
|
|
212
297
|
* own tone and take a black label instead, which is what a bright yellow button
|
|
213
298
|
* wants anyway. The 25-tone budget is what separates the two cases, and it is
|
|
214
|
-
* why 11 of the 13 presets carry white and exactly the two light ones
|
|
299
|
+
* why 11 of the 13 chromatic presets carry white and exactly the two light ones
|
|
300
|
+
* do not. (`mono` sits outside this rule — see MONOCHROME_FILL_TONE.)
|
|
215
301
|
*/
|
|
216
302
|
function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean): number {
|
|
217
303
|
// LIGHT: every fill comes down, no exemption. On a near-white page a bright
|
|
@@ -219,11 +305,11 @@ function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean
|
|
|
219
305
|
// smear there, indistinguishable from the same colour in dark, which is not a
|
|
220
306
|
// theme at all. Coming down is what makes faircoin a deep green in light and a
|
|
221
307
|
// bright lime in dark.
|
|
222
|
-
if (!isDark) return
|
|
308
|
+
if (!isDark) return Math.max(LIGHT_FILL_FLOOR, vividLegibleTone(palette) - LIGHT_DEPTH_STEP);
|
|
223
309
|
// DARK: the budget applies. Most brands come down to where white fits, but a
|
|
224
310
|
// seed that is already light cannot without ceasing to be itself, so it keeps
|
|
225
311
|
// its own tone and takes a black label. That is the eleven-and-two pattern.
|
|
226
|
-
const candidate = Math.max(
|
|
312
|
+
const candidate = Math.max(vividLegibleTone(palette), seedTone - TONE_BUDGET);
|
|
227
313
|
return contrastOf(palette.tone(candidate), true) >= AA
|
|
228
314
|
? candidate
|
|
229
315
|
: Math.max(seedTone, DARK_SEED_FLOOR);
|
|
@@ -236,10 +322,10 @@ function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean
|
|
|
236
322
|
* peaks dark comes down and carries white.
|
|
237
323
|
*/
|
|
238
324
|
function accentTone(hue: number, isDark: boolean): number {
|
|
239
|
-
if (!isDark) return LIGHT_FILL_TONE;
|
|
240
|
-
const peak = Math.max(peakTone(hue), DARK_ACCENT_FLOOR);
|
|
241
325
|
const palette = TonalPalette.fromHueAndChroma(hue, ACCENT_CHROMA);
|
|
242
|
-
|
|
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);
|
|
243
329
|
return contrastOf(palette.tone(candidate), true) >= AA ? candidate : peak;
|
|
244
330
|
}
|
|
245
331
|
|
|
@@ -306,12 +392,26 @@ export function buildPolicyTokens(
|
|
|
306
392
|
// tone <= 49, so that is the ceiling — in dark as much as in light. Letting the
|
|
307
393
|
// dark fill sit at the seed's own tone instead makes it brighter, but every one
|
|
308
394
|
// of those labels turns black, which costs more than the brightness buys.
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
395
|
+
const monochrome = seed.chroma <= MONOCHROME_MAX_CHROMA;
|
|
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.
|
|
402
|
+
// A monochrome seed has no chroma to preserve either way, and its fill goes to
|
|
403
|
+
// the far end of the scale rather than the white-label ceiling: a mid-grey
|
|
404
|
+
// button reads as disabled, where near-black on white reads as the primary
|
|
405
|
+
// action.
|
|
406
|
+
const brandPalette = monochrome
|
|
407
|
+
? TonalPalette.fromHueAndChroma(0, 0)
|
|
408
|
+
: TonalPalette.fromInt(argbFromHex(seedHex));
|
|
409
|
+
const primary = fillPair(
|
|
410
|
+
brandPalette,
|
|
411
|
+
monochrome
|
|
412
|
+
? (isDark ? MONOCHROME_FILL_TONE.dark : MONOCHROME_FILL_TONE.light)
|
|
413
|
+
: whiteLabelTone(brandPalette, seed.tone, isDark),
|
|
414
|
+
);
|
|
315
415
|
|
|
316
416
|
const tokens: PolicyTokens = {
|
|
317
417
|
'--primary': primary.fill,
|
|
@@ -320,7 +420,7 @@ export function buildPolicyTokens(
|
|
|
320
420
|
// does correctly, and which the fill must stop trying to do at the same time.
|
|
321
421
|
'--primary-text': roles.primary,
|
|
322
422
|
'--primary-subtle': rgba(
|
|
323
|
-
TonalPalette.fromHueAndChroma(seed.hue, 200).tone(SUBTLE_SOURCE_TONE),
|
|
423
|
+
TonalPalette.fromHueAndChroma(seed.hue, monochrome ? 0 : 200).tone(SUBTLE_SOURCE_TONE),
|
|
324
424
|
isDark ? SUBTLE_ALPHA.dark : SUBTLE_ALPHA.light,
|
|
325
425
|
),
|
|
326
426
|
'--ring': roles.primary,
|
|
@@ -332,7 +432,9 @@ export function buildPolicyTokens(
|
|
|
332
432
|
'--card': isDark ? roles.surfaceContainer : roles.surfaceContainerLowest,
|
|
333
433
|
// A touch darker than M3's page background: near-white reads as unfinished
|
|
334
434
|
// next to the card, and the extra step gives the surface ramp somewhere to sit.
|
|
335
|
-
'--background': rgb(
|
|
435
|
+
'--background': rgb(
|
|
436
|
+
TonalPalette.fromHueAndChroma(seed.hue, monochrome ? 0 : 10).tone(isDark ? 4 : 96),
|
|
437
|
+
),
|
|
336
438
|
// `--accent` stays what every consumer actually uses it for: a hover surface.
|
|
337
439
|
'--accent': roles.surfaceContainerHigh,
|
|
338
440
|
'--accent-foreground': roles.onSurfaceVariant,
|
|
@@ -344,9 +446,11 @@ export function buildPolicyTokens(
|
|
|
344
446
|
const hue = pin !== undefined ? Hct.fromInt(argbFromHex(pin)).hue : hues[role];
|
|
345
447
|
// One hue, three palettes. Every member shares it, so the family still reads
|
|
346
448
|
// as one colour; only how loudly each speaks differs.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
449
|
+
// A monochrome theme has no accents to rotate to; the trio becomes a ladder
|
|
450
|
+
// of greys, which still separates a standout button from a quiet one.
|
|
451
|
+
const fillPalette = TonalPalette.fromHueAndChroma(hue, monochrome ? 0 : ACCENT_CHROMA);
|
|
452
|
+
const textPalette = TonalPalette.fromHueAndChroma(hue, monochrome ? 0 : TEXT_CHROMA);
|
|
453
|
+
const vividPalette = TonalPalette.fromHueAndChroma(hue, monochrome ? 0 : 200);
|
|
350
454
|
// DARK sits at the hue's peak tone, floored by the same rule the brand fill
|
|
351
455
|
// uses: some hues peak dark (violet at 35), and rendering them there gives a
|
|
352
456
|
// slab rather than a highlight. LIGHT goes deep like every other fill, but
|
|
@@ -362,7 +466,20 @@ export function buildPolicyTokens(
|
|
|
362
466
|
// black. The accent chroma cap already keeps these hues out of neon territory,
|
|
363
467
|
// and the hue itself is the caller's brand rather than ours to move. The
|
|
364
468
|
// analyzer still runs where it was designed to, inside `color-roles`.
|
|
365
|
-
|
|
469
|
+
// Monochrome keeps `tertiary` AT the brand fill rather than a step off it:
|
|
470
|
+
// tertiary is what a compose button and a FAB paint with, and those are the
|
|
471
|
+
// primary action — a step of grey between them and `primary` reads as two
|
|
472
|
+
// buttons disagreeing, not as hierarchy. `secondary` carries the whole
|
|
473
|
+
// demotion instead.
|
|
474
|
+
const tone = monochrome
|
|
475
|
+
? role === 'tertiary'
|
|
476
|
+
? isDark
|
|
477
|
+
? MONOCHROME_FILL_TONE.dark
|
|
478
|
+
: MONOCHROME_FILL_TONE.light
|
|
479
|
+
: isDark
|
|
480
|
+
? MONOCHROME_SECONDARY_TONE.dark
|
|
481
|
+
: MONOCHROME_SECONDARY_TONE.light
|
|
482
|
+
: accentTone(hue, isDark);
|
|
366
483
|
const pair = fillPair(fillPalette, tone);
|
|
367
484
|
tokens[`--${role}`] = pair.fill;
|
|
368
485
|
tokens[`--${role}-foreground`] = pair.foreground;
|
|
@@ -377,9 +494,10 @@ export function buildPolicyTokens(
|
|
|
377
494
|
// The old ramp drew all five from the primary/secondary/tertiary trio, which
|
|
378
495
|
// spans ~35 degrees, so adjacent series were indistinguishable.
|
|
379
496
|
for (let i = 0; i < 5; i += 1) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
497
|
+
// Greyscale charts spread by TONE instead of hue — the only axis left.
|
|
498
|
+
tokens[`--chart-${i + 1}`] = monochrome
|
|
499
|
+
? rgb(TonalPalette.fromHueAndChroma(0, 0).tone(isDark ? 40 + i * 13 : 78 - i * 13))
|
|
500
|
+
: rgb(TonalPalette.fromHueAndChroma((seed.hue + i * 72) % 360, 60).tone(isDark ? 72 : 48));
|
|
383
501
|
}
|
|
384
502
|
|
|
385
503
|
// The status family, themed per mode. The four frozen hexes it replaces all
|
|
@@ -389,7 +507,12 @@ export function buildPolicyTokens(
|
|
|
389
507
|
for (const [role, hex] of Object.entries(STATUS_SEEDS)) {
|
|
390
508
|
const status = Hct.fromInt(argbFromHex(hex));
|
|
391
509
|
const palette = TonalPalette.fromHueAndChroma(status.hue, status.chroma);
|
|
392
|
-
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
|
+
);
|
|
393
516
|
tokens[`--${role}`] = pair.fill;
|
|
394
517
|
tokens[`--${role}-foreground`] = pair.foreground;
|
|
395
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'
|
|
@@ -13,7 +12,13 @@ export type AppColorName =
|
|
|
13
12
|
| 'orange'
|
|
14
13
|
| 'mint'
|
|
15
14
|
| 'oxy'
|
|
16
|
-
| 'faircoin'
|
|
15
|
+
| 'faircoin'
|
|
16
|
+
| 'pumpkin'
|
|
17
|
+
| 'gray'
|
|
18
|
+
| 'brown'
|
|
19
|
+
| 'peach'
|
|
20
|
+
| 'rose'
|
|
21
|
+
| 'mono';
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* A single colour preset: a brand SEED colour plus the tonal scheme variant to
|
|
@@ -56,25 +61,30 @@ export interface AppColorPreset {
|
|
|
56
61
|
*/
|
|
57
62
|
export type PresetTokens = Record<string, string>;
|
|
58
63
|
|
|
59
|
-
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'];
|
|
60
65
|
|
|
61
66
|
/** Premium-exclusive presets, hidden from the standard color picker. */
|
|
62
67
|
export const PREMIUM_COLOR_NAMES: readonly AppColorName[] = ['oxy', 'faircoin'];
|
|
63
68
|
|
|
64
69
|
export const HEX_TO_APP_COLOR: Record<string, AppColorName> = {
|
|
65
70
|
'#005c67': 'teal',
|
|
66
|
-
'#
|
|
71
|
+
'#0085fe': 'blue',
|
|
67
72
|
'#10b981': 'green',
|
|
68
|
-
'#
|
|
69
|
-
'#ffc300': 'yellow',
|
|
73
|
+
'#fcdc00': 'yellow',
|
|
70
74
|
'#ef4444': 'red',
|
|
71
|
-
'#
|
|
72
|
-
'#
|
|
73
|
-
'#
|
|
74
|
-
'#
|
|
75
|
+
'#b866ff': 'purple',
|
|
76
|
+
'#ff3c7f': 'pink',
|
|
77
|
+
'#03a9f4': 'sky',
|
|
78
|
+
'#ff5722': 'orange',
|
|
75
79
|
'#14b8a6': 'mint',
|
|
76
80
|
'#c46ede': 'oxy',
|
|
77
81
|
'#9ffb50': 'faircoin',
|
|
82
|
+
'#ff9800': 'pumpkin',
|
|
83
|
+
'#607d8b': 'gray',
|
|
84
|
+
'#813519': 'brown',
|
|
85
|
+
'#ffb28d': 'peach',
|
|
86
|
+
'#fcaffe': 'rose',
|
|
87
|
+
'#000000': 'mono',
|
|
78
88
|
};
|
|
79
89
|
|
|
80
90
|
export function hexToAppColorName(hex: string): AppColorName {
|
|
@@ -89,16 +99,33 @@ export function hexToAppColorName(hex: string): AppColorName {
|
|
|
89
99
|
*/
|
|
90
100
|
export const APP_COLOR_PRESETS: Record<AppColorName, AppColorPreset> = {
|
|
91
101
|
teal: { name: 'teal', hex: '#005c67', variant: 'vivid' },
|
|
92
|
-
blue: { name: 'blue', hex: '#
|
|
102
|
+
blue: { name: 'blue', hex: '#0085fe', variant: 'vivid' },
|
|
93
103
|
green: { name: 'green', hex: '#10b981', variant: 'vivid' },
|
|
94
|
-
|
|
95
|
-
yellow: { name: 'yellow', hex: '#ffc300', variant: 'vivid' },
|
|
104
|
+
yellow: { name: 'yellow', hex: '#fcdc00', variant: 'vivid' },
|
|
96
105
|
red: { name: 'red', hex: '#ef4444', variant: 'vivid' },
|
|
97
|
-
purple: { name: 'purple', hex: '#
|
|
98
|
-
pink: { name: 'pink', hex: '#
|
|
99
|
-
sky: { name: 'sky', hex: '#
|
|
100
|
-
|
|
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' },
|
|
101
116
|
mint: { name: 'mint', hex: '#14b8a6', variant: 'vivid' },
|
|
102
117
|
oxy: { name: 'oxy', hex: '#c46ede', variant: 'vivid' },
|
|
103
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' },
|
|
124
|
+
/**
|
|
125
|
+
* No colour at all — the black-and-white theme. Its seed carries zero chroma,
|
|
126
|
+
* which is the whole mechanism: the policy derives a greyscale palette from any
|
|
127
|
+
* seed with none, so a user who picks a grey in the colour wheel lands here too
|
|
128
|
+
* rather than on a nearly-grey approximation.
|
|
129
|
+
*/
|
|
130
|
+
mono: { name: 'mono', hex: '#000000', variant: 'monochrome' },
|
|
104
131
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateRoleColors, type RoleColors, type SchemeVariant } from '../color-engine';
|
|
2
|
-
import { buildPolicyTokens } from '../color-policy';
|
|
2
|
+
import { buildPolicyTokens, isColourlessSeed } from '../color-policy';
|
|
3
3
|
import { CANONICAL_TOKENS } from '../token-registry';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -104,7 +104,11 @@ export interface SeedScopeOptions {
|
|
|
104
104
|
export function buildSeedScopeVars(options: SeedScopeOptions): Record<string, string> {
|
|
105
105
|
const roles = generateRoleColors({
|
|
106
106
|
seed: options.seed,
|
|
107
|
-
|
|
107
|
+
// A seed with no chroma has no colour for a chromatic variant to work with,
|
|
108
|
+
// so it takes the greyscale scheme unless the caller asked for something else.
|
|
109
|
+
// This is what makes a grey picked in a colour wheel produce the SAME
|
|
110
|
+
// black-and-white theme as the `mono` preset instead of a tinted near-grey.
|
|
111
|
+
variant: options.variant ?? (isColourlessSeed(options.seed) ? 'monochrome' : 'vivid'),
|
|
108
112
|
isDark: options.mode === 'dark',
|
|
109
113
|
contrastLevel: options.contrastLevel ?? 0,
|
|
110
114
|
secondarySeed: options.secondarySeed,
|