@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
@@ -0,0 +1,268 @@
1
+ /**
2
+ * expo-router bindings for the in-screen tab strip.
3
+ *
4
+ * This subpath (`@oxyhq/bloom/tabs/expo-router`) is the ONLY place in the tabs
5
+ * feature allowed to import `expo-router`, which is why it is a separate entry:
6
+ * the core `@oxyhq/bloom/tabs` stays usable in an app with a different router,
7
+ * or none, and the optional peer is never pulled into a bundle that does not
8
+ * ask for it. Same split as `tab-bar` / `tab-bar/expo-router` and
9
+ * `scroll` / `scroll/expo-router`.
10
+ *
11
+ * Everything routing-shaped lives here on purpose: which tab the route means,
12
+ * what a committed swipe navigates to, and which links to warm. The strip
13
+ * itself stays presentational and knows only about measured geometry.
14
+ */
15
+ import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
16
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
17
+ import { runOnJS } from 'react-native-reanimated';
18
+ import { router, usePathname, type Href } from 'expo-router';
19
+
20
+ import { Tabs, TabsTrigger, type TabsDragController } from '../Tabs';
21
+ import type { TabsProps } from '../types';
22
+
23
+ export interface RouterTabItem {
24
+ /** Stable id for the tab. Used as a React key and a callback argument. */
25
+ value: string;
26
+ /** Tab label text. */
27
+ label: string;
28
+ /** The route this tab shows. Must be the tab's EXACT path. */
29
+ href: Href;
30
+ /** Icon rendered before the label. */
31
+ icon?: ReactNode;
32
+ /** Optional tally beside the label; `0` or omitted renders nothing. */
33
+ count?: number;
34
+ disabled?: boolean;
35
+ }
36
+
37
+ export type RouterTabsProps = Omit<
38
+ TabsProps,
39
+ // All three describe a selection the ROUTE already answers, so accepting them
40
+ // here would let a caller contradict the router.
41
+ 'value' | 'onValueChange' | 'hasSelection' | 'children'
42
+ > & {
43
+ items: RouterTabItem[];
44
+ /**
45
+ * Called instead of navigating when the ALREADY-active tab is pressed.
46
+ *
47
+ * Without it a re-tap would push a second history entry for the route the
48
+ * user is already on, so Back would appear to do nothing. Re-tapping the
49
+ * active tab conventionally means "refresh" or "scroll to top", and that is
50
+ * the caller's to define.
51
+ */
52
+ onReselect?: (value: string) => void;
53
+ /**
54
+ * Enable the horizontal swipe between neighbouring tabs. Defaults to `true`.
55
+ *
56
+ * The gesture is INDICATOR-LED: the underline tracks the finger toward the
57
+ * neighbour and commits on release past a threshold, while the content stays
58
+ * where it is. That asymmetry is deliberate — the underline has somewhere
59
+ * real to go and can complete its journey, whereas under a `<Slot/>` there is
60
+ * no neighbouring screen mounted to bring in, so translating the content
61
+ * would only open a blank gutter on the trailing edge.
62
+ */
63
+ swipeEnabled?: boolean;
64
+ };
65
+
66
+ /**
67
+ * Resolve which item the current route belongs to.
68
+ *
69
+ * LONGEST matching prefix, on a segment boundary — not an exact match. All
70
+ * three properties matter:
71
+ *
72
+ * - A prefix is needed because a tab may own a subtree (`/feeds/1/topics` and
73
+ * anything under it), and an exact match would drop the underline the moment
74
+ * the user goes one level deeper.
75
+ * - LONGEST is needed because the default tab's href is a prefix of every
76
+ * sibling's (`/@ana` vs `/@ana/replies`), so a first-match rule would light
77
+ * up the default tab on every tab.
78
+ * - The segment boundary is what stops `/feeds/12` from matching `/feeds/1`.
79
+ *
80
+ * Returns `undefined` when nothing matches, which is a real state — a strip
81
+ * rendered by a LAYOUT keeps rendering while a child route outside the tab set
82
+ * is showing — and leaves the underline hidden rather than parked on a lie.
83
+ */
84
+ function matchActiveValue(items: RouterTabItem[], pathname: string): string | undefined {
85
+ let best: { value: string; length: number } | undefined;
86
+ for (const item of items) {
87
+ // `Href` is a string or an object; only a string form can be compared to a
88
+ // pathname, and an object form describes params we cannot resolve here.
89
+ if (typeof item.href !== 'string') continue;
90
+ // Compare paths only: a query string describes what the tab shows, not
91
+ // which tab it is, and a trailing slash is the same route either way.
92
+ const [path = ''] = item.href.split('?');
93
+ const href = path.replace(/\/$/, '');
94
+ const isMatch = pathname === href || pathname.startsWith(`${href}/`);
95
+ if (!isMatch) continue;
96
+ if (!best || href.length > best.length) {
97
+ best = { value: item.value, length: href.length };
98
+ }
99
+ }
100
+ return best?.value;
101
+ }
102
+
103
+ /**
104
+ * A tab strip whose selection IS the route.
105
+ *
106
+ * Render it in the LAYOUT that owns the tabs, above the `<Slot/>` that renders
107
+ * them — not inside the tab screens themselves. That placement is the whole
108
+ * point, and it is load-bearing twice over: a strip inside the screens is
109
+ * unmounted and rebuilt by every navigation, so its underline has nothing to
110
+ * animate from and the chrome blanks while the incoming route's chunk loads;
111
+ * and a GESTURE inside the screens would be destroyed by the very navigation it
112
+ * commits, mid-release.
113
+ *
114
+ * ```tsx
115
+ * // app/(app)/[username]/_layout.tsx
116
+ * <RouterTabs
117
+ * items={[
118
+ * { value: 'posts', label: 'Posts', href: `/${username}` },
119
+ * { value: 'replies', label: 'Replies', href: `/${username}/replies` },
120
+ * ]}
121
+ * />
122
+ * <Slot />
123
+ * ```
124
+ */
125
+ export function RouterTabs({
126
+ items,
127
+ onReselect,
128
+ swipeEnabled = true,
129
+ ...tabsProps
130
+ }: RouterTabsProps) {
131
+ const pathname = usePathname();
132
+ const activeValue = useMemo(() => matchActiveValue(items, pathname), [items, pathname]);
133
+ const dragRef = useRef<TabsDragController>(null);
134
+ // What releasing right now would commit. A ref rather than state: it is
135
+ // written on every frame of the drag and read once, and re-rendering the
136
+ // strip mid-gesture would fight the animation the gesture is driving.
137
+ const pendingRef = useRef<string | null>(null);
138
+
139
+ const navigateTo = useCallback(
140
+ (value: string) => {
141
+ const target = items.find((item) => item.value === value);
142
+ if (!target) return;
143
+ // PUSH, not replace: a history entry per tab is what makes Back return to
144
+ // the tab the reader came from instead of leaving the screen entirely.
145
+ router.push(target.href);
146
+ },
147
+ [items],
148
+ );
149
+
150
+ const handlePress = useCallback(
151
+ (item: RouterTabItem) => {
152
+ if (item.value === activeValue) {
153
+ onReselect?.(item.value);
154
+ return;
155
+ }
156
+ navigateTo(item.value);
157
+ },
158
+ [activeValue, onReselect, navigateTo],
159
+ );
160
+
161
+ const onDrag = useCallback((translationX: number) => {
162
+ pendingRef.current = dragRef.current?.drag(translationX) ?? null;
163
+ }, []);
164
+
165
+ // `onEnd` does not fire for every terminated gesture, and `onFinalize` fires
166
+ // for all of them — including after `onEnd`. Guarding on the ref being
167
+ // non-null is what makes the second call a no-op instead of a double commit.
168
+ const releasedRef = useRef(true);
169
+ const onRelease = useCallback(() => {
170
+ if (releasedRef.current) return;
171
+ releasedRef.current = true;
172
+ const committed = pendingRef.current;
173
+ pendingRef.current = null;
174
+ // The underline commits first and instantly: it lives in the layout, it
175
+ // survives the swap, and it is the honest signal the gesture was accepted.
176
+ // The content area arrives when its chunk does.
177
+ dragRef.current?.release(committed);
178
+ if (committed !== null) navigateTo(committed);
179
+ }, [navigateTo]);
180
+
181
+ const onBegin = useCallback(() => {
182
+ releasedRef.current = false;
183
+ }, []);
184
+
185
+ const panGesture = useMemo(
186
+ () =>
187
+ Gesture.Pan()
188
+ // Horizontal intent only. This is a hint to the gesture system and is
189
+ // NOT what protects vertical scrolling on web — see `touchAction` below.
190
+ .activeOffsetX([-12, 12])
191
+ .failOffsetY([-12, 12])
192
+ .onBegin(() => {
193
+ runOnJS(onBegin)();
194
+ })
195
+ .onUpdate((event) => {
196
+ runOnJS(onDrag)(event.translationX);
197
+ })
198
+ .onFinalize(() => {
199
+ runOnJS(onRelease)();
200
+ }),
201
+ [onBegin, onDrag, onRelease],
202
+ );
203
+
204
+ // Warm every OTHER tab's async chunk. That fetch is the ~300ms the content
205
+ // area would otherwise spend empty after a commit, and a swipe offers no
206
+ // hover to warm on, so it is done up front rather than on interaction.
207
+ //
208
+ // `router.prefetch` — the public imperative API (declared in expo-router's
209
+ // `global-state/router.d.ts`) — rather than wrapping each trigger in
210
+ // `<Link prefetch asChild>`. Both reasons are about the WRAPPER, not about
211
+ // prefetching: `asChild` is Radix's `Slot`, which COMPOSES event handlers, so
212
+ // the trigger's own `onPress` and the link's navigation would both run — and
213
+ // the link's push knows nothing about `onReselect`, so re-tapping the active
214
+ // tab would push a duplicate entry and make Back appear to do nothing. It
215
+ // also injects an `href` a `Pressable` ignores. The prefetching itself is
216
+ // identical: `<Link prefetch>` renders `<Prefetch href/>` beside its element
217
+ // and does nothing else.
218
+ //
219
+ // Known trade: a real `<Link>` would render a web anchor, which is what gives
220
+ // middle-click and open-in-new-tab. A tab strip arguably wants that; getting
221
+ // both needs the trigger to render an anchor itself, which is a change to the
222
+ // core rather than to this file.
223
+ useEffect(() => {
224
+ for (const item of items) {
225
+ if (item.value === activeValue || item.disabled) continue;
226
+ router.prefetch(item.href);
227
+ }
228
+ }, [items, activeValue]);
229
+
230
+ const strip = (
231
+ // `hasSelection` is what stops the underline asserting a tab while a
232
+ // NON-TAB sibling route is showing — the layout that renders this strip
233
+ // renders those too (a profile's `/followers` beside its tab routes).
234
+ <Tabs {...tabsProps} ref={dragRef} hasSelection={activeValue !== undefined}>
235
+ {items.map((item) => (
236
+ <TabsTrigger
237
+ key={item.value}
238
+ value={item.value}
239
+ label={item.label}
240
+ icon={item.icon}
241
+ count={item.count}
242
+ disabled={item.disabled}
243
+ // Focus, never a press, is what drives the underline — so a deep
244
+ // link, a browser Back and a back gesture animate exactly like a tap.
245
+ isFocused={item.value === activeValue}
246
+ onPress={() => handlePress(item)}
247
+ />
248
+ ))}
249
+ </Tabs>
250
+ );
251
+
252
+ if (!swipeEnabled) return strip;
253
+
254
+ return (
255
+ // `touchAction="pan-y"` is MANDATORY and is the landmine of this file.
256
+ // RNGH sets the detector's view to `touch-action: none` on web
257
+ // (`GestureHandlerWebDelegate.js`), which disables browser-native VERTICAL
258
+ // scrolling on that element. `failOffsetY` above does not protect against
259
+ // it: that is a JS decision taken after `touch-action` has already told the
260
+ // browser not to scroll. Removing this prop is silent on native and breaks
261
+ // scrolling on mobile web.
262
+ <GestureDetector gesture={panGesture} touchAction="pan-y">
263
+ {strip}
264
+ </GestureDetector>
265
+ );
266
+ }
267
+
268
+ RouterTabs.displayName = 'RouterTabs';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Entry for `@oxyhq/bloom/tabs/expo-router`. The component lives in a sibling
3
+ * module because this file is the published entry path and must stay stable;
4
+ * the split also keeps the router binding in one place, which is what the
5
+ * optional-peer allowlist asserts about.
6
+ */
7
+ export { RouterTabs } from './RouterTabs';
8
+ export type { RouterTabItem, RouterTabsProps } from './RouterTabs';
package/src/tabs/types.ts CHANGED
@@ -3,10 +3,38 @@ import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
3
3
  export type TabsVariant = 'underline' | 'filled' | 'outlined';
