@thewhileloop/whileui 4.2.1 → 4.3.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/README.md +1235 -663
  2. package/dist/agent/catalog.json +1737 -0
  3. package/dist/agent/llms-components.txt +2787 -0
  4. package/dist/agent/llms-full.txt +2962 -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/sheet.js +1 -1
  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 +2 -2
  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/form-field/form-field.d.ts +18 -0
  32. package/dist/components/form-field/form-field.js +25 -4
  33. package/dist/components/input/input.js +2 -2
  34. package/dist/components/numeric-input/numeric-input.d.ts +3 -0
  35. package/dist/components/numeric-input/numeric-input.js +2 -2
  36. package/dist/components/otp-input/otp-input.d.ts +8 -0
  37. package/dist/components/pressable/pressable.d.ts +1 -0
  38. package/dist/components/segmented-control/segmented-control.d.ts +1 -0
  39. package/dist/components/surface/surface.d.ts +5 -0
  40. package/dist/components/textarea/textarea.js +2 -2
  41. package/dist/components/toast/toast.d.ts +10 -10
  42. package/dist/components/toast/toast.js +53 -35
  43. package/dist/index.d.ts +1 -0
  44. package/dist/index.js +1 -0
  45. package/dist/lib/frosted-surface.d.ts +3 -0
  46. package/dist/lib/theme-colors.d.ts +4 -3
  47. package/dist/lib/theme-colors.js +26 -1
  48. package/dist/lib/theme-contract.js +12 -12
  49. package/dist/lib/theme-presets.js +9 -9
  50. package/dist/lib/theme-seeds.d.ts +40 -0
  51. package/dist/lib/theme-seeds.js +143 -0
  52. package/dist/lib/visual-tokens.js +13 -0
  53. package/dist/metro.cjs +12 -5
  54. package/dist/tokens.d.ts +1 -0
  55. package/dist/tokens.js +1 -0
  56. package/package.json +3 -2
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, useContext, useMemo } from 'react';
2
+ import React, { createContext, useContext, useId, useMemo } from 'react';
3
3
  import { Text, View, } from 'react-native';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { tv } from '../../lib/tv';
