@oxyhq/bloom 0.80.0 → 0.81.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 (56) 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 +54 -10
  8. package/lib/commonjs/theme/color-policy.js.map +1 -1
  9. package/lib/commonjs/theme/color-presets.js +14 -2
  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 +53 -10
  20. package/lib/module/theme/color-policy.js.map +1 -1
  21. package/lib/module/theme/color-presets.js +14 -2
  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__/Tabs.test.tsx +286 -10
  47. package/src/__tests__/theme.test.ts +2 -1
  48. package/src/tabs/Tabs.tsx +172 -12
  49. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +120 -0
  50. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +70 -0
  51. package/src/theme/__tests__/policy-legibility.test.ts +12 -4
  52. package/src/theme/color-engine/index.ts +1 -1
  53. package/src/theme/color-engine/scheme-variants.ts +38 -1
  54. package/src/theme/color-policy.ts +68 -14
  55. package/src/theme/color-presets.ts +11 -2
  56. package/src/theme/color-scope/seed-scope.ts +6 -2
@@ -1558,5 +1558,125 @@
1558
1558
  "--info-foreground": "rgb(255 255 255)",
1559
1559
  "--info-text": "rgb(173 198 255)",
1560
1560
  "--info-subtle": "rgba(59, 130, 246, 0.24)"
