tangled
alpha
login
or
join now
jollywhoppers.com
/
witchsky.app
103
fork
atom
Bluesky app fork with some witchin' additions 💫
witchsky.app
bluesky
fork
client
103
fork
atom
overview
issues
45
pulls
pipelines
hue shift slider
authored by
whey.party
and committed by
Tangled
3 months ago
c0a03f26
1320f38b
0/1
deploy-web.yml
failed
2min 53s
+315
-13
8 changed files
expand all
collapse all
unified
split
package.json
src
alf
index.tsx
components
forms
Slider.tsx
lib
ThemeContext.tsx
screens
Settings
AppearanceSettings.tsx
state
persisted
schema.ts
shell
color-mode.tsx
yarn.lock
+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
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
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
2
-
import {type Theme, type ThemeName} from '@bsky.app/alf'
2
2
+
import {createTheme, type Theme, type ThemeName} from '@bsky.app/alf'
3
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
18
+
type Palette,
17
19
reddwarfscheme,
18
20
themes,
19
21
witchskyscheme,
···
68
70
69
71
export type SchemeType = typeof themes
70
72
73
73
+
function changeHue(color: string, hueShift: number) {
74
74
+
if (!hueShift || hueShift === 0) return color
75
75
+
76
76
+
let lablch = utilMode(modeOklch)
77
77
+
const parsed = lablch(color)
78
78
+
79
79
+
if (!parsed) return color
80
80
+
81
81
+
const {l, c, h} = parsed as {l: number; c: number; h: number | undefined}
82
82
+
83
83
+
const currentHue = h || 0
84
84
+
85
85
+
const newHue = (currentHue + hueShift + 360) % 360
86
86
+
87
87
+
return formatHex({mode: 'oklch', l, c, h: newHue})
88
88
+
}
89
89
+
90
90
+
export function shiftPalette(palette: Palette, hueShift: number): Palette {
91
91
+
const newPalette = {...palette}
92
92
+
const keys = Object.keys(newPalette) as Array<keyof Palette>
93
93
+
94
94
+
keys.forEach(key => {
95
95
+
newPalette[key] = changeHue(newPalette[key], hueShift)
96
96
+
})
97
97
+
98
98
+
return newPalette
99
99
+
}
100
100
+
101
101
+
export function hueShifter(scheme: SchemeType, hueShift: number): SchemeType {
102
102
+
if (!hueShift || hueShift === 0) {
103
103
+
return scheme
104
104
+
}
105
105
+
106
106
+
const lightPalette = shiftPalette(scheme.lightPalette, hueShift)
107
107
+
const darkPalette = shiftPalette(scheme.darkPalette, hueShift)
108
108
+
const dimPalette = shiftPalette(scheme.dimPalette, hueShift)
109
109
+
110
110
+
const light = createTheme({
111
111
+
scheme: 'light',
112
112
+
name: 'light',
113
113
+
palette: lightPalette,
114
114
+
})
115
115
+
116
116
+
const dark = createTheme({
117
117
+
scheme: 'dark',
118
118
+
name: 'dark',
119
119
+
palette: darkPalette,
120
120
+
options: {
121
121
+
shadowOpacity: 0.4,
122
122
+
},
123
123
+
})
124
124
+
125
125
+
const dim = createTheme({
126
126
+
scheme: 'dark',
127
127
+
name: 'dim',
128
128
+
palette: dimPalette,
129
129
+
options: {
130
130
+
shadowOpacity: 0.4,
131
131
+
},
132
132
+
})
133
133
+
134
134
+
return {
135
135
+
lightPalette,
136
136
+
darkPalette,
137
137
+
dimPalette,
138
138
+
light,
139
139
+
dark,
140
140
+
dim,
141
141
+
}
142
142
+
}
143
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
96
-
const {colorScheme} = useThemePrefs()
169
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
129
-
themes: currentScheme,
202
202
+
themes: hueShifter(currentScheme, hue),
130
203
themeName: themeName,
131
131
-
theme: currentScheme[themeName],
204
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
216
+
hue,
143
217
themeName,
144
218
fontScale,
219
219
+
fontScaleMultiplier,
220
220
+
fontFamily,
145
221
setFontScaleAndPersist,
146
146
-
fontFamily,
147
222
setFontFamilyAndPersist,
148
148
-
fontScaleMultiplier,
149
223
],
150
224
)
151
225
+187
src/components/forms/Slider.tsx
···
1
1
+
import {useCallback, useEffect, useRef, useState} from 'react'
2
2
+
import {type StyleProp, View, type ViewStyle} from 'react-native'
3
3
+
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
4
4
+
import Animated, {
5
5
+
runOnJS,
6
6
+
useAnimatedStyle,
7
7
+
useSharedValue,
8
8
+
withSpring,
9
9
+
} from 'react-native-reanimated'
10
10
+
11
11
+
import {useHaptics} from '#/lib/haptics'
12
12
+
import {atoms as a, platform, useTheme} from '#/alf'
13
13
+
14
14
+
export interface SliderProps {
15
15
+
value: number
16
16
+
onValueChange: (value: number) => void
17
17
+
min?: number
18
18
+
max?: number
19
19
+
step?: number
20
20
+
label?: string
21
21
+
accessibilityHint?: string
22
22
+
style?: StyleProp<ViewStyle>
23
23
+
debounce?: number
24
24
+
}
25
25
+
26
26
+
export function Slider({
27
27
+
value,
28
28
+
onValueChange,
29
29
+
min = 0,
30
30
+
max = 100,
31
31
+
step = 1,
32
32
+
label,
33
33
+
accessibilityHint,
34
34
+
style,
35
35
+
debounce,
36
36
+
}: SliderProps) {
37
37
+
const t = useTheme()
38
38
+
const playHaptic = useHaptics()
39
39
+
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined)
40
40
+
41
41
+
const [width, setWidth] = useState(0)
42
42
+
43
43
+
const progress = useSharedValue(0)
44
44
+
const isPressed = useSharedValue(false)
45
45
+
46
46
+
useEffect(() => {
47
47
+
if (!isPressed.value) {
48
48
+
const clamped = Math.min(Math.max(value, min), max)
49
49
+
const normalized = (clamped - min) / (max - min)
50
50
+
progress.value = withSpring(normalized, {overshootClamping: true})
51
51
+
}
52
52
+
}, [value, min, max, progress, isPressed])
53
53
+
54
54
+
useEffect(() => {
55
55
+
return () => {
56
56
+
if (timerRef.current) clearTimeout(timerRef.current)
57
57
+
}
58
58
+
}, [])
59
59
+
60
60
+
const updateValueJS = useCallback(
61
61
+
(val: number) => {
62
62
+
if (debounce && debounce > 0) {
63
63
+
if (timerRef.current) {
64
64
+
clearTimeout(timerRef.current)
65
65
+
}
66
66
+
timerRef.current = setTimeout(() => {
67
67
+
onValueChange(val)
68
68
+
}, debounce)
69
69
+
} else {
70
70
+
onValueChange(val)
71
71
+
}
72
72
+
},
73
73
+
[onValueChange, debounce],
74
74
+
)
75
75
+
76
76
+
const handleValueChange = useCallback(
77
77
+
(newProgress: number) => {
78
78
+
'worklet'
79
79
+
const rawValue = min + newProgress * (max - min)
80
80
+
81
81
+
const steppedValue = Math.round(rawValue / step) * step
82
82
+
const clamped = Math.min(Math.max(steppedValue, min), max)
83
83
+
84
84
+
runOnJS(updateValueJS)(clamped)
85
85
+
},
86
86
+
[min, max, step, updateValueJS],
87
87
+
)
88
88
+
89
89
+
const pan = Gesture.Pan()
90
90
+
.onBegin(e => {
91
91
+
isPressed.value = true
92
92
+
93
93
+
if (width > 0) {
94
94
+
const newProgress = Math.min(Math.max(e.x / width, 0), 1)
95
95
+
progress.value = newProgress
96
96
+
handleValueChange(newProgress)
97
97
+
}
98
98
+
})
99
99
+
.onUpdate(e => {
100
100
+
if (width === 0) return
101
101
+
const newProgress = Math.min(Math.max(e.x / width, 0), 1)
102
102
+
progress.value = newProgress
103
103
+
handleValueChange(newProgress)
104
104
+
})
105
105
+
.onFinalize(() => {
106
106
+
isPressed.value = false
107
107
+
runOnJS(playHaptic)('Light')
108
108
+
})
109
109
+
110
110
+
const thumbAnimatedStyle = useAnimatedStyle(() => {
111
111
+
const translateX = progress.value * width
112
112
+
return {
113
113
+
transform: [
114
114
+
{translateX: translateX - 12},
115
115
+
{scale: isPressed.value ? 1.1 : 1},
116
116
+
],
117
117
+
}
118
118
+
})
119
119
+
120
120
+
const trackAnimatedStyle = useAnimatedStyle(() => {
121
121
+
return {
122
122
+
width: `${progress.value * 100}%`,
123
123
+
}
124
124
+
})
125
125
+
126
126
+
return (
127
127
+
<View
128
128
+
style={[a.w_full, a.justify_center, {height: 28}, style]}
129
129
+
accessibilityRole="adjustable"
130
130
+
accessibilityLabel={label}
131
131
+
accessibilityHint={accessibilityHint}
132
132
+
accessibilityValue={{min, max, now: value}}>
133
133
+
<GestureDetector gesture={pan}>
134
134
+
<View
135
135
+
style={[a.flex_1, a.justify_center, {cursor: 'pointer'}]}
136
136
+
// @ts-ignore web-only style
137
137
+
onLayout={e => setWidth(e.nativeEvent.layout.width)}>
138
138
+
<View
139
139
+
style={[
140
140
+
a.w_full,
141
141
+
a.absolute,
142
142
+
t.atoms.bg_contrast_50,
143
143
+
{height: 4, borderRadius: 2},
144
144
+
]}
145
145
+
/>
146
146
+
147
147
+
<Animated.View
148
148
+
style={[
149
149
+
a.absolute,
150
150
+
a.rounded_full,
151
151
+
{height: 4, backgroundColor: t.palette.primary_500},
152
152
+
trackAnimatedStyle,
153
153
+
]}
154
154
+
/>
155
155
+
156
156
+
<Animated.View
157
157
+
style={[
158
158
+
a.absolute,
159
159
+
a.rounded_full,
160
160
+
t.atoms.bg,
161
161
+
{
162
162
+
left: 0,
163
163
+
width: 24,
164
164
+
height: 24,
165
165
+
borderWidth: 1,
166
166
+
borderColor: t.atoms.border_contrast_low.borderColor,
167
167
+
},
168
168
+
thumbAnimatedStyle,
169
169
+
platform({
170
170
+
web: {
171
171
+
boxShadow: '0px 2px 4px 0px #0000001A',
172
172
+
},
173
173
+
ios: {
174
174
+
shadowColor: '#000',
175
175
+
shadowOffset: {width: 0, height: 2},
176
176
+
shadowOpacity: 0.15,
177
177
+
shadowRadius: 4,
178
178
+
},
179
179
+
android: {elevation: 3},
180
180
+
}),
181
181
+
]}
182
182
+
/>
183
183
+
</View>
184
184
+
</GestureDetector>
185
185
+
</View>
186
186
+
)
187
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
7
-
import {type SchemeType, selectScheme} from '#/alf'
7
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
127
-
const {colorScheme} = useThemePrefs()
127
127
+
const {colorScheme, hue} = useThemePrefs()
128
128
129
129
const themeValue = useMemo(() => {
130
130
-
const currentScheme = selectScheme(colorScheme)
130
130
+
const currentScheme = hueShifter(selectScheme(colorScheme), hue)
131
131
return getTheme(theme, currentScheme)
132
132
-
}, [theme, colorScheme])
132
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
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
39
-
const {colorMode, colorScheme, darkTheme} = useThemePrefs()
40
40
-
const {setColorMode, setColorScheme, setDarkTheme} = useSetThemePrefs()
40
40
+
const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs()
41
41
+
const {setColorMode, setColorScheme, setDarkTheme, setHue} =
42
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
183
+
<Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
184
184
+
<Trans>Hue shift the colors:</Trans>
185
185
+
</Text>
186
186
+
<Slider
187
187
+
label="Volume"
188
188
+
value={hue}
189
189
+
onValueChange={setHue}
190
190
+
min={0}
191
191
+
max={360}
192
192
+
step={1}
193
193
+
debounce={0.3}
194
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
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
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
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
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
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
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
41
+
hue,
37
42
}),
38
38
-
[colorMode, darkTheme, colorScheme],
43
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
60
+
setHue: (_hue: persisted.Schema['hue']) => {
61
61
+
setHue(_hue)
62
62
+
persisted.write('hue', _hue)
63
63
+
},
55
64
}),
56
65
[],
57
66
)
···
66
75
const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => {
67
76
setColorScheme(nextColorScheme)
68
77
})
78
78
+
const unsub4 = persisted.onUpdate('hue', nextHue => {
79
79
+
setHue(nextHue)
80
80
+
})
69
81
return () => {
70
82
unsub1()
71
83
unsub2()
72
84
unsub3()
85
85
+
unsub4()
73
86
}
74
87
}, [])
75
88
+10
yarn.lock
···
7442
7442
dependencies:
7443
7443
"@types/node" "*"
7444
7444
7445
7445
+
"@types/culori@^4.0.1":
7446
7446
+
version "4.0.1"
7447
7447
+
resolved "https://registry.yarnpkg.com/@types/culori/-/culori-4.0.1.tgz#39ed095e0ef7107342d9091b1707ae8fb8681297"
7448
7448
+
integrity sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==
7449
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"
···
9974
9979
version "3.1.2"
9975
9980
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
9976
9981
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
9982
9982
+
9983
9983
+
culori@^4.0.2:
9984
9984
+
version "4.0.2"
9985
9985
+
resolved "https://registry.yarnpkg.com/culori/-/culori-4.0.2.tgz#fbb28dbeb8d13d0eeab7520191f74ab822a8ca71"
9986
9986
+
integrity sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==
9977
9987
9978
9988
data-urls@^3.0.2:
9979
9989
version "3.0.2"