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
···
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
···
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
"@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.debounce": "^4.0.7",
···
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
+
"culori": "^4.0.2",
"date-fns": "^2.30.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
···
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
+
"@types/culori": "^4.0.1",
"@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.debounce": "^4.0.7",
+80 -6
src/alf/index.tsx
···
import React from 'react'
-
import {type Theme, type ThemeName} from '@bsky.app/alf'
import {useThemePrefs} from '#/state/shell/color-mode'
import {
···
blueskyscheme,
deerscheme,
kittyscheme,
reddwarfscheme,
themes,
witchskyscheme,
···
export type SchemeType = typeof themes
export function selectScheme(colorScheme: string | undefined): SchemeType {
switch (colorScheme) {
case 'witchsky':
···
children,
theme: themeName,
}: React.PropsWithChildren<{theme: ThemeName}>) {
-
const {colorScheme} = useThemePrefs()
const currentScheme = selectScheme(colorScheme)
const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() =>
getFontScale(),
···
const value = React.useMemo<Alf>(
() => ({
-
themes: currentScheme,
themeName: themeName,
-
theme: currentScheme[themeName],
fonts: {
scale: fontScale,
scaleMultiplier: fontScaleMultiplier,
···
}),
[
currentScheme,
themeName,
fontScale,
-
setFontScaleAndPersist,
fontFamily,
setFontFamilyAndPersist,
-
fontScaleMultiplier,
],
)
···
import React from 'react'
+
import {createTheme, type Theme, type ThemeName} from '@bsky.app/alf'
+
import {formatHex, modeOklch, useMode as utilMode} from 'culori'
import {useThemePrefs} from '#/state/shell/color-mode'
import {
···
blueskyscheme,
deerscheme,
kittyscheme,
+
type Palette,
reddwarfscheme,
themes,
witchskyscheme,
···
export type SchemeType = typeof themes
+
function changeHue(color: string, hueShift: number) {
+
if (!hueShift || hueShift === 0) return color
+
+
let lablch = utilMode(modeOklch)
+
const parsed = lablch(color)
+
+
if (!parsed) return color
+
+
const {l, c, h} = parsed as {l: number; c: number; h: number | undefined}
+
+
const currentHue = h || 0
+
+
const newHue = (currentHue + hueShift + 360) % 360
+
+
return formatHex({mode: 'oklch', l, c, h: newHue})
+
}
+
+
export function shiftPalette(palette: Palette, hueShift: number): Palette {
+
const newPalette = {...palette}
+
const keys = Object.keys(newPalette) as Array<keyof Palette>
+
+
keys.forEach(key => {
+
newPalette[key] = changeHue(newPalette[key], hueShift)
+
})
+
+
return newPalette
+
}
+
+
export function hueShifter(scheme: SchemeType, hueShift: number): SchemeType {
+
if (!hueShift || hueShift === 0) {
+
return scheme
+
}
+
+
const lightPalette = shiftPalette(scheme.lightPalette, hueShift)
+
const darkPalette = shiftPalette(scheme.darkPalette, hueShift)
+
const dimPalette = shiftPalette(scheme.dimPalette, hueShift)
+
+
const light = createTheme({
+
scheme: 'light',
+
name: 'light',
+
palette: lightPalette,
+
})
+
+
const dark = createTheme({
+
scheme: 'dark',
+
name: 'dark',
+
palette: darkPalette,
+
options: {
+
shadowOpacity: 0.4,
+
},
+
})
+
+
const dim = createTheme({
+
scheme: 'dark',
+
name: 'dim',
+
palette: dimPalette,
+
options: {
+
shadowOpacity: 0.4,
+
},
+
})
+
+
return {
+
lightPalette,
+
darkPalette,
+
dimPalette,
+
light,
+
dark,
+
dim,
+
}
+
}
+
export function selectScheme(colorScheme: string | undefined): SchemeType {
switch (colorScheme) {
case 'witchsky':
···
children,
theme: themeName,
}: React.PropsWithChildren<{theme: ThemeName}>) {
+
const {colorScheme, hue} = useThemePrefs()
const currentScheme = selectScheme(colorScheme)
const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() =>
getFontScale(),
···
const value = React.useMemo<Alf>(
() => ({
+
themes: hueShifter(currentScheme, hue),
themeName: themeName,
+
theme: hueShifter(currentScheme, hue)[themeName],
fonts: {
scale: fontScale,
scaleMultiplier: fontScaleMultiplier,
···
}),
[
currentScheme,
+
hue,
themeName,
fontScale,
+
fontScaleMultiplier,
fontFamily,
+
setFontScaleAndPersist,
setFontFamilyAndPersist,
],
)
+187
src/components/forms/Slider.tsx
···
···
+
import {useCallback, useEffect, useRef, useState} from 'react'
+
import {type StyleProp, View, type ViewStyle} from 'react-native'
+
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+
import Animated, {
+
runOnJS,
+
useAnimatedStyle,
+
useSharedValue,
+
withSpring,
+
} from 'react-native-reanimated'
+
+
import {useHaptics} from '#/lib/haptics'
+
import {atoms as a, platform, useTheme} from '#/alf'
+
+
export interface SliderProps {
+
value: number
+
onValueChange: (value: number) => void
+
min?: number
+
max?: number
+
step?: number
+
label?: string
+
accessibilityHint?: string
+
style?: StyleProp<ViewStyle>
+
debounce?: number
+
}
+
+
export function Slider({
+
value,
+
onValueChange,
+
min = 0,
+
max = 100,
+
step = 1,
+
label,
+
accessibilityHint,
+
style,
+
debounce,
+
}: SliderProps) {
+
const t = useTheme()
+
const playHaptic = useHaptics()
+
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined)
+
+
const [width, setWidth] = useState(0)
+
+
const progress = useSharedValue(0)
+
const isPressed = useSharedValue(false)
+
+
useEffect(() => {
+
if (!isPressed.value) {
+
const clamped = Math.min(Math.max(value, min), max)
+
const normalized = (clamped - min) / (max - min)
+
progress.value = withSpring(normalized, {overshootClamping: true})
+
}
+
}, [value, min, max, progress, isPressed])
+
+
useEffect(() => {
+
return () => {
+
if (timerRef.current) clearTimeout(timerRef.current)
+
}
+
}, [])
+
+
const updateValueJS = useCallback(
+
(val: number) => {
+
if (debounce && debounce > 0) {
+
if (timerRef.current) {
+
clearTimeout(timerRef.current)
+
}
+
timerRef.current = setTimeout(() => {
+
onValueChange(val)
+
}, debounce)
+
} else {
+
onValueChange(val)
+
}
+
},
+
[onValueChange, debounce],
+
)
+
+
const handleValueChange = useCallback(
+
(newProgress: number) => {
+
'worklet'
+
const rawValue = min + newProgress * (max - min)
+
+
const steppedValue = Math.round(rawValue / step) * step
+
const clamped = Math.min(Math.max(steppedValue, min), max)
+
+
runOnJS(updateValueJS)(clamped)
+
},
+
[min, max, step, updateValueJS],
+
)
+
+
const pan = Gesture.Pan()
+
.onBegin(e => {
+
isPressed.value = true
+
+
if (width > 0) {
+
const newProgress = Math.min(Math.max(e.x / width, 0), 1)
+
progress.value = newProgress
+
handleValueChange(newProgress)
+
}
+
})
+
.onUpdate(e => {
+
if (width === 0) return
+
const newProgress = Math.min(Math.max(e.x / width, 0), 1)
+
progress.value = newProgress
+
handleValueChange(newProgress)
+
})
+
.onFinalize(() => {
+
isPressed.value = false
+
runOnJS(playHaptic)('Light')
+
})
+
+
const thumbAnimatedStyle = useAnimatedStyle(() => {
+
const translateX = progress.value * width
+
return {
+
transform: [
+
{translateX: translateX - 12},
+
{scale: isPressed.value ? 1.1 : 1},
+
],
+
}
+
})
+
+
const trackAnimatedStyle = useAnimatedStyle(() => {
+
return {
+
width: `${progress.value * 100}%`,
+
}
+
})
+
+
return (
+
<View
+
style={[a.w_full, a.justify_center, {height: 28}, style]}
+
accessibilityRole="adjustable"
+
accessibilityLabel={label}
+
accessibilityHint={accessibilityHint}
+
accessibilityValue={{min, max, now: value}}>
+
<GestureDetector gesture={pan}>
+
<View
+
style={[a.flex_1, a.justify_center, {cursor: 'pointer'}]}
+
// @ts-ignore web-only style
+
onLayout={e => setWidth(e.nativeEvent.layout.width)}>
+
<View
+
style={[
+
a.w_full,
+
a.absolute,
+
t.atoms.bg_contrast_50,
+
{height: 4, borderRadius: 2},
+
]}
+
/>
+
+
<Animated.View
+
style={[
+
a.absolute,
+
a.rounded_full,
+
{height: 4, backgroundColor: t.palette.primary_500},
+
trackAnimatedStyle,
+
]}
+
/>
+
+
<Animated.View
+
style={[
+
a.absolute,
+
a.rounded_full,
+
t.atoms.bg,
+
{
+
left: 0,
+
width: 24,
+
height: 24,
+
borderWidth: 1,
+
borderColor: t.atoms.border_contrast_low.borderColor,
+
},
+
thumbAnimatedStyle,
+
platform({
+
web: {
+
boxShadow: '0px 2px 4px 0px #0000001A',
+
},
+
ios: {
+
shadowColor: '#000',
+
shadowOffset: {width: 0, height: 2},
+
shadowOpacity: 0.15,
+
shadowRadius: 4,
+
},
+
android: {elevation: 3},
+
}),
+
]}
+
/>
+
</View>
+
</GestureDetector>
+
</View>
+
)
+
}
+4 -4
src/lib/ThemeContext.tsx
···
import {type ThemeName} from '@bsky.app/alf'
import {useThemePrefs} from '#/state/shell/color-mode'
-
import {type SchemeType, selectScheme} from '#/alf'
import {themes} from '#/alf/themes'
import {darkTheme, defaultTheme, dimTheme} from './themes'
···
theme,
children,
}) => {
-
const {colorScheme} = useThemePrefs()
const themeValue = useMemo(() => {
-
const currentScheme = selectScheme(colorScheme)
return getTheme(theme, currentScheme)
-
}, [theme, colorScheme])
return (
<ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
···
import {type ThemeName} from '@bsky.app/alf'
import {useThemePrefs} from '#/state/shell/color-mode'
+
import {hueShifter, type SchemeType, selectScheme} from '#/alf'
import {themes} from '#/alf/themes'
import {darkTheme, defaultTheme, dimTheme} from './themes'
···
theme,
children,
}) => {
+
const {colorScheme, hue} = useThemePrefs()
const themeValue = useMemo(() => {
+
const currentScheme = hueShifter(selectScheme(colorScheme), hue)
return getTheme(theme, currentScheme)
+
}, [theme, colorScheme, hue])
return (
<ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
+16 -2
src/screens/Settings/AppearanceSettings.tsx
···
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as Toggle from '#/components/forms/Toggle'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
···
const {fonts} = useAlf()
const t = useTheme()
-
const {colorMode, colorScheme, darkTheme} = useThemePrefs()
-
const {setColorMode, setColorScheme, setDarkTheme} = useSetThemePrefs()
const onChangeAppearance = useCallback(
(value: 'light' | 'system' | 'dark') => {
···
))}
</View>
</Toggle.Group>
</View>
</SettingsList.Group>
···
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
+
import {Slider} from '#/components/forms/Slider'
import * as Toggle from '#/components/forms/Toggle'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
···
const {fonts} = useAlf()
const t = useTheme()
+
const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs()
+
const {setColorMode, setColorScheme, setDarkTheme, setHue} =
+
useSetThemePrefs()
const onChangeAppearance = useCallback(
(value: 'light' | 'system' | 'dark') => {
···
))}
</View>
</Toggle.Group>
+
<Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
+
<Trans>Hue shift the colors:</Trans>
+
</Text>
+
<Slider
+
label="Volume"
+
value={hue}
+
onValueChange={setHue}
+
min={0}
+
max={360}
+
step={1}
+
debounce={0.3}
+
/>
</View>
</SettingsList.Group>
+2
src/state/persisted/schema.ts
···
'kitty',
'reddwarf',
]),
session: z.object({
accounts: z.array(accountSchema),
currentAccount: currentAccountSchema.optional(),
···
colorMode: 'system',
darkTheme: 'dim',
colorScheme: 'witchsky',
session: {
accounts: [],
currentAccount: undefined,
···
'kitty',
'reddwarf',
]),
+
hue: z.number(),
session: z.object({
accounts: z.array(accountSchema),
currentAccount: currentAccountSchema.optional(),
···
colorMode: 'system',
darkTheme: 'dim',
colorScheme: 'witchsky',
+
hue: 0,
session: {
accounts: [],
currentAccount: undefined,
+14 -1
src/state/shell/color-mode.tsx
···
colorMode: persisted.Schema['colorMode']
darkTheme: persisted.Schema['darkTheme']
colorScheme: persisted.Schema['colorScheme']
}
type SetContext = {
setColorMode: (v: persisted.Schema['colorMode']) => void
setDarkTheme: (v: persisted.Schema['darkTheme']) => void
setColorScheme: (v: persisted.Schema['colorScheme']) => void
}
const stateContext = React.createContext<StateContext>({
colorMode: 'system',
darkTheme: 'dark',
colorScheme: 'witchsky',
})
stateContext.displayName = 'ColorModeStateContext'
const setContext = React.createContext<SetContext>({} as SetContext)
···
const [colorScheme, setColorScheme] = React.useState(
persisted.get('colorScheme'),
)
const stateContextValue = React.useMemo(
() => ({
colorMode,
darkTheme,
colorScheme,
}),
-
[colorMode, darkTheme, colorScheme],
)
const setContextValue = React.useMemo(
···
setColorScheme(_colorScheme)
persisted.write('colorScheme', _colorScheme)
},
}),
[],
)
···
const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => {
setColorScheme(nextColorScheme)
})
return () => {
unsub1()
unsub2()
unsub3()
}
}, [])
···
colorMode: persisted.Schema['colorMode']
darkTheme: persisted.Schema['darkTheme']
colorScheme: persisted.Schema['colorScheme']
+
hue: persisted.Schema['hue']
}
type SetContext = {
setColorMode: (v: persisted.Schema['colorMode']) => void
setDarkTheme: (v: persisted.Schema['darkTheme']) => void
setColorScheme: (v: persisted.Schema['colorScheme']) => void
+
setHue: (v: persisted.Schema['hue']) => void
}
const stateContext = React.createContext<StateContext>({
colorMode: 'system',
darkTheme: 'dark',
colorScheme: 'witchsky',
+
hue: 0,
})
stateContext.displayName = 'ColorModeStateContext'
const setContext = React.createContext<SetContext>({} as SetContext)
···
const [colorScheme, setColorScheme] = React.useState(
persisted.get('colorScheme'),
)
+
const [hue, setHue] = React.useState(persisted.get('hue'))
const stateContextValue = React.useMemo(
() => ({
colorMode,
darkTheme,
colorScheme,
+
hue,
}),
+
[colorMode, darkTheme, colorScheme, hue],
)
const setContextValue = React.useMemo(
···
setColorScheme(_colorScheme)
persisted.write('colorScheme', _colorScheme)
},
+
setHue: (_hue: persisted.Schema['hue']) => {
+
setHue(_hue)
+
persisted.write('hue', _hue)
+
},
}),
[],
)
···
const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => {
setColorScheme(nextColorScheme)
})
+
const unsub4 = persisted.onUpdate('hue', nextHue => {
+
setHue(nextHue)
+
})
return () => {
unsub1()
unsub2()
unsub3()
+
unsub4()
}
}, [])
+10
yarn.lock
···
dependencies:
"@types/node" "*"
"@types/elliptic@^6.4.9":
version "6.4.18"
resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1"
···
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
data-urls@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"
···
dependencies:
"@types/node" "*"
+
"@types/culori@^4.0.1":
+
version "4.0.1"
+
resolved "https://registry.yarnpkg.com/@types/culori/-/culori-4.0.1.tgz#39ed095e0ef7107342d9091b1707ae8fb8681297"
+
integrity sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==
+
"@types/elliptic@^6.4.9":
version "6.4.18"
resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1"
···
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
+
culori@^4.0.2:
+
version "4.0.2"
+
resolved "https://registry.yarnpkg.com/culori/-/culori-4.0.2.tgz#fbb28dbeb8d13d0eeab7520191f74ab822a8ca71"
+
integrity sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==
+
data-urls@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"