@thewhileloop/whileui 4.2.3 → 4.4.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 (67) hide show
  1. package/README.md +1271 -663
  2. package/dist/agent/catalog.json +1737 -0
  3. package/dist/agent/llms-components.txt +2808 -0
  4. package/dist/agent/llms-full.txt +2998 -0
  5. package/dist/agent/llms.txt +152 -0
  6. package/dist/blocks/chat/chat.d.ts +10 -2
  7. package/dist/blocks/chat/chat.js +1 -1
  8. package/dist/blocks/chat/index.d.ts +1 -1
  9. package/dist/blocks/commerce/feature-gate.js +2 -2
  10. package/dist/blocks/commerce/metric-card.js +1 -1
  11. package/dist/blocks/commerce/subscription-card.d.ts +7 -0
  12. package/dist/blocks/commerce/usage-bar.d.ts +4 -0
  13. package/dist/blocks/layout/app-shell.d.ts +4 -0
  14. package/dist/blocks/layout/empty-state.js +1 -1
  15. package/dist/blocks/layout/error-state.js +1 -1
  16. package/dist/blocks/layout/page-skeleton.d.ts +1 -0
  17. package/dist/blocks/layout/pull-to-refresh-scroll-view.d.ts +1 -0
  18. package/dist/blocks/layout/screen-skeleton.d.ts +5 -0
  19. package/dist/blocks/layout/smart-input.js +2 -2
  20. package/dist/blocks/lists/list-item.js +1 -1
  21. package/dist/blocks/lists/notification-item.js +1 -1
  22. package/dist/blocks/media/smart-image.js +4 -2
  23. package/dist/blocks/navigation/bottom-nav.js +1 -1
  24. package/dist/blocks/navigation/drawer-menu.js +1 -1
  25. package/dist/blocks/navigation/floating-bottom-nav.js +3 -1
  26. package/dist/blocks/navigation/tab-bar.js +1 -1
  27. package/dist/components/alert/alert.d.ts +7 -0
  28. package/dist/components/alert/alert.js +23 -4
  29. package/dist/components/button/button.d.ts +3 -0
  30. package/dist/components/button/button.js +2 -1
  31. package/dist/components/fab/fab.d.ts +12 -0
  32. package/dist/components/fab/fab.js +14 -2
  33. package/dist/components/fab/index.d.ts +1 -1
  34. package/dist/components/fab/index.js +1 -1
  35. package/dist/components/form-field/form-field.d.ts +18 -0
  36. package/dist/components/form-field/form-field.js +25 -4
  37. package/dist/components/input/input.d.ts +5 -0
  38. package/dist/components/input/input.js +4 -4
  39. package/dist/components/numeric-input/numeric-input.d.ts +3 -0
  40. package/dist/components/numeric-input/numeric-input.js +4 -4
  41. package/dist/components/otp-input/otp-input.d.ts +8 -0
  42. package/dist/components/popover/popover.js +9 -1
  43. package/dist/components/pressable/pressable.d.ts +1 -0
  44. package/dist/components/segmented-control/segmented-control.d.ts +39 -1
  45. package/dist/components/segmented-control/segmented-control.js +29 -5
  46. package/dist/components/select/select.js +7 -1
  47. package/dist/components/surface/surface.d.ts +5 -0
  48. package/dist/components/textarea/textarea.js +3 -3
  49. package/dist/components/toast/toast.d.ts +10 -10
  50. package/dist/components/toast/toast.js +53 -35
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.js +2 -1
  53. package/dist/lib/dismiss-keyboard-on-open.d.ts +16 -0
  54. package/dist/lib/dismiss-keyboard-on-open.js +22 -0
  55. package/dist/lib/frosted-surface.d.ts +3 -0
  56. package/dist/lib/recipes.d.ts +20 -0
  57. package/dist/lib/recipes.js +19 -2
  58. package/dist/lib/theme-bridge.d.ts +9 -0
  59. package/dist/lib/theme-bridge.js +3 -1
  60. package/dist/lib/theme-contract.js +12 -12
  61. package/dist/lib/theme-presets.js +9 -9
  62. package/dist/lib/theme-seeds.d.ts +40 -0
  63. package/dist/lib/theme-seeds.js +143 -0
  64. package/dist/metro.cjs +5 -4
  65. package/dist/tokens.d.ts +1 -0
  66. package/dist/tokens.js +1 -0
  67. package/package.json +3 -2
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
3
- import { Pressable, View } from 'react-native';
4
- import Animated, { SlideInUp, SlideOutUp } from 'react-native-reanimated';
3
+ import { AccessibilityInfo, Platform, Pressable, View } from 'react-native';
4
+ import Animated, { FadeOut, SlideInUp } from 'react-native-reanimated';
5
5
  import { Text } from '../text';
6
6
  import { cn } from '../../lib/cn';
7
+ import { focusRing } from '../../lib/focus-ring';
8
+ import { useMotionPolicy } from '../../lib/motion-policy';
7
9
  import { tv } from '../../lib/tv';
8
10
  import { useVisualTokens } from '../../lib/visual-tokens';
9
11
  import { useThemeColors } from '../../lib/theme-colors';
@@ -17,15 +19,11 @@ export function useToast() {
17
19
  return context;
18
20
  }
19
21
  let toastId = 0;
