Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Material You theme for iOS and Web

authored by

uwx and committed by
xan.lol
774d50ca 7fe420bb

+2619 -291
+1 -1
rspack.config.ts
··· 3 3 import {RspackManifestPlugin} from 'rspack-manifest-plugin' 4 4 import {sentryWebpackPlugin} from '@sentry/webpack-plugin' 5 5 import {version} from './package.json' 6 - import { existsSync, readdirSync } from 'node:fs' 6 + import {existsSync, readdirSync} from 'node:fs' 7 7 8 8 const GENERATE_STATS = process.env.GENERATE_STATS === '1' 9 9 const isProduction = process.env.NODE_ENV === 'production'
+63 -41
src/alf/index.tsx
··· 17 17 deerscheme, 18 18 evergardenscheme, 19 19 kittyscheme, 20 - material3scheme, 21 20 type Palette, 22 21 reddwarfscheme, 23 22 themes, 24 23 witchskyscheme, 25 24 zeppelinscheme, 26 25 } from '#/alf/themes' 27 - import {IS_ANDROID} from '#/env' 28 26 import {type Device} from '#/storage' 29 - import {MaterialYouPaletteProvider} from './util/materialYou' 27 + import {getMaterial3Colors} from './util/material3Theme' 28 + import { 29 + MaterialYouPaletteProvider, 30 + useMaterialYouPalette, 31 + } from './util/materialYou' 30 32 31 33 export { 32 34 type TextStyleProp, ··· 153 155 } 154 156 } 155 157 156 - export function selectScheme(colorScheme: string | undefined): SchemeType { 157 - switch (colorScheme) { 158 - case 'witchsky': 159 - return witchskyscheme 160 - case 'bluesky': 161 - return blueskyscheme 162 - case 'blacksky': 163 - return blackskyscheme 164 - case 'deer': 165 - return deerscheme 166 - case 'zeppelin': 167 - return zeppelinscheme 168 - case 'kitty': 169 - return kittyscheme 170 - case 'reddwarf': 171 - return reddwarfscheme 172 - case 'catppuccin': 173 - return catppuccinscheme 174 - case 'evergarden': 175 - return evergardenscheme 176 - case 'material3': 177 - if (IS_ANDROID) { 178 - return material3scheme 179 - } 180 - return witchskyscheme 181 - default: 182 - return themes 183 - } 158 + export function useScheme(): SchemeType { 159 + const {hue, colorScheme} = useThemePrefs() 160 + const palette = useMaterialYouPalette() 161 + 162 + return useMemo(() => { 163 + let currentScheme = themes 164 + switch (colorScheme) { 165 + case 'witchsky': 166 + currentScheme = witchskyscheme 167 + break 168 + case 'bluesky': 169 + currentScheme = blueskyscheme 170 + break 171 + case 'blacksky': 172 + currentScheme = blackskyscheme 173 + break 174 + case 'deer': 175 + currentScheme = deerscheme 176 + break 177 + case 'zeppelin': 178 + currentScheme = zeppelinscheme 179 + break 180 + case 'kitty': 181 + currentScheme = kittyscheme 182 + break 183 + case 'reddwarf': 184 + currentScheme = reddwarfscheme 185 + break 186 + case 'catppuccin': 187 + currentScheme = catppuccinscheme 188 + break 189 + case 'evergarden': 190 + currentScheme = evergardenscheme 191 + break 192 + case 'material3': 193 + currentScheme = getMaterial3Colors(palette).scheme 194 + break 195 + default: 196 + currentScheme = themes 197 + break 198 + } 199 + 200 + return hueShifter(currentScheme, hue) 201 + }, [colorScheme, hue, palette]) 184 202 } 185 203 186 - export function ThemeProvider({ 204 + function ThemeProviderInner({ 187 205 children, 188 206 theme: themeName, 189 207 }: React.PropsWithChildren<{theme: ThemeName}>) { 190 - const {colorScheme, hue} = useThemePrefs() 191 - const currentScheme = selectScheme(colorScheme) 208 + const currentScheme = useScheme() 192 209 const [fontScale, setFontScale] = useState<Alf['fonts']['scale']>(() => 193 210 getFontScale(), 194 211 ) ··· 215 232 ) 216 233 217 234 const value = useMemo<Alf>(() => { 218 - const shiftedThemes = hueShifter(currentScheme, hue) 219 - 220 235 return { 221 - themes: shiftedThemes, 236 + themes: currentScheme, 222 237 themeName: themeName, 223 - theme: shiftedThemes[themeName], 238 + theme: currentScheme[themeName], 224 239 fonts: { 225 240 scale: fontScale, 226 241 scaleMultiplier: fontScaleMultiplier, ··· 232 247 } 233 248 }, [ 234 249 currentScheme, 235 - hue, 236 250 themeName, 237 251 fontScale, 238 252 fontScaleMultiplier, ··· 241 255 setFontFamilyAndPersist, 242 256 ]) 243 257 258 + return <Context.Provider value={value}>{children}</Context.Provider> 259 + } 260 + 261 + export function ThemeProvider({ 262 + children, 263 + theme: themeName, 264 + }: React.PropsWithChildren<{theme: ThemeName}>) { 265 + const {material3Accent, material3Style} = useThemePrefs() 244 266 return ( 245 - <MaterialYouPaletteProvider> 246 - <Context.Provider value={value}>{children}</Context.Provider> 267 + <MaterialYouPaletteProvider accent={material3Accent} style={material3Style}> 268 + <ThemeProviderInner theme={themeName}>{children}</ThemeProviderInner> 247 269 </MaterialYouPaletteProvider> 248 270 ) 249 271 }
+1 -180
src/alf/themes.ts
··· 24 24 GREEN_HUE as DEER_GREEN_HUE, 25 25 RED_HUE as DEER_RED_HUE, 26 26 } from '#/alf/util/deerColorGeneration' 27 - import { 28 - getMaterialYouColor, 29 - onMaterialYouPaletteChange, 30 - } from './util/materialYou' 31 27 32 28 export type Palette = { 33 29 white: string ··· 95 91 negative_975: string 96 92 } 97 93 98 - const STATIC_VALUES = { 94 + export const STATIC_VALUES = { 99 95 white: '#FEFBFB', 100 96 black: '#000000', 101 97 pink: '#EC4899', ··· 1390 1386 dark: EVERGARDEN_THEMES.dark, 1391 1387 dim: EVERGARDEN_THEMES.dim, 1392 1388 } 1393 - 1394 - function getMaterial3ColorScheme() { 1395 - const MATERIAL_3_PALETTE: Palette = { 1396 - white: STATIC_VALUES.white, 1397 - black: STATIC_VALUES.black, 1398 - pink: STATIC_VALUES.pink, 1399 - yellow: STATIC_VALUES.yellow, 1400 - like: STATIC_VALUES.pink, 1401 - 1402 - contrast_0: getMaterialYouColor('system_neutral1', 0), 1403 - contrast_25: getMaterialYouColor('system_neutral1', 10), 1404 - contrast_50: getMaterialYouColor('system_neutral1', 50), 1405 - contrast_100: getMaterialYouColor('system_neutral1', 100), 1406 - contrast_200: getMaterialYouColor('system_neutral1', 200), 1407 - contrast_300: getMaterialYouColor('system_neutral1', 300), 1408 - contrast_400: getMaterialYouColor('system_neutral1', 400), 1409 - contrast_500: getMaterialYouColor('system_neutral1', 500), 1410 - contrast_600: getMaterialYouColor('system_neutral1', 600), 1411 - contrast_700: getMaterialYouColor('system_neutral1', 700), 1412 - contrast_800: getMaterialYouColor('system_neutral1', 800), 1413 - contrast_900: getMaterialYouColor('system_neutral1', 900), 1414 - contrast_950: getMaterialYouColor('system_neutral1', 900), 1415 - contrast_975: getMaterialYouColor('system_neutral1', 900), 1416 - contrast_1000: getMaterialYouColor('system_neutral1', 1000), 1417 - 1418 - primary_25: getMaterialYouColor('system_accent1', 10), 1419 - primary_50: getMaterialYouColor('system_accent1', 50), 1420 - primary_100: getMaterialYouColor('system_accent1', 100), 1421 - primary_200: getMaterialYouColor('system_accent1', 200), 1422 - primary_300: getMaterialYouColor('system_accent1', 300), 1423 - primary_400: getMaterialYouColor('system_accent1', 400), 1424 - primary_500: getMaterialYouColor('system_accent1', 500), 1425 - primary_600: getMaterialYouColor('system_accent1', 600), 1426 - primary_700: getMaterialYouColor('system_accent1', 700), 1427 - primary_800: getMaterialYouColor('system_accent1', 800), 1428 - primary_900: getMaterialYouColor('system_accent1', 900), 1429 - primary_950: getMaterialYouColor('system_accent1', 900), 1430 - primary_975: getMaterialYouColor('system_accent1', 900), 1431 - 1432 - positive_25: getMaterialYouColor('system_accent3', 10), 1433 - positive_50: getMaterialYouColor('system_accent3', 50), 1434 - positive_100: getMaterialYouColor('system_accent3', 100), 1435 - positive_200: getMaterialYouColor('system_accent3', 200), 1436 - positive_300: getMaterialYouColor('system_accent3', 300), 1437 - positive_400: getMaterialYouColor('system_accent3', 400), 1438 - positive_500: getMaterialYouColor('system_accent3', 500), 1439 - positive_600: getMaterialYouColor('system_accent3', 600), 1440 - positive_700: getMaterialYouColor('system_accent3', 700), 1441 - positive_800: getMaterialYouColor('system_accent3', 800), 1442 - positive_900: getMaterialYouColor('system_accent3', 900), 1443 - positive_950: getMaterialYouColor('system_accent3', 900), 1444 - positive_975: getMaterialYouColor('system_accent3', 900), 1445 - 1446 - negative_25: '#FFF5F7', 1447 - negative_50: '#FEE7EC', 1448 - negative_100: '#FDD3DD', 1449 - negative_200: '#FBBBCA', 1450 - negative_300: '#F891A9', 1451 - negative_400: '#F65A7F', 1452 - negative_500: '#E91646', 1453 - negative_600: '#CA123D', 1454 - negative_700: '#A71134', 1455 - negative_800: '#7F0B26', 1456 - negative_900: '#5F071C', 1457 - negative_950: '#430413', 1458 - negative_975: '#30030D', 1459 - } 1460 - 1461 - const MATERIAL_3_SUBDUED_PALETTE: Palette = { 1462 - white: STATIC_VALUES.white, 1463 - black: STATIC_VALUES.black, 1464 - pink: STATIC_VALUES.pink, 1465 - yellow: STATIC_VALUES.yellow, 1466 - like: STATIC_VALUES.pink, 1467 - 1468 - contrast_0: getMaterialYouColor('system_neutral2', 0), 1469 - contrast_25: getMaterialYouColor('system_neutral2', 10), 1470 - contrast_50: getMaterialYouColor('system_neutral2', 50), 1471 - contrast_100: getMaterialYouColor('system_neutral2', 100), 1472 - contrast_200: getMaterialYouColor('system_neutral2', 200), 1473 - contrast_300: getMaterialYouColor('system_neutral2', 300), 1474 - contrast_400: getMaterialYouColor('system_neutral2', 400), 1475 - contrast_500: getMaterialYouColor('system_neutral2', 500), 1476 - contrast_600: getMaterialYouColor('system_neutral2', 600), 1477 - contrast_700: getMaterialYouColor('system_neutral2', 700), 1478 - contrast_800: getMaterialYouColor('system_neutral2', 800), 1479 - contrast_900: getMaterialYouColor('system_neutral2', 800), 1480 - contrast_950: getMaterialYouColor('system_neutral2', 800), 1481 - contrast_975: getMaterialYouColor('system_neutral2', 800), 1482 - contrast_1000: getMaterialYouColor('system_neutral2', 900), 1483 - 1484 - primary_25: getMaterialYouColor('system_accent2', 10), 1485 - primary_50: getMaterialYouColor('system_accent2', 10), 1486 - primary_100: getMaterialYouColor('system_accent2', 50), 1487 - primary_200: getMaterialYouColor('system_accent2', 100), 1488 - primary_300: getMaterialYouColor('system_accent2', 200), 1489 - primary_400: getMaterialYouColor('system_accent2', 300), 1490 - primary_500: getMaterialYouColor('system_accent2', 400), 1491 - primary_600: getMaterialYouColor('system_accent2', 500), 1492 - primary_700: getMaterialYouColor('system_accent2', 600), 1493 - primary_800: getMaterialYouColor('system_accent2', 700), 1494 - primary_900: getMaterialYouColor('system_accent2', 800), 1495 - primary_950: getMaterialYouColor('system_accent2', 900), 1496 - primary_975: getMaterialYouColor('system_accent2', 900), 1497 - 1498 - positive_25: getMaterialYouColor('system_accent3', 10), 1499 - positive_50: getMaterialYouColor('system_accent3', 50), 1500 - positive_100: getMaterialYouColor('system_accent3', 100), 1501 - positive_200: getMaterialYouColor('system_accent3', 200), 1502 - positive_300: getMaterialYouColor('system_accent3', 300), 1503 - positive_400: getMaterialYouColor('system_accent3', 400), 1504 - positive_500: getMaterialYouColor('system_accent3', 500), 1505 - positive_600: getMaterialYouColor('system_accent3', 600), 1506 - positive_700: getMaterialYouColor('system_accent3', 700), 1507 - positive_800: getMaterialYouColor('system_accent3', 800), 1508 - positive_900: getMaterialYouColor('system_accent3', 900), 1509 - positive_950: getMaterialYouColor('system_accent3', 900), 1510 - positive_975: getMaterialYouColor('system_accent3', 900), 1511 - 1512 - negative_25: '#FFF5F7', 1513 - negative_50: '#FEEBEF', 1514 - negative_100: '#FDD8E1', 1515 - negative_200: '#FCC0CE', 1516 - negative_300: '#F99AB0', 1517 - negative_400: '#F76486', 1518 - negative_500: '#EB2452', 1519 - negative_600: '#D81341', 1520 - negative_700: '#BA1239', 1521 - negative_800: '#910D2C', 1522 - negative_900: '#6F0B22', 1523 - negative_950: '#500B1C', 1524 - negative_975: '#3E0915', 1525 - } 1526 - 1527 - const MATERIAL_3_THEMES = createThemes({ 1528 - defaultPalette: MATERIAL_3_PALETTE, 1529 - subduedPalette: MATERIAL_3_SUBDUED_PALETTE, 1530 - }) 1531 - 1532 - // special case for disabled (primary) button text. we have a hack for primary_subtle in Button.tsx 1533 - MATERIAL_3_THEMES.dark.atoms.text_inverted.color = 1534 - MATERIAL_3_THEMES.dark.palette.primary_400 1535 - MATERIAL_3_THEMES.dim.atoms.text_inverted.color = 1536 - MATERIAL_3_THEMES.dim.palette.primary_400 1537 - 1538 - const material3scheme = { 1539 - lightPalette: MATERIAL_3_THEMES.light.palette, 1540 - darkPalette: MATERIAL_3_THEMES.dark.palette, 1541 - dimPalette: MATERIAL_3_THEMES.dim.palette, 1542 - light: MATERIAL_3_THEMES.light, 1543 - dark: MATERIAL_3_THEMES.dark, 1544 - dim: MATERIAL_3_THEMES.dim, 1545 - } 1546 - 1547 - return { 1548 - regular: MATERIAL_3_PALETTE, 1549 - subdued: MATERIAL_3_SUBDUED_PALETTE, 1550 - scheme: material3scheme, 1551 - } 1552 - } 1553 - 1554 - let material3colorSchemeData = getMaterial3ColorScheme() 1555 - 1556 - export let MATERIAL_3_PALETTE: Palette = material3colorSchemeData.regular 1557 - export let MATERIAL_3_SUBDUED_PALETTE: Palette = 1558 - material3colorSchemeData.subdued 1559 - export let material3scheme = material3colorSchemeData.scheme 1560 - 1561 - onMaterialYouPaletteChange(() => { 1562 - material3colorSchemeData = getMaterial3ColorScheme() 1563 - 1564 - MATERIAL_3_PALETTE = material3colorSchemeData.regular 1565 - MATERIAL_3_SUBDUED_PALETTE = material3colorSchemeData.subdued 1566 - material3scheme = material3colorSchemeData.scheme 1567 - }) 1568 1389 1569 1390 /** 1570 1391 * @deprecated use ALF and access palette from `useTheme()`
+980
src/alf/util/material3/Cam/Cam.ts
··· 1 + /* 2 + * This code was originally written in Java and has been converted to JavaScript. 3 + * 4 + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/com/android/internal/graphics/cam] 5 + * 6 + * Copyright (C) 2021 The Android Open Source Project. 7 + * The original code is licensed under the Apache License, Version 2.0. 8 + * 9 + * This JavaScript version is licensed under the MIT License. 10 + * 11 + * See the respective licenses for the specific terms, permissions, and limitations. 12 + */ 13 + 14 + import Color from '../Utils/Color' 15 + import ColorUtils from '../Utils/ColorUtils' 16 + import MathUtils from '../Utils/MathUtils' 17 + import HctSolver from './HctSolver' 18 + 19 + /** 20 + * A color appearance model, based on CAM16, extended to use L* as the lightness dimension, and coupled to a gamut mapping 21 + * algorithm. Creates a color system, enables a digital design system. 22 + */ 23 + export class Cam { 24 + // When the delta between the floor & ceiling of a binary search for chroma is less than this, 25 + // the binary search terminates. 26 + static CHROMA_SEARCH_ENDPOINT = 0.4 27 + 28 + // When the delta between the floor & ceiling of a binary search for J, lightness in CAM16, 29 + // is less than this, the binary search terminates. 30 + static LIGHTNESS_SEARCH_ENDPOINT = 0.01 31 + 32 + // The maximum difference between the requested L* and the L* returned. 33 + static DL_MAX = 0.2 34 + 35 + // The maximum color distance, in CAM16-UCS, between a requested color and the color returned. 36 + static DE_MAX = 1 37 + 38 + /** The maximum difference between the requested L* and the L* returned. */ 39 + DL_MAX = 0.2 40 + /** When the delta between the floor & ceiling of a binary search for chroma is less than this, the binary search terminates. */ 41 + CHROMA_SEARCH_ENDPOINT = 0.4 42 + /** 43 + * When the delta between the floor & ceiling of a binary search for J, lightness in CAM16, is less than this, the binary search 44 + * terminates. 45 + */ 46 + LIGHTNESS_SEARCH_ENDPOINT = 0.01 47 + /** 48 + * SRGB specification has D65 whitepoint - Stokes, Anderson, Chandrasekar, Motta - A Standard Default Color Space for the 49 + * Internet: sRGB, 1996 50 + */ 51 + WHITE_POINT_D65: [number, number, number] = [95.047, 100, 108.883] 52 + 53 + // CAM16 color dimensions, see getters for documentation. 54 + mHue: number 55 + mChroma: number 56 + mJ: number 57 + mQ: number 58 + mM: number 59 + mS: number 60 + 61 + // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. 62 + mJstar: number 63 + mAstar: number 64 + mBstar: number 65 + 66 + constructor( 67 + hue: number, 68 + chroma: number, 69 + j: number, 70 + q: number, 71 + m: number, 72 + s: number, 73 + jstar: number, 74 + astar: number, 75 + bstar: number, 76 + ) { 77 + this.mHue = hue 78 + this.mChroma = chroma 79 + this.mJ = j 80 + this.mQ = q 81 + this.mM = m 82 + this.mS = s 83 + this.mJstar = jstar 84 + this.mAstar = astar 85 + this.mBstar = bstar 86 + } 87 + 88 + /** Hue in CAM16 */ 89 + getHue() { 90 + return this.mHue 91 + } 92 + 93 + /** Chroma in CAM16 */ 94 + getChroma() { 95 + return this.mChroma 96 + } 97 + 98 + /** Lightness in CAM16 */ 99 + getJ() { 100 + return this.mJ 101 + } 102 + 103 + /** A* coordinate in CAM16-UCS */ 104 + getAstar() { 105 + return this.mAstar 106 + } 107 + 108 + /** Lightness coordinate in CAM16-UCS */ 109 + getJstar() { 110 + return this.mJstar 111 + } 112 + 113 + /** B* coordinate in CAM16-UCS */ 114 + getBstar() { 115 + return this.mBstar 116 + } 117 + 118 + /** 119 + * Create a CAM from lightness, chroma, and hue coordinates. It is assumed those coordinates were measured in the sRGB standard 120 + * frame. 121 + */ 122 + static fromJch(j: number, c: number, h: number) { 123 + return Cam.fromJchInFrame(j, c, h) 124 + } 125 + 126 + /** Create a CAM from lightness, chroma, and hue coordinates, and also specify the frame in which the color is being viewed. */ 127 + static fromJchInFrame(j: number, c: number, h: number) { 128 + const q = 129 + (4 / Frame.DEFAULT.getC()) * 130 + Math.sqrt(j / 100) * 131 + (Frame.DEFAULT.getAw() + 4) * 132 + Frame.DEFAULT.getFlRoot() 133 + const m = c * Frame.DEFAULT.getFlRoot() 134 + const alpha = c / Math.sqrt(j / 100) 135 + const s = 136 + 50 * 137 + Math.sqrt((alpha * Frame.DEFAULT.getC()) / (Frame.DEFAULT.getAw() + 4)) 138 + 139 + const hueRadians = (h * Math.PI) / 180 140 + const jstar = ((1 + 100 * 0.007) * j) / (1 + 0.007 * j) 141 + const mstar = (1 / 0.0228) * Math.log(1 + 0.0228 * m) 142 + const astar = mstar * Math.cos(hueRadians) 143 + const bstar = mstar * Math.sin(hueRadians) 144 + return new Cam(h, c, j, q, m, s, jstar, astar, bstar) 145 + } 146 + 147 + /** Returns perceived color as an ARGB integer, as viewed in standard sRGB frame. */ 148 + viewedInSrgb() { 149 + return this.viewed(Frame.DEFAULT) 150 + } 151 + 152 + /** Returns color perceived in a frame as an ARGB integer. */ 153 + viewed(frame: Frame) { 154 + const alpha = 155 + this.getChroma() === 0 || this.getJ() === 0 156 + ? 0 157 + : this.getChroma() / Math.sqrt(this.getJ() / 100) 158 + 159 + const t = Math.pow( 160 + alpha / Math.pow(1.64 - Math.pow(0.29, frame.getN()), 0.73), 161 + 1 / 0.9, 162 + ), 163 + hRad = (this.getHue() * Math.PI) / 180 164 + 165 + const eHue = 0.25 * (Math.cos(hRad + 2) + 3.8), 166 + ac = 167 + frame.getAw() * 168 + Math.pow(this.getJ() / 100, 1 / frame.getC() / frame.getZ()), 169 + p1 = eHue * (50000 / 13) * frame.getNc() * frame.getNcb(), 170 + p2 = ac / frame.getNbb() 171 + 172 + const hSin = Math.sin(hRad), 173 + hCos = Math.cos(hRad) 174 + 175 + const gamma = 176 + (23 * (p2 + 0.305) * t) / (23 * p1 + 11 * t * hCos + 108 * t * hSin), 177 + a = gamma * hCos, 178 + b = gamma * hSin, 179 + rA = (460 * p2 + 451 * a + 288 * b) / 1403, 180 + gA = (460 * p2 - 891 * a - 261 * b) / 1403, 181 + bA = (460 * p2 - 220 * a - 6300 * b) / 1403 182 + 183 + const rCBase = Math.max(0, (27.13 * Math.abs(rA)) / (400 - Math.abs(rA))), 184 + rC = Math.sign(rA) * (100 / frame.getFl()) * Math.pow(rCBase, 1 / 0.42), 185 + gCBase = Math.max(0, (27.13 * Math.abs(gA)) / (400 - Math.abs(gA))), 186 + gC = Math.sign(gA) * (100 / frame.getFl()) * Math.pow(gCBase, 1 / 0.42), 187 + bCBase = Math.max(0, (27.13 * Math.abs(bA)) / (400 - Math.abs(bA))), 188 + bC = Math.sign(bA) * (100 / frame.getFl()) * Math.pow(bCBase, 1 / 0.42), 189 + rF = rC / frame.getRgbD()[0], 190 + gF = gC / frame.getRgbD()[1], 191 + bF = bC / frame.getRgbD()[2] 192 + 193 + const matrix = CamUtils.CAM16RGB_TO_XYZ, 194 + x = rF * matrix[0][0] + gF * matrix[0][1] + bF * matrix[0][2], 195 + y = rF * matrix[1][0] + gF * matrix[1][1] + bF * matrix[1][2], 196 + z = rF * matrix[2][0] + gF * matrix[2][1] + bF * matrix[2][2] 197 + 198 + return ColorUtils.XYZToColor(x, y, z) 199 + } 200 + 201 + /** 202 + * Distance in CAM16-UCS space between two colors. 203 + * 204 + * Much like L_a_b* was designed to measure distance between colors, the CAM16 standard defined a color space called CAM16-UCS 205 + * to measure distance between CAM16 colors. 206 + */ 207 + distance(other: Cam) { 208 + const dJ = this.getJstar() - other.getJstar(), 209 + dA = this.getAstar() - other.getAstar(), 210 + dB = this.getBstar() - other.getBstar(), 211 + dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB), 212 + dE = 1.41 * Math.pow(dEPrime, 0.63) 213 + return dE 214 + } 215 + 216 + /** 217 + * Find J, lightness in CAM16 color space, that creates a color with L* = `lstar` in the L_a_b* color space. Returns null if no 218 + * J could be found that generated a color with L* `lstar`. 219 + */ 220 + static findCamByJ(hue: number, chroma: number, lstar: number) { 221 + let low = 0, 222 + high = 100, 223 + mid, 224 + bestdL = 1000, 225 + bestdE = 1000 226 + 227 + let bestCam: Cam | null = null 228 + while (Math.abs(low - high) > Cam.LIGHTNESS_SEARCH_ENDPOINT) { 229 + mid = low + (high - low) / 2 230 + 231 + // Create the intended CAM color 232 + const camBeforeClip = Cam.fromJch(mid, chroma, hue) 233 + 234 + // Convert the CAM color to RGB. If the color didn't fit in RGB, during the conversion, 235 + // the initial RGB values will be outside 0 to 255. The final RGB values are clipped to 236 + // 0 to 255, distorting the intended color. 237 + const clipped = camBeforeClip.viewedInSrgb() 238 + const clippedLstar = CamUtils.lstarFromInt(clipped) 239 + const dL = Math.abs(lstar - clippedLstar) 240 + 241 + // If the clipped color's L* is within error margin... 242 + if (dL < Cam.DL_MAX) { 243 + // ...check if the CAM equivalent of the clipped color is far away from intended CAM 244 + // color. For the intended color, use lightness and chroma from the clipped color, 245 + // and the intended hue. Callers are wondering what the lightness is, they know 246 + // chroma may be distorted, so the only concern here is if the hue slipped too far. 247 + const camClipped = Cam.fromInt(clipped) 248 + const dE = camClipped.distance( 249 + Cam.fromJch(camClipped.getJ(), camClipped.getChroma(), hue), 250 + ) 251 + if (dE <= Cam.DE_MAX) { 252 + bestdL = dL 253 + bestdE = dE 254 + bestCam = camClipped 255 + } 256 + } 257 + 258 + // If there's no error at all, there's no need to search more. 259 + // 260 + // Note: this happens much more frequently than expected, but this is a very delicate 261 + // property which relies on extremely precise sRGB <=> XYZ calculations, as well as fine 262 + // tuning of the constants that determine error margins and when the binary search can 263 + // terminate. 264 + if (bestdL === 0 && bestdE === 0) { 265 + break 266 + } 267 + 268 + if (clippedLstar < lstar) { 269 + low = mid 270 + } else { 271 + high = mid 272 + } 273 + } 274 + 275 + return bestCam 276 + } 277 + 278 + /** 279 + * Given a hue & chroma in CAM16, L* in L_a_b*, return an ARGB integer. The chroma of the color returned may, and frequently 280 + * will, be lower than requested. Assumes the color is viewed in the frame defined by the sRGB standard. 281 + */ 282 + static getInt(hue: number, chroma: number, lstar: number) { 283 + return Cam.getInt_(hue, chroma, lstar, Frame.DEFAULT) 284 + } 285 + 286 + /** 287 + * Given a hue & chroma in CAM16, L* in L_a_b*, and the frame in which the color will be viewed, return an ARGB integer. 288 + * 289 + * The chroma of the color returned may, and frequently will, be lower than requested. This is a fundamental property of color 290 + * that cannot be worked around by engineering. For example, a red hue, with high chroma, and high L* does not exist: red hues 291 + * have a maximum chroma below 10 in light shades, creating pink. 292 + */ 293 + static getInt_(hue: number, chroma: number, lstar: number, frame: Frame) { 294 + // This is a crucial routine for building a color system, CAM16 itself is not sufficient. 295 + // 296 + // * Why these dimensions? 297 + // Hue and chroma from CAM16 are used because they're the most accurate measures of those 298 + // quantities. L* from L*a*b* is used because it correlates with luminance, luminance is 299 + // used to measure contrast for a11y purposes, thus providing a key constraint on what 300 + // colors 301 + // can be used. 302 + // 303 + // * Why is this routine required to build a color system? 304 + // In all perceptually accurate color spaces (i.e. L*a*b* and later), `chroma` may be 305 + // impossible for a given `hue` and `lstar`. 306 + // For example, a high chroma light red does not exist - chroma is limited to below 10 at 307 + // light red shades, we call that pink. High chroma light green does exist, but not dark 308 + // Also, when converting from another color space to RGB, the color may not be able to be 309 + // represented in RGB. In those cases, the conversion process ends with RGB values 310 + // outside 0-255 311 + // The vast majority of color libraries surveyed simply round to 0 to 255. That is not an 312 + // option for this library, as it distorts the expected luminance, and thus the expected 313 + // contrast needed for a11y 314 + // 315 + // * What does this routine do? 316 + // Dealing with colors in one color space not fitting inside RGB is, loosely referred to as 317 + // gamut mapping or tone mapping. These algorithms are traditionally idiosyncratic, there is 318 + // no universal answer. However, because the intent of this library is to build a system for 319 + // digital design, and digital design uses luminance to measure contrast/a11y, we have one 320 + // very important constraint that leads to an objective algorithm: the L* of the returned 321 + // color _must_ match the requested L*. 322 + // 323 + // Intuitively, if the color must be distorted to fit into the RGB gamut, and the L* 324 + // requested *must* be fulfilled, than the hue or chroma of the returned color will need 325 + // to be different from the requested hue/chroma. 326 + // 327 + // After exploring both options, it was more intuitive that if the requested chroma could 328 + // not be reached, it used the highest possible chroma. The alternative was finding the 329 + // closest hue where the requested chroma could be reached, but that is not nearly as 330 + // intuitive, as the requested hue is so fundamental to the color description. 331 + 332 + // If the color doesn't have meaningful chroma, return a gray with the requested Lstar. 333 + // 334 + // Yellows are very chromatic at L = 100, and blues are very chromatic at L = 0. All the 335 + // other hues are white at L = 100, and black at L = 0. To preserve consistency for users of 336 + // this system, it is better to simply return white at L* > 99, and black and L* < 0. 337 + if (frame === Frame.DEFAULT) { 338 + // If the viewing conditions are the same as the default sRGB-like viewing conditions, 339 + // skip to using HctSolver: it uses geometrical insights to find the closest in-gamut 340 + // match to hue/chroma/lstar. 341 + return HctSolver.solveToInt(hue, chroma, lstar) 342 + } 343 + 344 + if (chroma < 1 || Math.round(lstar) <= 0 || Math.round(lstar) >= 100) { 345 + return CamUtils.intFromLstar(lstar) 346 + } 347 + 348 + hue = hue < 0 ? 0 : Math.min(360, hue) 349 + 350 + // The highest chroma possible. Updated as binary search proceeds. 351 + let high = chroma 352 + 353 + // The guess for the current binary search iteration. Starts off at the highest chroma, 354 + // thus, if a color is possible at the requested chroma, the search can stop after one try. 355 + let mid = chroma 356 + let low = 0 357 + let isFirstLoop = true 358 + 359 + let answer: Cam | null = null 360 + 361 + while (Math.abs(low - high) >= Cam.CHROMA_SEARCH_ENDPOINT) { 362 + // Given the current chroma guess, mid, and the desired hue, find J, lightness in 363 + // CAM16 color space, that creates a color with L* = `lstar` in the L*a*b* color space. 364 + const possibleAnswer = Cam.findCamByJ(hue, mid, lstar) 365 + 366 + if (isFirstLoop) { 367 + if (possibleAnswer != null) { 368 + return possibleAnswer.viewed(frame) 369 + } else { 370 + // If this binary search iteration was the first iteration, and this point 371 + // has been reached, it means the requested chroma was not available at the 372 + // requested hue and L*. 373 + // Proceed to a traditional binary search that starts at the midpoint between 374 + // the requested chroma and 0. 375 + isFirstLoop = false 376 + mid = low + (high - low) / 2 377 + continue 378 + } 379 + } 380 + 381 + if (possibleAnswer == null) { 382 + // There isn't a CAM16 J that creates a color with L* `lstar`. Try a lower chroma. 383 + high = mid 384 + } else { 385 + answer = possibleAnswer 386 + // It is possible to create a color. Try higher chroma. 387 + low = mid 388 + } 389 + 390 + mid = low + (high - low) / 2 391 + } 392 + 393 + // There was no answer: meaning, for the desired hue, there was no chroma low enough to 394 + // generate a color with the desired L*. 395 + // All values of L* are possible when there is 0 chroma. Return a color with 0 chroma, i.e. 396 + // a shade of gray, with the desired L*. 397 + if (answer == null) { 398 + return CamUtils.intFromLstar(lstar) 399 + } 400 + 401 + return answer.viewed(frame) 402 + } 403 + 404 + static intFromLstar(lstar: number) { 405 + if (lstar < 1) { 406 + return 0xff000000 407 + } else if (lstar > 99) { 408 + return 0xffffffff 409 + } 410 + 411 + // XYZ to LAB conversion routine, assume a and b are 0. 412 + const fy = (lstar + 16) / 116 413 + 414 + // fz = fx = fy because a and b are 0 415 + const fz = fy, 416 + fx = fy 417 + 418 + const kappa = 24389 / 27, 419 + epsilon = 216 / 24389, 420 + lExceedsEpsilonKappa = lstar > 8, 421 + yT = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa, 422 + cubeExceedEpsilon = fy * fy * fy > epsilon, 423 + xT = cubeExceedEpsilon ? fx * fx * fx : (116 * fx - 16) / kappa, 424 + zT = cubeExceedEpsilon ? fz * fz * fz : (116 * fx - 16) / kappa 425 + 426 + return ColorUtils.XYZToColor( 427 + xT * CamUtils.WHITE_POINT_D65[0], 428 + yT * CamUtils.WHITE_POINT_D65[1], 429 + zT * CamUtils.WHITE_POINT_D65[2], 430 + ) 431 + } 432 + 433 + static fromIntInFrame(argb: number, frame: Frame) { 434 + // Transform ARGB int to XYZ 435 + const xyz: [number, number, number] = CamUtils.xyzFromInt(argb) 436 + 437 + // Transform XYZ to 'cone'/'rgb' responses 438 + const matrix = CamUtils.XYZ_TO_CAM16RGB, 439 + rT: number = 440 + xyz[0] * matrix[0][0] + xyz[1] * matrix[0][1] + xyz[2] * matrix[0][2], 441 + gT: number = 442 + xyz[0] * matrix[1][0] + xyz[1] * matrix[1][1] + xyz[2] * matrix[1][2], 443 + bT: number = 444 + xyz[0] * matrix[2][0] + xyz[1] * matrix[2][1] + xyz[2] * matrix[2][2] 445 + 446 + // Discount illuminant 447 + const rD = frame.getRgbD()[0] * rT, 448 + gD = frame.getRgbD()[1] * gT, 449 + bD = frame.getRgbD()[2] * bT 450 + 451 + // Chromatic adaptation 452 + const rAF = Math.pow((frame.getFl() * Math.abs(rD)) / 100, 0.42), 453 + gAF = Math.pow((frame.getFl() * Math.abs(gD)) / 100, 0.42), 454 + bAF = Math.pow((frame.getFl() * Math.abs(bD)) / 100, 0.42), 455 + rA = (Math.sign(rD) * 400 * rAF) / (rAF + 27.13), 456 + gA = (Math.sign(gD) * 400 * gAF) / (gAF + 27.13), 457 + bA = (Math.sign(bD) * 400 * bAF) / (bAF + 27.13) 458 + 459 + // redness-greennes 460 + const a = (11 * rA + -12 * gA + bA) / 11 461 + // yellowness-blueness 462 + const b = (rA + gA - 2 * bA) / 9 463 + 464 + // auxiliary components 465 + const u = (20 * rA + 20 * gA + 21 * bA) / 20, 466 + p2 = (40 * rA + 20 * gA + bA) / 20 467 + 468 + // hue 469 + const atan2 = Math.atan2(b, a), 470 + atanDegrees = (atan2 * 180) / Math.PI, 471 + hue = 472 + atanDegrees < 0 473 + ? atanDegrees + 360 474 + : atanDegrees >= 360 475 + ? atanDegrees - 360 476 + : atanDegrees, 477 + hueRadians = (hue * Math.PI) / 180 478 + 479 + // achromatic response to color 480 + const ac = p2 * frame.getNbb() 481 + 482 + // CAM16 lightness and brightness 483 + const j = 100 * Math.pow(ac / frame.getAw(), frame.getC() * frame.getZ()) 484 + const q = 485 + (4 / frame.getC()) * 486 + Math.sqrt(j / 100) * 487 + (frame.getAw() + 4) * 488 + frame.getFlRoot() 489 + 490 + // CAM16 chroma, colorfulness, and saturation. 491 + const huePrime = hue < 20.14 ? hue + 360 : hue, 492 + eHue = 0.25 * (Math.cos((huePrime * Math.PI) / 180 + 2) + 3.8), 493 + p1 = (50000 / 13) * eHue * frame.getNc() * frame.getNcb(), 494 + t = (p1 * Math.sqrt(a * a + b * b)) / (u + 0.305), 495 + alpha = 496 + Math.pow(t, 0.9) * Math.pow(1.64 - Math.pow(0.29, frame.getN()), 0.73) 497 + 498 + // CAM16 chroma, colorfulness, saturation 499 + const c = alpha * Math.sqrt(j / 100), 500 + m = c * frame.getFlRoot(), 501 + s = 50 * Math.sqrt((alpha * frame.getC()) / (frame.getAw() + 4)) 502 + 503 + // CAM16-UCS components 504 + const jstar = ((1 + 100 * 0.007) * j) / (1 + 0.007 * j), 505 + mstar = (1 / 0.0228) * Math.log(1 + 0.0228 * m), 506 + astar = mstar * Math.cos(hueRadians), 507 + bstar = mstar * Math.sin(hueRadians) 508 + 509 + return new Cam(hue, c, j, q, m, s, jstar, astar, bstar) 510 + } 511 + 512 + /** 513 + * Create a color appearance model from a ARGB integer representing a color. It is assumed the color was viewed in the frame 514 + * defined in the sRGB standard. 515 + */ 516 + static fromInt(argb: number) { 517 + return Cam.fromIntInFrame(argb, Frame.DEFAULT) 518 + } 519 + } 520 + 521 + /** 522 + * Collection of methods for transforming between color spaces. 523 + * 524 + * Methods are named $xFrom$Y. For example, lstarFromInt() returns L* from an ARGB integer. 525 + * 526 + * These methods, generally, convert colors between the L_a_b*, XYZ, and sRGB spaces. 527 + * 528 + * L_a_b* is a perceptually accurate color space. This is particularly important in the L* dimension: it measures luminance and 529 + * unlike lightness measures traditionally used in UI work via RGB or HSL, this luminance transitions smoothly, permitting 530 + * creation of pleasing shades of a color, and more pleasing transitions between colors. 531 + * 532 + * XYZ is commonly used as an intermediate color space for converting between one color space to another. For example, to convert 533 + * RGB to L_a_b*, first RGB is converted to XYZ, then XYZ is converted to L_a_b*. 534 + * 535 + * SRGB is a "specification originated from work in 1990s through cooperation by Hewlett-Packard and Microsoft, and it was 536 + * designed to be a standard definition of RGB for the internet, which it indeed became...The standard is based on a sampling of 537 + * computer monitors at the time...The whole idea of sRGB is that if everyone assumed that RGB meant the same thing, then the 538 + * results would be consistent, and reasonably good. It worked." - Fairchild, Color Models and Systems: Handbook of Color 539 + * Psychology, 2015 540 + */ 541 + export class CamUtils { 542 + /** 543 + * This is a more precise sRGB to XYZ transformation matrix than traditionally used. It was derived using Schlomer's technique 544 + * of transforming the xyY primaries to XYZ, then applying a correction to ensure mapping from sRGB 1, 1, 1 to the reference 545 + * white point, D65. 546 + */ 547 + static SRGB_TO_XYZ: [ 548 + [number, number, number], 549 + [number, number, number], 550 + [number, number, number], 551 + ] = [ 552 + [0.41233895, 0.35762064, 0.18051042], 553 + [0.2126, 0.7152, 0.0722], 554 + [0.01932141, 0.11916382, 0.95034478], 555 + ] 556 + 557 + /** Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. */ 558 + static XYZ_TO_CAM16RGB: [ 559 + [number, number, number], 560 + [number, number, number], 561 + [number, number, number], 562 + ] = [ 563 + [0.401288, 0.650173, -0.051461], 564 + [-0.250268, 1.204414, 0.045854], 565 + [-0.002079, 0.048952, 0.953127], 566 + ] 567 + 568 + static XYZ_TO_SRGB: [ 569 + [number, number, number], 570 + [number, number, number], 571 + [number, number, number], 572 + ] = [ 573 + [3.2413774792388685, -1.5376652402851851, -0.49885366846268053], 574 + [-0.9691452513005321, 1.8758853451067872, 0.04156585616912061], 575 + [0.05562093689691305, -0.20395524564742123, 1.0571799111220335], 576 + ] 577 + 578 + /** Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. */ 579 + static CAM16RGB_TO_XYZ: [ 580 + [number, number, number], 581 + [number, number, number], 582 + [number, number, number], 583 + ] = [ 584 + [1.86206786, -1.01125463, 0.14918677], 585 + [0.38752654, 0.62144744, -0.00897398], 586 + [-0.0158415, -0.03412294, 1.04996444], 587 + ] 588 + 589 + /** 590 + * SRGB specification has D65 whitepoint - Stokes, Anderson, Chandrasekar, Motta - A Standard Default Color Space for the 591 + * Internet: sRGB, 1996 592 + */ 593 + static WHITE_POINT_D65: [number, number, number] = [95.047, 100, 108.883] 594 + 595 + /** Returns L* from L_a_b*, perceptual luminance, from an ARGB integer (ColorInt). */ 596 + static lstarFromInt(argb: number) { 597 + return CamUtils.lstarFromY(CamUtils.yFromInt(argb)) 598 + } 599 + 600 + static lstarFromY(y: number) { 601 + y = y / 100 602 + const e = 216 / 24389 603 + let yIntermediate 604 + if (y <= e) { 605 + return (24389 / 27) * y 606 + } else { 607 + yIntermediate = Math.cbrt(y) 608 + } 609 + return 116 * yIntermediate - 16 610 + } 611 + 612 + static yFromInt(argb: number) { 613 + const r = CamUtils.linearized(Color.red(argb)), 614 + g = CamUtils.linearized(Color.green(argb)), 615 + b = CamUtils.linearized(Color.blue(argb)), 616 + matrix = CamUtils.SRGB_TO_XYZ, 617 + y = r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2] 618 + return y 619 + } 620 + 621 + static xyzFromInt(argb: number): [number, number, number] { 622 + const r = CamUtils.linearized(Color.red(argb)), 623 + g = CamUtils.linearized(Color.green(argb)), 624 + b = CamUtils.linearized(Color.blue(argb)) 625 + 626 + const matrix = CamUtils.SRGB_TO_XYZ, 627 + x = r * matrix[0][0] + g * matrix[0][1] + b * matrix[0][2], 628 + y = r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2], 629 + z = r * matrix[2][0] + g * matrix[2][1] + b * matrix[2][2] 630 + 631 + return [x, y, z] 632 + } 633 + 634 + static linearized(rgbComponent: number): number { 635 + const normalized = rgbComponent / 255 636 + 637 + if (normalized <= 0.04045) { 638 + return (normalized / 12.92) * 100 639 + } else { 640 + return Math.pow((normalized + 0.055) / 1.055, 2.4) * 100 641 + } 642 + } 643 + 644 + /** 645 + * Converts an L* value to a Y value. 646 + * 647 + * L* in L_a_b* and Y in XYZ measure the same quantity, luminance. 648 + * 649 + * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a logarithmic scale. 650 + * 651 + * @param lstar L* in L_a_b* 652 + * @returns Y in XYZ 653 + */ 654 + static yFromLstar(lstar: number) { 655 + const ke = 8 656 + if (lstar > ke) { 657 + return Math.pow((lstar + 16) / 116, 3) * 100 658 + } else { 659 + return (lstar / (24389 / 27)) * 100 660 + } 661 + } 662 + 663 + /** 664 + * Clamps an integer between two integers. 665 + * 666 + * @returns Input when min <= input <= max, and either min or max otherwise. 667 + */ 668 + static clampInt(min: number, max: number, input: number) { 669 + if (input < min) { 670 + return min 671 + } else if (input > max) { 672 + return max 673 + } 674 + 675 + return input 676 + } 677 + 678 + /** 679 + * Delinearizes an RGB component. 680 + * 681 + * @param rgbComponent 0 <= rgb_component <= 100, represents linear R/G/B channel 682 + * @returns 0 <= output <= 255, color channel converted to regular RGB space 683 + */ 684 + static delinearized(rgbComponent: number) { 685 + const normalized = rgbComponent / 100 686 + let delinearized = 0 687 + if (normalized <= 0.0031308) { 688 + delinearized = normalized * 12.92 689 + } else { 690 + delinearized = 1.055 * Math.pow(normalized, 1 / 2.4) - 0.055 691 + } 692 + return CamUtils.clampInt(0, 255, Math.round(delinearized * 255)) 693 + } 694 + 695 + /** Converts a color from RGB components to ARGB format. */ 696 + static argbFromRgb(red: number, green: number, blue: number) { 697 + return ( 698 + (255 << 24) | ((red & 255) << 16) | ((green & 255) << 8) | (blue & 255) 699 + ) 700 + } 701 + 702 + /** Converts a color from ARGB to XYZ. */ 703 + static argbFromXyz(x: number, y: number, z: number) { 704 + const matrix = CamUtils.XYZ_TO_SRGB, 705 + linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z, 706 + linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z, 707 + linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z, 708 + r = CamUtils.delinearized(linearR), 709 + g = CamUtils.delinearized(linearG), 710 + b = CamUtils.delinearized(linearB) 711 + 712 + return CamUtils.argbFromRgb(r, g, b) 713 + } 714 + 715 + /** 716 + * Convert a color appearance model representation to an ARGB color. 717 + * 718 + * Note: the returned color may have a lower chroma than requested. Whether a chroma is available depends on luminance. For 719 + * example, there's no such thing as a high chroma light red, due to the limitations of our eyes and/or physics. If the 720 + * requested chroma is unavailable, the highest possible chroma at the requested luminance is returned. 721 + * 722 + * @param hue Hue, in degrees, in CAM coordinates 723 + * @param chroma Chroma in CAM coordinates. 724 + * @param lstar Perceptual luminance, L* in L_a_b* 725 + */ 726 + static CAMToColor(hue: number, chroma: number, lstar: number) { 727 + return Cam.getInt(hue, chroma, lstar) 728 + } 729 + 730 + /** 731 + * Converts an L* value to an ARGB representation. 732 + * 733 + * @param lstar L* in L_a_b* 734 + * @returns ARGB representation of grayscale color with lightness matching L* 735 + */ 736 + static argbFromLstar(lstar: number) { 737 + const fy = (lstar + 16) / 116, 738 + fz = fy, 739 + fx = fy, 740 + kappa = 24389 / 27, 741 + epsilon = 216 / 24389, 742 + lExceedsEpsilonKappa = lstar > 8, 743 + y = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa, 744 + cubeExceedEpsilon = fy * fy * fy > epsilon, 745 + x = cubeExceedEpsilon ? fx * fx * fx : lstar / kappa, 746 + z = cubeExceedEpsilon ? fz * fz * fz : lstar / kappa, 747 + whitePoint = CamUtils.WHITE_POINT_D65 748 + 749 + return CamUtils.argbFromXyz( 750 + x * whitePoint[0], 751 + y * whitePoint[1], 752 + z * whitePoint[2], 753 + ) 754 + } 755 + 756 + /** Converts a color from linear RGB components to ARGB format. */ 757 + static argbFromLinrgbComponents(r: number, g: number, b: number) { 758 + return CamUtils.argbFromRgb( 759 + CamUtils.delinearized(r), 760 + CamUtils.delinearized(g), 761 + CamUtils.delinearized(b), 762 + ) 763 + } 764 + 765 + /** 766 + * The signum function. 767 + * 768 + * @returns 1 if num > 0, -1 if num < 0, and 0 if num = 0 769 + */ 770 + static signum(num: number) { 771 + if (num < 0) { 772 + return -1 773 + } else if (num === 0) { 774 + return 0 775 + } else { 776 + return 1 777 + } 778 + } 779 + 780 + static intFromLstar(lstar: number) { 781 + if (lstar < 1) { 782 + return 0xff000000 783 + } else if (lstar > 99) { 784 + return 0xffffffff 785 + } 786 + 787 + // XYZ to LAB conversion routine, assume a and b are 0. 788 + const fy = (lstar + 16) / 116 789 + 790 + // fz = fx = fy because a and b are 0 791 + const fz = fy 792 + const fx = fy 793 + 794 + const kappa = 24389 / 27 795 + const epsilon = 216 / 24389 796 + const lExceedsEpsilonKappa = lstar > 8 797 + const yT = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa 798 + const cubeExceedEpsilon = fy * fy * fy > epsilon 799 + const xT = cubeExceedEpsilon ? fx * fx * fx : (116 * fx - 16) / kappa 800 + const zT = cubeExceedEpsilon ? fz * fz * fz : (116 * fx - 16) / kappa 801 + 802 + return ColorUtils.XYZToColor( 803 + xT * CamUtils.WHITE_POINT_D65[0], 804 + yT * CamUtils.WHITE_POINT_D65[1], 805 + zT * CamUtils.WHITE_POINT_D65[2], 806 + ) 807 + } 808 + } 809 + 810 + /** 811 + * The frame, or viewing conditions, where a color was seen. Used, along with a color, to create a color appearance model 812 + * representing the color. 813 + * 814 + * To convert a traditional color to a color appearance model, it requires knowing what conditions the color was observed in. Our 815 + * perception of color depends on, for example, the tone of the light illuminating the color, how bright that light was, etc. 816 + * 817 + * This class is modelled separately from the color appearance model itself because there are a number of calculations during the 818 + * color => CAM conversion process that depend only on the viewing conditions. Caching those calculations in a Frame instance 819 + * saves a significant amount of time. 820 + */ 821 + export class Frame { 822 + mN: number 823 + mAw: number 824 + mNbb: number 825 + mNcb: number 826 + mC: number 827 + mNc: number 828 + mRgbD: [number, number, number] 829 + mFl: number 830 + mFlRoot: number 831 + mZ: number 832 + 833 + constructor( 834 + n: number, 835 + aw: number, 836 + nbb: number, 837 + ncb: number, 838 + c: number, 839 + nc: number, 840 + rgbD: [number, number, number], 841 + fl: number, 842 + fLRoot: number, 843 + z: number, 844 + ) { 845 + this.mN = n 846 + this.mAw = aw 847 + this.mNbb = nbb 848 + this.mNcb = ncb 849 + this.mC = c 850 + this.mNc = nc 851 + this.mRgbD = rgbD 852 + this.mFl = fl 853 + this.mFlRoot = fLRoot 854 + this.mZ = z 855 + } 856 + 857 + getRgbD() { 858 + return this.mRgbD 859 + } 860 + getFl() { 861 + return this.mFl 862 + } 863 + getNbb() { 864 + return this.mNbb 865 + } 866 + getAw() { 867 + return this.mAw 868 + } 869 + getC() { 870 + return this.mC 871 + } 872 + getFlRoot() { 873 + return this.mFlRoot 874 + } 875 + getNc() { 876 + return this.mNc 877 + } 878 + getNcb() { 879 + return this.mNcb 880 + } 881 + getZ() { 882 + return this.mZ 883 + } 884 + getN() { 885 + return this.mN 886 + } 887 + 888 + static make( 889 + whitepoint: [number, number, number], 890 + adaptingLuminance: number, 891 + backgroundLstar: number, 892 + surround: number, 893 + discountingIlluminant: boolean, 894 + ) { 895 + // Transform white point XYZ to 'cone'/'rgb' responses 896 + const matrix = CamUtils.XYZ_TO_CAM16RGB, 897 + xyz = whitepoint, 898 + rW = 899 + xyz[0] * matrix[0][0] + xyz[1] * matrix[0][1] + xyz[2] * matrix[0][2], 900 + gW = 901 + xyz[0] * matrix[1][0] + xyz[1] * matrix[1][1] + xyz[2] * matrix[1][2], 902 + bW = xyz[0] * matrix[2][0] + xyz[1] * matrix[2][1] + xyz[2] * matrix[2][2] 903 + 904 + // Scale input surround, domain (0, 2), to CAM16 surround, domain (0.8, 1) 905 + const f = 0.8 + surround / 10 906 + // "Exponential non-linearity" 907 + const c = 908 + f >= 0.9 909 + ? MathUtils.lerp(0.59, 0.69, (f - 0.9) * 10) 910 + : MathUtils.lerp(0.525, 0.59, (f - 0.8) * 10) 911 + // Calculate degree of adaptation to illuminant 912 + let d = discountingIlluminant 913 + ? 1 914 + : f * (1 - (1 / 3.6) * Math.exp((-adaptingLuminance - 42) / 92)) 915 + // Per Li et al, if D is greater than 1 or less than 0, set it to 1 or 0. 916 + d = d > 1 ? 1 : d < 0 ? 0 : d 917 + // Chromatic induction factor 918 + const nc = f 919 + 920 + // Cone responses to the whitepoint, adjusted for illuminant discounting. 921 + // 922 + // Why use 100 instead of the white point's relative luminance? 923 + // 924 + // Some papers and implementations, for both CAM02 and CAM16, use the Y 925 + // value of the reference white instead of 100. Fairchild's Color Appearance 926 + // Models (3rd edition) notes that this is in error: it was included in the 927 + // CIE 2004a report on CIECAM02, but, later parts of the conversion process 928 + // account for scaling of appearance relative to the white point relative 929 + // luminance. This part should simply use 100 as luminance. 930 + const rgbD = [ 931 + d * (100 / rW) + 1 - d, 932 + d * (100 / gW) + 1 - d, 933 + d * (100 / bW) + 1 - d, 934 + ] as [number, number, number] 935 + // Luminance-level adaptation factor 936 + const k = 1 / (5 * adaptingLuminance + 1) 937 + const k4 = k * k * k * k 938 + const k4F = 1 - k4 939 + const fl = 940 + k4 * adaptingLuminance + 941 + 0.1 * k4F * k4F * Math.cbrt(5 * adaptingLuminance) 942 + 943 + // Intermediate factor, ratio of background relative luminance to white relative luminance 944 + const n = CamUtils.yFromLstar(backgroundLstar) / whitepoint[1] 945 + 946 + // Base exponential nonlinearity 947 + // note Schlomer 2018 has a typo and uses 1.58, the correct factor is 1.48 948 + const z = 1.48 + Math.sqrt(n) 949 + 950 + // Luminance-level induction factors 951 + const nbb = 0.725 / Math.pow(n, 0.2) 952 + const ncb = nbb 953 + 954 + // Discounted cone responses to the white point, adjusted for post-chromatic 955 + // adaptation perceptual nonlinearities. 956 + const rgbAFactors = [ 957 + Math.pow((fl * rgbD[0] * rW) / 100, 0.42), 958 + Math.pow((fl * rgbD[1] * gW) / 100, 0.42), 959 + Math.pow((fl * rgbD[2] * bW) / 100, 0.42), 960 + ] as const 961 + 962 + const rgbA = [ 963 + (400 * rgbAFactors[0]) / (rgbAFactors[0] + 27.13), 964 + (400 * rgbAFactors[1]) / (rgbAFactors[1] + 27.13), 965 + (400 * rgbAFactors[2]) / (rgbAFactors[2] + 27.13), 966 + ] as const 967 + 968 + const aw = (2 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]) * nbb 969 + 970 + return new Frame(n, aw, nbb, ncb, c, nc, rgbD, fl, Math.pow(fl, 0.25), z) 971 + } 972 + 973 + static DEFAULT = Frame.make( 974 + CamUtils.WHITE_POINT_D65, 975 + ((200 / Math.PI) * CamUtils.yFromLstar(50)) / 100, 976 + 50, 977 + 2, 978 + false, 979 + ) 980 + }
+574
src/alf/util/material3/Cam/HctSolver.ts
··· 1 + /* 2 + * This code was originally written in Java and has been converted to JavaScript. 3 + * 4 + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/com/android/internal/graphics/cam] 5 + * 6 + * Copyright (C) 2021 The Android Open Source Project. 7 + * The original code is licensed under the Apache License, Version 2.0. 8 + * 9 + * This JavaScript version is licensed under the MIT License. 10 + * 11 + * See the respective licenses for the specific terms, permissions, and limitations. 12 + */ 13 + 14 + import MathUtils from '../Utils/MathUtils' 15 + import {CamUtils, Frame} from './Cam' 16 + 17 + export default class HctSolver { 18 + /** Weights for transforming a set of linear RGB coordinates to Y in XYZ. */ 19 + static Y_FROM_LINRGB: [number, number, number] = [0.2126, 0.7152, 0.0722] 20 + 21 + // Matrix used when converting from CAM16 to linear RGB. 22 + static LINRGB_FROM_SCALED_DISCOUNT: [ 23 + [number, number, number], 24 + [number, number, number], 25 + [number, number, number], 26 + ] = [ 27 + [1373.2198709594231, -1100.4251190754821, -7.278681089101213], 28 + [-271.815969077903, 559.6580465940733, -32.46047482791194], 29 + [1.9622899599665666, -57.173814538844006, 308.7233197812385], 30 + ] 31 + 32 + /** Matrix used when converting from linear RGB to CAM16. */ 33 + static SCALED_DISCOUNT_FROM_LINRGB: [ 34 + [number, number, number], 35 + [number, number, number], 36 + [number, number, number], 37 + ] = [ 38 + [0.001200833568784504, 0.002389694492170889, 0.0002795742885861124], 39 + [0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398], 40 + [0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076], 41 + ] 42 + 43 + /** 44 + * Lookup table for plane in XYZ's Y axis (relative luminance) that corresponds to a given L* in L_a_b*. HCT's T is L*, and 45 + * XYZ's Y is directly correlated to linear RGB, this table allows us to thus find the intersection between HCT and RGB, giving 46 + * a solution to the RGB coordinates that correspond to a given set of HCT coordinates. 47 + */ 48 + static CRITICAL_PLANES = [ 49 + 0.015176349177441876, 0.045529047532325624, 0.07588174588720938, 50 + 0.10623444424209313, 0.13658714259697685, 0.16693984095186062, 51 + 0.19729253930674434, 0.2276452376616281, 0.2579979360165119, 52 + 0.28835063437139563, 0.3188300904430532, 0.350925934958123, 53 + 0.3848314933096426, 0.42057480301049466, 0.458183274052838, 54 + 0.4976837250274023, 0.5391024159806381, 0.5824650784040898, 55 + 0.6277969426914107, 0.6751227633498623, 0.7244668422128921, 56 + 0.775853049866786, 0.829304845476233, 0.8848452951698498, 0.942497089126609, 57 + 1.0022825574869039, 1.0642236851973577, 1.1283421258858297, 58 + 1.1946592148522128, 1.2631959812511864, 1.3339731595349034, 59 + 1.407011200216447, 1.4823302800086415, 1.5599503113873272, 60 + 1.6398909516233677, 1.7221716113234105, 1.8068114625156377, 61 + 1.8938294463134073, 1.9832442801866852, 2.075074464868551, 62 + 2.1693382909216234, 2.2660538449872063, 2.36523901573795, 63 + 2.4669114995532007, 2.5710888059345764, 2.6777882626779785, 64 + 2.7870270208169257, 2.898822059350997, 3.0131901897720907, 65 + 3.1301480604002863, 3.2497121605402226, 3.3718988244681087, 66 + 3.4967242352587946, 3.624204428461639, 3.754355295633311, 3.887192587735158, 67 + 4.022731918402185, 4.160988767090289, 4.301978482107941, 4.445716283538092, 68 + 4.592217266055746, 4.741496401646282, 4.893568542229298, 5.048448422192488, 69 + 5.20615066083972, 5.3666897647573375, 5.5300801301023865, 5.696336044816294, 70 + 5.865471690767354, 6.037501145825082, 6.212438385869475, 6.390297286737924, 71 + 6.571091626112461, 6.7548350853498045, 6.941541251256611, 7.131223617812143, 72 + 7.323895587840543, 7.5195704746346665, 7.7182615035334345, 73 + 7.919981813454504, 8.124744458384042, 8.332562408825165, 8.543448553206703, 74 + 8.757415699253682, 8.974476575321063, 9.194643831691977, 9.417930041841839, 75 + 9.644347703669503, 9.873909240696694, 10.106627003236781, 76 + 10.342513269534024, 10.58158024687427, 10.8238400726681, 11.069304815507364, 77 + 11.317986476196008, 11.569896988756009, 11.825048221409341, 78 + 12.083451977536606, 12.345119996613247, 12.610063955123938, 79 + 12.878295467455942, 13.149826086772048, 13.42466730586372, 80 + 13.702830557985108, 13.984327217668513, 14.269168601521828, 81 + 14.55736596900856, 14.848930523210871, 15.143873411576273, 82 + 15.44220572664832, 15.743938506781891, 16.04908273684337, 16.35764934889634, 83 + 16.66964922287304, 16.985093187232053, 17.30399201960269, 17.62635644741625, 84 + 17.95219714852476, 18.281524751807332, 18.614349837764564, 85 + 18.95068293910138, 19.290534541298456, 19.633915083172692, 86 + 19.98083495742689, 20.331304511189067, 20.685334046541502, 87 + 21.042933821039977, 21.404114048223256, 21.76888489811322, 88 + 22.137256497705877, 22.50923893145328, 22.884842241736916, 89 + 23.264076429332462, 23.6469514538663, 24.033477234264016, 24.42366364919083, 90 + 24.817520537484558, 25.21505769858089, 25.61628489293138, 91 + 26.021211842414342, 26.429848230738664, 26.842203703840827, 92 + 27.258287870275353, 27.678110301598522, 28.10168053274597, 93 + 28.529008062403893, 28.96010235337422, 29.39497283293396, 29.83362889318845, 94 + 30.276079891419332, 30.722335150426627, 31.172403958865512, 95 + 31.62629557157785, 32.08401920991837, 32.54558406207592, 33.010999283389665, 96 + 33.4802739966603, 33.953417292456834, 34.430438229418264, 97 + 34.911345834551085, 35.39614910352207, 35.88485700094671, 36.37747846067349, 98 + 36.87402238606382, 37.37449765026789, 37.87891309649659, 38.38727753828926, 99 + 38.89959975977785, 39.41588851594697, 39.93615253289054, 40.460400508064545, 100 + 40.98864111053629, 41.520882981230194, 42.05713473317016, 101 + 42.597404951718396, 43.141702194811224, 43.6900349931913, 44.24241185063697, 102 + 44.798841244188324, 45.35933162437017, 45.92389141541209, 46.49252901546552, 103 + 47.065252796817916, 47.64207110610409, 48.22299226451468, 104 + 48.808024568002054, 49.3971762874833, 49.9904556690408, 50.587870934119984, 105 + 51.189430279724725, 51.79514187861014, 52.40501387947288, 53.0190544071392, 106 + 53.637271562750364, 54.259673423945976, 54.88626804504493, 107 + 55.517063457223934, 56.15206766869424, 56.79128866487574, 57.43473440856916, 108 + 58.08241284012621, 58.734331877617365, 59.39049941699807, 60.05092333227251, 109 + 60.715611475655585, 61.38457167773311, 62.057811747619894, 62.7353394731159, 110 + 63.417162620860914, 64.10328893648692, 64.79372614476921, 65.48848194977529, 111 + 66.18756403501224, 66.89098006357258, 67.59873767827808, 68.31084450182222, 112 + 69.02730813691093, 69.74813616640164, 70.47333615344107, 71.20291564160104, 113 + 71.93688215501312, 72.67524319850172, 73.41800625771542, 74.16517879925733, 114 + 74.9167682708136, 75.67278210128072, 76.43322770089146, 77.1981124613393, 115 + 77.96744375590167, 78.74122893956174, 79.51947534912904, 80.30219030335869, 116 + 81.08938110306934, 81.88105503125999, 82.67721935322541, 83.4778813166706, 117 + 84.28304815182372, 85.09272707154808, 85.90692527145302, 86.72564993000343, 118 + 87.54890820862819, 88.3767072518277, 89.2090541872801, 90.04595612594655, 119 + 90.88742016217518, 91.73345337380438, 92.58406282226491, 93.43925555268066, 120 + 94.29903859396902, 95.16341895893969, 96.03240364439274, 96.9059996312159, 121 + 97.78421388448044, 98.6670533535366, 99.55452497210776, 122 + ] 123 + 124 + /** 125 + * Sanitizes a degree measure as a floating-point number. 126 + * 127 + * @returns A degree measure between 0.0 (inclusive) and 360.0 (exclusive). 128 + */ 129 + static sanitizeDegreesDouble(degrees: number) { 130 + degrees = degrees % 360.0 131 + if (degrees < 0) { 132 + degrees = degrees + 360.0 133 + } 134 + return degrees 135 + } 136 + 137 + /** Equation used in CAM16 conversion that removes the effect of chromatic adaptation. */ 138 + static inverseChromaticAdaptation(adapted: number) { 139 + const adaptedAbs = Math.abs(adapted) 140 + const base = Math.max(0, (27.13 * adaptedAbs) / (400 - adaptedAbs)) 141 + 142 + return CamUtils.signum(adapted) * Math.pow(base, 1 / 0.42) 143 + } 144 + 145 + /** 146 + * Finds a color with the given hue, chroma, and Y. 147 + * 148 + * @param hueRadians The desired hue in radians. 149 + * @param chroma The desired chroma. 150 + * @param y The desired Y. 151 + * @returns The desired color as a hexadecimal integer, if found; 0 otherwise. 152 + */ 153 + static findResultByJ(hueRadians: number, chroma: number, y: number) { 154 + // Initial estimate of j. 155 + let j = Math.sqrt(y) * 11.0 156 + 157 + // =========================================================== 158 + // Operations inlined from Cam16 to avoid repeated calculation 159 + // =========================================================== 160 + const viewingConditions = Frame.DEFAULT, 161 + tInnerCoeff = 162 + 1 / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73), 163 + eHue = 0.25 * (Math.cos(hueRadians + 2.0) + 3.8), 164 + p1 = 165 + eHue * 166 + (50000 / 13) * 167 + viewingConditions.getNc() * 168 + viewingConditions.getNcb(), 169 + hSin = Math.sin(hueRadians), 170 + hCos = Math.cos(hueRadians) 171 + 172 + for (let iterationRound = 0; iterationRound < 5; iterationRound++) { 173 + // =========================================================== 174 + // Operations inlined from Cam16 to avoid repeated calculation 175 + // =========================================================== 176 + const jNormalized = j / 100.0, 177 + alpha = chroma === 0 || j === 0 ? 0 : chroma / Math.sqrt(jNormalized), 178 + t = Math.pow(alpha * tInnerCoeff, 1 / 0.9), 179 + acExponent = 1 / viewingConditions.getC() / viewingConditions.getZ(), 180 + ac = viewingConditions.getAw() * Math.pow(jNormalized, acExponent), 181 + p2 = ac / viewingConditions.getNbb(), 182 + gamma = 183 + (23 * (p2 + 0.305) * t) / (23 * p1 + 11 * t * hCos + 108 * t * hSin), 184 + a = gamma * hCos, 185 + b = gamma * hSin, 186 + rA = (460 * p2 + 451 * a + 288 * b) / 1403.0, 187 + gA = (460 * p2 - 891 * a - 261 * b) / 1403.0, 188 + bA = (460 * p2 - 220 * a - 6300 * b) / 1403.0, 189 + rCScaled = HctSolver.inverseChromaticAdaptation(rA), 190 + gCScaled = HctSolver.inverseChromaticAdaptation(gA), 191 + bCScaled = HctSolver.inverseChromaticAdaptation(bA), 192 + matrix = HctSolver.LINRGB_FROM_SCALED_DISCOUNT, 193 + linrgbR = 194 + rCScaled * matrix[0][0] + 195 + gCScaled * matrix[0][1] + 196 + bCScaled * matrix[0][2], 197 + linrgbG = 198 + rCScaled * matrix[1][0] + 199 + gCScaled * matrix[1][1] + 200 + bCScaled * matrix[1][2], 201 + linrgbB = 202 + rCScaled * matrix[2][0] + 203 + gCScaled * matrix[2][1] + 204 + bCScaled * matrix[2][2] 205 + 206 + // =========================================================== 207 + // Operations inlined from Cam16 to avoid repeated calculation 208 + // =========================================================== 209 + if (linrgbR < 0 || linrgbG < 0 || linrgbB < 0) { 210 + return 0 211 + } 212 + 213 + const kR = HctSolver.Y_FROM_LINRGB[0], 214 + kG = HctSolver.Y_FROM_LINRGB[1], 215 + kB = HctSolver.Y_FROM_LINRGB[2], 216 + fnj = kR * linrgbR + kG * linrgbG + kB * linrgbB 217 + 218 + if (fnj <= 0) { 219 + return 0 220 + } 221 + if (iterationRound === 4 || Math.abs(fnj - y) < 0.002) { 222 + if (linrgbR > 100.01 || linrgbG > 100.01 || linrgbB > 100.01) { 223 + return 0 224 + } 225 + return CamUtils.argbFromLinrgbComponents(linrgbR, linrgbG, linrgbB) 226 + } 227 + 228 + // Iterates with Newton method, 229 + // Using 2 * fn(j) / j as the approximation of fn'(j) 230 + j = j - ((fnj - y) * j) / (2 * fnj) 231 + } 232 + 233 + return 0 234 + } 235 + 236 + /** 237 + * Finds an sRGB color with the given hue, chroma, and L*, if possible. 238 + * 239 + * @param hueDegrees The desired hue, in degrees. 240 + * @param chroma The desired chroma. 241 + * @param lstar The desired L*. 242 + * @returns A hexadecimal representing the sRGB color. The color has sufficiently close hue, chroma, and L* to the desired 243 + * values, if possible; otherwise, the hue and L* will be sufficiently close, and chroma will be maximized. 244 + */ 245 + static solveToInt(hueDegrees: number, chroma: number, lstar: number) { 246 + if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { 247 + return CamUtils.argbFromLstar(lstar) 248 + } 249 + 250 + hueDegrees = HctSolver.sanitizeDegreesDouble(hueDegrees) 251 + const hueRadians = MathUtils.toRadians(hueDegrees) 252 + const y = CamUtils.yFromLstar(lstar) 253 + 254 + const exactAnswer = HctSolver.findResultByJ(hueRadians, chroma, y) 255 + 256 + if (exactAnswer !== 0) { 257 + return exactAnswer 258 + } 259 + 260 + return HctSolver.bisectToLimit(y, hueRadians) 261 + } 262 + 263 + /** Ensure X is between 0 and 100. */ 264 + static isBounded(x: number) { 265 + return x >= 0 && x <= 100 266 + } 267 + 268 + /** 269 + * Returns the nth possible vertex of the polygonal intersection. 270 + * 271 + * @param y The Y value of the plane. 272 + * @param n The zero-based index of the point. 0 <= n <= 11. 273 + * @returns The nth possible vertex of the polygonal intersection of the y plane and the RGB cube in linear RGB coordinates, if 274 + * it exists. If the possible vertex lies outside of the cube, [-1.0, -1.0, -1.0] is returned. 275 + */ 276 + static nthVertex(y: number, n: number): [number, number, number] { 277 + const kR = HctSolver.Y_FROM_LINRGB[0], 278 + kG = HctSolver.Y_FROM_LINRGB[1], 279 + kB = HctSolver.Y_FROM_LINRGB[2], 280 + coordA = n % 4 <= 1 ? 0 : 100, 281 + coordB = n % 2 === 0 ? 0 : 100 282 + 283 + if (n < 4) { 284 + const g = coordA 285 + const b = coordB 286 + const r = (y - g * kG - b * kB) / kR 287 + if (HctSolver.isBounded(r)) { 288 + return [r, g, b] 289 + } else { 290 + return [-1.0, -1.0, -1.0] 291 + } 292 + } else if (n < 8) { 293 + const b = coordA 294 + const r = coordB 295 + const g = (y - r * kR - b * kB) / kG 296 + if (HctSolver.isBounded(g)) { 297 + return [r, g, b] 298 + } else { 299 + return [-1.0, -1.0, -1.0] 300 + } 301 + } else { 302 + const r = coordA 303 + const g = coordB 304 + const b = (y - r * kR - g * kG) / kB 305 + if (HctSolver.isBounded(b)) { 306 + return [r, g, b] 307 + } else { 308 + return [-1.0, -1.0, -1.0] 309 + } 310 + } 311 + } 312 + 313 + static chromaticAdaptation(component: number) { 314 + const af = Math.pow(Math.abs(component), 0.42) 315 + return (CamUtils.signum(component) * 400 * af) / (af + 27.13) 316 + } 317 + 318 + /** 319 + * Returns the hue of a linear RGB color in CAM16. 320 + * 321 + * @param linrgb The linear RGB coordinates of a color. 322 + * @returns The hue of the color in CAM16, in radians. 323 + */ 324 + static hueOf(linrgb: [number, number, number]) { 325 + // Calculate scaled discount components using in-lined matrix multiplication to avoid 326 + // an array allocation. 327 + const matrix = HctSolver.SCALED_DISCOUNT_FROM_LINRGB, 328 + row = linrgb, 329 + rD = 330 + linrgb[0] * matrix[0][0] + 331 + row[1] * matrix[0][1] + 332 + row[2] * matrix[0][2], 333 + gD = 334 + linrgb[0] * matrix[1][0] + 335 + row[1] * matrix[1][1] + 336 + row[2] * matrix[1][2], 337 + bD = 338 + linrgb[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2] 339 + 340 + const rA = HctSolver.chromaticAdaptation(rD), 341 + gA = HctSolver.chromaticAdaptation(gD), 342 + bA = HctSolver.chromaticAdaptation(bD) 343 + 344 + // redness-greenness 345 + const a = (11 * rA + -12 * gA + bA) / 11 346 + 347 + // yellowness-blueness 348 + const b = (rA + gA - 2 * bA) / 9 349 + 350 + return Math.atan2(b, a) 351 + } 352 + 353 + /** 354 + * Sanitizes a small enough angle in radians. 355 + * 356 + * @param angle An angle in radians; must not deviate too much from 0. 357 + * @returns A coterminal angle between 0 and 2pi. 358 + */ 359 + static sanitizeRadians(angle: number) { 360 + return (angle + Math.PI * 8) % (Math.PI * 2) 361 + } 362 + 363 + /** 364 + * Cyclic order is the idea that 330° → 5° → 200° is in order, but, 180° → 270° → 210° is not. Visually, A B and C are angles, 365 + * and they are in cyclic order if travelling from A to C in a way that increases angle (ex. counter-clockwise if +x axis = 0 366 + * degrees and +y = 90) means you must cross B. 367 + * 368 + * @param a First angle in possibly cyclic triplet 369 + * @param b Second angle in possibly cyclic triplet 370 + * @param c Third angle in possibly cyclic triplet 371 + * @returns True if B is between A and C 372 + */ 373 + static areInCyclicOrder(a: number, b: number, c: number) { 374 + const deltaAB = HctSolver.sanitizeRadians(b - a) 375 + const deltaAC = HctSolver.sanitizeRadians(c - a) 376 + 377 + return deltaAB < deltaAC 378 + } 379 + 380 + /** 381 + * Finds the segment containing the desired color. 382 + * 383 + * @param y The Y value of the color. 384 + * @param targetHue The hue of the color. 385 + * @returns A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the segment containing the 386 + * desired color. 387 + */ 388 + static bisectToSegment(y: number, targetHue: number) { 389 + let left: [number, number, number] = [-1.0, -1.0, -1.0], 390 + right = left, 391 + leftHue = 0.0, 392 + rightHue = 0.0, 393 + initialized = false, 394 + uncut = true 395 + 396 + for (let n = 0; n < 12; n++) { 397 + const mid = HctSolver.nthVertex(y, n) 398 + if (mid[0] < 0) { 399 + continue 400 + } 401 + 402 + const midHue = HctSolver.hueOf(mid) 403 + if (!initialized) { 404 + left = mid 405 + right = mid 406 + leftHue = midHue 407 + rightHue = midHue 408 + initialized = true 409 + continue 410 + } 411 + 412 + if (uncut || HctSolver.areInCyclicOrder(leftHue, midHue, rightHue)) { 413 + uncut = false 414 + if (HctSolver.areInCyclicOrder(leftHue, targetHue, midHue)) { 415 + right = mid 416 + rightHue = midHue 417 + } else { 418 + left = mid 419 + leftHue = midHue 420 + } 421 + } 422 + } 423 + 424 + return [left, right] as [[number, number, number], [number, number, number]] 425 + } 426 + 427 + static criticalPlaneBelow(x: number) { 428 + return Math.floor(x - 0.5) 429 + } 430 + 431 + static criticalPlaneAbove(x: number) { 432 + return Math.ceil(x - 0.5) 433 + } 434 + 435 + /** 436 + * Delinearizes an RGB component, returning a floating-point number. 437 + * 438 + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel 439 + * @returns 0.0 <= output <= 255.0, color channel converted to regular RGB space 440 + */ 441 + static trueDelinearized(rgbComponent: number) { 442 + const normalized = rgbComponent / 100.0 443 + 444 + let delinearized 445 + if (normalized <= 0.0031308) { 446 + delinearized = normalized * 12.92 447 + } else { 448 + delinearized = 1.055 * Math.pow(normalized, 1 / 2.4) - 0.055 449 + } 450 + 451 + return delinearized * 255.0 452 + } 453 + 454 + /** 455 + * Find an intercept using linear interpolation. 456 + * 457 + * @param source The starting number. 458 + * @param mid The number in the middle. 459 + * @param target The ending number. 460 + * @returns A number t such that lerp(source, target, t) = mid. 461 + */ 462 + static intercept(source: number, mid: number, target: number) { 463 + if (target === source) return target 464 + 465 + return (mid - source) / (target - source) 466 + } 467 + 468 + /** 469 + * Linearly interpolate between two points in three dimensions. 470 + * 471 + * @param source Three dimensions representing the starting point 472 + * @param t The percentage to travel between source and target, from 0 to 1 473 + * @param target Three dimensions representing the end point 474 + * @returns Three dimensions representing the point t percent from source to target. 475 + */ 476 + static lerpPoint( 477 + source: [number, number, number], 478 + t: number, 479 + target: [number, number, number], 480 + ) { 481 + return [ 482 + source[0] + (target[0] - source[0]) * t, 483 + source[1] + (target[1] - source[1]) * t, 484 + source[2] + (target[2] - source[2]) * t, 485 + ] as [number, number, number] 486 + } 487 + 488 + /** 489 + * Intersects a segment with a plane. 490 + * 491 + * @param source The coordinates of point A. 492 + * @param coordinate The R-, G-, or B-coordinate of the plane. 493 + * @param target The coordinates of point B. 494 + * @param axis The axis the plane is perpendicular with. (0: R, 1: G, 2: B) 495 + * @returns The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or B=coordinate 496 + */ 497 + static setCoordinate( 498 + source: [number, number, number], 499 + coordinate: number, 500 + target: [number, number, number], 501 + axis: 0 | 1 | 2, 502 + ) { 503 + const t = HctSolver.intercept(source[axis], coordinate, target[axis]) 504 + 505 + return HctSolver.lerpPoint(source, t, target) 506 + } 507 + 508 + /** 509 + * Finds a color with the given Y and hue on the boundary of the cube. 510 + * 511 + * @param y The Y value of the color. 512 + * @param targetHue The hue of the color. 513 + * @returns The desired color, in linear RGB coordinates. 514 + */ 515 + static bisectToLimit(y: number, targetHue: number) { 516 + const segment = HctSolver.bisectToSegment(y, targetHue) 517 + 518 + let left = segment[0], 519 + leftHue = HctSolver.hueOf(left), 520 + right = segment[1] 521 + 522 + for (let axis = 0 as 0 | 1 | 2; axis < 3; axis++) { 523 + if (left[axis] !== right[axis]) { 524 + let lPlane = -1 525 + let rPlane = 255 526 + if (left[axis] < right[axis]) { 527 + lPlane = HctSolver.criticalPlaneBelow( 528 + HctSolver.trueDelinearized(left[axis]), 529 + ) 530 + rPlane = HctSolver.criticalPlaneAbove( 531 + HctSolver.trueDelinearized(right[axis]), 532 + ) 533 + } else { 534 + lPlane = HctSolver.criticalPlaneAbove( 535 + HctSolver.trueDelinearized(left[axis]), 536 + ) 537 + rPlane = HctSolver.criticalPlaneBelow( 538 + HctSolver.trueDelinearized(right[axis]), 539 + ) 540 + } 541 + for (let i = 0; i < 8; i++) { 542 + if (Math.abs(rPlane - lPlane) <= 1) { 543 + break 544 + } else { 545 + const mPlane = Math.floor((lPlane + rPlane) / 2.0) 546 + const midPlaneCoordinate = HctSolver.CRITICAL_PLANES[mPlane] ?? 0 547 + 548 + const mid: [number, number, number] = HctSolver.setCoordinate( 549 + left, 550 + midPlaneCoordinate, 551 + right, 552 + axis, 553 + ) 554 + const midHue = HctSolver.hueOf(mid) 555 + if (HctSolver.areInCyclicOrder(leftHue, targetHue, midHue)) { 556 + right = mid 557 + rPlane = mPlane 558 + } else { 559 + left = mid 560 + leftHue = midHue 561 + lPlane = mPlane 562 + } 563 + } 564 + } 565 + } 566 + } 567 + 568 + return CamUtils.argbFromLinrgbComponents( 569 + (left[0] + right[0]) / 2, 570 + (left[1] + right[1]) / 2, 571 + (left[2] + right[2]) / 2, 572 + ) 573 + } 574 + }
+246
src/alf/util/material3/Monet/Palette.ts
··· 1 + /* 2 + * This code was originally written in Java and has been converted to JavaScript. 3 + * 4 + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/6844741fb8351f3aa82b96ce64a1bd83ea7989bd/packages/SystemUI/monet/src/com/android/systemui/monet?autodive=0%2F] 5 + * 6 + * Copyright (C) 2021 The Android Open Source Project. 7 + * The original code is licensed under the Apache License, Version 2.0. 8 + * 9 + * This JavaScript version is licensed under the MIT License. 10 + * 11 + * See the respective licenses for the specific terms, permissions, and limitations. 12 + */ 13 + 14 + import {Cam} from '../Cam/Cam' 15 + import {type GenerationStyle, type MaterialYouPalette} from '../Types' 16 + import Color from '../Utils/Color' 17 + import Shades from './Shades' 18 + 19 + type StyleType = Record< 20 + GenerationStyle, 21 + Record<'a1' | 'a2' | 'a3' | 'n1' | 'n2', [number, number]> 22 + > 23 + 24 + export default class Palette { 25 + static ACCENT1_CHROMA = 48 26 + static ACCENT2_CHROMA = 16 27 + static ACCENT3_CHROMA = 32 28 + static ACCENT3_HUE_SHIFT = 60 29 + static NEUTRAL1_CHROMA = 4 30 + static NEUTRAL2_CHROMA = 8 31 + static GOOGLE_BLUE = 0xff1b6ef3 32 + 33 + static getStyle(hue: number, chroma: number): StyleType { 34 + return { 35 + SPRITZ: { 36 + a1: [hue, 12], 37 + a2: [hue, 8], 38 + a3: [hue, 16], 39 + n1: [hue, 2], 40 + n2: [hue, 2], 41 + }, 42 + TONAL_SPOT: { 43 + a1: [hue, 36], 44 + a2: [hue, 16], 45 + a3: [Palette.HueAdd(hue, 60), 24], 46 + n1: [hue, 4], 47 + n2: [hue, 8], 48 + }, 49 + VIBRANT: { 50 + a1: [hue, 130], 51 + a2: [Palette.HueVibrantSecondary(hue), 24], 52 + a3: [Palette.HueVibrantTertiary(hue), 32], 53 + n1: [hue, 10], 54 + n2: [hue, 12], 55 + }, 56 + EXPRESSIVE: { 57 + a1: [Palette.HueAdd(hue, 240), 40], 58 + a2: [Palette.HueExpressiveSecondary(hue), 24], 59 + a3: [Palette.HueExpressiveTertiary(hue), 32], 60 + n1: [Palette.HueAdd(hue, 15), 8], 61 + n2: [Palette.HueAdd(hue, 15), 12], 62 + }, 63 + RAINBOW: { 64 + a1: [hue, 48], 65 + a2: [hue, 16], 66 + a3: [Palette.HueAdd(hue, 60), 24], 67 + n1: [hue, 0], 68 + n2: [hue, 0], 69 + }, 70 + FRUIT_SALAD: { 71 + a1: [Palette.HueSubtract(hue, 50), 48], 72 + a2: [Palette.HueSubtract(hue, 50), 36], 73 + a3: [hue, 36], 74 + n1: [hue, 10], 75 + n2: [hue, 16], 76 + }, 77 + CONTENT: { 78 + a1: [hue, chroma], 79 + a2: [hue, Palette.ChromaMultiple(chroma, 0.33)], 80 + a3: [hue, Palette.ChromaMultiple(chroma, 0.66)], 81 + n1: [hue, Palette.ChromaMultiple(chroma, 0.0833)], 82 + n2: [hue, Palette.ChromaMultiple(chroma, 0.1666)], 83 + }, 84 + MONOCHROMATIC: { 85 + a1: [hue, 0], 86 + a2: [hue, 0], 87 + a3: [hue, 0], 88 + n1: [hue, 0], 89 + n2: [hue, 0], 90 + }, 91 + } 92 + } 93 + 94 + static HueAdd(hue: number, amountDegrees: number): number { 95 + return Palette.wrapDegreesDouble(hue + amountDegrees) 96 + } 97 + 98 + static HueSubtract(hue: number, amountDegrees: number): number { 99 + return Palette.wrapDegreesDouble(hue - amountDegrees) 100 + } 101 + 102 + static ChromaMultiple(chroma: number, multiple: number): number { 103 + return chroma * multiple 104 + } 105 + 106 + static getHueRotation( 107 + sourceHue: number, 108 + hueAndRotations: Array<[number, number]>, 109 + ): number { 110 + const sanitizedSourceHue = sourceHue < 0 || sourceHue >= 360 ? 0 : sourceHue 111 + 112 + for (let i = 0; i < hueAndRotations.length - 1; i++) { 113 + const thisHue = hueAndRotations[i][0] 114 + const nextHue = hueAndRotations[i + 1][0] 115 + 116 + if (thisHue <= sanitizedSourceHue && sanitizedSourceHue < nextHue) { 117 + return Palette.wrapDegreesDouble( 118 + sanitizedSourceHue + hueAndRotations[i][1], 119 + ) 120 + } 121 + } 122 + 123 + // If this statement executes, something is wrong, there should have been a rotation 124 + // found using the arrays. 125 + return sourceHue 126 + } 127 + 128 + static HueVibrantSecondary(hue: number) { 129 + const hueToRotations: Array<[number, number]> = [ 130 + [0, 18], 131 + [41, 15], 132 + [61, 10], 133 + [101, 12], 134 + [131, 15], 135 + [181, 18], 136 + [251, 15], 137 + [301, 12], 138 + [360, 12], 139 + ] 140 + 141 + return Palette.getHueRotation(hue, hueToRotations) 142 + } 143 + 144 + static HueVibrantTertiary(hue: number) { 145 + const hueToRotations: Array<[number, number]> = [ 146 + [0, 35], 147 + [41, 30], 148 + [61, 20], 149 + [101, 25], 150 + [131, 30], 151 + [181, 35], 152 + [251, 30], 153 + [301, 25], 154 + [360, 25], 155 + ] 156 + 157 + return Palette.getHueRotation(hue, hueToRotations) 158 + } 159 + 160 + static HueExpressiveSecondary(hue: number) { 161 + const hueToRotations: Array<[number, number]> = [ 162 + [0, 45], 163 + [21, 95], 164 + [51, 45], 165 + [121, 20], 166 + [151, 45], 167 + [191, 90], 168 + [271, 45], 169 + [321, 45], 170 + [360, 45], 171 + ] 172 + 173 + return Palette.getHueRotation(hue, hueToRotations) 174 + } 175 + 176 + static HueExpressiveTertiary(hue: number) { 177 + const hueToRotations: Array<[number, number]> = [ 178 + [0, 120], 179 + [21, 120], 180 + [51, 20], 181 + [121, 45], 182 + [151, 20], 183 + [191, 15], 184 + [271, 20], 185 + [321, 120], 186 + [360, 120], 187 + ] 188 + 189 + return Palette.getHueRotation(hue, hueToRotations) 190 + } 191 + 192 + static wrapDegreesDouble(degrees: number): number { 193 + if (degrees < 0) { 194 + return (degrees % 360) + 360 195 + } else if (degrees >= 360) { 196 + return degrees % 360 197 + } else { 198 + return degrees 199 + } 200 + } 201 + 202 + static generate( 203 + seed: string, 204 + style: GenerationStyle = 'TONAL_SPOT', 205 + ): MaterialYouPalette { 206 + // Parse the HEX seed color string into an integer 207 + seed = seed.toUpperCase().substring(1, 7) 208 + const colorInt = parseInt('0xff' + seed, 16) 209 + 210 + let seedArgb 211 + if (colorInt === Color.TRANSPARENT) { 212 + seedArgb = Palette.GOOGLE_BLUE 213 + } else { 214 + seedArgb = colorInt 215 + } 216 + 217 + const camSeed = Cam.fromInt(seedArgb), 218 + hue = camSeed.getHue(), 219 + chroma = Math.max(camSeed.getChroma(), Palette.ACCENT1_CHROMA) 220 + 221 + const {a1, a2, a3, n1, n2} = Palette.getStyle(hue, chroma)[style] 222 + 223 + const accent1 = Shades.of(a1[0], a1[1]), 224 + accent2 = Shades.of(a2[0], a2[1]), 225 + accent3 = Shades.of(a3[0], a3[1]), 226 + neutral1 = Shades.of(n1[0], n1[1]), 227 + neutral2 = Shades.of(n2[0], n2[1]) 228 + 229 + const numberToHexColor = (colorNumber: number) => { 230 + return ( 231 + '#' + 232 + (colorNumber & 0xffffff).toString(16).padStart(6, '0').toUpperCase() 233 + ) 234 + } 235 + 236 + const results = { 237 + system_accent1: accent1.map(numberToHexColor), 238 + system_accent2: accent2.map(numberToHexColor), 239 + system_accent3: accent3.map(numberToHexColor), 240 + system_neutral1: neutral1.map(numberToHexColor), 241 + system_neutral2: neutral2.map(numberToHexColor), 242 + } 243 + 244 + return results as MaterialYouPalette 245 + } 246 + }
+58
src/alf/util/material3/Monet/Shades.ts
··· 1 + /* 2 + * This code was originally written in Java and has been converted to JavaScript. 3 + * 4 + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/6844741fb8351f3aa82b96ce64a1bd83ea7989bd/packages/SystemUI/monet/src/com/android/systemui/monet?autodive=0%2F] 5 + * 6 + * Copyright (C) 2021 The Android Open Source Project. 7 + * The original code is licensed under the Apache License, Version 2.0. 8 + * 9 + * This JavaScript version is licensed under the MIT License. 10 + * 11 + * See the respective licenses for the specific terms, permissions, and limitations. 12 + */ 13 + 14 + import ColorUtils from '../Utils/ColorUtils' 15 + 16 + export default class Shades { 17 + /** 18 + * Combining the ability to convert between relative luminance and perceptual luminance with contrast leads to a design system 19 + * that can be based on a linear value to determine contrast, rather than a ratio. 20 + * 21 + * This codebase implements a design system that has that property, and as a result, we can guarantee that any shades 5 steps 22 + * from each other have a contrast ratio of at least 4.5. 4.5 is the requirement for smaller text contrast in WCAG 2.1 and 23 + * earlier. 24 + * 25 + * However, lstar 50 does _not_ have a contrast ratio >= 4.5 with lstar 100. lstar 49.6 is the smallest lstar that will lead to 26 + * a contrast ratio >= 4.5 with lstar 100, and it also contrasts >= 4.5 with lstar 100. 27 + */ 28 + static MIDDLE_LSTAR = 49.6 29 + 30 + /** 31 + * Generate shades of a color. Ordered in lightness _descending_. The first shade will be at 95% lightness, the next at 90, 80, 32 + * etc. through 0. 33 + * 34 + * @param hue Hue in CAM16 color space 35 + * @param chroma Chroma in CAM16 color space 36 + * @returns Shades of a color, as argb integers. Ordered by lightness descending. 37 + */ 38 + static of(hue: number, chroma: number) { 39 + const shades: number[] = [] 40 + 41 + // At tone 90 and above, blue and yellow hues can reach a much higher chroma. 42 + // To preserve a consistent appearance across all hues, use a maximum chroma of 40. 43 + shades[0] = ColorUtils.CAMToColor(hue, Math.min(40, chroma), 99) 44 + shades[1] = ColorUtils.CAMToColor(hue, Math.min(40, chroma), 95) 45 + 46 + for (let i = 2; i < 12; i++) { 47 + const lStar = i === 6 ? Shades.MIDDLE_LSTAR : 100 - 10 * (i - 1) 48 + if (lStar >= 90) { 49 + chroma = Math.min(40, chroma) 50 + } 51 + shades[i] = ColorUtils.CAMToColor(hue, chroma, lStar) 52 + } 53 + 54 + shades.unshift(16777215) // first color is always pure white 55 + 56 + return shades 57 + } 58 + }
+106
src/alf/util/material3/Types.ts
··· 1 + type ShadesArr = [ 2 + string, 3 + string, 4 + string, 5 + string, 6 + string, 7 + string, 8 + string, 9 + string, 10 + string, 11 + string, 12 + string, 13 + string, 14 + string, 15 + ] 16 + 17 + export type MaterialYouPalette = { 18 + /** An array with `13` shades. */ 19 + system_accent1: ShadesArr 20 + /** An array with `13` shades. */ 21 + system_accent2: ShadesArr 22 + /** An array with `13` shades. */ 23 + system_accent3: ShadesArr 24 + /** An array with `13` shades. */ 25 + system_neutral1: ShadesArr 26 + /** An array with `13` shades. */ 27 + system_neutral2: ShadesArr 28 + } 29 + 30 + export type GenerationStyle = 31 + | 'SPRITZ' 32 + | 'TONAL_SPOT' 33 + | 'VIBRANT' 34 + | 'EXPRESSIVE' 35 + | 'RAINBOW' 36 + | 'FRUIT_SALAD' 37 + | 'CONTENT' 38 + | 'MONOCHROMATIC' 39 + 40 + export type ColorScheme = 'light' | 'dark' | 'auto' 41 + 42 + export type MapPaletteToThemeType = (palette: MaterialYouPalette) => { 43 + light: Record<string, unknown> 44 + dark: Record<string, unknown> 45 + } 46 + 47 + export type ThemeProviderProps = { 48 + /** 49 + * Specifies the initial color scheme for your app. 50 + * 51 + * `"auto" | "dark" | "light"` 52 + */ 53 + colorScheme?: ColorScheme 54 + /** 55 + * This is used to generate a fallback palette in case the platform does not support Material You colors. 56 + * 57 + * **Note:** provide a color only in HEX format 58 + */ 59 + fallbackColor?: string 60 + /** 61 + * If set to "auto", it tries to get the palette from the device, falling back to the provided color if unsupported. If set to a 62 + * color (HEX only), it generates a new palette without device retrieval. 63 + */ 64 + seedColor?: 'auto' | (string & NonNullable<unknown>) 65 + /** 66 + * Palette generation style. The style that dictates how the palette will be generated. 67 + * 68 + * `"SPRITZ"| "TONAL_SPOT"| "VIBRANT"| "EXPRESSIVE"| "RAINBOW"| "FRUIT_SALAD"| "CONTENT"| "MONOCHROMATIC"` 69 + */ 70 + generationStyle?: GenerationStyle 71 + children?: React.ReactNode 72 + } 73 + 74 + export type MaterialYouThemeContext = { 75 + /** Switch between themes (`dark` or `light`) or set to `auto` to follow system color scheme preference. */ 76 + setColorScheme: (value: ColorScheme) => void 77 + /** 78 + * Generate a new Material You palette and set it as the current theme. 79 + * 80 + * @param {string} seed 81 + * 82 + * - The seed color. It can be `"auto"` to follow the system theme if supported; otherwise, it will generate a palette using the 83 + * `fallbackColor` prop. If a HEX color is provided, it will generate a new palette using that seed color. 84 + * 85 + * @param {string} style - The style that dictates how the palette will be generated. 86 + */ 87 + setMaterialYouColor: ( 88 + seed: 'auto' | (string & NonNullable<unknown>), 89 + style?: GenerationStyle, 90 + ) => void 91 + /** 92 + * Change the palette generation style and set it as the current theme. 93 + * 94 + * **Disclaimer**: If the current Material You palette is set to `"auto"` (following the system theme), a new palette will be 95 + * generated using the `fallbackColor` prop. 96 + * 97 + * @param {string} style - The style that dictates how the palette will be generated. 98 + */ 99 + setPaletteStyle: (style: GenerationStyle) => void 100 + /** The current seed color used to generate the palette. If the palette follows the system theme, it will be `"auto"`. */ 101 + seedColor: 'auto' | (string & NonNullable<unknown>) 102 + /** The current generation style used to generate the palette */ 103 + style: GenerationStyle 104 + /** The current palette. */ 105 + palette: MaterialYouPalette 106 + }
+18
src/alf/util/material3/Utils/Color.ts
··· 1 + export default class Color { 2 + static TRANSPARENT = 0 3 + 4 + static clampRGB = (value: number) => Color.clamp(Math.round(value), 0, 255) 5 + static clamp = (value: number, min: number, max: number) => 6 + Math.max(min, Math.min(value, max)) 7 + 8 + static red = (color: number) => (color >> 16) & 0xff 9 + static green = (color: number) => (color >> 8) & 0xff 10 + static blue = (color: number) => color & 0xff 11 + 12 + static rgb(red: number, green: number, blue: number) { 13 + red = Color.clampRGB(red) 14 + green = Color.clampRGB(green) 15 + blue = Color.clampRGB(blue) 16 + return 0xff000000 | (red << 16) | (green << 8) | blue 17 + } 18 + }
+57
src/alf/util/material3/Utils/ColorUtils.ts
··· 1 + import {Cam} from '../Cam/Cam' 2 + import Color from './Color' 3 + 4 + export default class ColorUtils { 5 + static XYZ_WHITE_REFERENCE_X = 95.047 6 + static XYZ_WHITE_REFERENCE_Y = 100 7 + static XYZ_WHITE_REFERENCE_Z = 108.883 8 + 9 + static constrain(amount: number, low: number, high: number) { 10 + return amount < low ? low : amount > high ? high : amount 11 + } 12 + 13 + /** 14 + * Converts a color from CIE XYZ to its RGB representation. 15 + * 16 + * This method expects the XYZ representation to use the D65 illuminant and the CIE 2° Standard Observer (1931). 17 + * 18 + * @param x X component value [0...95.047) 19 + * @param y Y component value [0...100) 20 + * @param z Z component value [0...108.883) 21 + * @returns Int containing the RGB representation 22 + */ 23 + static XYZToColor(x: number, y: number, z: number) { 24 + x = Color.clamp(x, 0, ColorUtils.XYZ_WHITE_REFERENCE_X) 25 + y = Color.clamp(y, 0, ColorUtils.XYZ_WHITE_REFERENCE_Y) 26 + z = Color.clamp(z, 0, ColorUtils.XYZ_WHITE_REFERENCE_Z) 27 + 28 + let r = (x * 3.2406 + y * -1.5372 + z * -0.4986) / 100 29 + let g = (x * -0.9689 + y * 1.8758 + z * 0.0415) / 100 30 + let b = (x * 0.0557 + y * -0.204 + z * 1.057) / 100 31 + 32 + r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r 33 + g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g 34 + b = b > 0.0031308 ? 1.055 * Math.pow(b, 1 / 2.4) - 0.055 : 12.92 * b 35 + 36 + return Color.rgb( 37 + ColorUtils.constrain(Math.round(r * 255), 0, 255), 38 + ColorUtils.constrain(Math.round(g * 255), 0, 255), 39 + ColorUtils.constrain(Math.round(b * 255), 0, 255), 40 + ) 41 + } 42 + 43 + /** 44 + * Convert a color appearance model representation to an ARGB color. 45 + * 46 + * Note: the returned color may have a lower chroma than requested. Whether a chroma is available depends on luminance. For 47 + * example, there's no such thing as a high chroma light red, due to the limitations of our eyes and/or physics. If the 48 + * requested chroma is unavailable, the highest possible chroma at the requested luminance is returned. 49 + * 50 + * @param hue Hue, in degrees, in CAM coordinates 51 + * @param chroma Chroma in CAM coordinates. 52 + * @param lstar Perceptual luminance, L* in L_a_b* 53 + */ 54 + static CAMToColor(hue: number, chroma: number, lstar: number) { 55 + return Cam.getInt(hue, chroma, lstar) 56 + } 57 + }
+11
src/alf/util/material3/Utils/MathUtils.ts
··· 1 + class MathUtils { 2 + static lerp(start: number, stop: number, amount: number) { 3 + return start + (stop - start) * amount 4 + } 5 + 6 + static toRadians(degrees: number) { 7 + return degrees * (Math.PI / 180) 8 + } 9 + } 10 + 11 + export default MathUtils
+32
src/alf/util/material3/index.tsx
··· 1 + // https://github.com/alabsi91/react-native-material-you-colors 2 + /*! 3 + MIT License 4 + 5 + Copyright (c) 2023 Ahmed ALABSI 6 + Permission is hereby granted, free of charge, to any person obtaining a copy 7 + of this software and associated documentation files (the "Software"), to deal 8 + in the Software without restriction, including without limitation the rights 9 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 + copies of the Software, and to permit persons to whom the Software is 11 + furnished to do so, subject to the following conditions: 12 + 13 + The above copyright notice and this permission notice shall be included in all 14 + copies or substantial portions of the Software. 15 + 16 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 + SOFTWARE. 23 + */ 24 + 25 + import Palette from './Monet/Palette' 26 + import {type GenerationStyle} from './Types' 27 + 28 + export type {GenerationStyle} 29 + export const generatePaletteFromColor = ( 30 + colorSeed: string, 31 + style?: GenerationStyle, 32 + ) => Palette.generate(colorSeed, style)
+175
src/alf/util/material3Theme.ts
··· 1 + import {type MaterialYouPalette} from '@assembless/react-native-material-you' 2 + import {createThemes} from '@bsky.app/alf' 3 + 4 + import {type Palette, STATIC_VALUES} from '../themes' 5 + import {getMaterialYouColor} from './materialYou' 6 + 7 + export function getMaterial3Colors(palette: MaterialYouPalette) { 8 + const MATERIAL_3_PALETTE: Palette = { 9 + white: getMaterialYouColor(palette, 'system_neutral1', 0), 10 + black: getMaterialYouColor(palette, 'system_neutral1', 1000), 11 + pink: getMaterialYouColor(palette, 'system_accent3', 500), 12 + yellow: getMaterialYouColor( 13 + palette, 14 + 'system_error', 15 + 200, 16 + STATIC_VALUES.yellow, 17 + ), 18 + like: getMaterialYouColor(palette, 'system_accent3', 500), 19 + 20 + contrast_0: getMaterialYouColor(palette, 'system_neutral1', 0), 21 + contrast_25: getMaterialYouColor(palette, 'system_neutral1', 10), 22 + contrast_50: getMaterialYouColor(palette, 'system_neutral1', 50), 23 + contrast_100: getMaterialYouColor(palette, 'system_neutral1', 100), 24 + contrast_200: getMaterialYouColor(palette, 'system_neutral1', 200), 25 + contrast_300: getMaterialYouColor(palette, 'system_neutral1', 300), 26 + contrast_400: getMaterialYouColor(palette, 'system_neutral1', 400), 27 + contrast_500: getMaterialYouColor(palette, 'system_neutral1', 500), 28 + contrast_600: getMaterialYouColor(palette, 'system_neutral1', 600), 29 + contrast_700: getMaterialYouColor(palette, 'system_neutral1', 700), 30 + contrast_800: getMaterialYouColor(palette, 'system_neutral1', 800), 31 + contrast_900: getMaterialYouColor(palette, 'system_neutral1', 900), 32 + contrast_950: getMaterialYouColor(palette, 'system_neutral1', 900), 33 + contrast_975: getMaterialYouColor(palette, 'system_neutral1', 900), 34 + contrast_1000: getMaterialYouColor(palette, 'system_neutral1', 1000), 35 + 36 + primary_25: getMaterialYouColor(palette, 'system_accent1', 10), 37 + primary_50: getMaterialYouColor(palette, 'system_accent1', 50), 38 + primary_100: getMaterialYouColor(palette, 'system_accent1', 100), 39 + primary_200: getMaterialYouColor(palette, 'system_accent1', 200), 40 + primary_300: getMaterialYouColor(palette, 'system_accent1', 300), 41 + primary_400: getMaterialYouColor(palette, 'system_accent1', 400), 42 + primary_500: getMaterialYouColor(palette, 'system_accent1', 500), 43 + primary_600: getMaterialYouColor(palette, 'system_accent1', 600), 44 + primary_700: getMaterialYouColor(palette, 'system_accent1', 700), 45 + primary_800: getMaterialYouColor(palette, 'system_accent1', 800), 46 + primary_900: getMaterialYouColor(palette, 'system_accent1', 900), 47 + primary_950: getMaterialYouColor(palette, 'system_accent1', 900), 48 + primary_975: getMaterialYouColor(palette, 'system_accent1', 1000), 49 + 50 + positive_25: getMaterialYouColor(palette, 'system_accent2', 10), 51 + positive_50: getMaterialYouColor(palette, 'system_accent2', 50), 52 + positive_100: getMaterialYouColor(palette, 'system_accent2', 100), 53 + positive_200: getMaterialYouColor(palette, 'system_accent2', 200), 54 + positive_300: getMaterialYouColor(palette, 'system_accent2', 300), 55 + positive_400: getMaterialYouColor(palette, 'system_accent2', 400), 56 + positive_500: getMaterialYouColor(palette, 'system_accent2', 500), 57 + positive_600: getMaterialYouColor(palette, 'system_accent2', 600), 58 + positive_700: getMaterialYouColor(palette, 'system_accent2', 700), 59 + positive_800: getMaterialYouColor(palette, 'system_accent2', 800), 60 + positive_900: getMaterialYouColor(palette, 'system_accent2', 900), 61 + positive_950: getMaterialYouColor(palette, 'system_accent2', 900), 62 + positive_975: getMaterialYouColor(palette, 'system_accent2', 1000), 63 + 64 + negative_25: getMaterialYouColor(palette, 'system_error', 10, '#FFF5F7'), 65 + negative_50: getMaterialYouColor(palette, 'system_error', 50, '#FEEBEF'), 66 + negative_100: getMaterialYouColor(palette, 'system_error', 100, '#FDD8E1'), 67 + negative_200: getMaterialYouColor(palette, 'system_error', 200, '#FCC0CE'), 68 + negative_300: getMaterialYouColor(palette, 'system_error', 300, '#F99AB0'), 69 + negative_400: getMaterialYouColor(palette, 'system_error', 400, '#F76486'), 70 + negative_500: getMaterialYouColor(palette, 'system_error', 500, '#EB2452'), 71 + negative_600: getMaterialYouColor(palette, 'system_error', 600, '#D81341'), 72 + negative_700: getMaterialYouColor(palette, 'system_error', 700, '#BA1239'), 73 + negative_800: getMaterialYouColor(palette, 'system_error', 800, '#910D2C'), 74 + negative_900: getMaterialYouColor(palette, 'system_error', 900, '#6F0B22'), 75 + negative_950: getMaterialYouColor(palette, 'system_error', 900, '#500B1C'), 76 + negative_975: getMaterialYouColor(palette, 'system_error', 1000, '#3E0915'), 77 + } 78 + 79 + const MATERIAL_3_SUBDUED_PALETTE: Palette = { 80 + white: getMaterialYouColor(palette, 'system_neutral1', 50), 81 + black: getMaterialYouColor(palette, 'system_neutral1', 900), 82 + pink: getMaterialYouColor(palette, 'system_accent3', 500), 83 + yellow: getMaterialYouColor( 84 + palette, 85 + 'system_error', 86 + 200, 87 + STATIC_VALUES.yellow, 88 + ), 89 + like: getMaterialYouColor(palette, 'system_accent3', 500), 90 + 91 + contrast_0: getMaterialYouColor(palette, 'system_neutral1', 50), 92 + contrast_25: getMaterialYouColor(palette, 'system_neutral1', 50), 93 + contrast_50: getMaterialYouColor(palette, 'system_neutral1', 50), 94 + contrast_100: getMaterialYouColor(palette, 'system_neutral1', 100), 95 + contrast_200: getMaterialYouColor(palette, 'system_neutral1', 100), 96 + contrast_300: getMaterialYouColor(palette, 'system_neutral1', 200), 97 + contrast_400: getMaterialYouColor(palette, 'system_neutral1', 300), 98 + contrast_500: getMaterialYouColor(palette, 'system_neutral1', 400), 99 + contrast_600: getMaterialYouColor(palette, 'system_neutral1', 400), 100 + contrast_700: getMaterialYouColor(palette, 'system_neutral1', 500), 101 + contrast_800: getMaterialYouColor(palette, 'system_neutral1', 600), 102 + contrast_900: getMaterialYouColor(palette, 'system_neutral1', 700), 103 + contrast_950: getMaterialYouColor(palette, 'system_neutral1', 800), 104 + contrast_975: getMaterialYouColor(palette, 'system_neutral1', 800), 105 + contrast_1000: getMaterialYouColor(palette, 'system_neutral1', 900), 106 + 107 + primary_25: getMaterialYouColor(palette, 'system_accent1', 10), 108 + primary_50: getMaterialYouColor(palette, 'system_accent1', 50), 109 + primary_100: getMaterialYouColor(palette, 'system_accent1', 100), 110 + primary_200: getMaterialYouColor(palette, 'system_accent1', 200), 111 + primary_300: getMaterialYouColor(palette, 'system_accent1', 300), 112 + primary_400: getMaterialYouColor(palette, 'system_accent1', 400), 113 + primary_500: getMaterialYouColor(palette, 'system_accent1', 400), 114 + primary_600: getMaterialYouColor(palette, 'system_accent1', 500), 115 + primary_700: getMaterialYouColor(palette, 'system_accent1', 600), 116 + primary_800: getMaterialYouColor(palette, 'system_accent1', 700), 117 + primary_900: getMaterialYouColor(palette, 'system_accent1', 800), 118 + primary_950: getMaterialYouColor(palette, 'system_accent1', 800), 119 + primary_975: getMaterialYouColor(palette, 'system_accent1', 900), 120 + 121 + positive_25: getMaterialYouColor(palette, 'system_accent2', 10), 122 + positive_50: getMaterialYouColor(palette, 'system_accent2', 50), 123 + positive_100: getMaterialYouColor(palette, 'system_accent2', 100), 124 + positive_200: getMaterialYouColor(palette, 'system_accent2', 200), 125 + positive_300: getMaterialYouColor(palette, 'system_accent2', 300), 126 + positive_400: getMaterialYouColor(palette, 'system_accent2', 400), 127 + positive_500: getMaterialYouColor(palette, 'system_accent2', 400), 128 + positive_600: getMaterialYouColor(palette, 'system_accent2', 500), 129 + positive_700: getMaterialYouColor(palette, 'system_accent2', 600), 130 + positive_800: getMaterialYouColor(palette, 'system_accent2', 700), 131 + positive_900: getMaterialYouColor(palette, 'system_accent2', 800), 132 + positive_950: getMaterialYouColor(palette, 'system_accent2', 900), 133 + positive_975: getMaterialYouColor(palette, 'system_accent2', 900), 134 + 135 + negative_25: getMaterialYouColor(palette, 'system_error', 10, '#FFF5F7'), 136 + negative_50: getMaterialYouColor(palette, 'system_error', 50, '#FEEBEF'), 137 + negative_100: getMaterialYouColor(palette, 'system_error', 100, '#FDD8E1'), 138 + negative_200: getMaterialYouColor(palette, 'system_error', 200, '#FCC0CE'), 139 + negative_300: getMaterialYouColor(palette, 'system_error', 300, '#F99AB0'), 140 + negative_400: getMaterialYouColor(palette, 'system_error', 400, '#F76486'), 141 + negative_500: getMaterialYouColor(palette, 'system_error', 400, '#EB2452'), 142 + negative_600: getMaterialYouColor(palette, 'system_error', 500, '#D81341'), 143 + negative_700: getMaterialYouColor(palette, 'system_error', 600, '#BA1239'), 144 + negative_800: getMaterialYouColor(palette, 'system_error', 700, '#910D2C'), 145 + negative_900: getMaterialYouColor(palette, 'system_error', 800, '#6F0B22'), 146 + negative_950: getMaterialYouColor(palette, 'system_error', 800, '#500B1C'), 147 + negative_975: getMaterialYouColor(palette, 'system_error', 900, '#3E0915'), 148 + } 149 + 150 + const MATERIAL_3_THEMES = createThemes({ 151 + defaultPalette: MATERIAL_3_PALETTE, 152 + subduedPalette: MATERIAL_3_SUBDUED_PALETTE, 153 + }) 154 + 155 + // special case for disabled (primary) button text. we have a hack for primary_subtle in Button.tsx 156 + MATERIAL_3_THEMES.dark.atoms.text_inverted.color = 157 + MATERIAL_3_THEMES.dark.palette.primary_400 158 + MATERIAL_3_THEMES.dim.atoms.text_inverted.color = 159 + MATERIAL_3_THEMES.dim.palette.primary_400 160 + 161 + const material3scheme = { 162 + lightPalette: MATERIAL_3_THEMES.light.palette, 163 + darkPalette: MATERIAL_3_THEMES.dark.palette, 164 + dimPalette: MATERIAL_3_THEMES.dim.palette, 165 + light: MATERIAL_3_THEMES.light, 166 + dark: MATERIAL_3_THEMES.dark, 167 + dim: MATERIAL_3_THEMES.dim, 168 + } 169 + 170 + return { 171 + regular: MATERIAL_3_PALETTE, 172 + subdued: MATERIAL_3_SUBDUED_PALETTE, 173 + scheme: material3scheme, 174 + } 175 + }
+16 -10
src/alf/util/materialYou.android.tsx
··· 1 - import {createContext, useEffect, useState} from 'react' 1 + import {createContext, use, useEffect, useState} from 'react' 2 2 import {AppState} from 'react-native' 3 3 import { 4 4 getPalette, ··· 7 7 } from '@assembless/react-native-material-you' 8 8 9 9 let palette = getPaletteSync() as MaterialYouPalette 10 + 10 11 export function getMaterialYouColor( 12 + palette: MaterialYouPalette, 11 13 color: [ 12 14 'system_accent1', 13 15 'system_accent2', 14 16 'system_accent3', 15 17 'system_neutral1', 16 18 'system_neutral2', 19 + 'system_error', 17 20 ][number], 18 21 shade: [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000][number], 22 + fallback: string = '#000000', 19 23 ): string { 20 24 const shades = [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] 21 25 let shadeIndex = shades.findIndex(s => s === shade) ··· 25 29 ) 26 30 } 27 31 28 - return palette[color][shadeIndex] 32 + return palette[color]?.[shadeIndex] || fallback 29 33 } 30 34 31 35 const colorsChangedCallbacks = new Set<() => void>() ··· 36 40 // check if colors changed 37 41 const colorsChanged = Object.keys(newPalette).some(key => { 38 42 const colorKey = key as keyof MaterialYouPalette 39 - return newPalette[colorKey].some( 40 - (color, index) => color !== palette[colorKey][index], 43 + return newPalette[colorKey]?.some( 44 + (color, index) => color !== palette[colorKey]?.[index], 41 45 ) 42 46 }) 43 47 if (colorsChanged) { ··· 52 56 }) 53 57 }) 54 58 55 - export function onMaterialYouPaletteChange(callback: () => void) { 59 + function onMaterialYouPaletteChange(callback: () => void) { 56 60 colorsChangedCallbacks.add(callback) 57 61 return () => { 58 62 colorsChangedCallbacks.delete(callback) ··· 61 65 62 66 const PaletteProvider = createContext(palette) 63 67 64 - // context forces a rerender when palette changes even if nothing uses it (because unfortunately, we're forced to use it 65 - // via Alf) 66 68 export function MaterialYouPaletteProvider({ 67 69 children, 68 70 }: React.PropsWithChildren) { 69 - const [_palette, setPalette] = useState(palette) 71 + const [thePalette, setThePalette] = useState(palette) 70 72 useEffect(() => { 71 73 const unsubscribe = onMaterialYouPaletteChange(() => { 72 - setPalette(() => palette) 74 + setThePalette(() => palette) 73 75 }) 74 76 75 77 return unsubscribe 76 78 }, []) 77 79 78 80 return ( 79 - <PaletteProvider.Provider value={_palette}> 81 + <PaletteProvider.Provider value={thePalette}> 80 82 {children} 81 83 </PaletteProvider.Provider> 82 84 ) 83 85 } 86 + 87 + export function useMaterialYouPalette() { 88 + return use(PaletteProvider) 89 + }
+40 -21
src/alf/util/materialYou.tsx
··· 1 - import {type JSX} from 'react' 1 + import {createContext, type JSX, use, useMemo} from 'react' 2 + import {type MaterialYouPalette} from '@assembless/react-native-material-you' 3 + 4 + import {generatePaletteFromColor, type GenerationStyle} from './material3' 2 5 3 6 export function getMaterialYouColor( 4 - _color: [ 7 + palette: MaterialYouPalette, 8 + color: [ 5 9 'system_accent1', 6 10 'system_accent2', 7 11 'system_accent3', 8 12 'system_neutral1', 9 13 'system_neutral2', 14 + 'system_error', 10 15 ][number], 11 - _shade: [ 12 - 0, 13 - 10, 14 - 50, 15 - 100, 16 - 200, 17 - 300, 18 - 400, 19 - 500, 20 - 600, 21 - 700, 22 - 800, 23 - 900, 24 - 1000, 25 - ][number], 16 + shade: [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000][number], 17 + fallback: string = '#000000', 26 18 ): string { 27 - return '#000000' 19 + const shades = [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] 20 + let shadeIndex = shades.findIndex(s => s === shade) 21 + if (shadeIndex === -1) { 22 + throw new Error( 23 + `Invalid shade: ${shade}. Valid shades are: ${shades.join(', ')}`, 24 + ) 25 + } 26 + 27 + return palette[color]?.[shadeIndex] || fallback 28 28 } 29 29 30 - export function onMaterialYouPaletteChange(_callback: () => void) {} 30 + const PaletteProvider = createContext( 31 + generatePaletteFromColor('#000000', 'TONAL_SPOT'), 32 + ) 31 33 32 34 export function MaterialYouPaletteProvider({ 35 + accent, 36 + style, 33 37 children, 34 - }: React.PropsWithChildren): JSX.Element { 35 - return <>{children}</> 38 + }: React.PropsWithChildren<{ 39 + accent: string 40 + style?: GenerationStyle 41 + }>): JSX.Element { 42 + const value = useMemo(() => { 43 + return generatePaletteFromColor(accent, style) 44 + }, [accent, style]) 45 + 46 + return ( 47 + <PaletteProvider.Provider value={value}> 48 + {children} 49 + </PaletteProvider.Provider> 50 + ) 36 51 } 52 + 53 + export function useMaterialYouPalette() { 54 + return use(PaletteProvider) 55 + }
+3 -5
src/lib/ThemeContext.tsx
··· 3 3 import {type TextStyle, type ViewStyle} from 'react-native' 4 4 import {type ThemeName} from '@bsky.app/alf' 5 5 6 - import {useThemePrefs} from '#/state/shell/color-mode' 7 - import {hueShifter, type SchemeType, selectScheme} from '#/alf' 6 + import {type SchemeType, useScheme} from '#/alf' 8 7 import {themes} from '#/alf/themes' 9 8 import {darkTheme, defaultTheme, dimTheme} from './themes' 10 9 ··· 124 123 theme, 125 124 children, 126 125 }) => { 127 - const {colorScheme, hue} = useThemePrefs() 126 + const currentScheme = useScheme() 128 127 129 128 const themeValue = useMemo(() => { 130 - const currentScheme = hueShifter(selectScheme(colorScheme), hue) 131 129 return getTheme(theme, currentScheme) 132 - }, [theme, colorScheme, hue]) 130 + }, [theme, currentScheme]) 133 131 134 132 return ( 135 133 <ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
+171 -26
src/screens/Settings/AppearanceSettings.tsx
··· 1 - import {useCallback} from 'react' 1 + import {useCallback, useMemo} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import Animated, { 4 4 FadeInUp, ··· 14 14 type CommonNavigatorParams, 15 15 type NativeStackScreenProps, 16 16 } from '#/lib/routes/types' 17 + import {type Schema} from '#/state/persisted' 17 18 import { 18 19 useEnableSquareAvatars, 19 20 useSetEnableSquareAvatars, ··· 34 35 DEFAULT_PALETTE, 35 36 EVERGARDEN_PALETTE, 36 37 KITTY_PALETTE, 37 - MATERIAL_3_PALETTE, 38 38 REDDWARF_PALETTE, 39 39 ZEPPELIN_PALETTE, 40 40 } from '#/alf/themes' 41 + import {getMaterial3Colors} from '#/alf/util/material3Theme' 42 + import {useMaterialYouPalette} from '#/alf/util/materialYou' 41 43 import * as SegmentedControl from '#/components/forms/SegmentedControl' 42 44 import {Slider} from '#/components/forms/Slider' 43 45 import * as Toggle from '#/components/forms/Toggle' ··· 83 85 const {fonts} = useAlf() 84 86 const t = useTheme() 85 87 86 - const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs() 87 - const {setColorMode, setColorScheme, setDarkTheme, setHue} = 88 - useSetThemePrefs() 88 + const { 89 + colorMode, 90 + colorScheme, 91 + darkTheme, 92 + hue, 93 + material3Accent, 94 + material3Style, 95 + } = useThemePrefs() 96 + const { 97 + setColorMode, 98 + setColorScheme, 99 + setDarkTheme, 100 + setHue, 101 + setMaterial3Accent, 102 + setMaterial3Style, 103 + } = useSetThemePrefs() 89 104 90 105 const kawaiiMode = useKawaiiMode() 91 106 const setKawaiiMode = useSetKawaiiMode() ··· 96 111 const enableSquareButtons = useEnableSquareButtons() 97 112 const setEnableSquareButtons = useSetEnableSquareButtons() 98 113 114 + const material3Palette = useMaterialYouPalette() 115 + const cachedScheme = useMemo( 116 + () => getMaterial3Colors(material3Palette), 117 + [material3Palette], 118 + ) 119 + 99 120 const onChangeAppearance = useCallback( 100 121 (value: 'light' | 'system' | 'dark') => { 101 122 setColorMode(value) ··· 177 198 label: _(msg`Evergarden`), 178 199 primary: EVERGARDEN_PALETTE.primary_500, 179 200 }, 180 - ...(IS_ANDROID 181 - ? [ 182 - { 183 - name: 'material3', 184 - label: _(msg`Material You`), 185 - primary: MATERIAL_3_PALETTE.primary_500, 186 - } satisfies ColorSchemeOption, 187 - ] 188 - : []), 201 + { 202 + name: 'material3', 203 + label: _(msg`Material You`), 204 + primary: cachedScheme.regular.primary_500, 205 + }, 189 206 ] 190 207 191 208 return ( ··· 260 277 selectedScheme={colorScheme} 261 278 onSchemeChange={onChangeScheme} 262 279 /> 263 - <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 264 - <Trans>Hue shift the colors:</Trans> 265 - </Text> 266 - <Slider 267 - value={hue} 268 - onValueChange={setHue} 269 - minimumValue={0} 270 - maximumValue={360} 271 - step={1} 272 - debounceFull={true} 273 - /> 280 + {colorScheme === 'material3' && !IS_ANDROID && ( 281 + <> 282 + <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 283 + <Trans>Accent hue:</Trans> 284 + </Text> 285 + <Slider 286 + value={hexToHue(material3Accent)} 287 + onValueChange={v => { 288 + setMaterial3Accent(hueToHex(v)) 289 + setHue(0) 290 + }} 291 + minimumValue={0} 292 + maximumValue={360} 293 + step={1} 294 + debounceFull={true} 295 + /> 296 + 297 + <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 298 + <Trans>Style:</Trans> 299 + </Text> 300 + <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}> 301 + {MATERIAL3_STYLE_OPTIONS.map(({name, label}) => { 302 + const isSelected = material3Style === name 303 + return ( 304 + <Pressable 305 + accessibilityRole="button" 306 + key={name} 307 + onPress={() => setMaterial3Style(name)} 308 + style={[ 309 + a.flex_1, 310 + a.rounded_sm, 311 + a.align_center, 312 + a.px_sm, 313 + a.py_sm, 314 + a.border, 315 + {minWidth: '22%'}, 316 + { 317 + borderColor: isSelected 318 + ? t.palette.primary_500 319 + : t.atoms.border_contrast_low.borderColor, 320 + borderWidth: 2, 321 + backgroundColor: isSelected 322 + ? t.palette.primary_100 323 + : t.atoms.bg.backgroundColor, 324 + }, 325 + ]}> 326 + <Text 327 + style={[ 328 + a.text_xs, 329 + a.font_bold, 330 + isSelected 331 + ? {color: t.palette.primary_500} 332 + : t.atoms.text, 333 + ]}> 334 + {label} 335 + </Text> 336 + </Pressable> 337 + ) 338 + })} 339 + </View> 340 + </> 341 + )} 342 + {colorScheme !== 'material3' && ( 343 + <> 344 + <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 345 + <Trans>Hue shift the colors:</Trans> 346 + </Text> 347 + <Slider 348 + value={hue} 349 + onValueChange={setHue} 350 + minimumValue={0} 351 + maximumValue={360} 352 + step={1} 353 + debounceFull={true} 354 + /> 355 + </> 356 + )} 274 357 </View> 275 358 </SettingsList.Group> 276 359 ··· 419 502 borderColor: isSelected 420 503 ? primary 421 504 : t.atoms.border_contrast_low.borderColor, 422 - borderWidth: isSelected ? 2 : 1, 505 + borderWidth: 2, 423 506 }, 424 507 ]}> 425 508 <View ··· 510 593 </> 511 594 ) 512 595 } 596 + 597 + const MATERIAL3_STYLE_OPTIONS: { 598 + name: Schema['material3Style'] 599 + label: string 600 + }[] = [ 601 + {name: 'TONAL_SPOT', label: 'Tonal Spot'}, 602 + {name: 'VIBRANT', label: 'Vibrant'}, 603 + {name: 'EXPRESSIVE', label: 'Expressive'}, 604 + {name: 'SPRITZ', label: 'Spritz'}, 605 + {name: 'RAINBOW', label: 'Rainbow'}, 606 + {name: 'FRUIT_SALAD', label: 'Fruit Salad'}, 607 + {name: 'CONTENT', label: 'Content'}, 608 + {name: 'MONOCHROMATIC', label: 'Mono'}, 609 + ] 610 + 611 + function hueToHex(hue: number): string { 612 + const h = hue / 60 613 + const x = 1 - Math.abs((h % 2) - 1) 614 + let r = 0, 615 + g = 0, 616 + b = 0 617 + if (h < 1) { 618 + r = 1 619 + g = x 620 + } else if (h < 2) { 621 + r = x 622 + g = 1 623 + } else if (h < 3) { 624 + g = 1 625 + b = x 626 + } else if (h < 4) { 627 + g = x 628 + b = 1 629 + } else if (h < 5) { 630 + r = x 631 + b = 1 632 + } else { 633 + r = 1 634 + b = x 635 + } 636 + const toHex = (v: number) => 637 + Math.round(v * 255) 638 + .toString(16) 639 + .padStart(2, '0') 640 + return `#${toHex(r)}${toHex(g)}${toHex(b)}` 641 + } 642 + 643 + function hexToHue(hex: string): number { 644 + const r = parseInt(hex.slice(1, 3), 16) / 255 645 + const g = parseInt(hex.slice(3, 5), 16) / 255 646 + const b = parseInt(hex.slice(5, 7), 16) / 255 647 + const max = Math.max(r, g, b) 648 + const min = Math.min(r, g, b) 649 + const d = max - min 650 + if (d === 0) return 0 651 + let h = 0 652 + if (max === r) h = ((g - b) / d) % 6 653 + else if (max === g) h = (b - r) / d + 2 654 + else h = (r - g) / d + 4 655 + h = Math.round(h * 60) 656 + return h < 0 ? h + 360 : h 657 + }
+20
src/state/persisted/schema.ts
··· 64 64 'material3', 65 65 ]), 66 66 hue: z.number(), 67 + material3Accent: z 68 + .string() 69 + .regex(/^#[0-9a-fA-F]{6}$/, { 70 + message: 71 + 'Invalid color format. Must be a 7-character hex code (e.g., #RRGGBB).', 72 + }) 73 + .default('#ee6300'), 74 + material3Style: z 75 + .enum([ 76 + 'SPRITZ', 77 + 'TONAL_SPOT', 78 + 'VIBRANT', 79 + 'EXPRESSIVE', 80 + 'RAINBOW', 81 + 'FRUIT_SALAD', 82 + 'CONTENT', 83 + 'MONOCHROMATIC', 84 + ]) 85 + .default('TONAL_SPOT'), 67 86 session: z.object({ 68 87 accounts: z.array(accountSchema), 69 88 currentAccount: currentAccountSchema.optional(), ··· 225 244 darkTheme: 'dim', 226 245 colorScheme: 'witchsky', 227 246 hue: 0, 247 + material3Accent: '#ee6300', 228 248 session: { 229 249 accounts: [], 230 250 currentAccount: undefined,
+47 -7
src/state/shell/color-mode.tsx
··· 14 14 darkTheme: persisted.Schema['darkTheme'] 15 15 colorScheme: persisted.Schema['colorScheme'] 16 16 hue: persisted.Schema['hue'] 17 + material3Accent: persisted.Schema['material3Accent'] 18 + material3Style: persisted.Schema['material3Style'] 17 19 } 18 20 type SetContext = { 19 21 setColorMode: (v: persisted.Schema['colorMode']) => void 20 22 setDarkTheme: (v: persisted.Schema['darkTheme']) => void 21 23 setColorScheme: (v: persisted.Schema['colorScheme']) => void 22 24 setHue: (v: persisted.Schema['hue']) => void 25 + setMaterial3Accent: (v: persisted.Schema['material3Accent']) => void 26 + setMaterial3Style: (v: persisted.Schema['material3Style']) => void 23 27 } 24 28 25 29 const stateContext = createContext<StateContext>({ ··· 27 31 darkTheme: 'dark', 28 32 colorScheme: 'witchsky', 29 33 hue: 0, 34 + material3Accent: '#ee6300', 35 + material3Style: 'TONAL_SPOT', 30 36 }) 31 37 stateContext.displayName = 'ColorModeStateContext' 32 38 const setContext = createContext<SetContext>({} as SetContext) ··· 35 41 export function Provider({children}: PropsWithChildren<{}>) { 36 42 const [colorMode, setColorMode] = useState(() => persisted.get('colorMode')) 37 43 const [darkTheme, setDarkTheme] = useState(() => persisted.get('darkTheme')) 38 - const [colorScheme, setColorScheme] = useState(persisted.get('colorScheme')) 39 - const [hue, setHue] = useState(persisted.get('hue')) 44 + const [colorScheme, setColorScheme] = useState(() => 45 + persisted.get('colorScheme'), 46 + ) 47 + const [hue, setHue] = useState(() => persisted.get('hue')) 48 + const [material3Accent, setMaterial3Accent] = useState( 49 + () => persisted.get('material3Accent') ?? '#ee6300', 50 + ) 51 + const [material3Style, setMaterial3Style] = useState( 52 + () => persisted.get('material3Style') ?? 'TONAL_SPOT', 53 + ) 40 54 41 55 const stateContextValue = useMemo( 42 56 () => ({ ··· 44 58 darkTheme, 45 59 colorScheme, 46 60 hue, 61 + material3Accent, 62 + material3Style, 47 63 }), 48 - [colorMode, darkTheme, colorScheme, hue], 64 + [colorMode, darkTheme, colorScheme, hue, material3Accent, material3Style], 49 65 ) 50 66 51 67 const setContextValue = useMemo( 52 68 () => ({ 53 69 setColorMode: (_colorMode: persisted.Schema['colorMode']) => { 54 70 setColorMode(_colorMode) 55 - persisted.write('colorMode', _colorMode) 71 + void persisted.write('colorMode', _colorMode) 56 72 }, 57 73 setDarkTheme: (_darkTheme: persisted.Schema['darkTheme']) => { 58 74 setDarkTheme(_darkTheme) 59 - persisted.write('darkTheme', _darkTheme) 75 + void persisted.write('darkTheme', _darkTheme) 60 76 }, 61 77 setColorScheme: (_colorScheme: persisted.Schema['colorScheme']) => { 62 78 setColorScheme(_colorScheme) 63 - persisted.write('colorScheme', _colorScheme) 79 + void persisted.write('colorScheme', _colorScheme) 64 80 }, 65 81 setHue: (_hue: persisted.Schema['hue']) => { 66 82 setHue(_hue) 67 - persisted.write('hue', _hue) 83 + void persisted.write('hue', _hue) 84 + }, 85 + setMaterial3Accent: ( 86 + _material3Accent: persisted.Schema['material3Accent'], 87 + ) => { 88 + setMaterial3Accent(_material3Accent) 89 + void persisted.write('material3Accent', _material3Accent) 90 + }, 91 + setMaterial3Style: (_material3Style: persisted.Schema['material3Style']) => { 92 + setMaterial3Style(_material3Style) 93 + void persisted.write('material3Style', _material3Style) 68 94 }, 69 95 }), 70 96 [], ··· 83 109 const unsub4 = persisted.onUpdate('hue', nextHue => { 84 110 setHue(nextHue) 85 111 }) 112 + const unsub5 = persisted.onUpdate( 113 + 'material3Accent', 114 + nextMaterial3Accent => { 115 + setMaterial3Accent(nextMaterial3Accent) 116 + }, 117 + ) 118 + const unsub6 = persisted.onUpdate( 119 + 'material3Style', 120 + nextMaterial3Style => { 121 + setMaterial3Style(nextMaterial3Style) 122 + }, 123 + ) 86 124 return () => { 87 125 unsub1() 88 126 unsub2() 89 127 unsub3() 90 128 unsub4() 129 + unsub5() 130 + unsub6() 91 131 } 92 132 }, []) 93 133