@oxyhq/bloom 0.79.1 → 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 (99) hide show
  1. package/lib/commonjs/scroll/index.web.js +52 -18
  2. package/lib/commonjs/scroll/index.web.js.map +1 -1
  3. package/lib/commonjs/scroll/scrollable.web.js +9 -3
  4. package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
  5. package/lib/commonjs/tabs/Tabs.js +409 -71
  6. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  7. package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
  8. package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
  9. package/lib/commonjs/tabs/expo-router/index.js +13 -0
  10. package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
  11. package/lib/commonjs/theme/color-engine/index.js +6 -0
  12. package/lib/commonjs/theme/color-engine/index.js.map +1 -1
  13. package/lib/commonjs/theme/color-engine/scheme-variants.js +29 -1
  14. package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
  15. package/lib/commonjs/theme/color-policy.js +54 -10
  16. package/lib/commonjs/theme/color-policy.js.map +1 -1
  17. package/lib/commonjs/theme/color-presets.js +14 -2
  18. package/lib/commonjs/theme/color-presets.js.map +1 -1
  19. package/lib/commonjs/theme/color-scope/seed-scope.js +5 -1
  20. package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
  21. package/lib/module/scroll/index.web.js +52 -18
  22. package/lib/module/scroll/index.web.js.map +1 -1
  23. package/lib/module/scroll/scrollable.web.js +9 -3
  24. package/lib/module/scroll/scrollable.web.js.map +1 -1
  25. package/lib/module/tabs/Tabs.js +417 -73
  26. package/lib/module/tabs/Tabs.js.map +1 -1
  27. package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
  28. package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
  29. package/lib/module/tabs/expo-router/index.js +10 -0
  30. package/lib/module/tabs/expo-router/index.js.map +1 -0
  31. package/lib/module/theme/color-engine/index.js +1 -1
  32. package/lib/module/theme/color-engine/index.js.map +1 -1
  33. package/lib/module/theme/color-engine/scheme-variants.js +28 -1
  34. package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
  35. package/lib/module/theme/color-policy.js +53 -10
  36. package/lib/module/theme/color-policy.js.map +1 -1
  37. package/lib/module/theme/color-presets.js +14 -2
  38. package/lib/module/theme/color-presets.js.map +1 -1
  39. package/lib/module/theme/color-scope/seed-scope.js +6 -2
  40. package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
  41. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
  43. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
  45. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
  47. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  48. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
  49. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
  50. package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
  51. package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/theme/color-engine/index.d.ts +1 -1
  53. package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +13 -1
  55. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/theme/color-policy.d.ts +6 -0
  57. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/theme/color-presets.d.ts +1 -1
  59. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
  61. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  62. package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
  63. package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
  64. package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
  65. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  66. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
  67. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  68. package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
  69. package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
  70. package/lib/typescript/module/tabs/types.d.ts +48 -4
  71. package/lib/typescript/module/tabs/types.d.ts.map +1 -1
  72. package/lib/typescript/module/theme/color-engine/index.d.ts +1 -1
  73. package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
  74. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +13 -1
  75. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
  76. package/lib/typescript/module/theme/color-policy.d.ts +6 -0
  77. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  78. package/lib/typescript/module/theme/color-presets.d.ts +1 -1
  79. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  80. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
  81. package/package.json +15 -1
  82. package/src/__tests__/Tabs.test.tsx +470 -7
  83. package/src/__tests__/optional-peer-imports.test.ts +5 -0
  84. package/src/__tests__/scroll-web.test.tsx +255 -2
  85. package/src/__tests__/theme.test.ts +2 -1
  86. package/src/scroll/index.web.tsx +55 -19
  87. package/src/scroll/scrollable.web.ts +30 -8
  88. package/src/tabs/Tabs.tsx +518 -83
  89. package/src/tabs/expo-router/RouterTabs.tsx +268 -0
  90. package/src/tabs/expo-router/index.ts +8 -0
  91. package/src/tabs/types.ts +48 -4
  92. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +120 -0
  93. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +70 -0
  94. package/src/theme/__tests__/policy-legibility.test.ts +12 -4
  95. package/src/theme/color-engine/index.ts +1 -1
  96. package/src/theme/color-engine/scheme-variants.ts +38 -1
  97. package/src/theme/color-policy.ts +68 -14
  98. package/src/theme/color-presets.ts +11 -2
  99. package/src/theme/color-scope/seed-scope.ts +6 -2
@@ -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,