@rific/auto-paper 0.13.3 → 0.14.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.
@@ -3,6 +3,7 @@ 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 type { PaletteTone } from './utils/getChalkboardColor'
6
7
  import { getColorRoles } from './utils/getColorRoles'
7
8
  import { getHex } from './utils/getHex'
8
9
  import { getRgb } from './utils/getRgb'
@@ -57,7 +58,9 @@ export type SemanticColorRoles = {
57
58
  // with react-native-paper's own `useTheme<T>()` generic (see useTheme.ts's useAutoPaperTheme).
58
59
  export type AutoPaperTheme = Omit<MD3Theme, 'colors'> & { colors: MD3Theme['colors'] & SemanticColorRoles }
59
60
 
60
- export function useComputedTheme(appearance: ThemeAppearance, color: string | TriadicPalette, harmony: ColorHarmony = 'split-complementary'): AutoPaperTheme | null {
61
+ // `paletteTone` (optional, see Provider's prop of the same name) maps each resolved palette color
62
+ // before anything is derived from it. It must be referentially stable: it's a dependency.
63
+ export function useComputedTheme(appearance: ThemeAppearance, color: string | TriadicPalette, harmony: ColorHarmony = 'split-complementary', paletteTone?: PaletteTone): AutoPaperTheme | null {
61
64
  const [theme, setTheme] = useState<AutoPaperTheme | null>(null)
62
65
 
63
66
  // Destructured to primitives (rather than depending on `color` itself below) so that passing a
@@ -104,7 +107,12 @@ export function useComputedTheme(appearance: ThemeAppearance, color: string | Tr
104
107
  }
105
108
  }
106
109
 
107
- const palette: TriadicPalette = secondaryInput !== null && tertiaryInput !== null ? { primary: normalizeExplicitColor(primaryInput), secondary: normalizeExplicitColor(secondaryInput), tertiary: normalizeExplicitColor(tertiaryInput) } : getTriadicPalette(primaryInput, harmony)
110
+ const untoned: TriadicPalette = secondaryInput !== null && tertiaryInput !== null ? { primary: normalizeExplicitColor(primaryInput), secondary: normalizeExplicitColor(secondaryInput), tertiary: normalizeExplicitColor(tertiaryInput) } : getTriadicPalette(primaryInput, harmony)
111
+ // Toned per member, after the harmony rotation: a tone that moves lightness doesn't commute
112
+ // with rotating hue, so toning the seed first would give secondary and tertiary colors the
113
+ // pickers never showed. Everything below (roles, containers, outline, surfaceVariant,
114
+ // elevation) derives from the toned palette; the semantic roles above never pass through it.
115
+ const palette: TriadicPalette = paletteTone ? { primary: paletteTone(untoned.primary, base.dark), secondary: paletteTone(untoned.secondary, base.dark), tertiary: paletteTone(untoned.tertiary, base.dark) } : untoned
108
116
 
109
117
  const primaryRoles = getColorRoles(palette.primary, surface)
110
118
  _theme.colors.primary = primaryRoles.color
@@ -144,7 +152,7 @@ export function useComputedTheme(appearance: ThemeAppearance, color: string | Tr
144
152
  }
145
153
 
146
154
  setTheme(_theme)
147
- }, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony])
155
+ }, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony, paletteTone])
148
156
 