1561
+ },
1562
+ "mono/light": {
1563
+ "--background": "rgb(243 243 243)",
1564
+ "--foreground": "rgb(27 27 27)",
1565
+ "--surface": "rgb(243 243 243)",
1566
+ "--surface-foreground": "rgb(27 27 27)",
1567
+ "--popover": "rgb(238 238 238)",
1568
+ "--popover-foreground": "rgb(27 27 27)",
1569
+ "--primary": "rgb(31 31 31)",
1570
+ "--primary-foreground": "rgb(255 255 255)",
1571
+ "--secondary": "rgb(85 85 85)",
1572
+ "--secondary-foreground": "rgb(255 255 255)",
1573
+ "--tertiary": "rgb(31 31 31)",
1574
+ "--tertiary-foreground": "rgb(255 255 255)",
1575
+ "--muted": "rgb(232 232 232)",
1576
+ "--muted-foreground": "rgb(71 71 71)",
1577
+ "--accent": "rgb(232 232 232)",
1578
+ "--accent-foreground": "rgb(71 71 71)",
1579
+ "--destructive": "rgb(186 26 26)",
1580
+ "--border": "rgb(198 198 198)",
1581
+ "--input": "rgb(198 198 198)",
1582
+ "--ring": "rgb(0 0 0)",
1583
+ "--sidebar": "rgb(243 243 243)",
1584
+ "--card": "rgb(255 255 255)",
1585
+ "--card-foreground": "rgb(27 27 27)",
1586
+ "--chart-1": "rgb(193 193 193)",
1587
+ "--chart-2": "rgb(158 158 158)",
1588
+ "--chart-3": "rgb(124 124 124)",
1589
+ "--chart-4": "rgb(92 92 92)",
1590
+ "--chart-5": "rgb(62 62 62)",
1591
+ "--content-area": "rgb(249 249 249)",
1592
+ "--sidebar-foreground": "rgb(27 27 27)",
1593
+ "--sidebar-primary": "rgb(0 0 0)",
1594
+ "--sidebar-primary-foreground": "rgb(226 226 226)",
1595
+ "--sidebar-accent": "rgb(212 212 212)",
1596
+ "--sidebar-accent-foreground": "rgb(27 27 27)",
1597
+ "--sidebar-border": "rgb(198 198 198)",
1598
+ "--sidebar-ring": "rgb(0 0 0)",
1599
+ "--primary-text": "rgb(0 0 0)",
1600
+ "--primary-subtle": "rgba(145, 145, 145, 0.13)",
1601
+ "--secondary-text": "rgb(94 94 94)",
1602
+ "--secondary-subtle": "rgba(145, 145, 145, 0.13)",
1603
+ "--tertiary-text": "rgb(94 94 94)",
1604
+ "--tertiary-subtle": "rgba(145, 145, 145, 0.13)",
1605
+ "--success": "rgb(0 122 83)",
1606
+ "--success-foreground": "rgb(255 255 255)",
1607
+ "--success-text": "rgb(0 108 73)",
1608
+ "--success-subtle": "rgba(16, 185, 129, 0.13)",
1609
+ "--error": "rgb(203 41 46)",
1610
+ "--error-foreground": "rgb(255 255 255)",
1611
+ "--error-text": "rgb(167 55 53)",
1612
+ "--error-subtle": "rgba(239, 68, 68, 0.13)",
1613
+ "--warning": "rgb(150 94 0)",
1614
+ "--warning-foreground": "rgb(255 255 255)",
1615
+ "--warning-text": "rgb(133 83 0)",
1616
+ "--warning-subtle": "rgba(245, 158, 11, 0.13)",
1617
+ "--info": "rgb(5 102 217)",
1618
+ "--info-foreground": "rgb(255 255 255)",
1619
+ "--info-text": "rgb(1 90 194)",
1620
+ "--info-subtle": "rgba(59, 130, 246, 0.13)"
1621
+ },
1622
+ "mono/dark": {
1623
+ "--background": "rgb(14 14 14)",
1624
+ "--foreground": "rgb(226 226 226)",
1625
+ "--surface": "rgb(27 27 27)",
1626
+ "--surface-foreground": "rgb(226 226 226)",
1627
+ "--popover": "rgb(31 31 31)",
1628
+ "--popover-foreground": "rgb(226 226 226)",
1629
+ "--primary": "rgb(238 238 238)",
1630
+ "--primary-foreground": "rgb(0 0 0)",
1631
+ "--secondary": "rgb(176 176 176)",
1632
+ "--secondary-foreground": "rgb(0 0 0)",
1633
+ "--tertiary": "rgb(238 238 238)",
1634
+ "--tertiary-foreground": "rgb(0 0 0)",
1635
+ "--muted": "rgb(42 42 42)",
1636
+ "--muted-foreground": "rgb(198 198 198)",
1637
+ "--accent": "rgb(42 42 42)",
1638
+ "--accent-foreground": "rgb(198 198 198)",
1639
+ "--destructive": "rgb(255 180 171)",
1640
+ "--border": "rgb(71 71 71)",
1641
+ "--input": "rgb(71 71 71)",
1642
+ "--ring": "rgb(255 255 255)",
1643
+ "--sidebar": "rgb(27 27 27)",
1644
+ "--card": "rgb(31 31 31)",
1645
+ "--card-foreground": "rgb(226 226 226)",
1646
+ "--chart-1": "rgb(94 94 94)",
1647
+ "--chart-2": "rgb(127 127 127)",
1648
+ "--chart-3": "rgb(160 160 160)",
1649
+ "--chart-4": "rgb(196 196 196)",
1650
+ "--chart-5": "rgb(232 232 232)",
1651
+ "--content-area": "rgb(19 19 19)",
1652
+ "--sidebar-foreground": "rgb(226 226 226)",
1653
+ "--sidebar-primary": "rgb(255 255 255)",
1654
+ "--sidebar-primary-foreground": "rgb(27 27 27)",
1655
+ "--sidebar-accent": "rgb(71 71 71)",
1656
+ "--sidebar-accent-foreground": "rgb(226 226 226)",
1657
+ "--sidebar-border": "rgb(71 71 71)",
1658
+ "--sidebar-ring": "rgb(255 255 255)",
1659
+ "--primary-text": "rgb(255 255 255)",
1660
+ "--primary-subtle": "rgba(145, 145, 145, 0.24)",
1661
+ "--secondary-text": "rgb(198 198 198)",
1662
+ "--secondary-subtle": "rgba(145, 145, 145, 0.24)",
1663
+ "--tertiary-text": "rgb(198 198 198)",
1664
+ "--tertiary-subtle": "rgba(145, 145, 145, 0.24)",
1665
+ "--success": "rgb(0 133 91)",
1666
+ "--success-foreground": "rgb(255 255 255)",
1667
+ "--success-text": "rgb(59 224 161)",
1668
+ "--success-subtle": "rgba(16, 185, 129, 0.24)",
1669
+ "--error": "rgb(218 52 55)",
1670
+ "--error-foreground": "rgb(255 255 255)",
1671
+ "--error-text": "rgb(255 179 173)",
1672
+ "--error-subtle": "rgba(239, 68, 68, 0.24)",
1673
+ "--warning": "rgb(163 103 0)",
1674
+ "--warning-foreground": "rgb(255 255 255)",
1675
+ "--warning-text": "rgb(255 185 95)",
1676
+ "--warning-subtle": "rgba(245, 158, 11, 0.24)",
1677
+ "--info": "rgb(33 112 228)",
1678
+ "--info-foreground": "rgb(255 255 255)",
1679
+ "--info-text": "rgb(173 198 255)",
1680
+ "--info-subtle": "rgba(59, 130, 246, 0.24)"
1561
1681
  }
