@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.
- package/README.md +14 -2
- package/dist/index.d.mts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +188 -58
- package/dist/index.mjs +157 -34
- package/package.json +2 -2
- package/src/ThemeProvider.tsx +25 -9
- package/src/components/AutoPalettePicker.tsx +11 -2
- package/src/components/PalettePicker.tsx +23 -9
- package/src/index.ts +6 -1
- package/src/useComputedTheme.ts +11 -3
- package/src/utils/getChalkboardColor.ts +77 -0
- package/src/utils/getColorRoles.ts +13 -2
- package/src/utils/getContrastRatio.ts +20 -0
- package/src/utils/getOklch.ts +68 -0
package/src/useComputedTheme.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|