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 126 "babel-plugin-transform-remove-console": "^6.9.4", 127 127 "bcp-47": "^2.1.0", 128 128 "bcp-47-match": "^2.0.3", 129 + "culori": "^4.0.2", 129 130 "date-fns": "^2.30.0", 130 131 "email-validator": "^2.0.4", 131 132 "emoji-mart": "^5.5.2", ··· 235 236 "@sentry/webpack-plugin": "^3.2.2", 236 237 "@testing-library/jest-native": "^5.4.3", 237 238 "@testing-library/react-native": "^13.2.0", 239 + "@types/culori": "^4.0.1", 238 240 "@types/jest": "29.5.14", 239 241 "@types/lodash.chunk": "^4.2.7", 240 242 "@types/lodash.debounce": "^4.0.7",
+80 -6
src/alf/index.tsx
··· 1 1 import React from 'react' 2 - import {type Theme, type ThemeName} from '@bsky.app/alf' 2 + import {createTheme, type Theme, type ThemeName} from '@bsky.app/alf' 3 + import {formatHex, modeOklch, useMode as utilMode} from 'culori' 3 4 4 5 import {useThemePrefs} from '#/state/shell/color-mode' 5 6 import { ··· 14 15 blueskyscheme, 15 16 deerscheme, 16 17 kittyscheme, 18 + type Palette, 17 19 reddwarfscheme, 18 20 themes, 19 21 witchskyscheme, ··· 68 70 69 71 export type SchemeType = typeof themes 70 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 + 71 144 export function selectScheme(colorScheme: string | undefined): SchemeType { 72 145 switch (colorScheme) { 73 146 case 'witchsky': ··· 93 166 children, 94 167 theme: themeName, 95 168 }: React.PropsWithChildren<{theme: ThemeName}>) { 96 - const {colorScheme} = useThemePrefs() 169 + const {colorScheme, hue} = useThemePrefs() 97 170 const currentScheme = selectScheme(colorScheme) 98 171 const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() => 99 172 getFontScale(), ··· 126 199 127 200 const value = React.useMemo<Alf>( 128 201 () => ({ 129 - themes: currentScheme, 202 + themes: hueShifter(currentScheme, hue), 130 203 themeName: themeName, 131 - theme: currentScheme[themeName], 204 + theme: hueShifter(currentScheme, hue)[themeName], 132 205 fonts: { 133 206 scale: fontScale, 134 207 scaleMultiplier: fontScaleMultiplier, ··· 140 213 }), 141 214 [ 142 215 currentScheme, 216 + hue, 143 217 themeName, 144 218 fontScale, 145 - setFontScaleAndPersist, 219 + fontScaleMultiplier, 146 220 fontFamily, 221 + setFontScaleAndPersist, 147 222 setFontFamilyAndPersist, 148 - fontScaleMultiplier, 149 223 ], 150 224 ) 151 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 4 import {type ThemeName} from '@bsky.app/alf' 5 5 6 6 import {useThemePrefs} from '#/state/shell/color-mode' 7 - import {type SchemeType, selectScheme} from '#/alf' 7 + import {hueShifter, type SchemeType, selectScheme} from '#/alf' 8 8 import {themes} from '#/alf/themes' 9 9 import {darkTheme, defaultTheme, dimTheme} from './themes' 10 10 ··· 124 124 theme, 125 125 children, 126 126 }) => { 127 - const {colorScheme} = useThemePrefs() 127 + const {colorScheme, hue} = useThemePrefs() 128 128 129 129 const themeValue = useMemo(() => { 130 - const currentScheme = selectScheme(colorScheme) 130 + const currentScheme = hueShifter(selectScheme(colorScheme), hue) 131 131 return getTheme(theme, currentScheme) 132 - }, [theme, colorScheme]) 132 + }, [theme, colorScheme, hue]) 133 133 134 134 return ( 135 135 <ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
+16 -2
src/screens/Settings/AppearanceSettings.tsx
··· 18 18 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' 19 19 import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' 20 20 import * as SegmentedControl from '#/components/forms/SegmentedControl' 21 + import {Slider} from '#/components/forms/Slider' 21 22 import * as Toggle from '#/components/forms/Toggle' 22 23 import {type Props as SVGIconProps} from '#/components/icons/common' 23 24 import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' ··· 36 37 const {fonts} = useAlf() 37 38 const t = useTheme() 38 39 39 - const {colorMode, colorScheme, darkTheme} = useThemePrefs() 40 - const {setColorMode, setColorScheme, setDarkTheme} = useSetThemePrefs() 40 + const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs() 41 + const {setColorMode, setColorScheme, setDarkTheme, setHue} = 42 + useSetThemePrefs() 41 43 42 44 const onChangeAppearance = useCallback( 43 45 (value: 'light' | 'system' | 'dark') => { ··· 178 180 ))} 179 181 </View> 180 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 + /> 181 195 </View> 182 196 </SettingsList.Group> 183 197
+2
src/state/persisted/schema.ts
··· 58 58 'kitty', 59 59 'reddwarf', 60 60 ]), 61 + hue: z.number(), 61 62 session: z.object({ 62 63 accounts: z.array(accountSchema), 63 64 currentAccount: currentAccountSchema.optional(), ··· 174 175 colorMode: 'system', 175 176 darkTheme: 'dim', 176 177 colorScheme: 'witchsky', 178 + hue: 0, 177 179 session: { 178 180 accounts: [], 179 181 currentAccount: undefined,
+14 -1
src/state/shell/color-mode.tsx
··· 6 6 colorMode: persisted.Schema['colorMode'] 7 7 darkTheme: persisted.Schema['darkTheme'] 8 8 colorScheme: persisted.Schema['colorScheme'] 9 + hue: persisted.Schema['hue'] 9 10 } 10 11 type SetContext = { 11 12 setColorMode: (v: persisted.Schema['colorMode']) => void 12 13 setDarkTheme: (v: persisted.Schema['darkTheme']) => void 13 14 setColorScheme: (v: persisted.Schema['colorScheme']) => void 15 + setHue: (v: persisted.Schema['hue']) => void 14 16 } 15 17 16 18 const stateContext = React.createContext<StateContext>({ 17 19 colorMode: 'system', 18 20 darkTheme: 'dark', 19 21 colorScheme: 'witchsky', 22 + hue: 0, 20 23 }) 21 24 stateContext.displayName = 'ColorModeStateContext' 22 25 const setContext = React.createContext<SetContext>({} as SetContext) ··· 28 31 const [colorScheme, setColorScheme] = React.useState( 29 32 persisted.get('colorScheme'), 30 33 ) 34 + const [hue, setHue] = React.useState(persisted.get('hue')) 31 35 32 36 const stateContextValue = React.useMemo( 33 37 () => ({ 34 38 colorMode, 35 39 darkTheme, 36 40 colorScheme, 41 + hue, 37 42 }), 38 - [colorMode, darkTheme, colorScheme], 43 + [colorMode, darkTheme, colorScheme, hue], 39 44 ) 40 45 41 46 const setContextValue = React.useMemo( ··· 52 57 setColorScheme(_colorScheme) 53 58 persisted.write('colorScheme', _colorScheme) 54 59 }, 60 + setHue: (_hue: persisted.Schema['hue']) => { 61 + setHue(_hue) 62 + persisted.write('hue', _hue) 63 + }, 55 64 }), 56 65 [], 57 66 ) ··· 66 75 const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => { 67 76 setColorScheme(nextColorScheme) 68 77 }) 78 + const unsub4 = persisted.onUpdate('hue', nextHue => { 79 + setHue(nextHue) 80 + }) 69 81 return () => { 70 82 unsub1() 71 83 unsub2() 72 84 unsub3() 85 + unsub4() 73 86 } 74 87 }, []) 75 88
+10
yarn.lock
··· 7442 7442 dependencies: 7443 7443 "@types/node" "*" 7444 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 + 7445 7450 "@types/elliptic@^6.4.9": 7446 7451 version "6.4.18" 7447 7452 resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1" ··· 9975 9980 resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" 9976 9981 integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== 9977 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 + 9978 9988 data-urls@^3.0.2: 9979 9989 version "3.0.2" 9980 9990 resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"