@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.
Files changed (57) hide show
  1. package/lib/commonjs/tabs/Tabs.js +153 -15
  2. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  3. package/lib/commonjs/theme/color-engine/index.js +6 -0
  4. package/lib/commonjs/theme/color-engine/index.js.map +1 -1
  5. package/lib/commonjs/theme/color-engine/scheme-variants.js +29 -1
  6. package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
  7. package/lib/commonjs/theme/color-policy.js +130 -20
  8. package/lib/commonjs/theme/color-policy.js.map +1 -1
  9. package/lib/commonjs/theme/color-presets.js +62 -20
  10. package/lib/commonjs/theme/color-presets.js.map +1 -1
  11. package/lib/commonjs/theme/color-scope/seed-scope.js +5 -1
  12. package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
  13. package/lib/module/tabs/Tabs.js +153 -15
  14. package/lib/module/tabs/Tabs.js.map +1 -1
  15. package/lib/module/theme/color-engine/index.js +1 -1
  16. package/lib/module/theme/color-engine/index.js.map +1 -1
  17. package/lib/module/theme/color-engine/scheme-variants.js +28 -1
  18. package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
  19. package/lib/module/theme/color-policy.js +129 -20
  20. package/lib/module/theme/color-policy.js.map +1 -1
  21. package/lib/module/theme/color-presets.js +62 -20
  22. package/lib/module/theme/color-presets.js.map +1 -1
  23. package/lib/module/theme/color-scope/seed-scope.js +6 -2
  24. package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
  25. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/theme/color-engine/index.d.ts +1 -1
  27. package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +13 -1
  29. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/theme/color-policy.d.ts +6 -0
  31. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/theme/color-presets.d.ts +1 -1
  33. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
  35. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  36. package/lib/typescript/module/theme/color-engine/index.d.ts +1 -1
  37. package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
  38. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +13 -1
  39. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
  40. package/lib/typescript/module/theme/color-policy.d.ts +6 -0
  41. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  42. package/lib/typescript/module/theme/color-presets.d.ts +1 -1
  43. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  44. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/__tests__/BloomThemeProvider.test.tsx +1 -1
  47. package/src/__tests__/Tabs.test.tsx +286 -10
  48. package/src/__tests__/theme.test.ts +4 -3
  49. package/src/tabs/Tabs.tsx +172 -12
  50. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +1273 -673
  51. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +775 -425
  52. package/src/theme/__tests__/policy-legibility.test.ts +55 -13
  53. package/src/theme/color-engine/index.ts +1 -1
  54. package/src/theme/color-engine/scheme-variants.ts +38 -1
  55. package/src/theme/color-policy.ts +147 -24
  56. package/src/theme/color-presets.ts +44 -17
  57. 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 — 13 presets x 2 modes x 7 families — and asserts the property
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
- const white = { light: 0, dark: 0 };
127
- for (const preset of APP_COLOR_NAMES) {
128
- for (const mode of ['light', 'dark'] as const) {
129
- if (getResolvedTokens(preset, mode)['--primary-foreground'] === 'rgb(255 255 255)') {
130
- white[mode] += 1;
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
- // Light admits no exemption, so every preset carries white there. Dark keeps
135
- // the budget, so the seeds that are already light keep their colour instead.
136
- expect(white.light).toBe(APP_COLOR_NAMES.length);
137
- expect(white.dark).toBeGreaterThan(APP_COLOR_NAMES.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
+
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 = 'vivid' | 'vibrant' | 'expressive' | 'tonalSpot' | 'neutral';
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 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;
@@ -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 do not.
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 LIGHT_FILL_TONE;
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(DARK_FILL_TONE, seedTone - TONE_BUDGET);
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
- 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);
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
- // DARK keys off the seed itself so the fill IS the brand hex; LIGHT uses the
310
- // max-chroma palette, which is what stops a deepened fill from going pastel.
311
- const brandPalette = isDark
312
- ? TonalPalette.fromInt(argbFromHex(seedHex))
313
- : TonalPalette.fromHueAndChroma(seed.hue, 200);
314
- const primary = fillPair(brandPalette, whiteLabelTone(brandPalette, seed.tone, isDark));
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(TonalPalette.fromHueAndChroma(seed.hue, 10).tone(isDark ? 4 : 96)),
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
- const fillPalette = TonalPalette.fromHueAndChroma(hue, ACCENT_CHROMA);
348
- const textPalette = TonalPalette.fromHueAndChroma(hue, TEXT_CHROMA);
349
- const vividPalette = TonalPalette.fromHueAndChroma(hue, 200);
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
- const tone = accentTone(hue, isDark);
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
- tokens[`--chart-${i + 1}`] = rgb(
381
- TonalPalette.fromHueAndChroma((seed.hue + i * 72) % 360, 60).tone(isDark ? 72 : 48),
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(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
+ );
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', 'amber', 'yellow', 'red', 'purple', 'pink', 'sky', 'orange', 'mint', 'oxy', 'faircoin'];
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
- '#1d9bf0': 'blue',
71
+ '#0085fe': 'blue',
67
72
  '#10b981': 'green',
68
- '#f59e0b': 'amber',
69
- '#ffc300': 'yellow',
73
+ '#fcdc00': 'yellow',
70
74
  '#ef4444': 'red',
71
- '#8b5cf6': 'purple',
72
- '#ec4899': 'pink',
73
- '#0ea5e9': 'sky',
74
- '#f97316': 'orange',
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: '#1d9bf0', variant: 'vivid' },
102
+ blue: { name: 'blue', hex: '#0085fe', variant: 'vivid' },
93
103
  green: { name: 'green', hex: '#10b981', variant: 'vivid' },
94
- amber: { name: 'amber', hex: '#f59e0b', variant: 'vivid' },
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: '#8b5cf6', variant: 'vivid' },
98
- pink: { name: 'pink', hex: '#ec4899', variant: 'vivid' },
99
- sky: { name: 'sky', hex: '#0ea5e9', variant: 'vivid' },
100
- 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' },
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
- variant: options.variant ?? 'vivid',
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,