@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.
@@ -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