@retray-dev/ui-kit 4.0.0 → 5.2.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 (50) hide show
  1. package/COMPONENTS.md +1806 -663
  2. package/README.md +14 -10
  3. package/dist/index.d.mts +274 -85
  4. package/dist/index.d.ts +274 -85
  5. package/dist/index.js +1048 -321
  6. package/dist/index.mjs +1046 -324
  7. package/package.json +3 -2
  8. package/src/components/Accordion/Accordion.tsx +1 -1
  9. package/src/components/AlertBanner/AlertBanner.tsx +50 -45
  10. package/src/components/Avatar/Avatar.tsx +61 -17
  11. package/src/components/Badge/Badge.tsx +17 -15
  12. package/src/components/Button/Button.tsx +31 -42
  13. package/src/components/Card/Card.tsx +4 -4
  14. package/src/components/CategoryStrip/CategoryStrip.tsx +185 -0
  15. package/src/components/CategoryStrip/index.ts +2 -0
  16. package/src/components/Checkbox/Checkbox.tsx +44 -16
  17. package/src/components/Chip/Chip.tsx +1 -1
  18. package/src/components/ConfirmDialog/ConfirmDialog.tsx +9 -9
  19. package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +1 -0
  20. package/src/components/CurrencyInput/CurrencyInput.tsx +6 -4
  21. package/src/components/EmptyState/EmptyState.tsx +9 -9
  22. package/src/components/IconButton/IconButton.tsx +74 -34
  23. package/src/components/Input/Input.tsx +15 -13
  24. package/src/components/LabelValue/LabelValue.tsx +1 -1
  25. package/src/components/ListItem/ListItem.tsx +5 -5
  26. package/src/components/MediaCard/MediaCard.tsx +249 -0
  27. package/src/components/MediaCard/index.ts +2 -0
  28. package/src/components/Pressable/Pressable.tsx +100 -0
  29. package/src/components/Pressable/index.ts +1 -0
  30. package/src/components/Progress/Progress.tsx +14 -7
  31. package/src/components/RadioGroup/RadioGroup.tsx +1 -1
  32. package/src/components/Select/Select.tsx +5 -5
  33. package/src/components/Sheet/Sheet.tsx +35 -15
  34. package/src/components/Skeleton/Skeleton.tsx +34 -7
  35. package/src/components/Slider/Slider.tsx +2 -2
  36. package/src/components/Spinner/Spinner.tsx +1 -1
  37. package/src/components/Switch/Switch.tsx +31 -4
  38. package/src/components/Tabs/Tabs.tsx +63 -45
  39. package/src/components/Text/Text.tsx +59 -10
  40. package/src/components/Textarea/Textarea.tsx +4 -3
  41. package/src/components/Toast/Toast.tsx +77 -36
  42. package/src/components/Toggle/Toggle.tsx +3 -3
  43. package/src/index.ts +8 -2
  44. package/src/theme/ThemeProvider.tsx +11 -10
  45. package/src/theme/colorUtils.ts +80 -0
  46. package/src/theme/colors.ts +76 -35
  47. package/src/theme/index.ts +2 -2
  48. package/src/theme/types.ts +27 -13
  49. package/src/tokens.ts +150 -13
  50. package/src/utils/hover.ts +25 -0
@@ -16,7 +16,12 @@ import { useTheme } from '../../theme'
16
16
  import { s, vs, ms } from '../../utils/scaling'
17
17
  import { renderIcon } from '../../utils/icons'
18
18
 
19
- export type ToastVariant = 'default' | 'destructive' | 'success'
19
+ export type ToastVariant = 'default' | 'destructive' | 'success' | 'warning'
20
+
21
+ export interface ToastAction {
22
+ label: string
23
+ onPress: () => void
24
+ }
20
25
 