@@ -44,16 +44,37 @@ const FormFieldContext = createContext({
44
44
  export function useFormFieldState() {
45
45
  return useContext(FormFieldContext);
46
46
  }
47
+ /**
48
+ * Accessibility props that tie a text control to its FormField: named by the FormLabel
49
+ * unless the caller names or links it any of the four ways React Native allows, and
50
+ * required when the field is. Spread before the caller's props. iOS has no labelled-by;
51
+ * VoiceOver reads the visible label just before the field.
52
+ */
53
+ export function fieldAccessibilityProps(field, props) {
54
+ const named = props.accessibilityLabel ??
55
+ props['aria-label'] ??
56
+ props['aria-labelledby'] ??
57
+ props.accessibilityLabelledBy;
58
+ return {
59
+ 'aria-labelledby': named ? undefined : field.labelId,
60
+ 'aria-required': field.required || undefined,
61
+ };
62
+ }
47
63
  const FormField = React.forwardRef(({ className, density = 'default', invalid = false, disabled = false, required = false, ...props }, ref) => {
48
64
  const interaction = useInteractionTokens();
49
- const contextValue = useMemo(() => ({ density, invalid, disabled, required }), [density, disabled, invalid, required]);
65
+ const labelId = useId();
66
+ const contextValue = useMemo(() => ({ density, invalid, disabled, required, labelId }), [density, disabled, invalid, labelId, required]);
50
67
  return (_jsx(FormFieldContext.Provider, { value: contextValue, children: _jsx(View, { ref: ref, className: cn(formFieldVariants({ density }), className), style: disabled ? { opacity: interaction.disabledOpacitySoft } : undefined, ...props }) }));
51
68
  });
52
69
  FormField.displayName = 'FormField';
53
70
  const FormLabel = React.forwardRef(({ className, showRequiredIndicator = true, children, style, ...props }, ref) => {
54
- const { invalid, disabled, required } = useContext(FormFieldContext);
71
+ const { invalid, disabled, required, labelId } = useContext(FormFieldContext);
55
72
  const visual = useVisualTokens();
56
- return (_jsxs(Text, { ref: ref, className: cn('font-medium text-foreground', invalid && 'text-destructive', disabled && 'text-muted-foreground', className), style: [typographyStyle(visual, 'label'), style], ...props, children: [children, required && showRequiredIndicator ? _jsx(Text, { className: "text-destructive", children: " *" }) : null] }));
73
+ const indicator = required && showRequiredIndicator;
74
+ return (_jsxs(Text, { ref: ref, nativeID: labelId,
75
+ // iOS reads nested Text as one string, so hiding the asterisk alone still says
76
+ // "star"; a plain-text label names it once. The control announces "required".
77
+ accessibilityLabel: indicator && typeof children === 'string' ? children : undefined, className: cn('font-medium text-foreground', invalid && 'text-destructive', disabled && 'text-muted-foreground', className), style: [typographyStyle(visual, 'label'), style], ...props, children: [children, indicator ? (_jsx(Text, { "aria-hidden": true, className: "text-destructive", children: ' *' })) : null] }));
57
78
  });
58
79
  FormLabel.displayName = 'FormLabel';
59
80
  const FormControl = React.forwardRef(({ className, style, ...props }, ref) => {
@@ -9,7 +9,7 @@ import { useInteractionTokens } from '../../lib/interaction-tokens';
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
10
  import { typographyStyle } from '../../lib/recipes';
11
11
  import { webAutofillField } from '../../lib/web-autofill';
12
- import { useFormFieldState } from '../form-field/form-field';
12
+ import { fieldAccessibilityProps, useFormFieldState } from '../form-field/form-field';
13
13
  const inputVariants = tv({
14
14
  base: 'w-full border-border bg-muted text-foreground',
15
15
  variants: {
@@ -43,7 +43,7 @@ const Input = React.forwardRef(({ className, prefix, suffix, inputClassName, var
43
43
  paddingHorizontal: plain ? 0 : visual.controlPaddingXDefault,
44
44
  },
45
45
  !effectiveEditable ? { opacity: interaction.disabledOpacity } : undefined,
46
- ], children: [prefix && (_jsx(View, { style: { paddingRight: Math.max(8, Math.round(visual.controlPaddingXDefault * 0.5)) }, children: prefix })), _jsx(TextInput, { ref: ref, className: cn('flex-1 text-foreground placeholder:text-muted-foreground web:outline-none', !plain && webAutofillField, inputClassName), style: {
46
+ ], children: [prefix && (_jsx(View, { style: { paddingRight: Math.max(8, Math.round(visual.controlPaddingXDefault * 0.5)) }, children: prefix })), _jsx(TextInput, { ref: ref, ...fieldAccessibilityProps(field, props), className: cn('flex-1 text-foreground placeholder:text-muted-foreground web:outline-none', !plain && webAutofillField, inputClassName), style: {
47
47
  ...typographyStyle(visual, 'label'),
48
48
  minHeight: visual.controlHeightDefault - 2,
49
49
  paddingVertical: visual.controlPaddingYDefault,
@@ -52,12 +52,15 @@ export interface NumericInputProps extends Omit<TextInputProps, 'value' | 'defau
52
52
  inputClassName?: string;
53
53
  prefix?: React.ReactNode;
54
54
  suffix?: React.ReactNode;
55
+ /** Controlled numeric value. */
55
56
  value?: number | null;
56
57
  defaultValue?: number;
58
+ /** Numeric value change callback. */
57
59
  onValueChange?: (value: number | null) => void;
58
60
  min?: number;
59
61
  max?: number;
60
62
  step?: number;
63
+ /** Show decrement/increment controls. */
61
64
  showSteppers?: boolean;
62
65
  /** Drop the field shell (border, background, radius, padding) for embedding inside another field shell like LabeledField. */
63
66
  unstyled?: boolean;
@@ -9,7 +9,7 @@ import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/i
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
10
  import { typographyStyle } from '../../lib/recipes';
11
11
  import { webAutofillField } from '../../lib/web-autofill';
12
- import { useFormFieldState } from '../form-field/form-field';
12
+ import { fieldAccessibilityProps, useFormFieldState } from '../form-field/form-field';
13
13
  const numericInputVariants = tv({
14
14
  base: 'w-full flex-row items-center border-border bg-muted',
15
15
  variants: {
@@ -171,7 +171,7 @@ const NumericInput = React.forwardRef(({ className, inputClassName, prefix, suff
171
171
  paddingRight: unstyled ? 0 : showSteppers ? stepInset : 0,
172
172
  },
173
173
  !effectiveEditable ? { opacity: interaction.disabledOpacity } : undefined,
174
- ], children: [_jsx(NumericAffix, { side: "prefix", visual: visual, children: prefix }), _jsx(View, { className: "min-w-0 flex-1", style: Platform.OS === 'web' ? { minWidth: 0, flex: 1 } : undefined, children: _jsx(TextInput, { ref: ref, className: cn(numericInputTextVariants({ size }), 'w-full web:outline-none', !unstyled && webAutofillField, inputClassName), style: numericInputTextStyle(visual, size, unstyled, styleProp), value: model.textValue, onChangeText: model.handleTextChange, onFocus: (event) => {
174
+ ], children: [_jsx(NumericAffix, { side: "prefix", visual: visual, children: prefix }), _jsx(View, { className: "min-w-0 flex-1", style: Platform.OS === 'web' ? { minWidth: 0, flex: 1 } : undefined, children: _jsx(TextInput, { ref: ref, ...fieldAccessibilityProps(field, props), className: cn(numericInputTextVariants({ size }), 'w-full web:outline-none', !unstyled && webAutofillField, inputClassName), style: numericInputTextStyle(visual, size, unstyled, styleProp), value: model.textValue, onChangeText: model.handleTextChange, onFocus: (event) => {
175
175
  setFocused(true);
176
176
  onFocus?.(event);
177
177
  }, onBlur: (event) => {
@@ -34,14 +34,22 @@ declare const otpCellVariants: import("tailwind-variants").TVReturnType<{
34
34
  };
35
35
  }, undefined>>;
36
36
  export interface OTPInputProps extends Omit<ViewProps, 'children'> {
37
+ /** Number of digit cells. */
37
38
  length?: number;
39
+ /** Controlled value. */
38
40
  value?: string;
41
+ /** Called on each digit change. */
39
42
  onValueChange?: (value: string) => void;
43
+ /** Called when all digits are filled. */
40
44
  onComplete?: (code: string) => void;
45
+ /** Error variant triggers shake. */
41
46
  variant?: 'default' | 'error';
47
+ /** Cell size. */
42
48
  size?: 'default' | 'compact';
49
+ /** Mask digits with dots. */
43
50
  secure?: boolean;
44
51
  disabled?: boolean;
52
+ /** Focus first cell on mount. */
45
53
  autoFocus?: boolean;
46
54
  className?: string;
47
55
  }
@@ -2,6 +2,7 @@ import { type PressableInstance, type PressableProps as RNPressableProps } from
2
2
  import { type DisabledVariant, type PressedVariant } from '../../lib/interaction-tokens';
3
3
  import type { ForwardRefComponent } from '../../lib/ref-types';
4
4
  export interface PressableProps extends RNPressableProps {
5
+ /** Additional Tailwind classes. */
5
6
  className?: string;
6
7
  /** Semantic press feedback. `none` preserves a plain React Native Pressable. */
7
8
  feedback?: PressedVariant;
@@ -136,6 +136,7 @@ export interface SegmentedControlProps extends ViewProps {
136
136
  value?: string;
137
137
  defaultValue?: string;
138
138
  onValueChange?: (value: string) => void;
139
+ /** Pill = rounded-full items. */
139
140
  variant?: 'default' | 'pill';
140
141
  wrap?: boolean | 'auto';
141
142
  size?: 'default' | 'compact';
@@ -4,10 +4,15 @@ import { type ShadowTier, type SurfacePaddingSize } from '../../lib/recipes';
4
4
  import type { ForwardRefComponent } from '../../lib/ref-types';
5
5
  export type SurfaceLevel = 'base' | 'raised' | 'overlay';
6
6
  export interface SurfaceProps extends ViewProps, FrostedSurfaceProps {
7
+ /** Semantic background and depth tier. */
7
8
  level?: SurfaceLevel;
9
+ /** Token-backed interior spacing. */
8
10
  padding?: SurfacePaddingSize;
11
+ /** Token-backed corner radius. */
9
12
  radius?: 'sm' | 'md' | 'lg' | 'xl';
13
+ /** Draw the theme hairline border. */
10
14
  bordered?: boolean;
15
+ /** Override the level's depth. */
11
16
  shadow?: ShadowTier;
12
17
  }
13
18
  declare const Surface: ForwardRefComponent<ViewInstance, SurfaceProps>;
@@ -9,7 +9,7 @@ import { useInteractionTokens } from '../../lib/interaction-tokens';
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
10
  import { typographyStyle } from '../../lib/recipes';
11
11
  import { webAutofillField } from '../../lib/web-autofill';
12
- import { useFormFieldState } from '../form-field/form-field';
12
+ import { fieldAccessibilityProps, useFormFieldState } from '../form-field/form-field';
13
13
  const textareaVariants = tv({
14
14
  base: 'w-full border-border bg-muted text-foreground',
15
15
  variants: {
@@ -48,7 +48,7 @@ const Textarea = React.forwardRef(({ className, size = 'default', placeholderTex
48
48
  paddingVertical: visual.controlPaddingYDefault,
49
49
  ...(!effectiveEditable ? { opacity: interaction.disabledOpacity } : null),
50
50
  };
51
- return (_jsx(TextInput, { ref: ref, className: cn(textareaVariants({ size }), field.invalid && 'border-destructive', focusField, webAutofillField, className), style: style, multiline: true, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, ...props }));
51
+ return (_jsx(TextInput, { ref: ref, ...fieldAccessibilityProps(field, props), className: cn(textareaVariants({ size }), field.invalid && 'border-destructive', focusField, webAutofillField, className), style: style, multiline: true, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, ...props }));
52
52
  });
53
53
  Textarea.displayName = 'Textarea';
54
54
  export { Textarea, textareaVariants };
@@ -25,21 +25,21 @@ export interface ToastProviderProps {
25
25
  export declare function ToastProvider({ children }: ToastProviderProps): React.JSX.Element;
26
26
  declare const toastVariants: import("tailwind-variants").TVReturnType<{
27
27
  variant: {
28
- default: "border-border bg-background";
29
- destructive: "border-destructive bg-destructive";
30
- success: "border-success bg-success";
28
+ default: "border-border";
29
+ destructive: "border-destructive-soft-border";
30
+ success: "border-success-soft-border";
31
31
  };
32
- }, undefined, "w-full flex-row items-center justify-between border", {
32
+ }, undefined, "w-full flex-row items-center gap-3 border bg-popover", {
33
33
  variant: {
34
- default: "border-border bg-background";
35
- destructive: "border-destructive bg-destructive";
36
- success: "border-success bg-success";
34
+ default: "border-border";
35
+ destructive: "border-destructive-soft-border";
36
+ success: "border-success-soft-border";
37
37
  };
38
38
  }, undefined, import("tailwind-variants").TVReturnTypeLike<{
39
39
  variant: {
40
- default: "border-border bg-background";
41
- destructive: "border-destructive bg-destructive";
42
- success: "border-success bg-success";
40
+ default: "border-border";
41
+ destructive: "border-destructive-soft-border";
42
+ success: "border-success-soft-border";
43
43
  };
44
44
  }, undefined>>;
45
45
  export interface ToastProps extends ViewProps, VariantProps<typeof toastVariants> {
@@ -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';
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';
@@ -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 {
@@ -1,8 +1,7 @@
1
1
  import { type ThemeColors } from './theme-contract';
2
2
  export type { ThemeColors } from './theme-contract';
3
3
  export declare function useThemeColors(): ThemeColors;
4
- /** Icon colors derived from theme. For @expo/vector-icons which need hex values. */
5
- export declare function useIconColors(): {
4
+ export interface IconColors {
6
5
  foreground: string;
7
6
  muted: string;
8
7
  primary: string;
@@ -12,6 +11,8 @@ export declare function useIconColors(): {
12
11
  success: string;
13
12
  info: string;
14
13
  destructive: string;
15
- };
14
+ }
15
+ /** Icon colors derived from theme. For @expo/vector-icons which need hex values. */
16
+ export declare function useIconColors(): IconColors;
16
17
  /** Alias for useThemeColors. Returns RN-safe color strings (hex/rgb/rgba) for RefreshControl, LinearGradient, charts. */
17
18
  export declare const useThemeTokens: typeof useThemeColors;
@@ -41,10 +41,29 @@ function resolveThemeToken(cssValue, appValue) {
41
41
  }
42
42
  return parseThemeColor(cssValue) ?? parseThemeColor(appValue);
43
43
  }
44
+ // Every themed component calls this hook on every render, and parsing 104 colour
45
+ // strings plus deriving the palette cost about 0.16 ms per call in Node, several times
46
+ // that under Hermes. The inputs only change with the theme, so the result is derived
47
+ // once per distinct snapshot and shared: the same object comes back until the CSS
48
+ // variables change, which also keeps it stable for downstream memoisation.
49
+ const resolvedThemeColors = new Map();
50
+ const RESOLVED_THEME_LIMIT = 8;
44
51
  export function useThemeColors() {
45
52
  const { theme } = useUniwind();
46
53
  const values = useCSSVariable(THEME_TOKEN_VARIABLES);
47
54
  const scheme = theme === 'dark' ? 'dark' : 'light';
55
+ const key = `${scheme}\u0001${values.join('\u0001')}`;
56
+ const cached = resolvedThemeColors.get(key);
57
+ if (cached)
58
+ return cached;
59
+ const resolved = resolveThemeColors(values, scheme);
60
+ if (resolvedThemeColors.size >= RESOLVED_THEME_LIMIT) {
61
+ resolvedThemeColors.delete(resolvedThemeColors.keys().next().value);
62
+ }
63
+ resolvedThemeColors.set(key, resolved);
64
+ return resolved;
65
+ }
66
+ function resolveThemeColors(values, scheme) {
48
67
  const fallbackPalette = scheme === 'dark' ? WHILEUI_DARK_THEME_COLORS : WHILEUI_LIGHT_THEME_COLORS;
49
68
  const defined = {};
50
69
  let index = 0;
@@ -61,10 +80,14 @@ export function useThemeColors() {
61
80
  const derived = deriveThemeColors(base, scheme);
62
81
  return { ...derived, ...defined, placeholder: derived.placeholder };
63
82
  }
83
+ const iconColorsByTheme = new WeakMap();
64
84
  /** Icon colors derived from theme. For @expo/vector-icons which need hex values. */
65
85
  export function useIconColors() {
66
86
  const c = useThemeColors();
67
- return {
87
+ const cached = iconColorsByTheme.get(c);
88
+ if (cached)
89
+ return cached;
90
+ const icons = {
68
91
  foreground: c.foreground,
69
92
  muted: c.mutedForeground,
70
93
  primary: c.primary,
@@ -75,6 +98,8 @@ export function useIconColors() {
75
98
  info: c.info,
76
99
  destructive: c.destructive,
77
100
  };
101
+ iconColorsByTheme.set(c, icons);
102
+ return icons;
78
103
  }
79
104
  /** Alias for useThemeColors. Returns RN-safe color strings (hex/rgb/rgba) for RefreshControl, LinearGradient, charts. */
80
105
  export const useThemeTokens = useThemeColors;
@@ -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[];