@rific/auto-paper 0.7.0 → 0.8.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.
package/README.md CHANGED
@@ -7,6 +7,7 @@ Adaptive theming for [`react-native-paper`](https://callstack.github.io/react-na
7
7
  - Color harmony palette from a single seed color (primary → secondary → tertiary) — 6 harmony modes available, defaulting to `split-complementary`
8
8
  - System/light/dark appearance with live updates via `Appearance` API
9
9
  - Tinted surface, surfaceVariant, outline, and elevation levels derived from the seed
10
+ - Fixed `success`/`warning`/`danger` semantic color roles (each with `on*`/`*Container` variants) alongside MD3's built-in `error`
10
11
  - Optional Redux slice for wiring appearance and color into your store
11
12
  - Wrapper components (`Appbar`, `AppearancePicker`, `BlurView`, `BottomNavigation`, `Button`, `Chip`, `ColorPicker`, `Dialog`, `FAB`, `HarmonyPicker`, `IconButton`, `Menu`, `PalettePicker`, `TextInput`) with prop defaults via context
12
13
  - All color utilities exported for standalone use
@@ -228,13 +229,60 @@ set({ harmony: 'triadic' })
228
229
 
229
230
  ### `useComputedTheme(appearance, color, harmony?)`
230
231
 
231
- Returns `MD3Theme | null`. `null` on the first render while the theme computes.
232
+ Returns `AutoPaperTheme | null` — `MD3Theme` plus `success`/`warning`/`danger` color roles (see below). `null` on the first render while the theme computes.
232
233
 
233
234
  ```ts
234
235
  const theme = useComputedTheme('system', '#6750a4')
235
236
  const theme = useComputedTheme('system', '#6750a4', 'triadic')
236
237
  ```
237
238
 
239
+ Alongside the standard MD3 `primary`/`secondary`/`tertiary` roles derived from the seed color, `theme.colors` also carries three fixed semantic roles, each with the usual `color`/`onColor`/`Container`/`onContainer` quadruple (`success`, `onSuccess`, `successContainer`, `onSuccessContainer`, and likewise for `warning` and `danger`):
240
+
241
+ | Role | Base color |
242
+ |---|---|
243
+ | `success` | `SEMANTIC_BASE_COLORS.success` (`#2E7D32`) |
244
+ | `warning` | `SEMANTIC_BASE_COLORS.warning` (`#A15C00`) |
245
+ | `danger` | `SEMANTIC_BASE_COLORS.danger` (`#B00020`) |
246
+
247
+ These are fixed brand colors — like MD3's own `error`, which is left untouched — not derived from the seed; only their container blending adapts to light/dark mode. `danger` is deliberately separate from `error`: `react-native-paper` components (`TextInput`'s error state, `HelperText`, `Badge`) read `colors.error`/`colors.onError` directly, so redefining `error` itself would change form-validation colors app-wide.
248
+
249
+ ```ts
250
+ import { SEMANTIC_BASE_COLORS } from '@rific/auto-paper'
251
+ import type { AutoPaperTheme, SemanticColorRoles } from '@rific/auto-paper'
252
+ ```
253
+
254
+ ### `useAutoPaperTheme()`
255
+
256
+ A typed alternative to `react-native-paper`'s own `useTheme()` that returns `AutoPaperTheme` — the same theme, with `colors.success`/`warning`/`danger` (and their `on*`/`*Container` variants) typed in, so you get autocomplete without redeclaring the generic yourself at every call site.
257
+
258
+ ```tsx
259
+ import { useAutoPaperTheme } from '@rific/auto-paper'
260
+
261
+ const { colors } = useAutoPaperTheme()
262
+
263
+ <Text style={{ color: colors.warning }}>Check your connection</Text>
264
+ ```
265
+
266
+ ### `AppearancePicker`
267
+
268
+ A thin wrapper around `react-native-paper`'s `SegmentedButtons` for switching between `system`/`light`/`dark` appearance.
269
+
270
+ ```tsx
271
+ import { AppearancePicker, useThemeSettings } from '@rific/auto-paper'
272
+
273
+ function SettingsScreen() {
274
+ const { settings: { appearance }, set } = useThemeSettings()
275
+ return <AppearancePicker value={appearance} onChange={(a) => set({ appearance: a })} />
276
+ }
277
+ ```
278
+
279
+ | Prop | Type | Description |
280
+ |---|---|---|
281
+ | `value` | `ThemeAppearance` | Currently selected appearance |
282
+ | `onChange` | `(appearance: ThemeAppearance) => void` | Called when a segment is tapped |
283
+ | `showLabels` | `boolean` | Set `false` for icon-only segments — useful in tight layouts where "System" would truncate. `accessibilityLabel` is always set regardless, so screen readers still announce the full word. Defaults to `true` |
284
+ | `icons` | `AppearanceIcons` | Per-value icon overrides — pass just the ones you want to change (e.g. `{ system: 'theme-light-dark' }`), merged over the defaults (`monitor` / `white-balance-sunny` / `weather-night`) |
285
+
238
286
  ### `Appbar`
239
287
 
240
288
  A thin wrapper around `react-native-paper`'s `Appbar` that automatically syncs `StatusBar` background color and bar style to the current theme surface color.
