@thewhileloop/whileui 4.2.3 → 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 (52) 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/lists/list-item.js +1 -1
  20. package/dist/blocks/lists/notification-item.js +1 -1
  21. package/dist/blocks/media/smart-image.js +4 -2
  22. package/dist/blocks/navigation/bottom-nav.js +1 -1
  23. package/dist/blocks/navigation/drawer-menu.js +1 -1
  24. package/dist/blocks/navigation/floating-bottom-nav.js +3 -1
  25. package/dist/blocks/navigation/tab-bar.js +1 -1
  26. package/dist/components/alert/alert.d.ts +7 -0
  27. package/dist/components/alert/alert.js +23 -4
  28. package/dist/components/button/button.d.ts +3 -0
  29. package/dist/components/button/button.js +2 -1
  30. package/dist/components/form-field/form-field.d.ts +18 -0
  31. package/dist/components/form-field/form-field.js +25 -4
  32. package/dist/components/input/input.js +2 -2
  33. package/dist/components/numeric-input/numeric-input.d.ts +3 -0
  34. package/dist/components/numeric-input/numeric-input.js +2 -2
  35. package/dist/components/otp-input/otp-input.d.ts +8 -0
  36. package/dist/components/pressable/pressable.d.ts +1 -0
  37. package/dist/components/segmented-control/segmented-control.d.ts +1 -0
  38. package/dist/components/surface/surface.d.ts +5 -0
  39. package/dist/components/textarea/textarea.js +2 -2
  40. package/dist/components/toast/toast.d.ts +10 -10
  41. package/dist/components/toast/toast.js +53 -35
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.js +1 -0
  44. package/dist/lib/frosted-surface.d.ts +3 -0
  45. package/dist/lib/theme-contract.js +12 -12
  46. package/dist/lib/theme-presets.js +9 -9
  47. package/dist/lib/theme-seeds.d.ts +40 -0
  48. package/dist/lib/theme-seeds.js +143 -0
  49. package/dist/metro.cjs +5 -4
  50. package/dist/tokens.d.ts +1 -0
  51. package/dist/tokens.js +1 -0
  52. package/package.json +3 -2
@@ -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 {
@@ -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
+ }