149
157
  useEffect(() => {
150
158
  // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: first render must stay theme-less (README's onReady/splash-screen contract) and Appearance must not be read during render (SSR/hydration risk)
@@ -0,0 +1,77 @@
1
+ import { getLuminance } from './getContrastRatio'
2
+ import { getOklch, getOklchHex } from './getOklch'
3
+
4
+ // What Provider's `paletteTone` takes: a pure function from one palette color (primary, secondary
5
+ // or tertiary, after the harmony rotation) and the resolved appearance to the color the theme
6
+ // should actually use. It must be referentially stable (a module-level function, not an inline
7
+ // arrow), since the theme recomputes whenever it changes.
8
+ export type PaletteTone = (color: string, dark: boolean) => string
9
+
10
+ export type ChalkboardToneOptions = {
11
+ // Light mode, marker on a whiteboard: deep ink, never neon. A color lighter than `maxLightness`
12
+ // (OKLCH lightness) is brought down to it, and every color keeps `chroma` of its chroma.
13
+ marker?: { maxLightness?: number; chroma?: number }
14
+ // Dark mode, chalk on a chalkboard: pastel at an even value. Lightness moves `lift` of the way
15
+ // toward `lightness` (never darker), chroma is scaled by `chroma`, and then lightness rises until
16
+ // the color's WCAG luminance reaches `minLuminance`, so it reads as chalk on the board and takes
17
+ // dark text when it's a fill (0 turns that floor off).
18
+ chalk?: { lightness?: number; lift?: number; chroma?: number; minLuminance?: number }
19
+ }
20
+
21
+ const MARKER = { maxLightness: 0.6, chroma: 0.85 }
22
+ const CHALK = { lightness: 0.86, lift: 0.5, chroma: 0.8, minLuminance: 0.5 }
23
+
24
+ // The pickers tone 60 colors per render (20 swatches, three wedges each), and chalk's luminance
25
+ // floor is a search, so results are cached per tone. Bounded, since a picker only ever shows a
26
+ // few dozen distinct colors.
27
+ const CACHE_LIMIT = 512
28
+
29
+ // A PaletteTone for apps that want their palette muted like marker and chalk: a little in light
30
+ // mode (deeper, slightly less chroma), more in dark mode (pastel). Hue never moves, so a palette
31
+ // keeps its colors, only toned down. Signal colors (success, warning, danger, error) never pass
32
+ // through a tone. Pass the result straight to Provider's `paletteTone`; `getChalkboardColor` below
33
+ // is this with the defaults.
34
+ export const createChalkboardTone = (options: ChalkboardToneOptions = {}): PaletteTone => {
35
+ const marker = { ...MARKER, ...options.marker }
36
+ const chalk = { ...CHALK, ...options.chalk }
37
+ const cache = new Map<string, string>()
38
+
39
+ const toMarker = (color: string) => {
40
+ const oklch = getOklch(color)
41
+ if (!oklch) return color
42
+ return getOklchHex({ l: Math.min(oklch.l, marker.maxLightness), c: oklch.c * marker.chroma, h: oklch.h })
43
+ }
44
+
45
+ const toChalk = (color: string) => {
46
+ const oklch = getOklch(color)
47
+ if (!oklch) return color
48
+ const c = oklch.c * chalk.chroma
49
+ const lifted = oklch.l < chalk.lightness ? oklch.l + (chalk.lightness - oklch.l) * chalk.lift : oklch.l
50
+ const result = getOklchHex({ l: lifted, c, h: oklch.h })
51
+ if (getLuminance(result) >= chalk.minLuminance) return result
52
+ // Luminance rises with lightness at a fixed hue, so the lowest lightness that clears the floor
53
+ // is a bisection between the lifted lightness and white.
54
+ let low = lifted
55
+ let high = 1
56
+ for (let i = 0; i < 20; i++) {
57
+ const mid = (low + high) / 2
58
+ if (getLuminance(getOklchHex({ l: mid, c, h: oklch.h })) >= chalk.minLuminance) high = mid
59
+ else low = mid
60
+ }
61
+ return getOklchHex({ l: high, c, h: oklch.h })
62
+ }
63
+
64
+ return (color, dark) => {
65
+ const key = `${dark ? 'd' : 'l'}${color}`
66
+ const cached = cache.get(key)
67
+ if (cached !== undefined) return cached
68
+ const result = dark ? toChalk(color) : toMarker(color)
69
+ if (cache.size >= CACHE_LIMIT) cache.clear()
70
+ cache.set(key, result)
71
+ return result
72
+ }
73
+ }
74
+
75
+ export const getChalkboardColor: PaletteTone = createChalkboardTone()
76
+
77
+ export default getChalkboardColor
@@ -1,4 +1,5 @@
1
1
  import { getBlendedColor } from './getBlendedColor'
2
+ import { getContrastRatio } from './getContrastRatio'
2
3
  import { getTonalColor } from './getTonalColor'
3
4
  import { isDarkColor } from './isDarkColor'
4
5
 
@@ -9,18 +10,28 @@ export type ColorRoles = {
9
10
  onContainer: string
10
11
  }
11
12
 
13
+ // The lowest contrast light text may have on a color before onColor turns dark instead. The fleet's
14
+ // colored buttons have always had light text, and keep it wherever it reads about as well as it does on
15
+ // their reds, blues and greens (2.8 to 3.1:1 for the picker's Red, Blue, Emerald and Coral); only
16
+ // colors light enough that it fades take dark text: the picker's Lime, Amber, Cyan and Sky (Yellow
17
+ // already did). Judging by the color's own darkness alone gave those near-white text that all but
18
+ // vanished (Jay, 2026-10-19: 1.96:1 on a light green Ready button).
19
+ const MIN_LIGHT_ON_COLOR_CONTRAST = 2.5
20
+
12
21
  // The MD3 "color / onColor / container / onContainer" quadruple, generalized from the math
13
22
  // 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
23
+ // vivid color itself (light while it reads, MIN_LIGHT_ON_COLOR_CONTRAST, else dark), container is
24
+ // base blended toward the theme's real surface (so it inherits
15
25
  // that surface's actual lightness rather than a fixed light/dark guess), and onContainer is tuned
16
26
  // against the *computed* container rather than a light/dark assumption, so it stays readable
17
27
  // whatever surface color it's handed. onContainer also reads safely directly on `surface` itself
18
28
  // (not just on `container`), since container is only lightly blended toward it.
19
29
  export const getColorRoles = (base: string, surface: string, containerAlpha = 0.15): ColorRoles => {
20
30
  const container = getBlendedColor(base, surface, containerAlpha)
31
+ const lightOnColor = getTonalColor(base, 0.95)
21
32
  return {
22
33
  color: base,
23
- onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
34
+ onColor: getContrastRatio(lightOnColor, base) >= MIN_LIGHT_ON_COLOR_CONTRAST ? lightOnColor : getTonalColor(base, 0.08),
24
35
  container,
25
36
  onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
26
37
  }
@@ -0,0 +1,20 @@
1
+ import { getRgb } from './getRgb'
2
+
3
+ // WCAG 2's relative luminance (0 for black, 1 for white): the same formula isDarkColor applies.
4
+ export const getLuminance = (value: string): number => {
5
+ const rgb = getRgb(value)
6
+ if (!rgb) return 0
7
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((channel) => {
8
+ const c = channel / 255
9
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
10
+ })
11
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
12
+ }
13
+
14
+ // WCAG 2's contrast ratio between two colors, 1 (identical) to 21 (black on white). Order doesn't matter.
15
+ export const getContrastRatio = (a: string, b: string): number => {
16
+ const [lighter, darker] = [getLuminance(a), getLuminance(b)].sort((x, y) => y - x)
17
+ return (lighter + 0.05) / (darker + 0.05)
18
+ }
19
+
20
+ export default getContrastRatio
@@ -0,0 +1,68 @@
1
+ import { getRgb } from './getRgb'
2
+
3
+ // OKLCH: lightness 0-1, chroma (0 for greys, about 0.37 at most inside sRGB), hue in degrees.
4
+ export type Oklch = { l: number; c: number; h: number }
5
+
6
+ type Triple = [number, number, number]
7
+
8
+ const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
9
+ const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
10
+
11
+ // Björn Ottosson's OKLab matrices (https://bottosson.github.io/posts/oklab/), from linear sRGB.
12
+ const linearToOklab = ([r, g, b]: Triple): Triple => {
13
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
14
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
15
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
16
+ return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s]
17
+ }
18
+
19
+ const oklabToLinear = ([L, a, b]: Triple): Triple => {
20
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
21
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
22
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
23
+ return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]
24
+ }
25
+
26
+ const inGamut = (rgb: Triple) => rgb.every((v) => v >= -1e-6 && v <= 1 + 1e-6)
27
+
28
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
29
+
30
+ const toHex = (rgb: Triple) =>
31
+ `#${rgb
32
+ .map((v) =>
33
+ Math.round(clamp01(toGamma(clamp01(v))) * 255)
34
+ .toString(16)
35
+ .padStart(2, '0')
36
+ )
37
+ .join('')}`
38
+
39
+ // A color in OKLCH, the perceptual space where equal steps of lightness or chroma look equal
40
+ // across hues (HSL's lightness doesn't: a yellow and a blue at the same HSL lightness differ
41
+ // several-fold in brightness). Null for anything getRgb can't parse. Alpha is dropped.
42
+ export const getOklch = (value: string): Oklch | null => {
43
+ const rgb = getRgb(value)
44
+ if (!rgb) return null
45
+ const [l, a, b] = linearToOklab([toLinear(rgb.r / 255), toLinear(rgb.g / 255), toLinear(rgb.b / 255)])
46
+ return { l, c: Math.hypot(a, b), h: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 }
47
+ }
48
+
49
+ // Back to a hex string. A color outside sRGB keeps its lightness and hue and loses only as much
50
+ // chroma as it has to (a bisection on chroma, CSS Color 4's gamut mapping without its JND
51
+ // shortcut), so lifting a saturated blue toward pastel can't swing it off toward purple.
52
+ export const getOklchHex = ({ l, c, h }: Oklch): string => {
53
+ const lightness = Math.min(1, Math.max(0, l))
54
+ const radians = (h * Math.PI) / 180
55
+ const atChroma = (chroma: number) => oklabToLinear([lightness, chroma * Math.cos(radians), chroma * Math.sin(radians)])
56
+ const direct = atChroma(Math.max(0, c))
57
+ if (inGamut(direct)) return toHex(direct)
58
+ let low = 0
59
+ let high = c
60
+ for (let i = 0; i < 24; i++) {
61
+ const mid = (low + high) / 2
62
+ if (inGamut(atChroma(mid))) low = mid
63
+ else high = mid
64
+ }
65
+ return toHex(atChroma(low))
66
+ }
67
+
68
+ export default getOklch