21
26
  export interface ToastItem {
22
27
  id: string
@@ -24,15 +29,12 @@ export interface ToastItem {
24
29
  description?: string
25
30
  variant?: ToastVariant
26
31
  icon?: React.ReactNode
27
- /**
28
- * Icon name from `@expo/vector-icons`. See https://icons.expo.fyi.
29
- * Takes precedence over `icon`. When neither is set, a default variant icon is shown.
30
- */
31
32
  iconName?: string
32
- /** Override the resolved icon color. Defaults to the variant text color. */
33
33
  iconColor?: string
34
34
  /** Auto-dismiss delay in milliseconds. Defaults to `3000`. */
35
35
  duration?: number
36
+ /** Optional inline action button rendered at the end of the toast. */
37
+ action?: ToastAction
36
38
  }
37
39
 
38
40
  interface ToastContextValue {
@@ -99,30 +101,42 @@ function ToastNotification({ item, onDismiss }: { item: ToastItem; onDismiss: ()
99
101
  const variant = item.variant ?? 'default'
100
102
 
101
103
  const bgColor = {
102
- default: colors.foreground,
104
+ default: colors.card,
105
+ destructive: colors.destructiveTint,
106
+ success: colors.successTint,
107
+ warning: colors.warningTint,
108
+ }[variant]
109
+
110
+ const borderColor = {
111
+ default: colors.border,
103
112
  destructive: colors.destructiveBorder,
104
- success: colors.successBorder,
113
+ success: colors.successBorder,
114
+ warning: colors.warningBorder,
105
115
  }[variant]
106
116
 
107
- const textColor = {
108
- default: colors.background,
109
- destructive: '#991b1b',
110
- success: '#166534',
117
+ const accentColor = {
118
+ default: colors.primary,
119
+ destructive: colors.destructive,
120
+ success: colors.success,
121
+ warning: colors.warning,
111
122
  }[variant]
112
123
 
113
- const borderColor = textColor
124
+ const titleColor = variant === 'default' ? colors.foreground : accentColor
125
+ const descColor = variant === 'default' ? colors.foregroundMuted : accentColor
114
126
 
115
127
  const defaultIcon =
116
128
  variant === 'success' ? (
117
- <FontAwesome5 name="check-circle" size={18} color={textColor} />
129
+ <FontAwesome5 name="check-circle" size={16} color={accentColor} />
118
130
  ) : variant === 'destructive' ? (
119
- <AntDesign name="exclamation-circle" size={18} color={textColor} />
131
+ <AntDesign name="exclamation-circle" size={16} color={accentColor} />
132
+ ) : variant === 'warning' ? (
133
+ <MaterialIcons name="warning-amber" size={17} color={accentColor} />
120
134
  ) : (
121
- <Entypo name="info-with-circle" size={18} color={textColor} />
135
+ <Entypo name="info-with-circle" size={16} color={accentColor} />
122
136
  )
123
137
 
124
138
  const leftIcon: React.ReactNode = item.iconName
125
- ? renderIcon(item.iconName, 22, item.iconColor ?? textColor)
139
+ ? renderIcon(item.iconName, 16, item.iconColor ?? accentColor)
126
140
  : item.icon ?? defaultIcon
127
141
 
128
142
  return (
@@ -131,16 +145,27 @@ function ToastNotification({ item, onDismiss }: { item: ToastItem; onDismiss: ()
131
145
  <View style={styles.leftIconContainer}>{leftIcon}</View>
132
146
  <View style={styles.toastContent}>
133
147
  {item.title ? (
134
- <Text style={[styles.toastTitle, { color: textColor }]} allowFontScaling={true}>{item.title}</Text>
148
+ <Text style={[styles.toastTitle, { color: titleColor }]} allowFontScaling={true}>{item.title}</Text>
135
149
  ) : null}
136
150
  {item.description ? (
137
- <Text style={[styles.toastDescription, { color: textColor, opacity: 0.85 }]} allowFontScaling={true}>
151
+ <Text style={[styles.toastDescription, { color: descColor }]} allowFontScaling={true}>
138
152
  {item.description}
139
153
  </Text>
140
154
  ) : null}
141
155
  </View>
156
+ {item.action && (
157
+ <TouchableOpacity
158
+ onPress={() => { item.action!.onPress(); onDismiss() }}
159
+ style={styles.actionButton}
160
+ touchSoundDisabled={true}
161
+ >
162
+ <Text style={[styles.actionLabel, { color: accentColor }]} allowFontScaling={true}>
163
+ {item.action.label}
164
+ </Text>
165
+ </TouchableOpacity>
166
+ )}
142
167
  <TouchableOpacity onPress={onDismiss} style={styles.dismissButton} touchSoundDisabled={true}>
143
- <AntDesign name="close-circle" size={18} color={textColor} />
168
+ <AntDesign name="close-circle" size={16} color={descColor} />
144
169
  </TouchableOpacity>
145
170
  </Animated.View>
146
171
  </GestureDetector>
@@ -166,6 +191,8 @@ export function ToastProvider({ children }: ToastProviderProps) {
166
191
  notificationSuccess()
167
192
  } else if (item.variant === 'destructive') {
168
193
  notificationError()
194
+ } else if (item.variant === 'warning') {
195
+ notificationError()
169
196
  } else {
170
197
  impactLight()
171
198
  }
@@ -204,37 +231,51 @@ const styles = StyleSheet.create({
204
231
  },
205
232
  toast: {
206
233
  flexDirection: 'row',
207
- alignItems: 'center',
208
- borderRadius: ms(12),
209
- borderWidth: 1,
210
- paddingHorizontal: s(14),
211
- paddingVertical: vs(12),
234
+ alignItems: 'flex-start',
235
+ borderRadius: ms(10),
236
+ borderWidth: 0.5,
237
+ paddingHorizontal: s(12),
238
+ paddingVertical: vs(10),
212
239
  shadowColor: '#000',
213
- shadowOffset: { width: 0, height: 3 },
214
- shadowOpacity: 0.10,
215
- shadowRadius: 8,
216
- elevation: 5,
240
+ shadowOffset: { width: 0, height: 2 },
241
+ shadowOpacity: 0.06,
242
+ shadowRadius: 4,
243
+ elevation: 3,
217
244
  },
218
245
  toastContent: {
219
246
  flex: 1,
220
- gap: vs(4),
247
+ gap: vs(2),
221
248
  },
222
249
  leftIconContainer: {
223
- width: s(28),
250
+ marginTop: vs(1),
224
251
  alignItems: 'center',
225
252
  justifyContent: 'center',
226
253
  marginRight: s(10),
227
254
  },
228
255
  toastTitle: {
229
- fontFamily: 'Poppins-SemiBold',
230
- fontSize: ms(15),
256
+ fontFamily: 'Poppins-Medium',
257
+ fontSize: ms(13),
258
+ lineHeight: ms(18),
231
259
  },
232
260
  toastDescription: {
233
261
  fontFamily: 'Poppins-Regular',
234
- fontSize: ms(14),
262
+ fontSize: ms(12),
263
+ lineHeight: ms(17),
264
+ opacity: 0.85,
235
265
  },
236
- dismissButton: {
237
- padding: s(8),
266
+ actionButton: {
267
+ paddingHorizontal: s(8),
268
+ paddingVertical: vs(4),
238
269
  marginLeft: s(4),
239
270
  },
271
+ actionLabel: {
272
+ fontFamily: 'Poppins-Medium',
273
+ fontSize: ms(12),
274
+ textDecorationLine: 'underline',
275
+ },
276
+ dismissButton: {
277
+ padding: s(6),
278
+ marginLeft: s(2),
279
+ marginTop: vs(0),
280
+ },
240
281
  })
@@ -93,7 +93,7 @@ export function Toggle({
93
93
 
94
94
  const backgroundColor = pressAnim.interpolate({
95
95
  inputRange: [0, 1],
96
- outputRange: ['transparent', colors.accent],
96
+ outputRange: ['transparent', colors.surfaceStrong],
97
97
  })
98
98
 
99
99
  const textColor = pressAnim.interpolate({
@@ -117,12 +117,12 @@ export function Toggle({
117
117
  return <FontAwesome5 name="check-circle" size={iconSize} color={colors.primary} />
118
118
  }
119
119
 
120
- if (iconName) return <>{renderIcon(iconName, iconSize, iconColor ?? colors.mutedForeground)}</>
120
+ if (iconName) return <>{renderIcon(iconName, iconSize, iconColor ?? colors.foregroundMuted)}</>
121
121
  const custom = renderProp(icon)
122
122
  if (custom) return <>{custom}</>
123
123
 
124
124
  // Default: empty circle to signal an action is available
125
- return <FontAwesome5 name="circle" size={iconSize} color={colors.mutedForeground} />
125
+ return <FontAwesome5 name="circle" size={iconSize} color={colors.foregroundMuted} />
126
126
  }
127
127
 
128
128
  return (
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Theme
2
2
  export { ThemeProvider, useTheme } from './theme'
3
- export type { ThemeProviderProps, ThemeColors, Theme, ColorScheme } from './theme'
4
- export { defaultLight, defaultDark } from './theme'
3
+ export type { ThemeProviderProps, ThemeColors, ResolvedColors, Theme, ColorScheme } from './theme'
4
+ export { defaultLight, defaultDark, deriveColors } from './theme'
5
5
 
6
6
  // Components
7
7
  export * from './components/Button'
@@ -37,6 +37,9 @@ export * from './components/Chip'
37
37
  export * from './components/ConfirmDialog'
38
38
  export * from './components/LabelValue'
39
39
  export * from './components/MonthPicker'
40
+ export * from './components/MediaCard'
41
+ export * from './components/CategoryStrip'
42
+ export * from './components/Pressable'
40
43
 
41
44
  // Icon utility
42
45
  export { Icon, renderIcon } from './utils/icons'
@@ -49,6 +52,7 @@ export {
49
52
  RADIUS,
50
53
  SHADOWS,
51
54
  BREAKPOINTS,
55
+ TYPOGRAPHY,
52
56
  } from './tokens'
53
57
  export type {
54
58
  Spacing,
@@ -57,4 +61,6 @@ export type {
57
61
  IconSizeKey,
58
62
  Radius,
59
63
  RadiusKey,
64
+ Typography,
65
+ TypographyKey,
60
66
  } from './tokens'
@@ -1,25 +1,25 @@
1
1
  import React, { createContext, useContext, useMemo } from 'react'
2
2
  import { useColorScheme } from 'react-native'
3
3
  import { ThemeColors, Theme, ColorScheme, ThemeContextValue } from './types'
4
- import { defaultLight, defaultDark } from './colors'
4
+ import { defaultLight, defaultDark, deriveColors } from './colors'
5
5
 
6
6
  const ThemeContext = createContext<ThemeContextValue>({
7
- colors: defaultLight,
7
+ colors: deriveColors(defaultLight, 'light'),
8
8
  colorScheme: 'light',
9
9
  })
10
10
 
11
11
  export interface ThemeProviderProps {
12
12
  children: React.ReactNode
13
13
  /**
14
- * Optional full-palette overrides per scheme. Supply a partial or full `ThemeColors` object
15
- * for `light` and/or `dark` to override the defaults.
16
- * @example
17
- * { light: { primary: '#6366f1', card: '#fff' }, dark: { primary: '#818cf8' } }
14
+ * Optional token overrides per scheme. Supply any of the 12 public ThemeColors
15
+ * tokens for `light` and/or `dark`. All derived colors are recomputed automatically.
16
+ * @example
17
+ * { light: { primary: '#ff385c' }, dark: { primary: '#ff385c' } }
18
18
  */
19
19
  theme?: Theme
20
20
  /**
21
- * - `'system'` (default): auto-detects device setting and updates when it changes.
22
- * - `'light'` / `'dark'`: forces a specific scheme regardless of device setting.
21
+ * - `'system'` (default): auto-detects device setting.
22
+ * - `'light'` / `'dark'`: forces a specific scheme.
23
23
  */
24
24
  colorScheme?: ColorScheme
25
25
  }
@@ -28,10 +28,11 @@ export function ThemeProvider({ children, theme, colorScheme = 'system' }: Theme
28
28
  const systemScheme = useColorScheme() ?? 'light'
29
29
  const resolvedScheme: 'light' | 'dark' = colorScheme === 'system' ? systemScheme : colorScheme
30
30
 
31
- const colors = useMemo<ThemeColors>(() => {
31
+ const colors = useMemo(() => {
32
32
  const base = resolvedScheme === 'dark' ? defaultDark : defaultLight
33
33
  const override = resolvedScheme === 'dark' ? theme?.dark : theme?.light
34
- return override ? { ...base, ...override } : base
34
+ const merged: ThemeColors = override ? { ...base, ...override } : base
35
+ return deriveColors(merged, resolvedScheme)
35
36
  }, [resolvedScheme, theme])
36
37
 
37
38
  return (
@@ -0,0 +1,80 @@
1
+ // Hex color manipulation utilities for internal theme derivation.
2
+ // All functions are pure — no side effects, no React dependencies.
3
+
4
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
5
+ const clean = hex.replace('#', '')
6
+ const full = clean.length === 3
7
+ ? clean.split('').map(c => c + c).join('')
8
+ : clean
9
+ if (full.length !== 6) return null
10
+ return {
11
+ r: parseInt(full.slice(0, 2), 16),
12
+ g: parseInt(full.slice(2, 4), 16),
13
+ b: parseInt(full.slice(4, 6), 16),
14
+ }
15
+ }
16
+
17
+ function componentToHex(c: number): string {
18
+ return Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')
19
+ }
20
+
21
+ function rgbToHex(r: number, g: number, b: number): string {
22
+ return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`
23
+ }
24
+
25
+ // Returns hex color with alpha blended onto a white background (for tint derivation)
26
+ export function withAlphaOnWhite(hex: string, alpha: number): string {
27
+ const rgb = hexToRgb(hex)
28
+ if (!rgb) return hex
29
+ const r = rgb.r * alpha + 255 * (1 - alpha)
30
+ const g = rgb.g * alpha + 255 * (1 - alpha)
31
+ const b = rgb.b * alpha + 255 * (1 - alpha)
32
+ return rgbToHex(r, g, b)
33
+ }
34
+
35
+ // Returns hex color with alpha blended onto a dark background (for dark mode tints)
36
+ export function withAlphaOnDark(hex: string, alpha: number, bgHex = '#0f0f0f'): string {
37
+ const rgb = hexToRgb(hex)
38
+ const bg = hexToRgb(bgHex)
39
+ if (!rgb || !bg) return hex
40
+ const r = rgb.r * alpha + bg.r * (1 - alpha)
41
+ const g = rgb.g * alpha + bg.g * (1 - alpha)
42
+ const b = rgb.b * alpha + bg.b * (1 - alpha)
43
+ return rgbToHex(r, g, b)
44
+ }
45
+
46
+ // Mix foreground color with background at given opacity (for text hierarchy)
47
+ export function mixWithBackground(fgHex: string, bgHex: string, opacity: number): string {
48
+ const fg = hexToRgb(fgHex)
49
+ const bg = hexToRgb(bgHex)
50
+ if (!fg || !bg) return fgHex
51
+ const r = fg.r * opacity + bg.r * (1 - opacity)
52
+ const g = fg.g * opacity + bg.g * (1 - opacity)
53
+ const b = fg.b * opacity + bg.b * (1 - opacity)
54
+ return rgbToHex(r, g, b)
55
+ }
56
+
57
+ // Lighten a hex color by mixing with white
58
+ export function lighten(hex: string, amount: number): string {
59
+ return withAlphaOnWhite(hex, 1 - amount)
60
+ }
61
+
62
+ // Darken a hex color by mixing with black
63
+ export function darken(hex: string, amount: number): string {
64
+ const rgb = hexToRgb(hex)
65
+ if (!rgb) return hex
66
+ return rgbToHex(rgb.r * (1 - amount), rgb.g * (1 - amount), rgb.b * (1 - amount))
67
+ }
68
+
69
+ // Detect if a hex color is "dark" (luminance < 0.5)
70
+ export function isDark(hex: string): boolean {
71
+ const rgb = hexToRgb(hex)
72
+ if (!rgb) return false
73
+ // Relative luminance (WCAG formula)
74
+ const toLinear = (c: number) => {
75
+ const s = c / 255
76
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
77
+ }
78
+ const L = 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b)
79
+ return L < 0.5
80
+ }
@@ -1,54 +1,95 @@
1
- import { ThemeColors } from './types'
1
+ import { ThemeColors, ResolvedColors } from './types'
2
+ import { mixWithBackground, withAlphaOnWhite, withAlphaOnDark, lighten, darken, isDark } from './colorUtils'
3
+
4
+ // ─── Default palettes (12 tokens each) ───────────────────────────────────────
5
+ // These are the only values consumers need to override.
6
+ // Designed to look great out of the box with no customization.
2
7
 
3
- // Full, explicit theme palettes. No derivation — palettes are direct and fully customizable.
4
8
  export const defaultLight: ThemeColors = {
5
9
  background: '#ffffff',
6
- foreground: '#171717',
10
+ foreground: '#222222', // Airbnb ink — deep near-black, never pure black
7
11
  card: '#ffffff',
8
- cardForeground: '#171717',
9
- primary: '#1a1a1a',
12
+ primary: '#1a1a1a', // Near-black primary — clean, premium default
10
13
  primaryForeground: '#ffffff',
11
- secondary: '#f1f1f1',
12
- secondaryForeground: '#171717',
13
- muted: '#f1f1f1',
14
- mutedForeground: '#a2a2a2',
15
- accent: '#e4e4e4',
16
- accentForeground: '#171717',
17
- destructive: '#ef4444',
14
+ border: '#dddddd', // Airbnb hairline — light, airy
15
+ destructive: '#e53935',
18
16
  destructiveForeground: '#ffffff',
19
- border: '#e5e5e5',
20
- input: '#e5e5e5',
21
- ring: '#1a1a1a',
22
17
  success: '#1a7a45',
23
18
  successForeground: '#ffffff',
24
- destructiveTint: '#fff5f5',
25
- destructiveBorder: '#fecaca',
26
- successTint: '#f0fdf4',
27
- successBorder: '#bbf7d0',
19
+ warning: '#e67e00',
20
+ warningForeground: '#ffffff',
28
21
  }
29
22
 
30
23
  export const defaultDark: ThemeColors = {
31
24
  background: '#0f0f0f',
32
25
  foreground: '#fafafa',
33
26
  card: '#1c1c1c',
34
- cardForeground: '#fafafa',
35
27
  primary: '#fafafa',
36
28
  primaryForeground: '#0f0f0f',
37
- secondary: '#272727',
38
- secondaryForeground: '#fafafa',
39
- muted: '#272727',
40
- mutedForeground: '#9a9a9a',
41
- accent: '#2e2e2e',
42
- accentForeground: '#fafafa',
43
- destructive: '#dc2626',
44
- destructiveForeground: '#ffffff',
45
29
  border: '#303030',
46
- input: '#2a2a2a',
47
- ring: '#fafafa',
48
- success: '#166534',
30
+ destructive: '#ef5350',
31
+ destructiveForeground: '#ffffff',
32
+ success: '#2e7d52',
49
33
  successForeground: '#ffffff',
50
- destructiveTint: '#3b0a0a',
51
- destructiveBorder: '#7f1d1d',
52
- successTint: '#052e16',
53
- successBorder: '#166534',
34
+ warning: '#f57c00',
35
+ warningForeground: '#ffffff',
36
+ }
37
+
38
+ // ─── Color derivation ─────────────────────────────────────────────────────────
39
+ // Takes 12 public tokens → produces full ResolvedColors for component consumption.
40
+ // Dark mode uses bg-blended tints instead of white-blended to stay on-palette.
41
+
42
+ export function deriveColors(t: ThemeColors, scheme: 'light' | 'dark'): ResolvedColors {
43
+ const dark = scheme === 'dark'
44
+ const bg = t.background
45
+
46
+ // Text hierarchy: foreground mixed with background at different opacities
47
+ const foregroundSubtle = mixWithBackground(t.foreground, bg, 0.55)
48
+ const foregroundMuted = mixWithBackground(t.foreground, bg, 0.38)
49
+
50
+ // Surface fills: slight offset from background
51
+ const surface = dark
52
+ ? lighten(bg, -0.06) // slightly lighter than dark bg
53
+ : darken(bg, 0.04) // slightly darker than white → #f7f7f7 equivalent
54
+ const surfaceStrong = dark
55
+ ? lighten(bg, -0.12)
56
+ : darken(bg, 0.08) // #ebebeb equivalent
57
+
58
+ // Semantic tints: color blended toward background
59
+ const destructiveTint = dark
60
+ ? withAlphaOnDark(t.destructive, 0.15, bg)
61
+ : withAlphaOnWhite(t.destructive, 0.08)
62
+ const destructiveBorder = dark
63
+ ? withAlphaOnDark(t.destructive, 0.45, bg)
64
+ : withAlphaOnWhite(t.destructive, 0.30)
65
+
66
+ const successTint = dark
67
+ ? withAlphaOnDark(t.success, 0.15, bg)
68
+ : withAlphaOnWhite(t.success, 0.08)
69
+ const successBorder = dark
70
+ ? withAlphaOnDark(t.success, 0.45, bg)
71
+ : withAlphaOnWhite(t.success, 0.30)
72
+
73
+ const warningTint = dark
74
+ ? withAlphaOnDark(t.warning, 0.15, bg)
75
+ : withAlphaOnWhite(t.warning, 0.08)
76
+ const warningBorder = dark
77
+ ? withAlphaOnDark(t.warning, 0.45, bg)
78
+ : withAlphaOnWhite(t.warning, 0.30)
79
+
80
+ return {
81
+ ...t,
82
+ foregroundSubtle,
83
+ foregroundMuted,
84
+ surface,
85
+ surfaceStrong,
86
+ destructiveTint,
87
+ destructiveBorder,
88
+ successTint,
89
+ successBorder,
90
+ warningTint,
91
+ warningBorder,
92
+ ring: t.primary, // focus ring always = primary
93
+ input: t.border, // input border always = border
94
+ }
54
95
  }
@@ -1,4 +1,4 @@
1
1
  export { ThemeProvider, useTheme } from './ThemeProvider'
2
2
  export type { ThemeProviderProps } from './ThemeProvider'
3
- export type { ThemeColors, Theme, ColorScheme } from './types'
4
- export { defaultLight, defaultDark } from './colors'
3
+ export type { ThemeColors, ResolvedColors, Theme, ColorScheme } from './types'
4
+ export { defaultLight, defaultDark, deriveColors } from './colors'
@@ -1,31 +1,45 @@
1
+ // 12 public tokens — the only values consumers need to supply.
2
+ // All derived colors (tints, borders, surface, text hierarchy) are computed
3
+ // internally by deriveColors() and exposed via useTheme().colors as ResolvedColors.
1
4
  export type ThemeColors = {
2
5
  background: string
3
6
  foreground: string
4
7
  card: string
5
- cardForeground: string
6
8
  primary: string
7
9
  primaryForeground: string
8
- secondary: string
9
- secondaryForeground: string
10
- muted: string
11
- mutedForeground: string
12
- accent: string
13
- accentForeground: string
10
+ border: string
14
11
  destructive: string
15
12
  destructiveForeground: string
16
- border: string
17
- input: string
18
- ring: string
19
13
  success: string
20
14
  successForeground: string
15
+ warning: string
16
+ warningForeground: string
17
+ }
18
+
19
+ // Full resolved palette — what components actually consume via useTheme().
20
+ // Derived from ThemeColors. Never supplied by consumers directly.
21
+ export type ResolvedColors = ThemeColors & {
22
+ // Text hierarchy
23
+ foregroundSubtle: string // ~55% — body text, subtitles
24
+ foregroundMuted: string // ~35% — captions, timestamps, placeholders
25
+
26
+ // Surface fills (chips unselected, input bg, tag bg, skeleton)
27
+ surface: string // background slightly off-canvas
28
+ surfaceStrong: string // slightly stronger fill for pressed/hover states
29
+
30
+ // Semantic tints (light bg for alert banners, toast backgrounds)
21
31
  destructiveTint: string
22
32
  destructiveBorder: string
23
33
  successTint: string
24
34
  successBorder: string
35
+ warningTint: string
36
+ warningBorder: string
37
+
38
+ // Aliases (ring + input always equal primary + border for coherence)
39
+ ring: string // = primary
40
+ input: string // = border
25
41
  }
26
42
 
27
- // Theme overrides: consumers may supply partial or full `ThemeColors` objects
28
- // for `theme.light` / `theme.dark` to override only the tokens they want.
29
43
  export type Theme = {
30
44
  light?: Partial<ThemeColors>
31
45
  dark?: Partial<ThemeColors>
@@ -34,6 +48,6 @@ export type Theme = {
34
48
  export type ColorScheme = 'light' | 'dark' | 'system'
35
49
 
36
50
  export type ThemeContextValue = {
37
- colors: ThemeColors
51
+ colors: ResolvedColors
38
52
  colorScheme: 'light' | 'dark'
39
53
  }