hue shift slider #11

merged
opened by whey.party targeting main

this includes

  • a new slider component because bsky doesnt have one yet
  • culori (new dependency) to hue shift any color in oklch color space

ill send the video later

Changed files
+315 -13
src
alf
components
forms
lib
screens
state
persisted
shell
+2
package.json
··· 126 "babel-plugin-transform-remove-console": "^6.9.4", 127 "bcp-47": "^2.1.0", 128 "bcp-47-match": "^2.0.3", 129 "date-fns": "^2.30.0", 130 "email-validator": "^2.0.4", 131 "emoji-mart": "^5.5.2", ··· 235 "@sentry/webpack-plugin": "^3.2.2", 236 "@testing-library/jest-native": "^5.4.3", 237 "@testing-library/react-native": "^13.2.0", 238 "@types/jest": "29.5.14", 239 "@types/lodash.chunk": "^4.2.7", 240 "@types/lodash.debounce": "^4.0.7",
··· 126 "babel-plugin-transform-remove-console": "^6.9.4", 127 "bcp-47": "^2.1.0", 128 "bcp-47-match": "^2.0.3", 129 + "culori": "^4.0.2", 130 "date-fns": "^2.30.0", 131 "email-validator": "^2.0.4", 132 "emoji-mart": "^5.5.2", ··· 236 "@sentry/webpack-plugin": "^3.2.2", 237 "@testing-library/jest-native": "^5.4.3", 238 "@testing-library/react-native": "^13.2.0", 239 + "@types/culori": "^4.0.1", 240 "@types/jest": "29.5.14", 241 "@types/lodash.chunk": "^4.2.7", 242 "@types/lodash.debounce": "^4.0.7",
+80 -6
src/alf/index.tsx
··· 1 import React from 'react' 2 - import {type Theme, type ThemeName} from '@bsky.app/alf' 3 4 import {useThemePrefs} from '#/state/shell/color-mode' 5 import { ··· 14 blueskyscheme, 15 deerscheme, 16 kittyscheme, 17 reddwarfscheme, 18 themes, 19 witchskyscheme, ··· 68 69 export type SchemeType = typeof themes 70 71 export function selectScheme(colorScheme: string | undefined): SchemeType { 72 switch (colorScheme) { 73 case 'witchsky': ··· 93 children, 94 theme: themeName, 95 }: React.PropsWithChildren<{theme: ThemeName}>) { 96 - const {colorScheme} = useThemePrefs() 97 const currentScheme = selectScheme(colorScheme) 98 const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() => 99 getFontScale(), ··· 126 127 const value = React.useMemo<Alf>( 128 () => ({ 129 - themes: currentScheme, 130 themeName: themeName, 131 - theme: currentScheme[themeName], 132 fonts: { 133 scale: fontScale, 134 scaleMultiplier: fontScaleMultiplier, ··· 140 }), 141 [ 142 currentScheme, 143 themeName, 144 fontScale, 145 - setFontScaleAndPersist, 146 fontFamily, 147 setFontFamilyAndPersist, 148 - fontScaleMultiplier, 149 ], 150 ) 151
··· 1 import React from 'react' 2 + import {createTheme, type Theme, type ThemeName} from '@bsky.app/alf' 3 + import {formatHex, modeOklch, useMode as utilMode} from 'culori' 4 5 import {useThemePrefs} from '#/state/shell/color-mode' 6 import { ··· 15 blueskyscheme, 16 deerscheme, 17 kittyscheme, 18 + type Palette, 19 reddwarfscheme, 20 themes, 21 witchskyscheme, ··· 70 71 export type SchemeType = typeof themes 72 73 + function changeHue(color: string, hueShift: number) { 74 + if (!hueShift || hueShift === 0) return color 75 + 76 + let lablch = utilMode(modeOklch) 77 + const parsed = lablch(color) 78 + 79 + if (!parsed) return color 80 + 81 + const {l, c, h} = parsed as {l: number; c: number; h: number | undefined} 82 + 83 + const currentHue = h || 0 84 + 85 + const newHue = (currentHue + hueShift + 360) % 360 86 + 87 + return formatHex({mode: 'oklch', l, c, h: newHue}) 88 + } 89 + 90 + export function shiftPalette(palette: Palette, hueShift: number): Palette { 91 + const newPalette = {...palette} 92 + const keys = Object.keys(newPalette) as Array<keyof Palette> 93 + 94 + keys.forEach(key => { 95 + newPalette[key] = changeHue(newPalette[key], hueShift) 96 + }) 97 + 98 + return newPalette 99 + } 100 + 101 + export function hueShifter(scheme: SchemeType, hueShift: number): SchemeType { 102 + if (!hueShift || hueShift === 0) { 103 + return scheme 104 + } 105 + 106 + const lightPalette = shiftPalette(scheme.lightPalette, hueShift) 107 + const darkPalette = shiftPalette(scheme.darkPalette, hueShift) 108 + const dimPalette = shiftPalette(scheme.dimPalette, hueShift) 109 + 110 + const light = createTheme({ 111 + scheme: 'light', 112 + name: 'light', 113 + palette: lightPalette, 114 + }) 115 + 116 + const dark = createTheme({ 117 + scheme: 'dark', 118 + name: 'dark', 119 + palette: darkPalette, 120 + options: { 121 + shadowOpacity: 0.4, 122 + }, 123 + }) 124 + 125 + const dim = createTheme({ 126 + scheme: 'dark', 127 + name: 'dim', 128 + palette: dimPalette, 129 + options: { 130 + shadowOpacity: 0.4, 131 + }, 132 + }) 133 + 134 + return { 135 + lightPalette, 136 + darkPalette, 137 + dimPalette, 138 + light, 139 + dark, 140 + dim, 141 + } 142 + } 143 + 144 export function selectScheme(colorScheme: string | undefined): SchemeType { 145 switch (colorScheme) { 146 case 'witchsky': ··· 166 children, 167 theme: themeName, 168 }: React.PropsWithChildren<{theme: ThemeName}>) { 169 + const {colorScheme, hue} = useThemePrefs() 170 const currentScheme = selectScheme(colorScheme) 171 const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() => 172 getFontScale(), ··· 199 200 const value = React.useMemo<Alf>( 201 () => ({ 202 + themes: hueShifter(currentScheme, hue), 203 themeName: themeName, 204 + theme: hueShifter(currentScheme, hue)[themeName], 205 fonts: { 206 scale: fontScale, 207 scaleMultiplier: fontScaleMultiplier, ··· 213 }), 214 [ 215 currentScheme, 216 + hue, 217 themeName, 218 fontScale, 219 + fontScaleMultiplier, 220 fontFamily, 221 + setFontScaleAndPersist, 222 setFontFamilyAndPersist, 223 ], 224 ) 225
+187
src/components/forms/Slider.tsx
···
··· 1 + import {useCallback, useEffect, useRef, useState} from 'react' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 + import {Gesture, GestureDetector} from 'react-native-gesture-handler' 4 + import Animated, { 5 + runOnJS, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withSpring, 9 + } from 'react-native-reanimated' 10 + 11 + import {useHaptics} from '#/lib/haptics' 12 + import {atoms as a, platform, useTheme} from '#/alf' 13 + 14 + export interface SliderProps { 15 + value: number 16 + onValueChange: (value: number) => void 17 + min?: number 18 + max?: number 19 + step?: number 20 + label?: string 21 + accessibilityHint?: string 22 + style?: StyleProp<ViewStyle> 23 + debounce?: number 24 + } 25 + 26 + export function Slider({ 27 + value, 28 + onValueChange, 29 + min = 0, 30 + max = 100, 31 + step = 1, 32 + label, 33 + accessibilityHint, 34 + style, 35 + debounce, 36 + }: SliderProps) { 37 + const t = useTheme() 38 + const playHaptic = useHaptics() 39 + const timerRef = useRef<NodeJS.Timeout | undefined>(undefined) 40 + 41 + const [width, setWidth] = useState(0) 42 + 43 + const progress = useSharedValue(0) 44 + const isPressed = useSharedValue(false) 45 + 46 + useEffect(() => { 47 + if (!isPressed.value) { 48 + const clamped = Math.min(Math.max(value, min), max) 49 + const normalized = (clamped - min) / (max - min) 50 + progress.value = withSpring(normalized, {overshootClamping: true}) 51 + } 52 + }, [value, min, max, progress, isPressed]) 53 + 54 + useEffect(() => { 55 + return () => { 56 + if (timerRef.current) clearTimeout(timerRef.current) 57 + } 58 + }, []) 59 + 60 + const updateValueJS = useCallback( 61 + (val: number) => { 62 + if (debounce && debounce > 0) { 63 + if (timerRef.current) { 64 + clearTimeout(timerRef.current) 65 + } 66 + timerRef.current = setTimeout(() => { 67 + onValueChange(val) 68 + }, debounce) 69 + } else { 70 + onValueChange(val) 71 + } 72 + }, 73 + [onValueChange, debounce], 74 + ) 75 + 76 + const handleValueChange = useCallback( 77 + (newProgress: number) => { 78 + 'worklet' 79 + const rawValue = min + newProgress * (max - min) 80 + 81 + const steppedValue = Math.round(rawValue / step) * step 82 + const clamped = Math.min(Math.max(steppedValue, min), max) 83 + 84 + runOnJS(updateValueJS)(clamped) 85 + }, 86 + [min, max, step, updateValueJS], 87 + ) 88 + 89 + const pan = Gesture.Pan() 90 + .onBegin(e => { 91 + isPressed.value = true 92 + 93 + if (width > 0) { 94 + const newProgress = Math.min(Math.max(e.x / width, 0), 1) 95 + progress.value = newProgress 96 + handleValueChange(newProgress) 97 + } 98 + }) 99 + .onUpdate(e => { 100 + if (width === 0) return 101 + const newProgress = Math.min(Math.max(e.x / width, 0), 1) 102 + progress.value = newProgress 103 + handleValueChange(newProgress) 104 + }) 105 + .onFinalize(() => { 106 + isPressed.value = false 107 + runOnJS(playHaptic)('Light') 108 + }) 109 + 110 + const thumbAnimatedStyle = useAnimatedStyle(() => { 111 + const translateX = progress.value * width 112 + return { 113 + transform: [ 114 + {translateX: translateX - 12}, 115 + {scale: isPressed.value ? 1.1 : 1}, 116 + ], 117 + } 118 + }) 119 + 120 + const trackAnimatedStyle = useAnimatedStyle(() => { 121 + return { 122 + width: `${progress.value * 100}%`, 123 + } 124 + }) 125 + 126 + return ( 127 + <View 128 + style={[a.w_full, a.justify_center, {height: 28}, style]} 129 + accessibilityRole="adjustable" 130 + accessibilityLabel={label} 131 + accessibilityHint={accessibilityHint} 132 + accessibilityValue={{min, max, now: value}}> 133 + <GestureDetector gesture={pan}> 134 + <View 135 + style={[a.flex_1, a.justify_center, {cursor: 'pointer'}]} 136 + // @ts-ignore web-only style 137 + onLayout={e => setWidth(e.nativeEvent.layout.width)}> 138 + <View 139 + style={[ 140 + a.w_full, 141 + a.absolute, 142 + t.atoms.bg_contrast_50, 143 + {height: 4, borderRadius: 2}, 144 + ]} 145 + /> 146 + 147 + <Animated.View 148 + style={[ 149 + a.absolute, 150 + a.rounded_full, 151 + {height: 4, backgroundColor: t.palette.primary_500}, 152 + trackAnimatedStyle, 153 + ]} 154 + /> 155 + 156 + <Animated.View 157 + style={[ 158 + a.absolute, 159 + a.rounded_full, 160 + t.atoms.bg, 161 + { 162 + left: 0, 163 + width: 24, 164 + height: 24, 165 + borderWidth: 1, 166 + borderColor: t.atoms.border_contrast_low.borderColor, 167 + }, 168 + thumbAnimatedStyle, 169 + platform({ 170 + web: { 171 + boxShadow: '0px 2px 4px 0px #0000001A', 172 + }, 173 + ios: { 174 + shadowColor: '#000', 175 + shadowOffset: {width: 0, height: 2}, 176 + shadowOpacity: 0.15, 177 + shadowRadius: 4, 178 + }, 179 + android: {elevation: 3}, 180 + }), 181 + ]} 182 + /> 183 + </View> 184 + </GestureDetector> 185 + </View> 186 + ) 187 + }
+4 -4
src/lib/ThemeContext.tsx
··· 4 import {type ThemeName} from '@bsky.app/alf' 5 6 import {useThemePrefs} from '#/state/shell/color-mode' 7 - import {type SchemeType, selectScheme} from '#/alf' 8 import {themes} from '#/alf/themes' 9 import {darkTheme, defaultTheme, dimTheme} from './themes' 10 ··· 124 theme, 125 children, 126 }) => { 127 - const {colorScheme} = useThemePrefs() 128 129 const themeValue = useMemo(() => { 130 - const currentScheme = selectScheme(colorScheme) 131 return getTheme(theme, currentScheme) 132 - }, [theme, colorScheme]) 133 134 return ( 135 <ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
··· 4 import {type ThemeName} from '@bsky.app/alf' 5 6 import {useThemePrefs} from '#/state/shell/color-mode' 7 + import {hueShifter, type SchemeType, selectScheme} from '#/alf' 8 import {themes} from '#/alf/themes' 9 import {darkTheme, defaultTheme, dimTheme} from './themes' 10 ··· 124 theme, 125 children, 126 }) => { 127 + const {colorScheme, hue} = useThemePrefs() 128 129 const themeValue = useMemo(() => { 130 + const currentScheme = hueShifter(selectScheme(colorScheme), hue) 131 return getTheme(theme, currentScheme) 132 + }, [theme, colorScheme, hue]) 133 134 return ( 135 <ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
+16 -2
src/screens/Settings/AppearanceSettings.tsx
··· 18 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' 19 import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' 20 import * as SegmentedControl from '#/components/forms/SegmentedControl' 21 import * as Toggle from '#/components/forms/Toggle' 22 import {type Props as SVGIconProps} from '#/components/icons/common' 23 import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' ··· 36 const {fonts} = useAlf() 37 const t = useTheme() 38 39 - const {colorMode, colorScheme, darkTheme} = useThemePrefs() 40 - const {setColorMode, setColorScheme, setDarkTheme} = useSetThemePrefs() 41 42 const onChangeAppearance = useCallback( 43 (value: 'light' | 'system' | 'dark') => { ··· 178 ))} 179 </View> 180 </Toggle.Group> 181 </View> 182 </SettingsList.Group> 183
··· 18 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' 19 import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' 20 import * as SegmentedControl from '#/components/forms/SegmentedControl' 21 + import {Slider} from '#/components/forms/Slider' 22 import * as Toggle from '#/components/forms/Toggle' 23 import {type Props as SVGIconProps} from '#/components/icons/common' 24 import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' ··· 37 const {fonts} = useAlf() 38 const t = useTheme() 39 40 + const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs() 41 + const {setColorMode, setColorScheme, setDarkTheme, setHue} = 42 + useSetThemePrefs() 43 44 const onChangeAppearance = useCallback( 45 (value: 'light' | 'system' | 'dark') => { ··· 180 ))} 181 </View> 182 </Toggle.Group> 183 + <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 184 + <Trans>Hue shift the colors:</Trans> 185 + </Text> 186 + <Slider 187 + label="Volume" 188 + value={hue} 189 + onValueChange={setHue} 190 + min={0} 191 + max={360} 192 + step={1} 193 + debounce={0.3} 194 + /> 195 </View> 196 </SettingsList.Group> 197
+2
src/state/persisted/schema.ts
··· 58 'kitty', 59 'reddwarf', 60 ]), 61 session: z.object({ 62 accounts: z.array(accountSchema), 63 currentAccount: currentAccountSchema.optional(), ··· 174 colorMode: 'system', 175 darkTheme: 'dim', 176 colorScheme: 'witchsky', 177 session: { 178 accounts: [], 179 currentAccount: undefined,
··· 58 'kitty', 59 'reddwarf', 60 ]), 61 + hue: z.number(), 62 session: z.object({ 63 accounts: z.array(accountSchema), 64 currentAccount: currentAccountSchema.optional(), ··· 175 colorMode: 'system', 176 darkTheme: 'dim', 177 colorScheme: 'witchsky', 178 + hue: 0, 179 session: { 180 accounts: [], 181 currentAccount: undefined,
+14 -1
src/state/shell/color-mode.tsx
··· 6 colorMode: persisted.Schema['colorMode'] 7 darkTheme: persisted.Schema['darkTheme'] 8 colorScheme: persisted.Schema['colorScheme'] 9 } 10 type SetContext = { 11 setColorMode: (v: persisted.Schema['colorMode']) => void 12 setDarkTheme: (v: persisted.Schema['darkTheme']) => void 13 setColorScheme: (v: persisted.Schema['colorScheme']) => void 14 } 15 16 const stateContext = React.createContext<StateContext>({ 17 colorMode: 'system', 18 darkTheme: 'dark', 19 colorScheme: 'witchsky', 20 }) 21 stateContext.displayName = 'ColorModeStateContext' 22 const setContext = React.createContext<SetContext>({} as SetContext) ··· 28 const [colorScheme, setColorScheme] = React.useState( 29 persisted.get('colorScheme'), 30 ) 31 32 const stateContextValue = React.useMemo( 33 () => ({ 34 colorMode, 35 darkTheme, 36 colorScheme, 37 }), 38 - [colorMode, darkTheme, colorScheme], 39 ) 40 41 const setContextValue = React.useMemo( ··· 52 setColorScheme(_colorScheme) 53 persisted.write('colorScheme', _colorScheme) 54 }, 55 }), 56 [], 57 ) ··· 66 const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => { 67 setColorScheme(nextColorScheme) 68 }) 69 return () => { 70 unsub1() 71 unsub2() 72 unsub3() 73 } 74 }, []) 75
··· 6 colorMode: persisted.Schema['colorMode'] 7 darkTheme: persisted.Schema['darkTheme'] 8 colorScheme: persisted.Schema['colorScheme'] 9 + hue: persisted.Schema['hue'] 10 } 11 type SetContext = { 12 setColorMode: (v: persisted.Schema['colorMode']) => void 13 setDarkTheme: (v: persisted.Schema['darkTheme']) => void 14 setColorScheme: (v: persisted.Schema['colorScheme']) => void 15 + setHue: (v: persisted.Schema['hue']) => void 16 } 17 18 const stateContext = React.createContext<StateContext>({ 19 colorMode: 'system', 20 darkTheme: 'dark', 21 colorScheme: 'witchsky', 22 + hue: 0, 23 }) 24 stateContext.displayName = 'ColorModeStateContext' 25 const setContext = React.createContext<SetContext>({} as SetContext) ··· 31 const [colorScheme, setColorScheme] = React.useState( 32 persisted.get('colorScheme'), 33 ) 34 + const [hue, setHue] = React.useState(persisted.get('hue')) 35 36 const stateContextValue = React.useMemo( 37 () => ({ 38 colorMode, 39 darkTheme, 40 colorScheme, 41 + hue, 42 }), 43 + [colorMode, darkTheme, colorScheme, hue], 44 ) 45 46 const setContextValue = React.useMemo( ··· 57 setColorScheme(_colorScheme) 58 persisted.write('colorScheme', _colorScheme) 59 }, 60 + setHue: (_hue: persisted.Schema['hue']) => { 61 + setHue(_hue) 62 + persisted.write('hue', _hue) 63 + }, 64 }), 65 [], 66 ) ··· 75 const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => { 76 setColorScheme(nextColorScheme) 77 }) 78 + const unsub4 = persisted.onUpdate('hue', nextHue => { 79 + setHue(nextHue) 80 + }) 81 return () => { 82 unsub1() 83 unsub2() 84 unsub3() 85 + unsub4() 86 } 87 }, []) 88
+10
yarn.lock
··· 7442 dependencies: 7443 "@types/node" "*" 7444 7445 "@types/elliptic@^6.4.9": 7446 version "6.4.18" 7447 resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1" ··· 9975 resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" 9976 integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== 9977 9978 data-urls@^3.0.2: 9979 version "3.0.2" 9980 resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"
··· 7442 dependencies: 7443 "@types/node" "*" 7444 7445 + "@types/culori@^4.0.1": 7446 + version "4.0.1" 7447 + resolved "https://registry.yarnpkg.com/@types/culori/-/culori-4.0.1.tgz#39ed095e0ef7107342d9091b1707ae8fb8681297" 7448 + integrity sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ== 7449 + 7450 "@types/elliptic@^6.4.9": 7451 version "6.4.18" 7452 resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1" ··· 9980 resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" 9981 integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== 9982 9983 + culori@^4.0.2: 9984 + version "4.0.2" 9985 + resolved "https://registry.yarnpkg.com/culori/-/culori-4.0.2.tgz#fbb28dbeb8d13d0eeab7520191f74ab822a8ca71" 9986 + integrity sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw== 9987 + 9988 data-urls@^3.0.2: 9989 version "3.0.2" 9990 resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"