20
- const TEXT_COLOR_BY_VARIANT = {
21
- default: 'text-foreground',
22
- destructive: 'text-destructive-foreground',
23
- success: 'text-success-foreground',
24
- };
25
- const DESCRIPTION_COLOR_BY_VARIANT = {
26
- default: 'text-muted-foreground',
27
- destructive: 'text-destructive-foreground-muted',
28
- success: 'text-success-foreground-muted',
22
+ /** Status variants carry a small dot, not a fill, so every toast sits on the theme's surface. */
23
+ const ACCENT_BY_VARIANT = {
24
+ default: undefined,
25
+ destructive: 'bg-destructive',
26
+ success: 'bg-success',
29
27
  };
30
28
  export function ToastProvider({ children }) {
31
29
  const [toasts, setToasts] = useState([]);
@@ -44,12 +42,12 @@ export function ToastProvider({ children }) {
44
42
  return _jsx(ToastContext.Provider, { value: contextValue, children: children });
45
43
  }
46
44
  const toastVariants = tv({
47
- base: 'w-full flex-row items-center justify-between border',
45
+ base: 'w-full flex-row items-center gap-3 border bg-popover',
48
46
  variants: {
49
47
  variant: {
50
- default: 'border-border bg-background',
51
- destructive: 'border-destructive bg-destructive',
52
- success: 'border-success bg-success',
48
+ default: 'border-border',
49
+ destructive: 'border-destructive-soft-border',
50
+ success: 'border-success-soft-border',
53
51
  },
54
52
  },
55
53
  defaultVariants: {
@@ -60,31 +58,51 @@ function Toast({ toast: toastData, onDismiss, className, style, ...props }) {
60
58
  const { title, description, variant = 'default', duration = 4000, action } = toastData;
61
59
  const visual = useVisualTokens();
62
60
  const colors = useThemeColors();
61
+ const motion = useMotionPolicy();
62
+ const accent = ACCENT_BY_VARIANT[variant];
63
63
  useEffect(() => {
64
64
  if (duration > 0) {
65
65
  const timer = setTimeout(onDismiss, duration);
66
66
  return () => clearTimeout(timer);
67
67
  }
68
68
  }, [duration, onDismiss]);
69
- const textColor = TEXT_COLOR_BY_VARIANT[variant];
70
- const descColor = DESCRIPTION_COLOR_BY_VARIANT[variant];
71
- return (_jsxs(Animated.View, { entering: SlideInUp.duration(300), exiting: SlideOutUp.duration(200), className: cn(toastVariants({ variant }), className), style: [
72
- {
73
- borderRadius: surfaceRadius(visual, 'lg'),
74
- borderWidth: visual.borderWidthHairline,
75
- padding: surfacePadding(visual, 'sm'),
76
- },
77
- shadowStyle(visual, colors, 'lg'),
78
- style,
79
- ], ...props, children: [_jsxs(View, { className: "flex-1 gap-1", children: [title && (_jsx(Text, { className: cn('font-semibold', textColor), style: typographyStyle(visual, 'label'), children: title })), description && (_jsx(Text, { className: cn(descColor), style: typographyStyle(visual, 'label'), children: description }))] }), _jsxs(View, { className: "flex-row items-center gap-2", children: [action && (_jsx(Pressable, { accessibilityRole: "button", onPress: () => {
80
- action.onPress();
81
- onDismiss();
82
- }, className: "border border-border", style: {
83
- borderRadius: visual.radiusMd,
84
- borderWidth: visual.borderWidthControl,
85
- paddingHorizontal: visual.controlPaddingXSm,
86
- paddingVertical: visual.controlPaddingYSm,
87
- }, children: _jsx(Text, { className: cn('font-medium', textColor), style: typographyStyle(visual, 'label'), children: action.label }) })), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Dismiss", onPress: onDismiss, className: "p-1", children: _jsx(Text, { className: cn(textColor), style: typographyStyle(visual, 'emphasis'), children: "\u00D7" }) })] })] }));
69
+ // Android and the web announce through the live region below; iOS has no live
70
+ // regions, so it is told once, when the toast appears.
71
+ useEffect(() => {
72
+ if (Platform.OS !== 'ios')
73
+ return;
74
+ const message = [title, description].filter(Boolean).join('. ');
75
+ if (message)
76
+ AccessibilityInfo.announceForAccessibility(message);
77
+ // Announce on appearance only; a re-render must not repeat it.
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per toast, by design
79
+ }, []);
80
+ return (_jsx(Animated.View, { entering: motion.animationsEnabled ? SlideInUp.duration(motion.durations.normal) : undefined, exiting: motion.animationsEnabled ? FadeOut.duration(motion.durations.fast) : undefined, children: _jsxs(View
81
+ // Announced where it appears, without moving focus. Set before the spread so a
82
+ // caller can still override.
83
+ , {
84
+ // Announced where it appears, without moving focus. Set before the spread so a
85
+ // caller can still override.
86
+ accessibilityRole: "alert", accessibilityLiveRegion: "polite", className: cn(toastVariants({ variant }), className), style: [
87
+ {
88
+ borderRadius: surfaceRadius(visual, 'lg'),
89
+ borderWidth: visual.borderWidthHairline,
90
+ padding: surfacePadding(visual, 'sm'),
91
+ },
92
+ shadowStyle(visual, colors, 'lg'),
93
+ style,
94
+ ], ...props, children: [_jsxs(View, { className: "flex-1 gap-0.5", children: [title ? (_jsxs(View, { className: "flex-row items-center gap-2", children: [accent ? _jsx(View, { className: cn('h-2 w-2 rounded-full', accent) }) : null, _jsx(Text, { className: "font-semibold text-popover-foreground", style: typographyStyle(visual, 'label'), children: title })] })) : null, description ? (_jsx(Text, { className: "text-muted-foreground", style: typographyStyle(visual, 'label'), children: description })) : null] }), action ? (_jsx(Pressable, { accessibilityRole: "button", onPress: () => {
95
+ action.onPress();
96
+ onDismiss();
97
+ }, className: cn('border border-border active:bg-state-pressed', focusRing), style: {
98
+ borderRadius: visual.radiusMd,
99
+ borderWidth: visual.borderWidthControl,
100
+ paddingHorizontal: visual.controlPaddingXSm,
101
+ paddingVertical: visual.controlPaddingYSm,
102
+ }, children: _jsx(Text, { className: "font-medium text-popover-foreground", style: typographyStyle(visual, 'label'), children: action.label }) })) : null, _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Dismiss", onPress: onDismiss,
103
+ // 44 px everywhere, since hitSlop does nothing on the web; the negative margin
104
+ // keeps the toast as compact as the old 32 px button left it.
105
+ hitSlop: 2, className: cn('-my-1.5 -mr-1.5 h-11 w-11 items-center justify-center rounded-full active:bg-state-pressed', focusRing), children: _jsx(Text, { className: "text-muted-foreground", style: typographyStyle(visual, 'emphasis'), children: "\u00D7" }) })] }) }));
88
106
  }
89
107
  function ToastContainerItem({ toast, dismiss, }) {
90
108
  const handleDismiss = useCallback(() => dismiss(toast.id), [dismiss, toast.id]);
@@ -94,6 +112,6 @@ export function ToastContainer({ position = 'top', className, style, ...props })
94
112
  const { toasts, dismiss } = useToast();
95
113
  const visual = useVisualTokens();
96
114
  const positionClass = position === 'top' ? 'top-0' : 'bottom-0';
97
- return (_jsx(View, { className: cn('absolute left-0 right-0 z-50 py-2', positionClass, className), style: [{ paddingHorizontal: visual.surfacePaddingSm }, style], pointerEvents: "box-none", ...props, children: toasts.map((toast) => (_jsx(ToastContainerItem, { toast: toast, dismiss: dismiss }, toast.id))) }));
115
+ return (_jsx(View, { className: cn('absolute left-0 right-0 z-50 py-2', positionClass, className), style: [{ paddingHorizontal: visual.surfacePaddingSm }, style], pointerEvents: "box-none", ...props, children: _jsx(View, { className: "w-full max-w-md self-center", pointerEvents: "box-none", children: toasts.map((toast) => (_jsx(ToastContainerItem, { toast: toast, dismiss: dismiss }, toast.id))) }) }));
98
116
  }
99
117
  export { Toast, toastVariants };
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { registerFrostedBlurView, type FrostedSurfaceProps, type FrostedTintToke
15
15
  export { controlRecipe, fieldRecipe, surfaceRecipe, surfacePadding, surfaceRadius, shadowStyle, typographyStyle, useControlRecipe, useFieldRecipe, useSurfaceRecipe, useShadowStyle, useTypography, type ControlSize, type SurfacePaddingSize, type SurfaceRecipeOptions, type ShadowTier, type TypographyRole, } from './lib/recipes';
16
16
  export { WHILEUI_PRESET_SOFT, WHILEUI_PRESET_ATELIER, WHILEUI_THEME_PRESETS, themePresetToCssVariables, themePresetToCss, type ThemePreset, } from './lib/theme-presets';
17
17
  export { deriveThemeColors, THEME_BASE_TOKEN_KEYS, type ThemeBaseColors } from './lib/theme-derive';
18
+ export { checkThemeContrast, themeFromSeeds, type ContrastCheck, type ThemeSeeds, } from './lib/theme-seeds';
18
19
  export { Text, textVariants, type TextProps } from './components/text';
19
20
  export { Surface, type SurfaceLevel, type SurfaceProps } from './components/surface';
20
21
  export { View, type ViewProps } from './components/view';
@@ -29,7 +30,7 @@ export { OTPInput, otpCellVariants, type OTPInputProps } from './components/otp-
29
30
  export { FormField, FormLabel, FormControl, FormHint, FormMessage, useFormFieldState, formFieldVariants, formControlVariants, type FormFieldProps, type FormLabelProps, type FormControlProps, type FormHintProps, type FormMessageProps, type FormFieldState, } from './components/form-field';
30
31
  export { LabeledField, LabeledFieldControl, type LabeledFieldProps, type LabeledFieldControlProps, } from './components/labeled-field';
31
32
  export { Badge, BadgeText, badgeVariants, badgeTextVariants, type BadgeProps, type BadgeTextProps, } from './components/badge';
32
- export { FAB, FABGroup, fabVariants, type FabProps, type FabGroupProps } from './components/fab';
33
+ export { FAB, FABGroup, fabClearance, fabVariants, type FabProps, type FabGroupProps, } from './components/fab';
33
34
  export { Alert, AlertTitle, AlertDescription, alertVariants, type AlertProps, type AlertTitleProps, type AlertDescriptionProps, } from './components/alert';
34
35
  export { Switch, type SwitchProps } from './components/switch';
35
36
  export { Checkbox, type CheckboxProps } from './components/checkbox';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export { registerFrostedBlurView, } from './lib/frosted-surface';
15
15
  export { controlRecipe, fieldRecipe, surfaceRecipe, surfacePadding, surfaceRadius, shadowStyle, typographyStyle, useControlRecipe, useFieldRecipe, useSurfaceRecipe, useShadowStyle, useTypography, } from './lib/recipes';
16
16
  export { WHILEUI_PRESET_SOFT, WHILEUI_PRESET_ATELIER, WHILEUI_THEME_PRESETS, themePresetToCssVariables, themePresetToCss, } from './lib/theme-presets';
17
17
  export { deriveThemeColors, THEME_BASE_TOKEN_KEYS } from './lib/theme-derive';
18
+ export { checkThemeContrast, themeFromSeeds, } from './lib/theme-seeds';
18
19
  export { Text, textVariants } from './components/text';
19
20
  export { Surface } from './components/surface';
20
21
  export { View } from './components/view';
@@ -29,7 +30,7 @@ export { OTPInput, otpCellVariants } from './components/otp-input';
29
30
  export { FormField, FormLabel, FormControl, FormHint, FormMessage, useFormFieldState, formFieldVariants, formControlVariants, } from './components/form-field';
30
31
  export { LabeledField, LabeledFieldControl, } from './components/labeled-field';
31
32
  export { Badge, BadgeText, badgeVariants, badgeTextVariants, } from './components/badge';
32
- export { FAB, FABGroup, fabVariants } from './components/fab';
33
+ export { FAB, FABGroup, fabClearance, fabVariants, } from './components/fab';
33
34
  export { Alert, AlertTitle, AlertDescription, alertVariants, } from './components/alert';
34
35
  export { Switch } from './components/switch';
35
36
  export { Checkbox } from './components/checkbox';
@@ -0,0 +1,16 @@
1
+ import { type RefObject } from 'react';
2
+ import type { LayoutPosition } from '@rn-primitives/hooks';
3
+ interface Measurable {
4
+ measure: (callback: (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void) => void;
5
+ }
6
+ /**
7
+ * Closes the keyboard when a portal overlay opens, then measures its trigger again.
8
+ *
9
+ * `Select` and `Popover` draw in a portal rather than a Modal, so iOS and Android leave the
10
+ * keyboard up and it covers the lower half of the content. Closing it moves any
11
+ * keyboard-avoiding screen, which leaves the trigger away from where it was measured on
12
+ * press, so the position is taken again once the keyboard has gone. Modal-hosted overlays
13
+ * need none of this: the platform closes the keyboard when the Modal's window takes focus.
14
+ */
15
+ export declare function useDismissKeyboardOnOpen(open: boolean, triggerRef: RefObject<Measurable | null>, setTriggerPosition: (position: LayoutPosition) => void): void;
16
+ export {};
@@ -0,0 +1,22 @@
1
+ import { useEffect } from 'react';
2
+ import { Keyboard, Platform } from 'react-native';
3
+ /**
4
+ * Closes the keyboard when a portal overlay opens, then measures its trigger again.
5
+ *
6
+ * `Select` and `Popover` draw in a portal rather than a Modal, so iOS and Android leave the
7
+ * keyboard up and it covers the lower half of the content. Closing it moves any
8
+ * keyboard-avoiding screen, which leaves the trigger away from where it was measured on
9
+ * press, so the position is taken again once the keyboard has gone. Modal-hosted overlays
10
+ * need none of this: the platform closes the keyboard when the Modal's window takes focus.
11
+ */
12
+ export function useDismissKeyboardOnOpen(open, triggerRef, setTriggerPosition) {
13
+ useEffect(() => {
14
+ if (!open || Platform.OS === 'web' || !Keyboard.isVisible())
15
+ return;
16
+ const subscription = Keyboard.addListener('keyboardDidHide', () => {
17
+ triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => setTriggerPosition({ width, height, pageX, pageY }));
18
+ });
19
+ Keyboard.dismiss();
20
+ return () => subscription.remove();
21
+ }, [open, triggerRef, setTriggerPosition]);
22
+ }
@@ -3,8 +3,11 @@ import { type ViewStyle } from 'react-native';
3
3
  export type FrostedTintToken = 'surfaceElevated' | 'surfaceTranslucent' | 'card' | 'popover';
4
4
  export type FrostedBlurPreset = 'subtle' | 'medium' | 'strong';
5
5
  export interface FrostedSurfaceProps {
6
+ /** Enable the optional frosted overlay. */
6
7
  frosted?: boolean;
8
+ /** Override blur amount directly. */
7
9
  blurIntensity?: number;
10
+ /** Tint token used for frosted mode. */
8
11
  blurTintToken?: FrostedTintToken;
9
12
  }
10
13
  interface UseFrostedSurfaceOptions extends FrostedSurfaceProps {
@@ -22,6 +22,26 @@ export declare function surfacePadding(visual: VisualTokens, padding?: SurfacePa
22
22
  export declare function surfaceRadius(visual: VisualTokens, radius?: 'sm' | 'md' | 'lg' | 'xl'): number;
23
23
  export declare function surfaceRecipe(visual: VisualTokens, options?: SurfaceRecipeOptions): ViewStyle;
24
24
  export declare function shadowStyle(visual: VisualTokens, colors: ThemeColors, tier: ShadowTier): ViewStyle;
25
+ /**
26
+ * Caret and selection colours for a text field: the theme's primary, where React Native
27
+ * would otherwise use the platform blue. iOS tints the caret, handles and highlight from
28
+ * `selectionColor` alone. Android paints `selectionColor` as the highlight at full
29
+ * strength, which would hide the selected text, so it gets a translucent highlight and
30
+ * the caret and handles take the solid colour. The web keeps the browser's caret.
31
+ */
32
+ export declare function textCursorColors(colors: ThemeColors): {
33
+ cursorColor: string;
34
+ selectionHandleColor: string;
35
+ selectionColor: string;
36
+ } | {
37
+ selectionColor: string;
38
+ cursorColor?: undefined;
39
+ selectionHandleColor?: undefined;
40
+ } | {
41
+ cursorColor?: undefined;
42
+ selectionHandleColor?: undefined;
43
+ selectionColor?: undefined;
44
+ };
25
45
  export declare function typographyStyle(visual: VisualTokens, role: TypographyRole): TextStyle;
26
46
  export declare function useControlRecipe(size?: ControlSize): ViewStyle;
27
47
  export declare function useFieldRecipe(size?: ControlSize): ViewStyle;
@@ -108,17 +108,34 @@ export function shadowStyle(visual, colors, tier) {
108
108
  offsetX: 0,
109
109
  offsetY,
110
110
  blurRadius: radius * 2,
111
- color: shadowColorWithOpacity(colors.shadow, opacity),
111
+ color: colorWithOpacity(colors.shadow, opacity),
112
112
  },
113
113
  ],
114
114
  };
115
115
  }
116
- function shadowColorWithOpacity(color, opacity) {
116
+ function colorWithOpacity(color, opacity) {
117
117
  const parsed = parse(color);
118
118
  if (!parsed)
119
119
  return color;
120
120
  return formatRgb({ ...parsed, alpha: opacity });
121
121
  }
122
+ /**
123
+ * Caret and selection colours for a text field: the theme's primary, where React Native
124
+ * would otherwise use the platform blue. iOS tints the caret, handles and highlight from
125
+ * `selectionColor` alone. Android paints `selectionColor` as the highlight at full
126
+ * strength, which would hide the selected text, so it gets a translucent highlight and
127
+ * the caret and handles take the solid colour. The web keeps the browser's caret.
128
+ */
129
+ export function textCursorColors(colors) {
130
+ if (Platform.OS === 'android') {
131
+ return {
132
+ cursorColor: colors.primary,
133
+ selectionHandleColor: colors.primary,
134
+ selectionColor: colorWithOpacity(colors.primary, 0.3),
135
+ };
136
+ }
137
+ return Platform.OS === 'ios' ? { selectionColor: colors.primary } : {};
138
+ }
122
139
  // Component-owned text sizes. Font family/weight stays on className
123
140
  // (font-medium etc.) so app font mappings keep working.
124
141
  export function typographyStyle(visual, role) {
@@ -14,10 +14,19 @@ export interface UseThemeBridgeOptions {
14
14
  adapter?: ThemeBridgeAdapter;
15
15
  }
16
16
  export interface UseThemeBridgeResult {
17
+ /** The chosen mode, `system` included. */
17
18
  mode: ThemeMode;
19
+ /** The theme on screen: `light`, `dark`, or a registered custom theme. */
18
20
  resolvedTheme: string;
19
21
  setMode: (mode: ThemeMode) => void;
22
+ /**
23
+ * Steps light → dark → system. For a control that shows all three modes; a two-state
24
+ * sun/moon button wants `toggleTheme`, because a step into `system` that resolves to
25
+ * the theme already on screen looks like a tap that did nothing.
26
+ */
20
27
  cycleMode: () => void;
28
+ /** Switches to the opposite of the theme on screen: dark from light, light from anything else. */
29
+ toggleTheme: () => void;
21
30
  }
22
31
  export declare function applyThemeMode(mode: ThemeMode): void;
23
32
  export declare function resolveThemeMode(adapter?: ThemeBridgeAdapter, fallback?: ThemeMode): Promise<ThemeMode>;
@@ -101,12 +101,14 @@ export function useThemeBridge({ initialMode = 'system', adapter, } = {}) {
101
101
  dispatch({ type: 'select', mode });
102
102
  }, []);
103
103
  const cycleMode = useCallback(() => dispatch({ type: 'cycle' }), []);
104
+ const toggleTheme = useCallback(() => dispatch({ type: 'select', mode: theme === 'light' ? 'dark' : 'light' }), [theme]);
104
105
  return useMemo(() => ({
105
106
  mode: state.mode,
106
107
  resolvedTheme: theme,
107
108
  setMode,
108
109
  cycleMode,
109
- }), [cycleMode, setMode, state.mode, theme]);
110
+ toggleTheme,
111
+ }), [cycleMode, setMode, state.mode, theme, toggleTheme]);
110
112
  }
111
113
  function ControlledThemeBridge({ mode, adapter, }) {
112
114
  useEffect(() => {
@@ -13,16 +13,16 @@ export const WHILEUI_LIGHT_THEME_COLORS = {
13
13
  mutedForeground: '#645783',
14
14
  border: 'rgba(68, 87, 255, 0.16)',
15
15
  input: 'rgba(255, 255, 255, 0.84)',
16
- ring: 'rgba(61, 139, 255, 0.34)',
16
+ ring: '#3d8bff',
17
17
  accent: '#a7f432',
18
18
  accentForeground: '#163218',
19
- destructive: '#f66151',
19
+ destructive: '#d03d31',
20
20
  destructiveForeground: '#fff8f6',
21
- success: '#13a86d',
21
+ success: '#00864d',
22
22
  successForeground: '#f5fff9',
23
23
  warning: '#ffb224',
24
24
  warningForeground: '#3d2200',
25
- info: '#3d8bff',
25
+ info: '#2070e1',
26
26
  infoForeground: '#f8fbff',
27
27
  overlay: 'rgba(39, 31, 72, 0.14)',
28
28
  overlayStrong: 'rgba(39, 31, 72, 0.22)',
@@ -37,19 +37,19 @@ export const WHILEUI_LIGHT_THEME_COLORS = {
37
37
  shadow: '#271f48',
38
38
  primaryActive: '#3346e6',
39
39
  secondaryActive: 'rgba(255, 123, 92, 0.18)',
40
- destructiveActive: '#e0503f',
40
+ destructiveActive: '#bb261d',
41
41
  primarySoft: 'rgba(68, 87, 255, 0.13)',
42
42
  primarySoftSubtle: 'rgba(68, 87, 255, 0.07)',
43
43
  secondarySoft: 'rgba(255, 123, 92, 0.16)',
44
44
  mutedSoft: 'rgba(74, 55, 122, 0.05)',
45
- destructiveSoft: 'rgba(246, 97, 81, 0.12)',
46
- destructiveSoftBorder: 'rgba(246, 97, 81, 0.30)',
47
- successSoft: 'rgba(19, 168, 109, 0.12)',
48
- successSoftBorder: 'rgba(19, 168, 109, 0.30)',
45
+ destructiveSoft: 'rgba(208, 61, 49, 0.12)',
46
+ destructiveSoftBorder: 'rgba(208, 61, 49, 0.30)',
47
+ successSoft: 'rgba(0, 134, 77, 0.12)',
48
+ successSoftBorder: 'rgba(0, 134, 77, 0.30)',
49
49
  warningSoft: 'rgba(255, 178, 36, 0.14)',
50
50
  warningSoftBorder: 'rgba(255, 178, 36, 0.34)',
51
- infoSoft: 'rgba(61, 139, 255, 0.12)',
52
- infoSoftBorder: 'rgba(61, 139, 255, 0.30)',
51
+ infoSoft: 'rgba(32, 112, 225, 0.12)',
52
+ infoSoftBorder: 'rgba(32, 112, 225, 0.30)',
53
53
  placeholder: '#645783',
54
54
  };
55
55
  export const WHILEUI_DARK_THEME_COLORS = {
@@ -67,7 +67,7 @@ export const WHILEUI_DARK_THEME_COLORS = {
67
67
  mutedForeground: '#c5bad9',
68
68
  border: 'rgba(255, 255, 255, 0.12)',
69
69
  input: 'rgba(27, 18, 49, 0.86)',
70
- ring: 'rgba(127, 240, 197, 0.36)',
70
+ ring: '#7ff0c5',
71
71
  accent: '#7ff0c5',
72
72
  accentForeground: '#0f2d24',
73
73
  destructive: '#ff6b57',
@@ -38,7 +38,7 @@ export const WHILEUI_PRESET_SOFT = {
38
38
  };
39
39
  // Compact geometry, hairline-plus borders, real drop shadows, warm ink.
40
40
  const ATELIER_LIGHT = {
41
- primary: '#b4552d',
41
+ primary: '#af5028',
42
42
  primaryForeground: '#fdf6ee',
43
43
  foreground: '#2b2118',
44
44
  background: '#f6efe4',
@@ -49,10 +49,10 @@ const ATELIER_LIGHT = {
49
49
  secondary: 'rgba(43, 33, 24, 0.06)',
50
50
  secondaryForeground: '#4a3a2a',
51
51
  muted: 'rgba(43, 33, 24, 0.05)',
52
- mutedForeground: '#7a6a58',
52
+ mutedForeground: '#716150',
53
53
  border: 'rgba(43, 33, 24, 0.22)',
54
54
  input: '#fffdf8',
55
- ring: 'rgba(180, 85, 45, 0.45)',
55
+ ring: '#af5028',
56
56
  accent: '#1f6f54',
57
57
  accentForeground: '#f0faf5',
58
58
  destructive: '#b3261e',
@@ -70,15 +70,15 @@ const ATELIER_LIGHT = {
70
70
  surfaceTranslucentBorder: 'rgba(43, 33, 24, 0.16)',
71
71
  surfaceBorder: 'rgba(43, 33, 24, 0.22)',
72
72
  surfaceHighlight: 'rgba(255, 255, 255, 0.65)',
73
- stateHover: 'rgba(180, 85, 45, 0.07)',
74
- statePressed: 'rgba(180, 85, 45, 0.14)',
73
+ stateHover: 'rgba(175, 80, 40, 0.07)',
74
+ statePressed: 'rgba(175, 80, 40, 0.14)',
75
75
  stateDisabled: 'rgba(122, 106, 88, 0.30)',
76
76
  shadow: '#3a2c1c',
77
77
  primaryActive: '#96431f',
78
78
  secondaryActive: 'rgba(43, 33, 24, 0.12)',
79
79
  destructiveActive: '#8f1d17',
80
- primarySoft: 'rgba(180, 85, 45, 0.12)',
81
- primarySoftSubtle: 'rgba(180, 85, 45, 0.06)',
80
+ primarySoft: 'rgba(175, 80, 40, 0.12)',
81
+ primarySoftSubtle: 'rgba(175, 80, 40, 0.06)',
82
82
  secondarySoft: 'rgba(31, 111, 84, 0.10)',
83
83
  mutedSoft: 'rgba(43, 33, 24, 0.04)',
84
84
  destructiveSoft: 'rgba(179, 38, 30, 0.10)',
@@ -89,7 +89,7 @@ const ATELIER_LIGHT = {
89
89
  warningSoftBorder: 'rgba(192, 123, 18, 0.36)',
90
90
  infoSoft: 'rgba(47, 93, 158, 0.10)',
91
91
  infoSoftBorder: 'rgba(47, 93, 158, 0.32)',
92
- placeholder: '#7a6a58',
92
+ placeholder: '#716150',
93
93
  };
94
94
  const ATELIER_DARK = {
95
95
  primary: '#e8935f',
@@ -106,7 +106,7 @@ const ATELIER_DARK = {
106
106
  mutedForeground: '#a3937d',
107
107
  border: 'rgba(239, 230, 216, 0.18)',
108
108
  input: '#211a14',
109
- ring: 'rgba(232, 147, 95, 0.45)',
109
+ ring: '#e8935f',
110
110
  accent: '#6fd0a8',
111
111
  accentForeground: '#0b2419',
112
112
  destructive: '#e5695f',
@@ -0,0 +1,40 @@
1
+ import type { ThemeColors } from './theme-contract';
2
+ import { type ThemeBaseColors } from './theme-derive';
3
+ import { type ThemePreset } from './theme-presets';
4
+ import type { VisualTokens } from './visual-token-contract';
5
+ export interface ThemeSeeds {
6
+ /** Page background. */
7
+ background: string;
8
+ /** Body text. */
9
+ foreground: string;
10
+ /** Brand colour: primary buttons, links, focus ring. */
11
+ primary: string;
12
+ /** Second voice for highlights and selected states. Derived from `primary` if left out. */
13
+ accent?: string;
14
+ }
15
+ /**
16
+ * A complete preset from seeds. `light` is required; `dark` defaults to a dark page with
17
+ * the same brand colours. `overrides` replaces any base colour before the rest is
18
+ * derived, so nothing is out of reach; derived tokens can be adjusted on the result.
19
+ */
20
+ export declare function themeFromSeeds(input: {
21
+ name: string;
22
+ description: string;
23
+ light: ThemeSeeds;
24
+ dark?: ThemeSeeds;
25
+ overrides?: {
26
+ light?: Partial<ThemeBaseColors>;
27
+ dark?: Partial<ThemeBaseColors>;
28
+ };
29
+ visual?: Partial<VisualTokens>;
30
+ }): ThemePreset;
31
+ export interface ContrastCheck {
32
+ /** "foreground on background" */
33
+ pair: string;
34
+ ratio: number;
35
+ /** 4.5 for text, 3 for focus rings and other non-text UI (WCAG 1.4.3, 1.4.11). */
36
+ min: number;
37
+ pass: boolean;
38
+ }
39
+ /** Every text and focus-ring pairing the components render, checked against WCAG AA. */
40
+ export declare function checkThemeContrast(colors: ThemeColors): ContrastCheck[];
@@ -0,0 +1,143 @@
1
+ // A full theme from a few seed colours, plus a contrast check for any palette.
2
+ // Renderer-free: safe in build scripts and for agents generating themes.
3
+ //
4
+ // Seeds are never altered: a brand colour is a brand decision, so when it fails
5
+ // contrast the check reports it instead. Only derived colours (muted text, the text on
6
+ // each filled colour, status colours) are tuned to meet WCAG.
7
+ import { blend, converter, formatHex, parse, wcagContrast } from 'culori';
8
+ import { createThemePreset } from './theme-presets';
9
+ const toOklch = converter('oklch');
10
+ /** WCAG 2.x contrast ratio between two CSS colours, 1–21. */
11
+ function contrastRatio(a, b) {
12
+ return wcagContrast(a, b);
13
+ }
14
+ function oklch(color) {
15
+ const parsed = parse(color);
16
+ if (!parsed)
17
+ throw new Error(`Not a CSS colour: "${color}"`);
18
+ return toOklch(parsed);
19
+ }
20
+ const hex = (l, c, h) => formatHex({ mode: 'oklch', l: Math.min(1, Math.max(0, l)), c, h });
21
+ /** Mix `a` toward `b` by `t` in OKLCH lightness and chroma, keeping `a`'s hue. */
22
+ function toward(a, b, t) {
23
+ const x = oklch(a);
24
+ const y = oklch(b);
25
+ return hex(x.l + (y.l - x.l) * t, (x.c ?? 0) + ((y.c ?? 0) - (x.c ?? 0)) * t, x.h ?? y.h ?? 0);
26
+ }
27
+ /** Move `color` away from `against` in lightness until it reaches `min` contrast. */
28
+ function ensureContrast(color, against, min) {
29
+ const c = oklch(color);
30
+ const darker = oklch(against).l > 0.5;
31
+ for (let l = c.l; l >= 0 && l <= 1; l += darker ? -0.01 : 0.01) {
32
+ const candidate = hex(l, c.c ?? 0, c.h ?? 0);
33
+ if (contrastRatio(candidate, against) >= min)
34
+ return candidate;
35
+ }
36
+ return darker ? '#000000' : '#ffffff';
37
+ }
38
+ /** Black or white tinted toward the theme, whichever reads better on `fill`. */
39
+ function onFill(fill, seeds) {
40
+ const light = oklch(seeds.background).l > oklch(seeds.foreground).l ? seeds.background : seeds.foreground;
41
+ const dark = light === seeds.background ? seeds.foreground : seeds.background;
42
+ return contrastRatio(light, fill) >= contrastRatio(dark, fill) ? light : dark;
43
+ }
44
+ // Status hues stay recognisable across brands; only their lightness adapts to the page.
45
+ const STATUS_HUES = { destructive: 27, success: 150, warning: 75, info: 240 };
46
+ /** The 25 base colours a theme needs, derived from its seeds. */
47
+ function themeBaseFromSeeds(seeds) {
48
+ const { background, foreground, primary } = seeds;
49
+ const dark = oklch(background).l < 0.5;
50
+ const accent = seeds.accent ?? toward(primary, background, 0.82);
51
+ const surface = toward(background, foreground, dark ? 0.06 : 0.025);
52
+ const muted = toward(background, foreground, dark ? 0.1 : 0.06);
53
+ const status = (hue) => ensureContrast(hex(dark ? 0.72 : 0.55, 0.15, hue), background, 4.5);
54
+ const destructive = status(STATUS_HUES.destructive);
55
+ const success = status(STATUS_HUES.success);
56
+ const warning = status(STATUS_HUES.warning);
57
+ const info = status(STATUS_HUES.info);
58
+ const fills = { background, foreground };
59
+ return {
60
+ background,
61
+ foreground,
62
+ card: surface,
63
+ cardForeground: foreground,
64
+ popover: surface,
65
+ popoverForeground: foreground,
66
+ primary,
67
+ primaryForeground: onFill(primary, fills),
68
+ secondary: muted,
69
+ secondaryForeground: foreground,
70
+ accent,
71
+ accentForeground: onFill(accent, fills),
72
+ muted,
73
+ mutedForeground: ensureContrast(toward(foreground, background, 0.42), muted, 4.5),
74
+ border: toward(background, foreground, dark ? 0.16 : 0.13),
75
+ input: muted,
76
+ ring: primary,
77
+ destructive,
78
+ destructiveForeground: onFill(destructive, fills),
79
+ success,
80
+ successForeground: onFill(success, fills),
81
+ warning,
82
+ warningForeground: onFill(warning, fills),
83
+ info,
84
+ infoForeground: onFill(info, fills),
85
+ };
86
+ }
87
+ /**
88
+ * A complete preset from seeds. `light` is required; `dark` defaults to a dark page with
89
+ * the same brand colours. `overrides` replaces any base colour before the rest is
90
+ * derived, so nothing is out of reach; derived tokens can be adjusted on the result.
91
+ */
92
+ export function themeFromSeeds(input) {
93
+ const { light } = input;
94
+ const page = oklch(light.background);
95
+ const darkPage = hex(0.17, Math.min(page.c ?? 0, 0.02), page.h ?? 0);
96
+ // Generated, not supplied, so brand colours may be lifted until they read on a dark page.
97
+ const dark = input.dark ?? {
98
+ background: darkPage,
99
+ foreground: hex(0.94, Math.min(page.c ?? 0, 0.015), page.h ?? 0),
100
+ primary: ensureContrast(light.primary, darkPage, 4.5),
101
+ accent: light.accent && ensureContrast(light.accent, darkPage, 3),
102
+ };
103
+ return createThemePreset({
104
+ name: input.name,
105
+ description: input.description,
106
+ light: { ...themeBaseFromSeeds(light), ...input.overrides?.light },
107
+ dark: { ...themeBaseFromSeeds(dark), ...input.overrides?.dark },
108
+ visual: input.visual,
109
+ });
110
+ }
111
+ const TEXT_PAIRS = [
112
+ ['foreground', 'background'],
113
+ ['cardForeground', 'card'],
114
+ ['popoverForeground', 'popover'],
115
+ ['mutedForeground', 'background'],
116
+ ['mutedForeground', 'muted'],
117
+ ['primaryForeground', 'primary'],
118
+ ['secondaryForeground', 'secondary'],
119
+ ['accentForeground', 'accent'],
120
+ ['destructiveForeground', 'destructive'],
121
+ ['successForeground', 'success'],
122
+ ['warningForeground', 'warning'],
123
+ ['infoForeground', 'info'],
124
+ ['primary', 'background'],
125
+ ['destructive', 'background'],
126
+ ];
127
+ const UI_PAIRS = [['ring', 'background']];
128
+ /** A translucent colour as it actually renders: blended onto the page. */
129
+ function onPage(color, page) {
130
+ const parsed = parse(color);
131
+ if (!parsed || (parsed.alpha ?? 1) >= 1)
132
+ return color;
133
+ return formatHex(blend([page, parsed], 'normal'));
134
+ }
135
+ /** Every text and focus-ring pairing the components render, checked against WCAG AA. */
136
+ export function checkThemeContrast(colors) {
137
+ const check = ([fg, bg], min) => {
138
+ const background = onPage(colors[bg], colors.background);
139
+ const ratio = Math.round(contrastRatio(onPage(colors[fg], background), background) * 100) / 100;
140
+ return { pair: `${fg} on ${bg}`, ratio, min, pass: ratio >= min };
141
+ };
142
+ return [...TEXT_PAIRS.map((p) => check(p, 4.5)), ...UI_PAIRS.map((p) => check(p, 3))];
143
+ }
package/dist/metro.cjs CHANGED
@@ -4,10 +4,11 @@ const { withUniwindConfig } = require('uniwind/metro');
4
4
 
5
5
  /**
6
6
  * Packages that must resolve the real `react-native`. Uniwind's native resolver
7
- * rewrites every `from 'react-native'` to its component wrapper, which re-exports
8
- * components and nothing else, so a package importing a non-component export gets
9
- * `undefined` with no build error. `expo-asset` imports `AssetRegistry` that way, and
10
- * without it every `@expo/vector-icons` glyph is silently absent on iOS and Android.
7
+ * rewrites every `from 'react-native'` to its component wrapper. The wrapper re-exports
8
+ * the components and most of the API, but not all of it, and a missing export arrives
9
+ * as `undefined` with no build error. `expo-asset` imports `AssetRegistry`, which the
10
+ * wrapper lacks, and without it every `@expo/vector-icons` glyph is silently absent on
11
+ * iOS and Android.
11
12
  */
12
13
  const NEEDS_REAL_REACT_NATIVE = ['expo-asset', 'expo-font', '@expo/vector-icons'];
13
14