1562
1682
  }
@@ -352,6 +352,76 @@ exports[`theme.colors snapshot for all presets (catches unintended palette shift
352
352
  "tint": "rgb(0 120 108)",
353
353
  "warning": "rgb(150 94 0)",
354
354
  },
355
+ "mono/dark": {
356
+ "background": "rgb(14 14 14)",
357
+ "backgroundSecondary": "rgb(27 27 27)",
358
+ "backgroundTertiary": "rgb(31 31 31)",
359
+ "border": "rgb(71 71 71)",
360
+ "borderLight": "rgb(71 71 71)",
361
+ "card": "rgb(31 31 31)",
362
+ "contrast50": "rgb(42 42 42)",
363
+ "error": "rgb(218 52 55)",
364
+ "icon": "rgb(198 198 198)",
365
+ "iconActive": "rgb(238 238 238)",
366
+ "info": "rgb(33 112 228)",
367
+ "negative": "rgb(255 180 171)",
368
+ "negativeForeground": "rgb(105 0 5)",
369
+ "negativeSubtle": "rgba(239, 68, 68, 0.24)",
370
+ "negativeSubtleForeground": "rgb(255 179 173)",
371
+ "overlay": "rgba(0, 0, 0, 0.5)",
372
+ "primary": "rgb(238 238 238)",
373
+ "primaryDark": "rgb(14 14 14)",
374
+ "primaryForeground": "rgb(0 0 0)",
375
+ "primaryLight": "rgb(27 27 27)",
376
+ "primarySubtle": "rgba(145, 145, 145, 0.24)",
377
+ "primarySubtleForeground": "rgb(0 0 0)",
378
+ "secondary": "rgb(176 176 176)",
379
+ "secondaryForeground": "rgb(0 0 0)",
380
+ "shadow": "rgba(0, 0, 0, 0.3)",
381
+ "success": "rgb(0 133 91)",
382
+ "tertiary": "rgb(238 238 238)",
383
+ "tertiaryForeground": "rgb(0 0 0)",
384
+ "text": "rgb(226 226 226)",
385
+ "textSecondary": "rgb(198 198 198)",
386
+ "textTertiary": "rgb(145 145 145)",
387
+ "tint": "rgb(238 238 238)",
388
+ "warning": "rgb(163 103 0)",
389
+ },
390
+ "mono/light": {
391
+ "background": "rgb(243 243 243)",
392
+ "backgroundSecondary": "rgb(243 243 243)",
393
+ "backgroundTertiary": "rgb(238 238 238)",
394
+ "border": "rgb(198 198 198)",
395
+ "borderLight": "rgb(198 198 198)",
396
+ "card": "rgb(255 255 255)",
397
+ "contrast50": "rgb(232 232 232)",
398
+ "error": "rgb(203 41 46)",
399
+ "icon": "rgb(71 71 71)",
400
+ "iconActive": "rgb(31 31 31)",
401
+ "info": "rgb(5 102 217)",
402
+ "negative": "rgb(186 26 26)",
403
+ "negativeForeground": "rgb(255 255 255)",
404
+ "negativeSubtle": "rgba(239, 68, 68, 0.13)",
405
+ "negativeSubtleForeground": "rgb(167 55 53)",
406
+ "overlay": "rgba(0, 0, 0, 0.5)",
407
+ "primary": "rgb(31 31 31)",
408
+ "primaryDark": "rgb(243 243 243)",
409
+ "primaryForeground": "rgb(255 255 255)",
410
+ "primaryLight": "rgb(243 243 243)",
411
+ "primarySubtle": "rgba(145, 145, 145, 0.13)",
412
+ "primarySubtleForeground": "rgb(255 255 255)",
413
+ "secondary": "rgb(85 85 85)",
414
+ "secondaryForeground": "rgb(255 255 255)",
415
+ "shadow": "rgba(0, 0, 0, 0.1)",
416
+ "success": "rgb(0 122 83)",
417
+ "tertiary": "rgb(31 31 31)",
418
+ "tertiaryForeground": "rgb(255 255 255)",
419
+ "text": "rgb(27 27 27)",
420
+ "textSecondary": "rgb(71 71 71)",
421
+ "textTertiary": "rgb(119 119 119)",
422
+ "tint": "rgb(31 31 31)",
423
+ "warning": "rgb(150 94 0)",
424
+ },
355
425
  "orange/dark": {
356
426
  "background": "rgb(23 11 5)",
357
427
  "backgroundSecondary": "rgb(38 24 17)",
@@ -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:
@@ -124,7 +124,11 @@ describe('colour policy legibility', () => {
124
124
  // Each half was individually legible, so nothing else could catch either.
125
125
  it('the brand fill keeps light exemption-free and dark budgeted', () => {
126
126
  const white = { light: 0, dark: 0 };
127
- for (const preset of APP_COLOR_NAMES) {
127
+ // `mono` is not governed by the budget — it has no chroma to preserve, and its
128
+ // 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
+ const chromatic = APP_COLOR_NAMES.filter((name) => name !== 'mono');
131
+ for (const preset of chromatic) {
128
132
  for (const mode of ['light', 'dark'] as const) {
129
133
  if (getResolvedTokens(preset, mode)['--primary-foreground'] === 'rgb(255 255 255)') {
130
134
  white[mode] += 1;
@@ -133,7 +137,11 @@ describe('colour policy legibility', () => {
133
137
  }
134
138
  // Light admits no exemption, so every preset carries white there. Dark keeps
135
139
  // 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);
140
+ expect(white.light).toBe(chromatic.length);
141
+ expect(white.dark).toBeGreaterThan(chromatic.length - 4);
142
+ // And the monochrome exception itself, stated rather than tolerated: a fill at
143
+ // each end of the scale, carrying the opposite label.
144
+ expect(getResolvedTokens('mono', 'light')['--primary-foreground']).toBe('rgb(255 255 255)');
145
+ expect(getResolvedTokens('mono', 'dark')['--primary-foreground']).toBe('rgb(0 0 0)');
138
146
  });
139
147
  });
@@ -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
  /**
@@ -131,6 +131,29 @@ const TEXT_CHROMA = 60;
131
131
  const SUBTLE_SOURCE_TONE = 60;
132
132
  const SUBTLE_ALPHA = { light: 0.13, dark: 0.24 } as const;
133
133
 
134
+ /**
135
+ * A seed at or below this chroma has no colour to derive from, so the whole
136
+ * palette goes greyscale — the black-and-white theme. Keyed on the seed rather
137
+ * than on a flag, so picking any grey in a colour wheel lands there too instead
138
+ * of on a nearly-grey approximation of it.
139
+ */
140
+ const MONOCHROME_MAX_CHROMA = 6;
141
+
142
+ /**
143
+ * Whether a seed carries no colour to derive a theme from — the one authority for
144
+ * that question, so the policy and the scheme choice can never disagree about a
145
+ * given seed and produce a grey palette wearing chromatic surfaces.
146
+ */
147
+ export function isColourlessSeed(seedHex: string): boolean {
148
+ return Hct.fromInt(argbFromHex(seedHex)).chroma <= MONOCHROME_MAX_CHROMA;
149
+ }
150
+
151
+ /** Where a monochrome brand fill sits: near-black on a white page, near-white on a black one. */
152
+ const MONOCHROME_FILL_TONE = { light: 12, dark: 94 } as const;
153
+
154
+ /** The quiet member of a monochrome trio — the only one that is actually grey. */
155
+ const MONOCHROME_SECONDARY_TONE = { light: 36, dark: 72 } as const;
156
+
134
157
  /** Split-complementary: the two accents sit either side of the seed's complement. */
135
158
  const SPLIT_ROTATIONS = [150, 210] as const;
136
159
 
@@ -211,7 +234,8 @@ function vividHueNear(hue: number): number {
211
234
  * ceasing to be itself: yellow turns to brown and lime to moss. Those keep their
212
235
  * own tone and take a black label instead, which is what a bright yellow button
213
236
  * 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.
237
+ * why 11 of the 13 chromatic presets carry white and exactly the two light ones
238
+ * do not. (`mono` sits outside this rule — see MONOCHROME_FILL_TONE.)
215
239
  */
216
240
  function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean): number {
217
241
  // LIGHT: every fill comes down, no exemption. On a near-white page a bright
@@ -306,12 +330,24 @@ export function buildPolicyTokens(
306
330
  // tone <= 49, so that is the ceiling — in dark as much as in light. Letting the
307
331
  // dark fill sit at the seed's own tone instead makes it brighter, but every one
308
332
  // of those labels turns black, which costs more than the brightness buys.
333
+ const monochrome = seed.chroma <= MONOCHROME_MAX_CHROMA;
309
334
  // DARK keys off the seed itself so the fill IS the brand hex; LIGHT uses the
310
335
  // 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));
336
+ // A monochrome seed has no chroma to preserve either way, and its fill goes to
337
+ // the far end of the scale rather than the white-label ceiling: a mid-grey
338
+ // button reads as disabled, where near-black on white reads as the primary
339
+ // action.
340
+ const brandPalette = monochrome
341
+ ? TonalPalette.fromHueAndChroma(0, 0)
342
+ : isDark
343
+ ? TonalPalette.fromInt(argbFromHex(seedHex))
344
+ : TonalPalette.fromHueAndChroma(seed.hue, 200);
345
+ const primary = fillPair(
346
+ brandPalette,
347
+ monochrome
348
+ ? (isDark ? MONOCHROME_FILL_TONE.dark : MONOCHROME_FILL_TONE.light)
349
+ : whiteLabelTone(brandPalette, seed.tone, isDark),
350
+ );
315
351
 
316
352
  const tokens: PolicyTokens = {
317
353
  '--primary': primary.fill,
@@ -320,7 +356,7 @@ export function buildPolicyTokens(
320
356
  // does correctly, and which the fill must stop trying to do at the same time.
321
357
  '--primary-text': roles.primary,
322
358
  '--primary-subtle': rgba(
323
- TonalPalette.fromHueAndChroma(seed.hue, 200).tone(SUBTLE_SOURCE_TONE),
359
+ TonalPalette.fromHueAndChroma(seed.hue, monochrome ? 0 : 200).tone(SUBTLE_SOURCE_TONE),
324
360
  isDark ? SUBTLE_ALPHA.dark : SUBTLE_ALPHA.light,
325
361
  ),
326
362
  '--ring': roles.primary,
@@ -332,7 +368,9 @@ export function buildPolicyTokens(
332
368
  '--card': isDark ? roles.surfaceContainer : roles.surfaceContainerLowest,
333
369
  // A touch darker than M3's page background: near-white reads as unfinished
334
370
  // 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)),
371
+ '--background': rgb(
372
+ TonalPalette.fromHueAndChroma(seed.hue, monochrome ? 0 : 10).tone(isDark ? 4 : 96),
373
+ ),
336
374
  // `--accent` stays what every consumer actually uses it for: a hover surface.
337
375
  '--accent': roles.surfaceContainerHigh,
338
376
  '--accent-foreground': roles.onSurfaceVariant,
@@ -344,9 +382,11 @@ export function buildPolicyTokens(
344
382
  const hue = pin !== undefined ? Hct.fromInt(argbFromHex(pin)).hue : hues[role];
345
383
  // One hue, three palettes. Every member shares it, so the family still reads
346
384
  // 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);
385
+ // A monochrome theme has no accents to rotate to; the trio becomes a ladder
386
+ // of greys, which still separates a standout button from a quiet one.
387
+ const fillPalette = TonalPalette.fromHueAndChroma(hue, monochrome ? 0 : ACCENT_CHROMA);
388
+ const textPalette = TonalPalette.fromHueAndChroma(hue, monochrome ? 0 : TEXT_CHROMA);
389
+ const vividPalette = TonalPalette.fromHueAndChroma(hue, monochrome ? 0 : 200);
350
390
  // DARK sits at the hue's peak tone, floored by the same rule the brand fill
351
391
  // uses: some hues peak dark (violet at 35), and rendering them there gives a
352
392
  // slab rather than a highlight. LIGHT goes deep like every other fill, but
@@ -362,7 +402,20 @@ export function buildPolicyTokens(
362
402
  // black. The accent chroma cap already keeps these hues out of neon territory,
363
403
  // and the hue itself is the caller's brand rather than ours to move. The
364
404
  // analyzer still runs where it was designed to, inside `color-roles`.
365
- const tone = accentTone(hue, isDark);
405
+ // Monochrome keeps `tertiary` AT the brand fill rather than a step off it:
406
+ // tertiary is what a compose button and a FAB paint with, and those are the
407
+ // primary action — a step of grey between them and `primary` reads as two
408
+ // buttons disagreeing, not as hierarchy. `secondary` carries the whole
409
+ // demotion instead.
410
+ const tone = monochrome
411
+ ? role === 'tertiary'
412
+ ? isDark
413
+ ? MONOCHROME_FILL_TONE.dark
414
+ : MONOCHROME_FILL_TONE.light
415
+ : isDark
416
+ ? MONOCHROME_SECONDARY_TONE.dark
417
+ : MONOCHROME_SECONDARY_TONE.light
418
+ : accentTone(hue, isDark);
366
419
  const pair = fillPair(fillPalette, tone);
367
420
  tokens[`--${role}`] = pair.fill;
368
421
  tokens[`--${role}-foreground`] = pair.foreground;
@@ -377,9 +430,10 @@ export function buildPolicyTokens(
377
430
  // The old ramp drew all five from the primary/secondary/tertiary trio, which
378
431
  // spans ~35 degrees, so adjacent series were indistinguishable.
379
432
  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
- );
433
+ // Greyscale charts spread by TONE instead of hue — the only axis left.
434
+ tokens[`--chart-${i + 1}`] = monochrome
435
+ ? rgb(TonalPalette.fromHueAndChroma(0, 0).tone(isDark ? 40 + i * 13 : 78 - i * 13))
436
+ : rgb(TonalPalette.fromHueAndChroma((seed.hue + i * 72) % 360, 60).tone(isDark ? 72 : 48));
383
437
  }
384
438
 
385
439
  // The status family, themed per mode. The four frozen hexes it replaces all
@@ -13,7 +13,8 @@ export type AppColorName =
13
13
  | 'orange'
14
14
  | 'mint'
15
15
  | 'oxy'
16
- | 'faircoin';
16
+ | 'faircoin'
17
+ | 'mono';
17
18
 
18
19
  /**
19
20
  * A single colour preset: a brand SEED colour plus the tonal scheme variant to
@@ -56,7 +57,7 @@ export interface AppColorPreset {
56
57
  */
57
58
  export type PresetTokens = Record<string, string>;
58
59
 
59
- export const APP_COLOR_NAMES: readonly AppColorName[] = ['teal', 'blue', 'green', 'amber', 'yellow', 'red', 'purple', 'pink', 'sky', 'orange', 'mint', 'oxy', 'faircoin'];
60
+ export const APP_COLOR_NAMES: readonly AppColorName[] = ['teal', 'blue', 'green', 'amber', 'yellow', 'red', 'purple', 'pink', 'sky', 'orange', 'mint', 'oxy', 'faircoin', 'mono'];
60
61
 
61
62
  /** Premium-exclusive presets, hidden from the standard color picker. */
62
63
  export const PREMIUM_COLOR_NAMES: readonly AppColorName[] = ['oxy', 'faircoin'];
@@ -75,6 +76,7 @@ export const HEX_TO_APP_COLOR: Record<string, AppColorName> = {
75
76
  '#14b8a6': 'mint',
76
77
  '#c46ede': 'oxy',
77
78
  '#9ffb50': 'faircoin',
79
+ '#000000': 'mono',
78
80
  };
79
81
 
80
82
  export function hexToAppColorName(hex: string): AppColorName {
@@ -101,4 +103,11 @@ export const APP_COLOR_PRESETS: Record<AppColorName, AppColorPreset> = {
101
103
  mint: { name: 'mint', hex: '#14b8a6', variant: 'vivid' },
102
104
  oxy: { name: 'oxy', hex: '#c46ede', variant: 'vivid' },
103
105
  faircoin: { name: 'faircoin', hex: '#9ffb50', variant: 'vivid' },
106
+ /**
107
+ * No colour at all — the black-and-white theme. Its seed carries zero chroma,
108
+ * which is the whole mechanism: the policy derives a greyscale palette from any
109
+ * seed with none, so a user who picks a grey in the colour wheel lands here too
110
+ * rather than on a nearly-grey approximation.
111
+ */
112
+ mono: { name: 'mono', hex: '#000000', variant: 'monochrome' },
104
113
  };
@@ -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,