@@ -387,7 +435,7 @@ const appearance = useSelector((state: RootState) => selectThemeAppearance(state
387
435
  ### Color utilities
388
436
 
389
437
  ```ts
390
- import { getTriadicPalette, getBlendedColor, isDarkColor, getRgb, getHex, getTonalColor, getTintTextColor } from '@rific/auto-paper'
438
+ import { getTriadicPalette, getBlendedColor, getColorRoles, getContrastColor, isDarkColor, getRgb, getHex, getTonalColor, getTintTextColor } from '@rific/auto-paper'
391
439
  import type { ColorHarmony } from '@rific/auto-paper'
392
440
 
393
441
  getTriadicPalette('#6750a4') // split-complementary (default)
@@ -397,7 +445,9 @@ getTriadicPalette('#6750a4', 'square')
397
445
  getTriadicPalette('#6750a4', 'complementary')
398
446
  getTriadicPalette('#6750a4', 'double-split')
399
447
 
400
- getBlendedColor('#ff0000', '#0000ff', 0.5) // → '#800080'
448
+ getBlendedColor('#ff0000', '#0000ff', 0.5) // → '#800080'
449
+ getColorRoles('#6750a4', '#fffbfe') // → { color, onColor, container, onContainer } — the MD3 color/onColor/container/onContainer math, generalized (this is what useComputedTheme uses internally for every role)
450
+ getContrastColor('#6750a4') // → '#ffffff' — black/white pick for content sitting on a fully saturated fill
401
451
  isDarkColor('#6750a4') // → true
402
452
  getRgb('coral') // → { r: 255, g: 127, b: 80 }
403
453
  getHex('rgb(255, 0, 0)') // → '#ff0000'
package/dist/index.d.mts CHANGED
@@ -29,13 +29,38 @@ type TriadicPalette = {
29
29
  declare function getTriadicPalette(primaryColor: string, harmony?: ColorHarmony): TriadicPalette;
30
30
 
31
31
  type ThemeAppearance = 'system' | 'light' | 'dark';
32
- declare function useComputedTheme(appearance: ThemeAppearance, color: string, harmony?: ColorHarmony): MD3Theme | null;
32
+ declare const SEMANTIC_BASE_COLORS: {
33
+ success: string;
34
+ warning: string;
35
+ danger: string;
36
+ };
37
+ type SemanticColorRoles = {
38
+ success: string;
39
+ onSuccess: string;
40
+ successContainer: string;
41
+ onSuccessContainer: string;
42
+ warning: string;
43
+ onWarning: string;
44
+ warningContainer: string;
45
+ onWarningContainer: string;
46
+ danger: string;
47
+ onDanger: string;
48
+ dangerContainer: string;
49
+ onDangerContainer: string;
50
+ };
51
+ type AutoPaperTheme = Omit<MD3Theme, 'colors'> & {
52
+ colors: MD3Theme['colors'] & SemanticColorRoles;
53
+ };
54
+ declare function useComputedTheme(appearance: ThemeAppearance, color: string, harmony?: ColorHarmony): AutoPaperTheme | null;
33
55
 
56
+ type AppearanceIcons = Partial<Record<ThemeAppearance, string>>;
34
57
  type Props$3 = {
35
58
  value: ThemeAppearance;
36
59
  onChange: (appearance: ThemeAppearance) => void;
60
+ showLabels?: boolean;
61
+ icons?: AppearanceIcons;
37
62
  };
38
- declare const AppearancePicker: ({ value, onChange }: Props$3) => react.JSX.Element;
63
+ declare const AppearancePicker: ({ value, onChange, showLabels, icons }: Props$3) => react.JSX.Element;
39
64
 
40
65
  type ExpoBlurTint = 'light' | 'dark' | 'default' | 'extraLight' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
41
66
  type ExpoBlurMethod = 'none' | 'dimezisBlurView' | 'dimezisBlurViewSdk31Plus';
@@ -240,10 +265,20 @@ type ProviderProps = {
240
265
  };
241
266
  declare function Provider({ initialValue, onChange, children, defaults, onNavBarChange, onReady, statusBarProps, style }: ProviderProps): react.JSX.Element | null;
242
267
 
268
+ declare const useAutoPaperTheme: () => AutoPaperTheme;
269
+
243
270
  declare const useThemeSettings: () => ThemeSettingsContextType;
244
271
 
245
272
  declare const getBlendedColor: (foreground: string, background: string, alpha: number) => string;
246
273
 
274
+ type ColorRoles = {
275
+ color: string;
276
+ onColor: string;
277
+ container: string;
278
+ onContainer: string;
279
+ };
280
+ declare const getColorRoles: (base: string, surface: string, containerAlpha?: number) => ColorRoles;
281
+
247
282
  declare const getHex: (value: string) => string | null;
248
283
 
249
284
  type RGB = {
@@ -254,10 +289,12 @@ type RGB = {
254
289
  };
255
290
  declare const getRgb: (value: string) => RGB | null;
256
291
 
292
+ declare const getContrastColor: (color: string) => string;
293
+
257
294
  declare const getTintTextColor: (tintColor: string, backdropColor: string, tintOpacity: number) => string;
258
295
 
259
296
  declare const getTonalColor: (color: string, lightness: number) => string;
260
297
 
261
298
  declare const isDarkColor: (value: string) => boolean;
262
299
 
263
- export { Appbar, AppearancePicker, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type SeedColor, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
300
+ export { Appbar, type AppearanceIcons, AppearancePicker, type AutoPaperTheme, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, type ColorRoles, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, SEMANTIC_BASE_COLORS, type SeedColor, type SemanticColorRoles, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getColorRoles, getContrastColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useAutoPaperTheme, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
package/dist/index.d.ts CHANGED
@@ -29,13 +29,38 @@ type TriadicPalette = {
29
29
  declare function getTriadicPalette(primaryColor: string, harmony?: ColorHarmony): TriadicPalette;
30
30
 
31
31
  type ThemeAppearance = 'system' | 'light' | 'dark';
32
- declare function useComputedTheme(appearance: ThemeAppearance, color: string, harmony?: ColorHarmony): MD3Theme | null;
32
+ declare const SEMANTIC_BASE_COLORS: {
33
+ success: string;
34
+ warning: string;
35
+ danger: string;
36
+ };
37
+ type SemanticColorRoles = {
38
+ success: string;
39
+ onSuccess: string;
40
+ successContainer: string;
41
+ onSuccessContainer: string;
42
+ warning: string;
43
+ onWarning: string;
44
+ warningContainer: string;
45
+ onWarningContainer: string;
46
+ danger: string;
47
+ onDanger: string;
48
+ dangerContainer: string;
49
+ onDangerContainer: string;
50
+ };
51
+ type AutoPaperTheme = Omit<MD3Theme, 'colors'> & {
52
+ colors: MD3Theme['colors'] & SemanticColorRoles;
53
+ };
54
+ declare function useComputedTheme(appearance: ThemeAppearance, color: string, harmony?: ColorHarmony): AutoPaperTheme | null;
33
55
 
56
+ type AppearanceIcons = Partial<Record<ThemeAppearance, string>>;
34
57
  type Props$3 = {
35
58
  value: ThemeAppearance;
36
59
  onChange: (appearance: ThemeAppearance) => void;
60
+ showLabels?: boolean;
61
+ icons?: AppearanceIcons;
37
62
  };
38
- declare const AppearancePicker: ({ value, onChange }: Props$3) => react.JSX.Element;
63
+ declare const AppearancePicker: ({ value, onChange, showLabels, icons }: Props$3) => react.JSX.Element;
39
64
 
40
65
  type ExpoBlurTint = 'light' | 'dark' | 'default' | 'extraLight' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
41
66
  type ExpoBlurMethod = 'none' | 'dimezisBlurView' | 'dimezisBlurViewSdk31Plus';
@@ -240,10 +265,20 @@ type ProviderProps = {
240
265
  };
241
266
  declare function Provider({ initialValue, onChange, children, defaults, onNavBarChange, onReady, statusBarProps, style }: ProviderProps): react.JSX.Element | null;
242
267
 
268
+ declare const useAutoPaperTheme: () => AutoPaperTheme;
269
+
243
270
  declare const useThemeSettings: () => ThemeSettingsContextType;
244
271
 
245
272
  declare const getBlendedColor: (foreground: string, background: string, alpha: number) => string;
246
273
 
274
+ type ColorRoles = {
275
+ color: string;
276
+ onColor: string;
277
+ container: string;
278
+ onContainer: string;
279
+ };
280
+ declare const getColorRoles: (base: string, surface: string, containerAlpha?: number) => ColorRoles;
281
+
247
282
  declare const getHex: (value: string) => string | null;
248
283
 
249
284
  type RGB = {
@@ -254,10 +289,12 @@ type RGB = {
254
289
  };
255
290
  declare const getRgb: (value: string) => RGB | null;
256
291
 
292
+ declare const getContrastColor: (color: string) => string;
293
+
257
294
  declare const getTintTextColor: (tintColor: string, backdropColor: string, tintOpacity: number) => string;
258
295
 
259
296
  declare const getTonalColor: (color: string, lightness: number) => string;
260
297
 
261
298
  declare const isDarkColor: (value: string) => boolean;
262
299
 
263
- export { Appbar, AppearancePicker, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type SeedColor, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
300
+ export { Appbar, type AppearanceIcons, AppearancePicker, type AutoPaperTheme, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, type ColorRoles, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, SEMANTIC_BASE_COLORS, type SeedColor, type SemanticColorRoles, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getColorRoles, getContrastColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useAutoPaperTheme, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
package/dist/index.js CHANGED
@@ -44,12 +44,15 @@ __export(index_exports, {
44
44
  Menu: () => Menu,
45
45
  PalettePicker: () => PalettePicker,
46
46
  Provider: () => Provider,
47
+ SEMANTIC_BASE_COLORS: () => SEMANTIC_BASE_COLORS,
47
48
  TextInput: () => TextInput,
48
49
  ThemeSettingsContext: () => ThemeSettingsContext,
49
50
  createThemeReducer: () => createThemeReducer,
50
51
  defaultColors: () => defaultColors,
51
52
  defaultThemeSettings: () => defaultThemeSettings,
52
53
  getBlendedColor: () => getBlendedColor,
54
+ getColorRoles: () => getColorRoles,
55
+ getContrastColor: () => getContrastColor,
53
56
  getHex: () => getHex,
54
57
  getRgb: () => getRgb,
55
58
  getTintTextColor: () => getTintTextColor,
@@ -62,6 +65,7 @@ __export(index_exports, {
62
65
  selectThemeHarmony: () => selectThemeHarmony,
63
66
  themeActions: () => themeActions,
64
67
  themeReducer: () => themeReducer,
68
+ useAutoPaperTheme: () => useAutoPaperTheme,
65
69
  useBlur: () => useBlur,
66
70
  useComputedTheme: () => useComputedTheme,
67
71
  usePaperDefaults: () => usePaperDefaults,
@@ -125,11 +129,19 @@ var Appbar = {
125
129
  var import_react_native_paper2 = require("react-native-paper");
126
130
  var import_jsx_runtime2 = require("react/jsx-runtime");
127
131
  var APPEARANCES = [
128
- { label: "System", value: "system", icon: "brightness-auto" },
129
- { label: "Light", value: "light", icon: "white-balance-sunny" },
130
- { label: "Dark", value: "dark", icon: "weather-night" }
132
+ { label: "System", value: "system" },
133
+ { label: "Light", value: "light" },
134
+ { label: "Dark", value: "dark" }
131
135
  ];
132
- var AppearancePicker = ({ value, onChange }) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.SegmentedButtons, { value, onValueChange: (v) => onChange(v), buttons: APPEARANCES.map((a) => ({ value: a.value, label: a.label, icon: a.icon })) });
136
+ var DEFAULT_ICONS = {
137
+ system: "monitor",
138
+ light: "white-balance-sunny",
139
+ dark: "weather-night"
140
+ };
141
+ var AppearancePicker = ({ value, onChange, showLabels = true, icons }) => {
142
+ const resolvedIcons = { ...DEFAULT_ICONS, ...icons };
143
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.SegmentedButtons, { value, onValueChange: (v) => onChange(v), buttons: APPEARANCES.map((a) => ({ value: a.value, label: showLabels ? a.label : void 0, icon: resolvedIcons[a.value], accessibilityLabel: a.label })) });
144
+ };
133
145
 
134
146
  // src/components/BlurView.tsx
135
147
  var import_react4 = require("react");
@@ -471,6 +483,27 @@ var getTonalColor = (color, lightness) => {
471
483
  return getHex(`rgb(${toInt(r2)}, ${toInt(g2)}, ${toInt(b2)})`) ?? color;
472
484
  };
473
485
 
486
+ // src/utils/isDarkColor.ts
487
+ var isDarkColor = (value) => {
488
+ const rgb = getRgb(value);
489
+ if (!rgb) return false;
490
+ const [r, g, b] = [rgb.r / 255, rgb.g / 255, rgb.b / 255];
491
+ const [rs, gs, bs] = [r, g, b].map((c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
492
+ const luminance = 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
493
+ return luminance < 0.5;
494
+ };
495
+
496
+ // src/utils/getColorRoles.ts
497
+ var getColorRoles = (base, surface, containerAlpha = 0.15) => {
498
+ const container = getBlendedColor(base, surface, containerAlpha);
499
+ return {
500
+ color: base,
501
+ onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
502
+ container,
503
+ onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
504
+ };
505
+ };
506
+
474
507
  // src/utils/getTriadicPalette.ts
475
508
  var HARMONY_OFFSETS = {
476
509
  analogous: [1 / 12, 2 / 12],
@@ -552,17 +585,12 @@ function hslToHex(h, s, l) {
552
585
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
553
586
  }
554
587
 
555
- // src/utils/isDarkColor.ts
556
- var isDarkColor = (value) => {
557
- const rgb = getRgb(value);
558
- if (!rgb) return false;
559
- const [r, g, b] = [rgb.r / 255, rgb.g / 255, rgb.b / 255];
560
- const [rs, gs, bs] = [r, g, b].map((c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
561
- const luminance = 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
562
- return luminance < 0.5;
563
- };
564
-
565
588
  // src/useComputedTheme.ts
589
+ var SEMANTIC_BASE_COLORS = {
590
+ success: "#2E7D32",
591
+ warning: "#A15C00",
592
+ danger: "#B00020"
593
+ };
566
594
  function useComputedTheme(appearance, color, harmony = "split-complementary") {
567
595
  const [theme, setTheme] = (0, import_react5.useState)(null);
568
596
  const computeTheme = (0, import_react5.useCallback)(() => {
@@ -570,27 +598,45 @@ function useComputedTheme(appearance, color, harmony = "split-complementary") {
570
598
  if (resolved === "system") resolved = import_react_native3.Appearance.getColorScheme();
571
599
  if (resolved === "no-preference" || !resolved) resolved = "light";
572
600
  const base = resolved === "dark" ? import_react_native_paper4.MD3DarkTheme : import_react_native_paper4.MD3LightTheme;
601
+ const surface = base.colors.surface;
602
+ const successRoles = getColorRoles(SEMANTIC_BASE_COLORS.success, surface);
603
+ const warningRoles = getColorRoles(SEMANTIC_BASE_COLORS.warning, surface);
604
+ const dangerRoles = getColorRoles(SEMANTIC_BASE_COLORS.danger, surface);
573
605
  const _theme = {
574
606
  ...base,
575
607
  colors: {
576
608
  ...base.colors,
577
- elevation: { ...base.colors.elevation }
609
+ elevation: { ...base.colors.elevation },
610
+ success: successRoles.color,
611
+ onSuccess: successRoles.onColor,
612
+ successContainer: successRoles.container,
613
+ onSuccessContainer: successRoles.onContainer,
614
+ warning: warningRoles.color,
615
+ onWarning: warningRoles.onColor,
616
+ warningContainer: warningRoles.container,
617
+ onWarningContainer: warningRoles.onContainer,
618
+ danger: dangerRoles.color,
619
+ onDanger: dangerRoles.onColor,
620
+ dangerContainer: dangerRoles.container,
621
+ onDangerContainer: dangerRoles.onContainer
578
622
  }
579
623
  };
580
624
  const palette = getTriadicPalette(color, harmony);
581
- const surface = base.colors.surface;
582
- _theme.colors.primary = palette.primary;
583
- _theme.colors.onPrimary = getTonalColor(palette.primary, isDarkColor(palette.primary) ? 0.95 : 0.08);
584
- _theme.colors.primaryContainer = getBlendedColor(palette.primary, surface, 0.15);
585
- _theme.colors.onPrimaryContainer = getTonalColor(palette.primary, isDarkColor(_theme.colors.primaryContainer) ? 0.92 : 0.12);
586
- _theme.colors.secondary = palette.secondary;
587
- _theme.colors.onSecondary = getTonalColor(palette.secondary, isDarkColor(palette.secondary) ? 0.95 : 0.08);
588
- _theme.colors.secondaryContainer = getBlendedColor(palette.secondary, surface, 0.15);
589
- _theme.colors.onSecondaryContainer = getTonalColor(palette.secondary, isDarkColor(_theme.colors.secondaryContainer) ? 0.92 : 0.12);
590
- _theme.colors.tertiary = palette.tertiary;
591
- _theme.colors.onTertiary = getTonalColor(palette.tertiary, isDarkColor(palette.tertiary) ? 0.95 : 0.08);
592
- _theme.colors.tertiaryContainer = getBlendedColor(palette.tertiary, surface, 0.15);
593
- _theme.colors.onTertiaryContainer = getTonalColor(palette.tertiary, isDarkColor(_theme.colors.tertiaryContainer) ? 0.92 : 0.12);
625
+ const primaryRoles = getColorRoles(palette.primary, surface);
626
+ _theme.colors.primary = primaryRoles.color;
627
+ _theme.colors.onPrimary = primaryRoles.onColor;
628
+ _theme.colors.primaryContainer = primaryRoles.container;
629
+ _theme.colors.onPrimaryContainer = primaryRoles.onContainer;
630
+ const secondaryRoles = getColorRoles(palette.secondary, surface);
631
+ _theme.colors.secondary = secondaryRoles.color;
632
+ _theme.colors.onSecondary = secondaryRoles.onColor;
633
+ _theme.colors.secondaryContainer = secondaryRoles.container;
634
+ _theme.colors.onSecondaryContainer = secondaryRoles.onContainer;
635
+ const tertiaryRoles = getColorRoles(palette.tertiary, surface);
636
+ _theme.colors.tertiary = tertiaryRoles.color;
637
+ _theme.colors.onTertiary = tertiaryRoles.onColor;
638
+ _theme.colors.tertiaryContainer = tertiaryRoles.container;
639
+ _theme.colors.onTertiaryContainer = tertiaryRoles.onContainer;
594
640
  const blended = getBlendedColor(color, palette.secondary, 0.5);
595
641
  const tintSurface = (alpha) => getBlendedColor(blended, surface, alpha);
596
642
  _theme.colors.background = base.dark ? "#0F0F0F" : "#F0F0F0";
@@ -714,6 +760,15 @@ function useSwatchSize(fallback) {
714
760
  return { onLayout, size };
715
761
  }
716
762
 
763
+ // src/utils/getSwatchContrast.ts
764
+ var RING_WIDTH = 1;
765
+ var SELECTED_RING_WIDTH = 3;
766
+ var getContrastColor = (color) => isDarkColor(color) ? "#ffffff" : "#000000";
767
+ var getSwatchRing = (color, selected, outlineColor) => ({
768
+ borderColor: selected ? getContrastColor(color) : outlineColor,
769
+ borderWidth: selected ? SELECTED_RING_WIDTH : RING_WIDTH
770
+ });
771
+
717
772
  // src/components/Dialog.tsx
718
773
  var import_react9 = __toESM(require("react"));
719
774
  var import_react_native6 = require("react-native");
@@ -805,10 +860,11 @@ var defaultColors = [
805
860
  ];
806
861
  var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp }) => {
807
862
  const blur = useBlur(blurProp);
863
+ const { colors: themeColors } = (0, import_react_native_paper10.useTheme)();
808
864
  const [open, setOpen] = (0, import_react10.useState)(false);
809
865
  const { onLayout, size } = useSwatchSize(SIZE);
810
866
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
811
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.TouchableRipple, { onPress: () => setOpen(true), style: [styles3.trigger, { backgroundColor: value }], borderless: true, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.Icon, { source: "palette", size: 20, color: "white" }) }),
867
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.TouchableRipple, { onPress: () => setOpen(true), style: [styles3.trigger, { backgroundColor: value }, getSwatchRing(value, false, themeColors.outlineVariant)], borderless: true, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.Icon, { source: "palette", size: 20, color: getContrastColor(value) }) }),
812
868
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Dialog.Content, { children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native7.View, { style: styles3.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
813
869
  import_react_native7.TouchableOpacity,
814
870
  {
@@ -816,15 +872,14 @@ var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp })
816
872
  onChange(hex);
817
873
  setOpen(false);
818
874
  },
819
- style: [styles3.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, value === hex && styles3.selected],
820
- children: value === hex && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.Icon, { source: "check", size: 20, color: "white" })
875
+ style: [styles3.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)],
876
+ children: value === hex && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.Icon, { source: "check", size: 20, color: getContrastColor(hex) })
821
877
  },
822
878
  hex
823
879
  )) }) }) })
824
880
  ] });
825
881
  };
826
882
  var styles3 = import_react_native7.StyleSheet.create({
827
- selected: { borderColor: "white", borderWidth: 3 },
828
883
  swatch: { alignItems: "center", justifyContent: "center" },
829
884
  swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
830
885
  trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE / 2, height: SIZE, justifyContent: "center", width: SIZE }
@@ -926,12 +981,13 @@ var buildWedges = (weights) => {
926
981
  var Pie = ({ palette, weights = defaultWeights, size = SIZE2 }) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.pie, { borderRadius: size / 2, height: size, width: size }], children: buildWedges(weights).map(({ key, rotate, share }) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.rotator, { transform: [{ rotate: `${rotate}deg` }] }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: styles5.halfMask, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.innerRotator, { transform: [{ rotate: `${share - 180}deg` }] }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.wedgeFill, { backgroundColor: palette[key] }] }) }) }) }, key)) });
927
982
  var PalettePicker = ({ value, onChange, harmony = "split-complementary", weights, colors = defaultColors, blur: blurProp }) => {
928
983
  const blur = useBlur(blurProp);
984
+ const { colors: themeColors } = (0, import_react_native_paper15.useTheme)();
929
985
  const [open, setOpen] = (0, import_react12.useState)(false);
930
986
  const { onLayout, size } = useSwatchSize(SIZE2);
931
987
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
932
988
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.TouchableRipple, { onPress: () => setOpen(true), style: styles5.trigger, borderless: true, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native9.View, { style: styles5.triggerContent, children: [
933
989
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Pie, { palette: getTriadicPalette(value, harmony), weights }),
934
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { pointerEvents: "none", style: styles5.triggerIcon, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.Icon, { source: "palette", size: 20, color: "white" }) })
990
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { pointerEvents: "none", style: [styles5.triggerIcon, { borderRadius: SIZE2 / 2 }, getSwatchRing(value, false, themeColors.outlineVariant)], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.Icon, { source: "palette", size: 20, color: getContrastColor(value) }) })
935
991
  ] }) }),
936
992
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Dialog.Content, { children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: styles5.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
937
993
  import_react_native9.TouchableOpacity,
@@ -943,7 +999,7 @@ var PalettePicker = ({ value, onChange, harmony = "split-complementary", weights
943
999
  style: [styles5.swatch, { height: size, width: size }],
944
1000
  children: [
945
1001
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Pie, { palette: getTriadicPalette(hex, harmony), weights, size }),
946
- value === hex && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { pointerEvents: "none", style: [styles5.selectedRing, { borderRadius: size / 2 }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.Icon, { source: "check", size: 20, color: "white" }) })
1002
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { pointerEvents: "none", style: [styles5.ring, { borderRadius: size / 2 }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)], children: value === hex && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.Icon, { source: "check", size: 20, color: getContrastColor(hex) }) })
947
1003
  ]
948
1004
  },
949
1005
  hex
@@ -955,7 +1011,7 @@ var styles5 = import_react_native9.StyleSheet.create({
955
1011
  innerRotator: { height: "100%", left: "-100%", overflow: "hidden", position: "absolute", top: 0, width: "200%" },
956
1012
  pie: { overflow: "hidden" },
957
1013
  rotator: { height: "100%", left: 0, overflow: "hidden", position: "absolute", top: 0, width: "100%" },
958
- selectedRing: { alignItems: "center", borderColor: "white", borderWidth: 3, bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
1014
+ ring: { alignItems: "center", bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
959
1015
  swatch: { alignItems: "center", justifyContent: "center" },
960
1016
  swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
961
1017
  trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE2 / 2, height: SIZE2, justifyContent: "center", width: SIZE2 },
@@ -1010,6 +1066,10 @@ var selectThemeBlur = (state) => state.blur;
1010
1066
  var selectThemeColor = (state) => state.color;
1011
1067
  var selectThemeHarmony = (state) => state.harmony;
1012
1068
 
1069
+ // src/useTheme.ts
1070
+ var import_react_native_paper17 = require("react-native-paper");
1071
+ var useAutoPaperTheme = () => (0, import_react_native_paper17.useTheme)();
1072
+
1013
1073
  // src/useThemeSettings.ts
1014
1074
  var import_react14 = require("react");
1015
1075
  var useThemeSettings = () => (0, import_react14.useContext)(ThemeSettingsContext);
@@ -1035,12 +1095,15 @@ var getTintTextColor = (tintColor, backdropColor, tintOpacity) => {
1035
1095
  Menu,
1036
1096
  PalettePicker,
1037
1097
  Provider,
1098
+ SEMANTIC_BASE_COLORS,
1038
1099
  TextInput,
1039
1100
  ThemeSettingsContext,
1040
1101
  createThemeReducer,
1041
1102
  defaultColors,
1042
1103
  defaultThemeSettings,
1043
1104
  getBlendedColor,
1105
+ getColorRoles,
1106
+ getContrastColor,
1044
1107
  getHex,
1045
1108
  getRgb,
1046
1109
  getTintTextColor,
@@ -1053,6 +1116,7 @@ var getTintTextColor = (tintColor, backdropColor, tintOpacity) => {
1053
1116
  selectThemeHarmony,
1054
1117
  themeActions,
1055
1118
  themeReducer,
1119
+ useAutoPaperTheme,
1056
1120
  useBlur,
1057
1121
  useComputedTheme,
1058
1122
  usePaperDefaults,
package/dist/index.mjs CHANGED
@@ -61,11 +61,19 @@ var Appbar = {
61
61
  import { SegmentedButtons } from "react-native-paper";
62
62
  import { jsx as jsx2 } from "react/jsx-runtime";
63
63
  var APPEARANCES = [
64
- { label: "System", value: "system", icon: "brightness-auto" },
65
- { label: "Light", value: "light", icon: "white-balance-sunny" },
66
- { label: "Dark", value: "dark", icon: "weather-night" }
64
+ { label: "System", value: "system" },
65
+ { label: "Light", value: "light" },
66
+ { label: "Dark", value: "dark" }
67
67
  ];
68
- var AppearancePicker = ({ value, onChange }) => /* @__PURE__ */ jsx2(SegmentedButtons, { value, onValueChange: (v) => onChange(v), buttons: APPEARANCES.map((a) => ({ value: a.value, label: a.label, icon: a.icon })) });
68
+ var DEFAULT_ICONS = {
69
+ system: "monitor",
70
+ light: "white-balance-sunny",
71
+ dark: "weather-night"
72
+ };
73
+ var AppearancePicker = ({ value, onChange, showLabels = true, icons }) => {
74
+ const resolvedIcons = { ...DEFAULT_ICONS, ...icons };
75
+ return /* @__PURE__ */ jsx2(SegmentedButtons, { value, onValueChange: (v) => onChange(v), buttons: APPEARANCES.map((a) => ({ value: a.value, label: showLabels ? a.label : void 0, icon: resolvedIcons[a.value], accessibilityLabel: a.label })) });
76
+ };
69
77
 
70
78
  // src/components/BlurView.tsx
71
79
  import { useContext as useContext3 } from "react";
@@ -407,6 +415,27 @@ var getTonalColor = (color, lightness) => {
407
415
  return getHex(`rgb(${toInt(r2)}, ${toInt(g2)}, ${toInt(b2)})`) ?? color;
408
416
  };
409
417
 
418
+ // src/utils/isDarkColor.ts
419
+ var isDarkColor = (value) => {
420
+ const rgb = getRgb(value);
421
+ if (!rgb) return false;
422
+ const [r, g, b] = [rgb.r / 255, rgb.g / 255, rgb.b / 255];
423
+ const [rs, gs, bs] = [r, g, b].map((c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
424
+ const luminance = 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
425
+ return luminance < 0.5;
426
+ };
427
+
428
+ // src/utils/getColorRoles.ts
429
+ var getColorRoles = (base, surface, containerAlpha = 0.15) => {
430
+ const container = getBlendedColor(base, surface, containerAlpha);
431
+ return {
432
+ color: base,
433
+ onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
434
+ container,
435
+ onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
436
+ };
437
+ };
438
+
410
439
  // src/utils/getTriadicPalette.ts
411
440
  var HARMONY_OFFSETS = {
412
441
  analogous: [1 / 12, 2 / 12],
@@ -488,17 +517,12 @@ function hslToHex(h, s, l) {
488
517
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
489
518
  }
490
519
 
491
- // src/utils/isDarkColor.ts
492
- var isDarkColor = (value) => {
493
- const rgb = getRgb(value);
494
- if (!rgb) return false;
495
- const [r, g, b] = [rgb.r / 255, rgb.g / 255, rgb.b / 255];
496
- const [rs, gs, bs] = [r, g, b].map((c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
497
- const luminance = 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
498
- return luminance < 0.5;
499
- };
500
-
501
520
  // src/useComputedTheme.ts
521
+ var SEMANTIC_BASE_COLORS = {
522
+ success: "#2E7D32",
523
+ warning: "#A15C00",
524
+ danger: "#B00020"
525
+ };
502
526
  function useComputedTheme(appearance, color, harmony = "split-complementary") {
503
527
  const [theme, setTheme] = useState(null);
504
528
  const computeTheme = useCallback(() => {
@@ -506,27 +530,45 @@ function useComputedTheme(appearance, color, harmony = "split-complementary") {
506
530
  if (resolved === "system") resolved = Appearance.getColorScheme();
507
531
  if (resolved === "no-preference" || !resolved) resolved = "light";
508
532
  const base = resolved === "dark" ? MD3DarkTheme : MD3LightTheme;
533
+ const surface = base.colors.surface;
534
+ const successRoles = getColorRoles(SEMANTIC_BASE_COLORS.success, surface);
535
+ const warningRoles = getColorRoles(SEMANTIC_BASE_COLORS.warning, surface);
536
+ const dangerRoles = getColorRoles(SEMANTIC_BASE_COLORS.danger, surface);
509
537
  const _theme = {
510
538
  ...base,
511
539
  colors: {
512
540
  ...base.colors,
513
- elevation: { ...base.colors.elevation }
541
+ elevation: { ...base.colors.elevation },
542
+ success: successRoles.color,
543
+ onSuccess: successRoles.onColor,
544
+ successContainer: successRoles.container,
545
+ onSuccessContainer: successRoles.onContainer,
546
+ warning: warningRoles.color,
547
+ onWarning: warningRoles.onColor,
548
+ warningContainer: warningRoles.container,
549
+ onWarningContainer: warningRoles.onContainer,
550
+ danger: dangerRoles.color,
551
+ onDanger: dangerRoles.onColor,
552
+ dangerContainer: dangerRoles.container,
553
+ onDangerContainer: dangerRoles.onContainer
514
554
  }
515
555
  };
516
556
  const palette = getTriadicPalette(color, harmony);
517
- const surface = base.colors.surface;
518
- _theme.colors.primary = palette.primary;
519
- _theme.colors.onPrimary = getTonalColor(palette.primary, isDarkColor(palette.primary) ? 0.95 : 0.08);
520
- _theme.colors.primaryContainer = getBlendedColor(palette.primary, surface, 0.15);
521
- _theme.colors.onPrimaryContainer = getTonalColor(palette.primary, isDarkColor(_theme.colors.primaryContainer) ? 0.92 : 0.12);
522
- _theme.colors.secondary = palette.secondary;
523
- _theme.colors.onSecondary = getTonalColor(palette.secondary, isDarkColor(palette.secondary) ? 0.95 : 0.08);
524
- _theme.colors.secondaryContainer = getBlendedColor(palette.secondary, surface, 0.15);
525
- _theme.colors.onSecondaryContainer = getTonalColor(palette.secondary, isDarkColor(_theme.colors.secondaryContainer) ? 0.92 : 0.12);
526
- _theme.colors.tertiary = palette.tertiary;
527
- _theme.colors.onTertiary = getTonalColor(palette.tertiary, isDarkColor(palette.tertiary) ? 0.95 : 0.08);
528
- _theme.colors.tertiaryContainer = getBlendedColor(palette.tertiary, surface, 0.15);
529
- _theme.colors.onTertiaryContainer = getTonalColor(palette.tertiary, isDarkColor(_theme.colors.tertiaryContainer) ? 0.92 : 0.12);
557
+ const primaryRoles = getColorRoles(palette.primary, surface);
558
+ _theme.colors.primary = primaryRoles.color;
559
+ _theme.colors.onPrimary = primaryRoles.onColor;
560
+ _theme.colors.primaryContainer = primaryRoles.container;
561
+ _theme.colors.onPrimaryContainer = primaryRoles.onContainer;
562
+ const secondaryRoles = getColorRoles(palette.secondary, surface);
563
+ _theme.colors.secondary = secondaryRoles.color;
564
+ _theme.colors.onSecondary = secondaryRoles.onColor;
565
+ _theme.colors.secondaryContainer = secondaryRoles.container;
566
+ _theme.colors.onSecondaryContainer = secondaryRoles.onContainer;
567
+ const tertiaryRoles = getColorRoles(palette.tertiary, surface);
568
+ _theme.colors.tertiary = tertiaryRoles.color;
569
+ _theme.colors.onTertiary = tertiaryRoles.onColor;
570
+ _theme.colors.tertiaryContainer = tertiaryRoles.container;
571
+ _theme.colors.onTertiaryContainer = tertiaryRoles.onContainer;
530
572
  const blended = getBlendedColor(color, palette.secondary, 0.5);
531
573
  const tintSurface = (alpha) => getBlendedColor(blended, surface, alpha);
532
574
  _theme.colors.background = base.dark ? "#0F0F0F" : "#F0F0F0";
@@ -637,7 +679,7 @@ function Chip({ variant, style, selectedColor, ...props }) {
637
679
  // src/components/ColorPicker.tsx
638
680
  import { useState as useState5 } from "react";
639
681
  import { StyleSheet as StyleSheet4, TouchableOpacity, View as View4 } from "react-native";
640
- import { Icon, TouchableRipple } from "react-native-paper";
682
+ import { Icon, TouchableRipple, useTheme as useTheme6 } from "react-native-paper";
641
683
 
642
684
  // src/useSwatchGrid.ts
643
685
  import { useState as useState3 } from "react";
@@ -650,6 +692,15 @@ function useSwatchSize(fallback) {
650
692
  return { onLayout, size };
651
693
  }
652
694
 
695
+ // src/utils/getSwatchContrast.ts
696
+ var RING_WIDTH = 1;
697
+ var SELECTED_RING_WIDTH = 3;
698
+ var getContrastColor = (color) => isDarkColor(color) ? "#ffffff" : "#000000";
699
+ var getSwatchRing = (color, selected, outlineColor) => ({
700
+ borderColor: selected ? getContrastColor(color) : outlineColor,
701
+ borderWidth: selected ? SELECTED_RING_WIDTH : RING_WIDTH
702
+ });
703
+
653
704
  // src/components/Dialog.tsx
654
705
  import React2, { useEffect as useEffect4, useRef as useRef2, useState as useState4 } from "react";
655
706
  import { Animated, BackHandler, Easing, Pressable, StyleSheet as StyleSheet3, View as View3 } from "react-native";
@@ -741,10 +792,11 @@ var defaultColors = [
741
792
  ];
742
793
  var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp }) => {
743
794
  const blur = useBlur(blurProp);
795
+ const { colors: themeColors } = useTheme6();
744
796
  const [open, setOpen] = useState5(false);
745
797
  const { onLayout, size } = useSwatchSize(SIZE);
746
798
  return /* @__PURE__ */ jsxs5(Fragment2, { children: [
747
- /* @__PURE__ */ jsx9(TouchableRipple, { onPress: () => setOpen(true), style: [styles3.trigger, { backgroundColor: value }], borderless: true, children: /* @__PURE__ */ jsx9(Icon, { source: "palette", size: 20, color: "white" }) }),
799
+ /* @__PURE__ */ jsx9(TouchableRipple, { onPress: () => setOpen(true), style: [styles3.trigger, { backgroundColor: value }, getSwatchRing(value, false, themeColors.outlineVariant)], borderless: true, children: /* @__PURE__ */ jsx9(Icon, { source: "palette", size: 20, color: getContrastColor(value) }) }),
748
800
  /* @__PURE__ */ jsx9(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ jsx9(Dialog.Content, { children: /* @__PURE__ */ jsx9(View4, { style: styles3.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ jsx9(
749
801
  TouchableOpacity,
750
802
  {
@@ -752,15 +804,14 @@ var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp })
752
804
  onChange(hex);
753
805
  setOpen(false);
754
806
  },
755
- style: [styles3.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, value === hex && styles3.selected],
756
- children: value === hex && /* @__PURE__ */ jsx9(Icon, { source: "check", size: 20, color: "white" })
807
+ style: [styles3.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)],
808
+ children: value === hex && /* @__PURE__ */ jsx9(Icon, { source: "check", size: 20, color: getContrastColor(hex) })
757
809
  },
758
810
  hex
759
811
  )) }) }) })
760
812
  ] });
761
813
  };
762
814
  var styles3 = StyleSheet4.create({
763
- selected: { borderColor: "white", borderWidth: 3 },
764
815
  swatch: { alignItems: "center", justifyContent: "center" },
765
816
  swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
766
817
  trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE / 2, height: SIZE, justifyContent: "center", width: SIZE }
@@ -831,11 +882,11 @@ var HarmonyPicker = ({ value, onChange, blur: blurProp }) => {
831
882
  };
832
883
 
833
884
  // src/components/IconButton.tsx
834
- import { IconButton as PaperIconButton, useTheme as useTheme6 } from "react-native-paper";
885
+ import { IconButton as PaperIconButton, useTheme as useTheme7 } from "react-native-paper";
835
886
  import { jsx as jsx13 } from "react/jsx-runtime";
836
887
  function IconButton({ variant, iconColor, containerColor, ...props }) {
837
888
  const defaults = usePaperDefaults();
838
- const { colors } = useTheme6();
889
+ const { colors } = useTheme7();
839
890
  const variantContainerColor = variant === "primary" ? colors.primaryContainer : variant === "secondary" ? colors.secondaryContainer : variant === "tertiary" ? colors.tertiaryContainer : variant === "surface" ? colors.surface : void 0;
840
891
  const variantIconColor = variant === "primary" ? colors.onPrimaryContainer : variant === "secondary" ? colors.onSecondaryContainer : variant === "tertiary" ? colors.onTertiaryContainer : variant === "surface" ? colors.onSurface : void 0;
841
892
  return /* @__PURE__ */ jsx13(PaperIconButton, { ...defaults.IconButton, containerColor: containerColor ?? variantContainerColor, iconColor: iconColor ?? variantIconColor, ...props });
@@ -844,7 +895,7 @@ function IconButton({ variant, iconColor, containerColor, ...props }) {
844
895
  // src/components/PalettePicker.tsx
845
896
  import { useState as useState7 } from "react";
846
897
  import { StyleSheet as StyleSheet6, TouchableOpacity as TouchableOpacity2, View as View5 } from "react-native";
847
- import { Icon as Icon2, TouchableRipple as TouchableRipple2 } from "react-native-paper";
898
+ import { Icon as Icon2, TouchableRipple as TouchableRipple2, useTheme as useTheme8 } from "react-native-paper";
848
899
  import { Fragment as Fragment3, jsx as jsx14, jsxs as jsxs6 } from "react/jsx-runtime";
849
900
  var SIZE2 = 48;
850
901
  var defaultWeights = { primary: 2, secondary: 1, tertiary: 1 };
@@ -862,12 +913,13 @@ var buildWedges = (weights) => {
862
913
  var Pie = ({ palette, weights = defaultWeights, size = SIZE2 }) => /* @__PURE__ */ jsx14(View5, { style: [styles5.pie, { borderRadius: size / 2, height: size, width: size }], children: buildWedges(weights).map(({ key, rotate, share }) => /* @__PURE__ */ jsx14(View5, { style: [styles5.rotator, { transform: [{ rotate: `${rotate}deg` }] }], children: /* @__PURE__ */ jsx14(View5, { style: styles5.halfMask, children: /* @__PURE__ */ jsx14(View5, { style: [styles5.innerRotator, { transform: [{ rotate: `${share - 180}deg` }] }], children: /* @__PURE__ */ jsx14(View5, { style: [styles5.wedgeFill, { backgroundColor: palette[key] }] }) }) }) }, key)) });
863
914
  var PalettePicker = ({ value, onChange, harmony = "split-complementary", weights, colors = defaultColors, blur: blurProp }) => {
864
915
  const blur = useBlur(blurProp);
916
+ const { colors: themeColors } = useTheme8();
865
917
  const [open, setOpen] = useState7(false);
866
918
  const { onLayout, size } = useSwatchSize(SIZE2);
867
919
  return /* @__PURE__ */ jsxs6(Fragment3, { children: [
868
920
  /* @__PURE__ */ jsx14(TouchableRipple2, { onPress: () => setOpen(true), style: styles5.trigger, borderless: true, children: /* @__PURE__ */ jsxs6(View5, { style: styles5.triggerContent, children: [
869
921
  /* @__PURE__ */ jsx14(Pie, { palette: getTriadicPalette(value, harmony), weights }),
870
- /* @__PURE__ */ jsx14(View5, { pointerEvents: "none", style: styles5.triggerIcon, children: /* @__PURE__ */ jsx14(Icon2, { source: "palette", size: 20, color: "white" }) })
922
+ /* @__PURE__ */ jsx14(View5, { pointerEvents: "none", style: [styles5.triggerIcon, { borderRadius: SIZE2 / 2 }, getSwatchRing(value, false, themeColors.outlineVariant)], children: /* @__PURE__ */ jsx14(Icon2, { source: "palette", size: 20, color: getContrastColor(value) }) })
871
923
  ] }) }),
872
924
  /* @__PURE__ */ jsx14(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ jsx14(Dialog.Content, { children: /* @__PURE__ */ jsx14(View5, { style: styles5.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ jsxs6(
873
925
  TouchableOpacity2,
@@ -879,7 +931,7 @@ var PalettePicker = ({ value, onChange, harmony = "split-complementary", weights
879
931
  style: [styles5.swatch, { height: size, width: size }],
880
932
  children: [
881
933
  /* @__PURE__ */ jsx14(Pie, { palette: getTriadicPalette(hex, harmony), weights, size }),
882
- value === hex && /* @__PURE__ */ jsx14(View5, { pointerEvents: "none", style: [styles5.selectedRing, { borderRadius: size / 2 }], children: /* @__PURE__ */ jsx14(Icon2, { source: "check", size: 20, color: "white" }) })
934
+ /* @__PURE__ */ jsx14(View5, { pointerEvents: "none", style: [styles5.ring, { borderRadius: size / 2 }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)], children: value === hex && /* @__PURE__ */ jsx14(Icon2, { source: "check", size: 20, color: getContrastColor(hex) }) })
883
935
  ]
884
936
  },
885
937
  hex
@@ -891,7 +943,7 @@ var styles5 = StyleSheet6.create({
891
943
  innerRotator: { height: "100%", left: "-100%", overflow: "hidden", position: "absolute", top: 0, width: "200%" },
892
944
  pie: { overflow: "hidden" },
893
945
  rotator: { height: "100%", left: 0, overflow: "hidden", position: "absolute", top: 0, width: "100%" },
894
- selectedRing: { alignItems: "center", borderColor: "white", borderWidth: 3, bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
946
+ ring: { alignItems: "center", bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
895
947
  swatch: { alignItems: "center", justifyContent: "center" },
896
948
  swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
897
949
  trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE2 / 2, height: SIZE2, justifyContent: "center", width: SIZE2 },
@@ -946,6 +998,10 @@ var selectThemeBlur = (state) => state.blur;
946
998
  var selectThemeColor = (state) => state.color;
947
999
  var selectThemeHarmony = (state) => state.harmony;
948
1000
 
1001
+ // src/useTheme.ts
1002
+ import { useTheme as usePaperTheme } from "react-native-paper";
1003
+ var useAutoPaperTheme = () => usePaperTheme();
1004
+
949
1005
  // src/useThemeSettings.ts
950
1006
  import { useContext as useContext5 } from "react";
951
1007
  var useThemeSettings = () => useContext5(ThemeSettingsContext);
@@ -970,12 +1026,15 @@ export {
970
1026
  Menu,
971
1027
  PalettePicker,
972
1028
  Provider,
1029
+ SEMANTIC_BASE_COLORS,
973
1030
  TextInput,
974
1031
  ThemeSettingsContext,
975
1032
  createThemeReducer,
976
1033
  defaultColors,
977
1034
  defaultThemeSettings,
978
1035
  getBlendedColor,
1036
+ getColorRoles,
1037
+ getContrastColor,
979
1038
  getHex,
980
1039
  getRgb,
981
1040
  getTintTextColor,
@@ -988,6 +1047,7 @@ export {
988
1047
  selectThemeHarmony,
989
1048
  themeActions,
990
1049
  themeReducer,
1050
+ useAutoPaperTheme,
991
1051
  useBlur,
992
1052
  useComputedTheme,
993
1053
  usePaperDefaults,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/auto-paper",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Adaptive react-native-paper theming: triadic palette from a seed color + system/light/dark appearance",
5
5
  "keywords": [
6
6
  "react-native",
@@ -2,15 +2,35 @@ import { SegmentedButtons } from 'react-native-paper'
2
2
 
3
3
  import type { ThemeAppearance } from '../useComputedTheme'
4
4
 
5
- const APPEARANCES: { label: string; value: ThemeAppearance; icon: string }[] = [
6
- { label: 'System', value: 'system', icon: 'brightness-auto' },
7
- { label: 'Light', value: 'light', icon: 'white-balance-sunny' },
8
- { label: 'Dark', value: 'dark', icon: 'weather-night' }
5
+ const APPEARANCES: { label: string; value: ThemeAppearance }[] = [
6
+ { label: 'System', value: 'system' },
7
+ { label: 'Light', value: 'light' },
8
+ { label: 'Dark', value: 'dark' }
9
9
  ]
10
10
 
11
+ export type AppearanceIcons = Partial<Record<ThemeAppearance, string>>
12
+
13
+ // 'monitor' (a plain device/screen glyph) reads more clearly as "match my device" than
14
+ // 'brightness-auto' (an exposure/brightness symbol) — swap via the `icons` prop for a different
15
+ // convention, e.g. `theme-light-dark`'s half-sun/half-moon.
16
+ const DEFAULT_ICONS: Record<ThemeAppearance, string> = {
17
+ system: 'monitor',
18
+ light: 'white-balance-sunny',
19
+ dark: 'weather-night'
20
+ }
21
+
11
22
  type Props = {
12
23
  value: ThemeAppearance
13
24
  onChange: (appearance: ThemeAppearance) => void
25
+ // Hides the text label, leaving just the icon per segment — useful in tight layouts where
26
+ // "System" truncates. accessibilityLabel is always set regardless, so screen readers still
27
+ // announce the full word either way.
28
+ showLabels?: boolean
29
+ // Per-value icon overrides, merged over DEFAULT_ICONS — pass just the ones you want to change.
30
+ icons?: AppearanceIcons
14
31
  }
15
32
 
16
- export const AppearancePicker = ({ value, onChange }: Props) => <SegmentedButtons value={value} onValueChange={(v) => onChange(v as ThemeAppearance)} buttons={APPEARANCES.map((a) => ({ value: a.value, label: a.label, icon: a.icon }))} />
33
+ export const AppearancePicker = ({ value, onChange, showLabels = true, icons }: Props) => {
34
+ const resolvedIcons = { ...DEFAULT_ICONS, ...icons }
35
+ return <SegmentedButtons value={value} onValueChange={(v) => onChange(v as ThemeAppearance)} buttons={APPEARANCES.map((a) => ({ value: a.value, label: showLabels ? a.label : undefined, icon: resolvedIcons[a.value], accessibilityLabel: a.label }))} />
36
+ }
@@ -1,9 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import { StyleSheet, TouchableOpacity, View } from 'react-native'
3
- import { Icon, TouchableRipple } from 'react-native-paper'
3
+ import { Icon, TouchableRipple, useTheme } from 'react-native-paper'
4
4
 
5
5
  import { useBlur } from '../BlurContext'
6
6
  import { SWATCH_GRID_GAP, useSwatchSize } from '../useSwatchGrid'
7
+ import { getContrastColor, getSwatchRing } from '../utils/getSwatchContrast'
7
8
  import { Dialog } from './Dialog'
8
9
 
9
10
  const SIZE = 48
@@ -42,13 +43,14 @@ type Props = {
42
43
 
43
44
  export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp }: Props) => {
44
45
  const blur = useBlur(blurProp)
46
+ const { colors: themeColors } = useTheme()
45
47
  const [open, setOpen] = useState(false)
46
48
  const { onLayout, size } = useSwatchSize(SIZE)
47
49
 
48
50
  return (
49
51
  <>
50
- <TouchableRipple onPress={() => setOpen(true)} style={[styles.trigger, { backgroundColor: value }]} borderless>
51
- <Icon source='palette' size={20} color='white' />
52
+ <TouchableRipple onPress={() => setOpen(true)} style={[styles.trigger, { backgroundColor: value }, getSwatchRing(value, false, themeColors.outlineVariant)]} borderless>
53
+ <Icon source='palette' size={20} color={getContrastColor(value)} />
52
54
  </TouchableRipple>
53
55
 
54
56
  <Dialog blur={blur} visible={open} onDismiss={() => setOpen(false)}>
@@ -61,9 +63,9 @@ export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blu
61
63
  onChange(hex)
62
64
  setOpen(false)
63
65
  }}
64
- style={[styles.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, value === hex && styles.selected]}
66
+ style={[styles.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)]}
65
67
  >
66
- {value === hex && <Icon source='check' size={20} color='white' />}
68
+ {value === hex && <Icon source='check' size={20} color={getContrastColor(hex)} />}
67
69
  </TouchableOpacity>
68
70
  ))}
69
71
  </View>
@@ -74,7 +76,6 @@ export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blu
74
76
  }
75
77
 
76
78
  const styles = StyleSheet.create({
77
- selected: { borderColor: 'white', borderWidth: 3 },
78
79
  swatch: { alignItems: 'center', justifyContent: 'center' },
79
80
  swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: SWATCH_GRID_GAP, paddingTop: 12 },
80
81
  trigger: { alignItems: 'center', alignSelf: 'flex-start', borderRadius: SIZE / 2, height: SIZE, justifyContent: 'center', width: SIZE }
@@ -1,9 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import { StyleSheet, TouchableOpacity, View } from 'react-native'
3
- import { Icon, TouchableRipple } from 'react-native-paper'
3
+ import { Icon, TouchableRipple, useTheme } from 'react-native-paper'
4
4
 
5
5
  import { useBlur } from '../BlurContext'
6
6
  import { SWATCH_GRID_GAP, useSwatchSize } from '../useSwatchGrid'
7
+ import { getContrastColor, getSwatchRing } from '../utils/getSwatchContrast'
7
8
  import type { ColorHarmony, TriadicPalette } from '../utils/getTriadicPalette'
8
9
  import { getTriadicPalette } from '../utils/getTriadicPalette'
9
10
  import type { SeedColor } from './ColorPicker'
@@ -61,6 +62,7 @@ type Props = {
61
62
 
62
63
  export const PalettePicker = ({ value, onChange, harmony = 'split-complementary', weights, colors = defaultColors, blur: blurProp }: Props) => {
63
64
  const blur = useBlur(blurProp)
65
+ const { colors: themeColors } = useTheme()
64
66
  const [open, setOpen] = useState(false)
65
67
  const { onLayout, size } = useSwatchSize(SIZE)
66
68
 
@@ -69,8 +71,8 @@ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary'
69
71
  <TouchableRipple onPress={() => setOpen(true)} style={styles.trigger} borderless>
70
72
  <View style={styles.triggerContent}>
71
73
  <Pie palette={getTriadicPalette(value, harmony)} weights={weights} />
72
- <View pointerEvents='none' style={styles.triggerIcon}>
73
- <Icon source='palette' size={20} color='white' />
74
+ <View pointerEvents='none' style={[styles.triggerIcon, { borderRadius: SIZE / 2 }, getSwatchRing(value, false, themeColors.outlineVariant)]}>
75
+ <Icon source='palette' size={20} color={getContrastColor(value)} />
74
76
  </View>
75
77
  </View>
76
78
  </TouchableRipple>
@@ -88,11 +90,9 @@ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary'
88
90
  style={[styles.swatch, { height: size, width: size }]}
89
91
  >
90
92
  <Pie palette={getTriadicPalette(hex, harmony)} weights={weights} size={size} />
91
- {value === hex && (
92
- <View pointerEvents='none' style={[styles.selectedRing, { borderRadius: size / 2 }]}>
93
- <Icon source='check' size={20} color='white' />
94
- </View>
95
- )}
93
+ <View pointerEvents='none' style={[styles.ring, { borderRadius: size / 2 }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)]}>
94
+ {value === hex && <Icon source='check' size={20} color={getContrastColor(hex)} />}
95
+ </View>
96
96
  </TouchableOpacity>
97
97
  ))}
98
98
  </View>
@@ -107,7 +107,7 @@ const styles = StyleSheet.create({
107
107
  innerRotator: { height: '100%', left: '-100%', overflow: 'hidden', position: 'absolute', top: 0, width: '200%' },
108
108
  pie: { overflow: 'hidden' },
109
109
  rotator: { height: '100%', left: 0, overflow: 'hidden', position: 'absolute', top: 0, width: '100%' },
110
- selectedRing: { alignItems: 'center', borderColor: 'white', borderWidth: 3, bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0 },
110
+ ring: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0 },
111
111
  swatch: { alignItems: 'center', justifyContent: 'center' },
112
112
  swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: SWATCH_GRID_GAP, paddingTop: 12 },
113
113
  trigger: { alignItems: 'center', alignSelf: 'flex-start', borderRadius: SIZE / 2, height: SIZE, justifyContent: 'center', width: SIZE },
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { useBlur } from './BlurContext'
2
2
  export { Appbar } from './components/Appbar'
3
+ export type { AppearanceIcons } from './components/AppearancePicker'
3
4
  export { AppearancePicker } from './components/AppearancePicker'
4
5
  export type { BlurViewProps } from './components/BlurView'
5
6
  export { BlurView } from './components/BlurView'
@@ -28,13 +29,17 @@ export type { ProviderProps } from './ThemeProvider'
28
29
  export { Provider } from './ThemeProvider'
29
30
  export type { ThemeSettings, ThemeSettingsContextType } from './ThemeSettingsContext'
30
31
  export { defaultThemeSettings, ThemeSettingsContext } from './ThemeSettingsContext'
31
- export type { ThemeAppearance } from './useComputedTheme'
32
- export { useComputedTheme } from './useComputedTheme'
32
+ export type { AutoPaperTheme, SemanticColorRoles, ThemeAppearance } from './useComputedTheme'
33
+ export { SEMANTIC_BASE_COLORS, useComputedTheme } from './useComputedTheme'
34
+ export { useAutoPaperTheme } from './useTheme'
33
35
  export { useThemeSettings } from './useThemeSettings'
34
36
  export { getBlendedColor } from './utils/getBlendedColor'
37
+ export type { ColorRoles } from './utils/getColorRoles'
38
+ export { getColorRoles } from './utils/getColorRoles'
35
39
  export { getHex } from './utils/getHex'
36
40
  export type { RGB } from './utils/getRgb'
37
41
  export { getRgb } from './utils/getRgb'
42
+ export { getContrastColor } from './utils/getSwatchContrast'
38
43
  export { getTintTextColor } from './utils/getTintTextColor'
39
44
  export { getTonalColor } from './utils/getTonalColor'
40
45
  export type { ColorHarmony, TriadicPalette } from './utils/getTriadicPalette'
@@ -3,46 +3,112 @@ import { Appearance } from 'react-native'
3
3
  import { MD3DarkTheme, MD3LightTheme, MD3Theme } from 'react-native-paper'
4
4
 
5
5
  import { getBlendedColor } from './utils/getBlendedColor'
6
- import { getTonalColor } from './utils/getTonalColor'
6
+ import { getColorRoles } from './utils/getColorRoles'
7
7
  import { type ColorHarmony, getTriadicPalette } from './utils/getTriadicPalette'
8
- import { isDarkColor } from './utils/isDarkColor'
9
8
 
10
9
  export type ThemeAppearance = 'system' | 'light' | 'dark'
11
10
 
12
- export function useComputedTheme(appearance: ThemeAppearance, color: string, harmony: ColorHarmony = 'split-complementary'): MD3Theme | null {
13
- const [theme, setTheme] = useState<MD3Theme | null>(null)
11
+ // Fixed hue/saturation, like MD3's own `error` role (see LightTheme.tsx/DarkTheme.tsx — error is a
12
+ // static Material palette constant, never derived from the seed color either). A "warning" amber
13
+ // shouldn't drift toward the app's accent hue any more than the built-in danger red does; only
14
+ // lightness adapts per light/dark mode below, via getColorRoles. #A15C00 (not the more obvious
15
+ // goldenrod #B8860B) was picked because its onWarning text fails WCAG AA (3.05:1) against the base
16
+ // color itself — see getColorRoles.test.ts's "onColor" caveat: the onColor heuristic (isDarkColor-
17
+ // based binary lightness pick) isn't a verified guarantee like onContainer is, so the base hex
18
+ // itself has to be chosen to land safely, not assumed to.
19
+ //
20
+ // `danger` is deliberately a separate namespace from MD3's own `error` (below, untouched) rather
21
+ // than a fourth tier folded into it — react-native-paper's own components (TextInput's error state,
22
+ // HelperText, Badge) read theme.colors.error/onError directly, so a consumer that wants a
23
+ // getColorRoles-derived "high severity" color without changing what those components render needs
24
+ // its own name. #B00020 already clears WCAG AA for both onDanger-vs-danger (6.16:1) and
25
+ // dangerContainer/onDangerContainer (12–17:1 across modes) — verified the same way as warning's
26
+ // base above.
27
+ export const SEMANTIC_BASE_COLORS = {
28
+ success: '#2E7D32',
29
+ warning: '#A15C00',
30
+ danger: '#B00020'
31
+ }
32
+
33
+ export type SemanticColorRoles = {
34
+ success: string
35
+ onSuccess: string
36
+ successContainer: string
37
+ onSuccessContainer: string
38
+ warning: string
39
+ onWarning: string
40
+ warningContainer: string
41
+ onWarningContainer: string
42
+ danger: string
43
+ onDanger: string
44
+ dangerContainer: string
45
+ onDangerContainer: string
46
+ }
47
+
48
+ // MD3Theme's `colors` is a closed `type` (not an `interface`), so it can't be widened via
49
+ // TypeScript's declare-module augmentation — this is the extension point instead, meant to be used
50
+ // with react-native-paper's own `useTheme<T>()` generic (see useTheme.ts's useAutoPaperTheme).
51
+ export type AutoPaperTheme = Omit<MD3Theme, 'colors'> & { colors: MD3Theme['colors'] & SemanticColorRoles }
52
+
53
+ export function useComputedTheme(appearance: ThemeAppearance, color: string, harmony: ColorHarmony = 'split-complementary'): AutoPaperTheme | null {
54
+ const [theme, setTheme] = useState<AutoPaperTheme | null>(null)
14
55
 
15
56
  const computeTheme = useCallback(() => {
16
57
  let resolved: string | null | undefined = appearance
17
58
  if (resolved === 'system') resolved = Appearance.getColorScheme()
18
59
  if (resolved === 'no-preference' || !resolved) resolved = 'light'
19
60
  const base = resolved === 'dark' ? MD3DarkTheme : MD3LightTheme
61
+ const surface = base.colors.surface
20
62
 
21
- const _theme: MD3Theme = {
63
+ // error/onError/errorContainer/onErrorContainer are deliberately left untouched below (not run
64
+ // through getColorRoles like success/warning/primary/secondary/tertiary are) — they're a fixed,
65
+ // Google-verified Material palette that react-native-paper's own components (TextInput's error
66
+ // state, HelperText, Badge) read directly, so re-deriving them would change form-validation
67
+ // colors across every consuming app and risk diverging from a contrast guarantee that's already
68
+ // correct as shipped.
69
+ const successRoles = getColorRoles(SEMANTIC_BASE_COLORS.success, surface)
70
+ const warningRoles = getColorRoles(SEMANTIC_BASE_COLORS.warning, surface)
71
+ const dangerRoles = getColorRoles(SEMANTIC_BASE_COLORS.danger, surface)
72
+
73
+ const _theme: AutoPaperTheme = {
22
74
  ...base,
23
75
  colors: {
24
76
  ...base.colors,
25
- elevation: { ...base.colors.elevation }
77
+ elevation: { ...base.colors.elevation },
78
+ success: successRoles.color,
79
+ onSuccess: successRoles.onColor,
80
+ successContainer: successRoles.container,
81
+ onSuccessContainer: successRoles.onContainer,
82
+ warning: warningRoles.color,
83
+ onWarning: warningRoles.onColor,
84
+ warningContainer: warningRoles.container,
85
+ onWarningContainer: warningRoles.onContainer,
86
+ danger: dangerRoles.color,
87
+ onDanger: dangerRoles.onColor,
88
+ dangerContainer: dangerRoles.container,
89
+ onDangerContainer: dangerRoles.onContainer
26
90
  }
27
91
  }
28
92
 
29
93
  const palette = getTriadicPalette(color, harmony)
30
- const surface = base.colors.surface
31
-
32
- _theme.colors.primary = palette.primary
33
- _theme.colors.onPrimary = getTonalColor(palette.primary, isDarkColor(palette.primary) ? 0.95 : 0.08)
34
- _theme.colors.primaryContainer = getBlendedColor(palette.primary, surface, 0.15)
35
- _theme.colors.onPrimaryContainer = getTonalColor(palette.primary, isDarkColor(_theme.colors.primaryContainer) ? 0.92 : 0.12)
36
-
37
- _theme.colors.secondary = palette.secondary
38
- _theme.colors.onSecondary = getTonalColor(palette.secondary, isDarkColor(palette.secondary) ? 0.95 : 0.08)
39
- _theme.colors.secondaryContainer = getBlendedColor(palette.secondary, surface, 0.15)
40
- _theme.colors.onSecondaryContainer = getTonalColor(palette.secondary, isDarkColor(_theme.colors.secondaryContainer) ? 0.92 : 0.12)
41
94
 
42
- _theme.colors.tertiary = palette.tertiary
43
- _theme.colors.onTertiary = getTonalColor(palette.tertiary, isDarkColor(palette.tertiary) ? 0.95 : 0.08)
44
- _theme.colors.tertiaryContainer = getBlendedColor(palette.tertiary, surface, 0.15)
45
- _theme.colors.onTertiaryContainer = getTonalColor(palette.tertiary, isDarkColor(_theme.colors.tertiaryContainer) ? 0.92 : 0.12)
95
+ const primaryRoles = getColorRoles(palette.primary, surface)
96
+ _theme.colors.primary = primaryRoles.color
97
+ _theme.colors.onPrimary = primaryRoles.onColor
98
+ _theme.colors.primaryContainer = primaryRoles.container
99
+ _theme.colors.onPrimaryContainer = primaryRoles.onContainer
100
+
101
+ const secondaryRoles = getColorRoles(palette.secondary, surface)
102
+ _theme.colors.secondary = secondaryRoles.color
103
+ _theme.colors.onSecondary = secondaryRoles.onColor
104
+ _theme.colors.secondaryContainer = secondaryRoles.container
105
+ _theme.colors.onSecondaryContainer = secondaryRoles.onContainer
106
+
107
+ const tertiaryRoles = getColorRoles(palette.tertiary, surface)
108
+ _theme.colors.tertiary = tertiaryRoles.color
109
+ _theme.colors.onTertiary = tertiaryRoles.onColor
110
+ _theme.colors.tertiaryContainer = tertiaryRoles.container
111
+ _theme.colors.onTertiaryContainer = tertiaryRoles.onContainer
46
112
 
47
113
  const blended = getBlendedColor(color, palette.secondary, 0.5)
48
114
  const tintSurface = (alpha: number) => getBlendedColor(blended, surface, alpha)
@@ -0,0 +1,11 @@
1
+ import { useTheme as usePaperTheme } from 'react-native-paper'
2
+
3
+ import type { AutoPaperTheme } from './useComputedTheme'
4
+
5
+ // react-native-paper's own useTheme is generic (`useTheme<T = MD3Theme>()`) specifically so
6
+ // consumers can extend MD3Theme with their own fields — this is that extension point, so any
7
+ // component can read theme.colors.success/warning (and their onX/Container variants) with full
8
+ // type safety, without redeclaring the type parameter at every call site. Named distinctly from
9
+ // react-native-paper's own `useTheme` (not re-exported under the same name) to avoid the two being
10
+ // silently interchangeable at an import statement.
11
+ export const useAutoPaperTheme = (): AutoPaperTheme => usePaperTheme<AutoPaperTheme>()
@@ -0,0 +1,29 @@
1
+ import { getBlendedColor } from './getBlendedColor'
2
+ import { getTonalColor } from './getTonalColor'
3
+ import { isDarkColor } from './isDarkColor'
4
+
5
+ export type ColorRoles = {
6
+ color: string
7
+ onColor: string
8
+ container: string
9
+ onContainer: string
10
+ }
11
+
12
+ // The MD3 "color / onColor / container / onContainer" quadruple, generalized from the math
13
+ // useComputedTheme already uses for primary/secondary/tertiary: onColor is tuned to sit on the
14
+ // vivid color itself, container is base blended toward the theme's real surface (so it inherits
15
+ // that surface's actual lightness rather than a fixed light/dark guess), and onContainer is tuned
16
+ // against the *computed* container rather than a light/dark assumption — so it stays readable
17
+ // whatever surface color it's handed. onContainer also reads safely directly on `surface` itself
18
+ // (not just on `container`), since container is only lightly blended toward it.
19
+ export const getColorRoles = (base: string, surface: string, containerAlpha = 0.15): ColorRoles => {
20
+ const container = getBlendedColor(base, surface, containerAlpha)
21
+ return {
22
+ color: base,
23
+ onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
24
+ container,
25
+ onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
26
+ }
27
+ }
28
+
29
+ export default getColorRoles
@@ -0,0 +1,26 @@
1
+ import { isDarkColor } from './isDarkColor'
2
+
3
+ // Width of the outline every swatch carries so its edge is defined against the surface behind it.
4
+ const RING_WIDTH = 1
5
+ // The selected swatch trades that outline for a thicker one in a contrasting colour.
6
+ const SELECTED_RING_WIDTH = 3
7
+
8
+ // Colour for content sitting directly on a fill of `color` — a check mark or a 20px icon. Plain
9
+ // white/black rather than a tinted tone from getTonalColor, because this has to stay legible on
10
+ // fully saturated fills and on pure black/white alike.
11
+ export const getContrastColor = (color: string): string => (isDarkColor(color) ? '#ffffff' : '#000000')
12
+
13
+ // Two different jobs, which is why the unselected and selected rings resolve against different
14
+ // things:
15
+ //
16
+ // - Unselected: separate the swatch from the *surface* it sits on. A white swatch on a light
17
+ // dialog and a black swatch on a dark one are otherwise edgeless — invisible, not just subtle.
18
+ // The theme's outline colour tracks the surface, so it works in both appearances.
19
+ // - Selected: read as a marker against the *swatch's own fill*. A hardcoded white ring vanished on
20
+ // the white swatch, so picking white gave no feedback that anything had been picked.
21
+ export const getSwatchRing = (color: string, selected: boolean, outlineColor: string) => ({
22
+ borderColor: selected ? getContrastColor(color) : outlineColor,
23
+ borderWidth: selected ? SELECTED_RING_WIDTH : RING_WIDTH
24
+ })
25
+
26
+ export default getContrastColor