Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Material You theme for iOS and Web

authored by

uwx and committed by
Tangled
894fdcec f1473b61

+2612 -289
+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: getMaterialYouColor('system_neutral1', 0), 1397 - black: getMaterialYouColor('system_neutral1', 1000), 1398 - pink: getMaterialYouColor('system_accent3', 500), 1399 - yellow: getMaterialYouColor('system_error', 200, STATIC_VALUES.yellow), 1400 - like: getMaterialYouColor('system_accent3', 500), 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', 1000), 1431 - 1432 - positive_25: getMaterialYouColor('system_accent2', 10), 1433 - positive_50: getMaterialYouColor('system_accent2', 50), 1434 - positive_100: getMaterialYouColor('system_accent2', 100), 1435 - positive_200: getMaterialYouColor('system_accent2', 200), 1436 - positive_300: getMaterialYouColor('system_accent2', 300), 1437 - positive_400: getMaterialYouColor('system_accent2', 400), 1438 - positive_500: getMaterialYouColor('system_accent2', 500), 1439 - positive_600: getMaterialYouColor('system_accent2', 600), 1440 - positive_700: getMaterialYouColor('system_accent2', 700), 1441 - positive_800: getMaterialYouColor('system_accent2', 800), 1442 - positive_900: getMaterialYouColor('system_accent2', 900), 1443 - positive_950: getMaterialYouColor('system_accent2', 900), 1444 - positive_975: getMaterialYouColor('system_accent2', 1000), 1445 - 1446 - negative_25: getMaterialYouColor('system_error', 10, '#FFF5F7'), 1447 - negative_50: getMaterialYouColor('system_error', 50, '#FEEBEF'), 1448 - negative_100: getMaterialYouColor('system_error', 100, '#FDD8E1'), 1449 - negative_200: getMaterialYouColor('system_error', 200, '#FCC0CE'), 1450 - negative_300: getMaterialYouColor('system_error', 300, '#F99AB0'), 1451 - negative_400: getMaterialYouColor('system_error', 400, '#F76486'), 1452 - negative_500: getMaterialYouColor('system_error', 500, '#EB2452'), 1453 - negative_600: getMaterialYouColor('system_error', 600, '#D81341'), 1454 - negative_700: getMaterialYouColor('system_error', 700, '#BA1239'), 1455 - negative_800: getMaterialYouColor('system_error', 800, '#910D2C'), 1456 - negative_900: getMaterialYouColor('system_error', 900, '#6F0B22'), 1457 - negative_950: getMaterialYouColor('system_error', 900, '#500B1C'), 1458 - negative_975: getMaterialYouColor('system_error', 1000, '#3E0915'), 1459 - } 1460 - 1461 - const MATERIAL_3_SUBDUED_PALETTE: Palette = { 1462 - white: getMaterialYouColor('system_neutral1', 50), 1463 - black: getMaterialYouColor('system_neutral1', 900), 1464 - pink: getMaterialYouColor('system_accent3', 500), 1465 - yellow: getMaterialYouColor('system_error', 200, STATIC_VALUES.yellow), 1466 - like: getMaterialYouColor('system_accent3', 500), 1467 - 1468 - contrast_0: getMaterialYouColor('system_neutral1', 50), 1469 - contrast_25: getMaterialYouColor('system_neutral1', 50), 1470 - contrast_50: getMaterialYouColor('system_neutral1', 50), 1471 - contrast_100: getMaterialYouColor('system_neutral1', 100), 1472 - contrast_200: getMaterialYouColor('system_neutral1', 100), 1473 - contrast_300: getMaterialYouColor('system_neutral1', 200), 1474 - contrast_400: getMaterialYouColor('system_neutral1', 300), 1475 - contrast_500: getMaterialYouColor('system_neutral1', 400), 1476 - contrast_600: getMaterialYouColor('system_neutral1', 400), 1477 - contrast_700: getMaterialYouColor('system_neutral1', 500), 1478 - contrast_800: getMaterialYouColor('system_neutral1', 600), 1479 - contrast_900: getMaterialYouColor('system_neutral1', 700), 1480 - contrast_950: getMaterialYouColor('system_neutral1', 800), 1481 - contrast_975: getMaterialYouColor('system_neutral1', 800), 1482 - contrast_1000: getMaterialYouColor('system_neutral1', 900), 1483 - 1484 - primary_25: getMaterialYouColor('system_accent1', 10), 1485 - primary_50: getMaterialYouColor('system_accent1', 50), 1486 - primary_100: getMaterialYouColor('system_accent1', 100), 1487 - primary_200: getMaterialYouColor('system_accent1', 200), 1488 - primary_300: getMaterialYouColor('system_accent1', 300), 1489 - primary_400: getMaterialYouColor('system_accent1', 400), 1490 - primary_500: getMaterialYouColor('system_accent1', 400), 1491 - primary_600: getMaterialYouColor('system_accent1', 500), 1492 - primary_700: getMaterialYouColor('system_accent1', 600), 1493 - primary_800: getMaterialYouColor('system_accent1', 700), 1494 - primary_900: getMaterialYouColor('system_accent1', 800), 1495 - primary_950: getMaterialYouColor('system_accent1', 800), 1496 - primary_975: getMaterialYouColor('system_accent1', 900), 1497 - 1498 - positive_25: getMaterialYouColor('system_accent2', 10), 1499 - positive_50: getMaterialYouColor('system_accent2', 50), 1500 - positive_100: getMaterialYouColor('system_accent2', 100), 1501 - positive_200: getMaterialYouColor('system_accent2', 200), 1502 - positive_300: getMaterialYouColor('system_accent2', 300), 1503 - positive_400: getMaterialYouColor('system_accent2', 400), 1504 - positive_500: getMaterialYouColor('system_accent2', 400), 1505 - positive_600: getMaterialYouColor('system_accent2', 500), 1506 - positive_700: getMaterialYouColor('system_accent2', 600), 1507 - positive_800: getMaterialYouColor('system_accent2', 700), 1508 - positive_900: getMaterialYouColor('system_accent2', 800), 1509 - positive_950: getMaterialYouColor('system_accent2', 900), 1510 - positive_975: getMaterialYouColor('system_accent2', 900), 1511 - 1512 - negative_25: getMaterialYouColor('system_error', 10, '#FFF5F7'), 1513 - negative_50: getMaterialYouColor('system_error', 50, '#FEEBEF'), 1514 - negative_100: getMaterialYouColor('system_error', 100, '#FDD8E1'), 1515 - negative_200: getMaterialYouColor('system_error', 200, '#FCC0CE'), 1516 - negative_300: getMaterialYouColor('system_error', 300, '#F99AB0'), 1517 - negative_400: getMaterialYouColor('system_error', 400, '#F76486'), 1518 - negative_500: getMaterialYouColor('system_error', 400, '#EB2452'), 1519 - negative_600: getMaterialYouColor('system_error', 500, '#D81341'), 1520 - negative_700: getMaterialYouColor('system_error', 600, '#BA1239'), 1521 - negative_800: getMaterialYouColor('system_error', 700, '#910D2C'), 1522 - negative_900: getMaterialYouColor('system_error', 800, '#6F0B22'), 1523 - negative_950: getMaterialYouColor('system_error', 800, '#500B1C'), 1524 - negative_975: getMaterialYouColor('system_error', 900, '#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 + }
+10 -7
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, ··· 9 9 let palette = getPaletteSync() as MaterialYouPalette 10 10 11 11 export function getMaterialYouColor( 12 + palette: MaterialYouPalette, 12 13 color: [ 13 14 'system_accent1', 14 15 'system_accent2', ··· 55 56 }) 56 57 }) 57 58 58 - export function onMaterialYouPaletteChange(callback: () => void) { 59 + function onMaterialYouPaletteChange(callback: () => void) { 59 60 colorsChangedCallbacks.add(callback) 60 61 return () => { 61 62 colorsChangedCallbacks.delete(callback) ··· 64 65 65 66 const PaletteProvider = createContext(palette) 66 67 67 - // context forces a rerender when palette changes even if nothing uses it (because unfortunately, we're forced to use it 68 - // via Alf) 69 68 export function MaterialYouPaletteProvider({ 70 69 children, 71 70 }: React.PropsWithChildren) { 72 - const [_palette, setPalette] = useState(palette) 71 + const [thePalette, setThePalette] = useState(palette) 73 72 useEffect(() => { 74 73 const unsubscribe = onMaterialYouPaletteChange(() => { 75 - setPalette(() => palette) 74 + setThePalette(() => palette) 76 75 }) 77 76 78 77 return unsubscribe 79 78 }, []) 80 79 81 80 return ( 82 - <PaletteProvider.Provider value={_palette}> 81 + <PaletteProvider.Provider value={thePalette}> 83 82 {children} 84 83 </PaletteProvider.Provider> 85 84 ) 86 85 } 86 + 87 + export function useMaterialYouPalette() { 88 + return use(PaletteProvider) 89 + }
+39 -22
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', ··· 9 13 'system_neutral2', 10 14 'system_error', 11 15 ][number], 12 - _shade: [ 13 - 0, 14 - 10, 15 - 50, 16 - 100, 17 - 200, 18 - 300, 19 - 400, 20 - 500, 21 - 600, 22 - 700, 23 - 800, 24 - 900, 25 - 1000, 26 - ][number], 27 - _fallback: string = '#000000', 16 + shade: [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000][number], 17 + fallback: string = '#000000', 28 18 ): string { 29 - 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 30 28 } 31 29 32 - export function onMaterialYouPaletteChange(_callback: () => void) {} 30 + const PaletteProvider = createContext( 31 + generatePaletteFromColor('#000000', 'TONAL_SPOT'), 32 + ) 33 33 34 34 export function MaterialYouPaletteProvider({ 35 + accent, 36 + style, 35 37 children, 36 - }: React.PropsWithChildren): JSX.Element { 37 - 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 + ) 38 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