@rific/auto-paper 0.13.4 → 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 +11 -1
- package/dist/index.d.mts +39 -4
- package/dist/index.d.ts +39 -4
- package/dist/index.js +166 -57
- package/dist/index.mjs +137 -33
- package/package.json +1 -1
- package/src/ThemeProvider.tsx +25 -9
- package/src/components/AutoPalettePicker.tsx +11 -2
- package/src/components/PalettePicker.tsx +23 -9
- package/src/index.ts +5 -1
- package/src/useComputedTheme.ts +11 -3
- package/src/utils/getChalkboardColor.ts +77 -0
- package/src/utils/getOklch.ts +68 -0
|
@@ -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
|
|
@@ -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
|