4
4
 
5
5
  export interface TabsProps {
6
- /** The currently selected tab value. */
7
- value: string;
8
- /** Called when a tab is selected. */
9
- onValueChange: (value: string) => void;
6
+ /**
7
+ * The currently selected tab value — the CONTROLLED path, where the bar owns
8
+ * the underline and reports presses through {@link TabsProps.onValueChange}.
9
+ *
10
+ * OMIT it for the focus-driven path, where each trigger supplies its own
11
+ * {@link TabsTriggerProps.isFocused} and performs its own navigation. That is
12
+ * how `@oxyhq/bloom/tabs/expo-router` keeps the underline correct through
13
+ * deep links, browser Back and back gestures — none of which pass through a
14
+ * press handler. Providing both would put two writers on one shared value.
15
+ */
16
+ value?: string;
17
+ /** Called when a tab is selected. Controlled path only. */
18
+ onValueChange?: (value: string) => void;
19
+ /**
20
+ * Focus-driven path only: whether ANY trigger is currently focused.
21
+ *
22
+ * `false` fades the underline out where it stands. This is not the same as
23
+ * "the selection moved", and it is not hypothetical: a strip rendered by a
24
+ * LAYOUT keeps rendering while the layout's other, non-tab children are
25
+ * showing — a profile's `/followers` sits beside its tab routes under one
26
+ * layout — and without this the underline stays parked on whichever tab was
27
+ * last visited, asserting a selection that is not there.
28
+ *
29
+ * Fading is the whole response; the underline is deliberately NOT moved,
30
+ * because there is no position that means "nowhere" and travelling to a
31
+ * sentinel would drag it across every tab on the way out.
32
+ *
33
+ * `RouterTabs` sets it, so an app author never passes it by hand. Defaults to
34
+ * `true`, which is correct for the controlled path (a `value` naming no
35
+ * trigger simply leaves the underline where it was, as it always has).
36
+ */
37
+ hasSelection?: boolean;
10
38
  /** Visual variant for the tab bar. */
11
39
  variant?: TabsVariant;
12
40
  /**
@@ -29,8 +57,24 @@ export interface TabsTriggerProps {
29
57
  label: string;
30
58
  /** Icon rendered before the label. */
31
59
  icon?: React.ReactNode;
60
+ /**
61
+ * Optional tally rendered as a small muted number beside the label. Omitted
62
+ * (or `0`) renders nothing. The underline tracks the trigger's measured
63
+ * width, so a count never changes where it sits.
64
+ */
65
+ count?: number;
66
+ /**
67
+ * Whether the ROUTER considers this tab focused — the focus-driven path.
68
+ * Supplying it makes this trigger, not the bar, the writer of the shared
69
+ * underline, and suppresses {@link TabsProps.onValueChange} on press because
70
+ * navigation is then the caller's job. Leave it `undefined` on the controlled
71
+ * path.
72
+ */
73
+ isFocused?: boolean;
32
74
  /** Whether this tab is disabled. */
33
75
  disabled?: boolean;
76
+ /** Extra press handler. Runs on both paths, before any selection is reported. */
77
+ onPress?: () => void;
34
78
  style?: StyleProp<ViewStyle>;
35
79
  textStyle?: StyleProp<TextStyle>;
36
80
  }
@@ -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
  /**