Merge branch 'main' of github.com:Jellify-Music/App into feature/nitro-player

This commit is contained in:
Violet Caulfield
2026-02-02 07:00:26 -06:00
20 changed files with 589 additions and 187 deletions

18
App.tsx
View File

@@ -10,12 +10,12 @@ import { queryClient } from './src/constants/query-client'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { NavigationContainer } from '@react-navigation/native'
import { JellifyDarkTheme, JellifyLightTheme, JellifyOLEDTheme } from './src/components/theme'
import { getJellifyNavTheme } from './src/components/theme'
import ErrorBoundary from './src/components/ErrorBoundary'
import OTAUpdateScreen from './src/components/OtaUpdates'
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
import navigationRef from './navigation'
import { useThemeSetting } from './src/stores/settings/app'
import { useColorPresetSetting, useThemeSetting } from './src/stores/settings/app'
import { getApi } from './src/stores'
import CarPlayNavigation from './src/components/CarPlay/Navigation'
import { CarPlay } from 'react-native-carplay'
@@ -75,23 +75,15 @@ export default function App(): React.JSX.Element {
function Container(): React.JSX.Element {
const [theme] = useThemeSetting()
const [colorPreset] = useColorPresetSetting()
const isDarkMode = useColorScheme() === 'dark'
const resolvedMode = theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme
return (
<NavigationContainer
ref={navigationRef}
theme={
theme === 'system'
? isDarkMode
? JellifyDarkTheme
: JellifyLightTheme
: theme === 'dark'
? JellifyDarkTheme
: theme === 'oled'
? JellifyOLEDTheme
: JellifyLightTheme
}
theme={getJellifyNavTheme(colorPreset, resolvedMode)}
>
<GestureHandlerRootView>
<TamaguiProvider config={jellifyConfig}>

View File

@@ -2,9 +2,6 @@ apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
def keystoreFile = file("./jellify.keystore")
def keystoreExists = keystoreFile.exists()
@@ -93,7 +90,6 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 173
versionName "1.0.14"
resValue "string", "build_config_package", "com.jellify"
}
signingConfigs {

View File

@@ -9,13 +9,13 @@
"@jellyfin/sdk": "0.13.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/cli": "20.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-community/netinfo": "11.5.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.10.1",
"@react-navigation/material-top-tabs": "7.4.13",
"@react-navigation/native": "7.1.28",
"@react-navigation/native-stack": "7.10.1",
"@react-navigation/native-stack": "7.11.0",
"@sentry/react-native": "7.8.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.144.3",
@@ -33,7 +33,6 @@
"react-native-blob-util": "^0.22.2",
"react-native-blurhash": "^2.1.3",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-config": "1.5.6",
"react-native-device-info": "15.0.1",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.30.0",
@@ -48,11 +47,12 @@
"react-native-pager-view": "8.0.0",
"react-native-reanimated": "4.1.6",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.20.0",
"react-native-screens": "4.21.0",
"react-native-sortables": "1.9.4",
"react-native-superconfig": "^0.6.0",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-turbo-image": "^1.23.1",
"react-native-turbo-image": "1.24.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-worklets": "^0.7.1",
@@ -576,7 +576,7 @@
"@react-navigation/native": ["@react-navigation/native@7.1.28", "", { "dependencies": { "@react-navigation/core": "^7.14.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ=="],
"@react-navigation/native-stack": ["@react-navigation/native-stack@7.10.1", "", { "dependencies": { "@react-navigation/elements": "^2.9.5", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.28", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-8jt7olKysn07HuKKSjT/ahZZTV+WaZa96o9RI7gAwh7ATlUDY02rIRttwvCyjovhSjD9KCiuJ+Hd4kwLidHwJw=="],
"@react-navigation/native-stack": ["@react-navigation/native-stack@7.11.0", "", { "dependencies": { "@react-navigation/elements": "^2.9.5", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.28", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-yNx9Wr4dfpOHpqjf2sGog4eH6KCYwTAEPlUPrKbvWlQbCRm5bglwPmaTXw9hTovX9v3HIa42yo7bXpbYfq4jzg=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.3", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg=="],
@@ -1898,8 +1898,6 @@
"react-native-cli-bump-version": ["react-native-cli-bump-version@1.5.1", "", {}, "sha512-C7Vss+BBD4iNMnn2YR00cU+GDDPZ+LDmIqWoh3FPwI/LBsJ/Vp5qanwtyVYRPcIe7Cg1PPB8WdeZ8XcnqF5Klw=="],
"react-native-config": ["react-native-config@1.5.6", "", { "peerDependencies": { "react-native-windows": ">=0.61" }, "optionalPeers": ["react-native-windows"] }, "sha512-UB3LEco0FGGbbGvS+DfH2VmGKiP/y5C2MkmfBmfsIaxHSbM1KOTMKYG7YRf6xFhZbJ/01BedHG7SIny5i7N9BQ=="],
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
"react-native-fs": ["react-native-fs@2.20.0", "", { "dependencies": { "base-64": "^0.1.0", "utf8": "^3.0.0" }, "peerDependencies": { "react-native": "*", "react-native-windows": "*" }, "optionalPeers": ["react-native-windows"] }, "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ=="],
@@ -1930,10 +1928,12 @@
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
"react-native-screens": ["react-native-screens@4.20.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-wg3ILSd8yHM2YMsWqDjr1+Rxj1qn9CrzZ8qAqDXYd+jf6p3GIMwi+NugFUbRBRZMXs3MNEXCS1vAkvc2ZwpaAA=="],
"react-native-screens": ["react-native-screens@4.21.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vUgbfKntx4LZ/1k+UU/miKohK0Ih6xoF3gYJr9QqZNOpPARksPxt4hq3HdCirvCLClieLYC9oLpGdizz/S+BGg=="],
"react-native-sortables": ["react-native-sortables@1.9.4", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g=="],
"react-native-superconfig": ["react-native-superconfig@0.6.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-kW9SjpKmuB7F54JNzaWHX5Ncr7jM8852FcIcBvfIyR0ofACaGvqf59hLKg7i8DfSIi0f5DXOCXbvu88cr8PIVw=="],
"react-native-tab-view": ["react-native-tab-view@4.2.2", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-NXtrG6OchvbGjsvbySJGVocXxo4Y2vA17ph4rAaWtA2jh+AasD8OyikKBRg2SmllEfeQ+GEhcKe8kulHv8BhTg=="],
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],

View File

@@ -169,6 +169,36 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroSuperconfig (0.6.0):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- NitroModules
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-callinvoker
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- PromisesObjC (2.4.0)
- RCT-Folly (2024.11.18.00):
- boost
@@ -2114,10 +2144,6 @@ PODS:
- Yoga
- react-native-carplay (2.4.1-beta.0):
- React
- react-native-config (1.5.6):
- react-native-config/App (= 1.5.6)
- react-native-config/App (1.5.6):
- React-Core
- react-native-google-cast (4.9.1):
- google-cast-sdk
- PromisesObjC
@@ -3090,7 +3116,7 @@ PODS:
- RNWorklets
- SocketRocket
- Yoga
- RNScreens (4.20.0):
- RNScreens (4.21.0):
- boost
- DoubleConversion
- fast_float
@@ -3117,10 +3143,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNScreens/common (= 4.20.0)
- RNScreens/common (= 4.21.0)
- SocketRocket
- Yoga
- RNScreens/common (4.20.0):
- RNScreens/common (4.21.0):
- boost
- DoubleConversion
- fast_float
@@ -3288,6 +3314,7 @@ DEPENDENCIES:
- NitroModules (from `../node_modules/react-native-nitro-modules`)
- NitroOta (from `../node_modules/react-native-nitro-ota`)
- NitroPlayer (from `../node_modules/react-native-nitro-player`)
- NitroSuperconfig (from `../node_modules/react-native-superconfig`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
@@ -3329,7 +3356,6 @@ DEPENDENCIES:
- react-native-blob-util (from `../node_modules/react-native-blob-util`)
- react-native-blurhash (from `../node_modules/react-native-blurhash`)
- react-native-carplay (from `../node_modules/react-native-carplay`)
- react-native-config (from `../node_modules/react-native-config`)
- react-native-google-cast (from `../node_modules/react-native-google-cast`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
@@ -3425,6 +3451,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-nitro-ota"
NitroPlayer:
:path: "../node_modules/react-native-nitro-player"
NitroSuperconfig:
:path: "../node_modules/react-native-superconfig"
RCT-Folly:
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTDeprecation:
@@ -3505,8 +3533,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-blurhash"
react-native-carplay:
:path: "../node_modules/react-native-carplay"
react-native-config:
:path: "../node_modules/react-native-config"
react-native-google-cast:
:path: "../node_modules/react-native-google-cast"
react-native-netinfo:
@@ -3630,6 +3656,7 @@ SPEC CHECKSUMS:
NitroOta: 92d4eb528566b6babf5e4a30adbda44bfa803a9b
NitroOtaBundleManager: 8fad871db2daf6b9ee6f04a100c79605cfa81e8d
NitroPlayer: 8bc7be5caa2240ed636e4c1128791473eaf07a8b
NitroSuperconfig: 54d86ee90bb78cbca09d119ea775a53ffbedb0fc
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1
@@ -3671,7 +3698,6 @@ SPEC CHECKSUMS:
react-native-blob-util: e2162ce4757849682559754bca954b65dc7eeb2f
react-native-blurhash: 93b024ff78f7912d22b1cdba262f3c91d3e2002e
react-native-carplay: 8f388f6f73e5e0f73ed154ad8794371343ee20c0
react-native-config: f1dde39f8468ad922fc7e8bd4308c8e6223d5ee8
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-netinfo: 34238ef2d5902cd505de92671b36eb7c28a55184
react-native-pager-view: d7d2aa47f54343bf55fdcee3973503dd27c2bd37
@@ -3719,7 +3745,7 @@ SPEC CHECKSUMS:
RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
RNReanimated: 942d757148da78f5663d1fdf9ab71d1e75946c22
RNScreens: 714e10b6b554f7dc7ad9f78dcf36dc8e3fc73415
RNScreens: e66f520506371042666e38a06d5d7e9580f0d3a0
RNSentry: fdb39d5f294e492aa2f08ad80e510310dc223772
RNWorklets: 01efdd402d236a13651ea5ea5437ca85a44e7afa
Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6

View File

@@ -16,6 +16,15 @@ jest.mock('../../src/api/info', () => {
}
})
jest.mock('react-native-superconfig', () => ({
__esModule: true,
default: {
OTA_UPDATE_ENABLED: 'false',
IS_MAESTRO_BUILD: 'false',
GLITCHTIP_DSN: '',
},
}))
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter')
jest.mock('react-native-haptic-feedback', () => {

View File

@@ -41,13 +41,13 @@
"@jellyfin/sdk": "0.13.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/cli": "20.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-community/netinfo": "11.5.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.10.1",
"@react-navigation/material-top-tabs": "7.4.13",
"@react-navigation/native": "7.1.28",
"@react-navigation/native-stack": "7.10.1",
"@react-navigation/native-stack": "7.11.0",
"@sentry/react-native": "7.8.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.144.3",
@@ -65,7 +65,6 @@
"react-native-blob-util": "^0.22.2",
"react-native-blurhash": "^2.1.3",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-config": "1.5.6",
"react-native-device-info": "15.0.1",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.30.0",
@@ -80,11 +79,12 @@
"react-native-pager-view": "8.0.0",
"react-native-reanimated": "4.1.6",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.20.0",
"react-native-screens": "4.21.0",
"react-native-sortables": "1.9.4",
"react-native-superconfig": "^0.6.0",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-turbo-image": "^1.23.1",
"react-native-turbo-image": "1.24.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-worklets": "^0.7.1",

View File

@@ -64,6 +64,16 @@ export default function Icon({
const pressStyle = animation ? { opacity: 0.6 } : undefined
// Tamagui theme keys are unprefixed (e.g. "primary" not "$primary"); resolve for token strings
const themeColorKey =
color && typeof color === 'string' && color.startsWith('$') ? color.slice(1) : color
const resolvedColor =
color && !disabled
? (theme[themeColorKey as keyof typeof theme]?.val ?? theme.color.val)
: disabled
? theme.neutral.val
: theme.color.val
return (
<YStack
animation={animation}
@@ -76,13 +86,7 @@ export default function Icon({
flex={flex}
>
<MaterialDesignIcon
color={
color && !disabled
? theme[color]?.val
: disabled
? theme.neutral.val
: theme.color.val
}
color={resolvedColor}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name={name as any}
size={size}

View File

@@ -1,4 +1,4 @@
import { SizeTokens, XStack, Separator, Switch, styled, getToken } from 'tamagui'
import { SizeTokens, XStack, Separator, Switch, styled } from 'tamagui'
import { Label } from './text'
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
@@ -10,9 +10,10 @@ interface SwitchWithLabelProps {
width?: number | undefined
}
// Use theme tokens so thumb colors follow the active color preset
const JellifySliderThumb = styled(Switch.Thumb, {
borderColor: getToken('$color.amethyst'),
backgroundColor: getToken('$color.purpleDark'),
borderColor: '$primary',
backgroundColor: '$background',
})
export function SwitchWithLabel(props: SwitchWithLabelProps) {

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { getToken, useTheme, View, YStack, ZStack } from 'tamagui'
import { useTheme, View, YStack, ZStack } from 'tamagui'
import { useWindowDimensions } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import { getBlurhashFromDto } from '../../../utils/parsing/blurhash'
@@ -26,13 +26,13 @@ export default function BlurredBackground(): React.JSX.Element {
// Get blurhash safely
const blurhash = nowPlaying?.item ? getBlurhashFromDto(nowPlaying.item) : null
// Define gradient colors
const darkGradientColors = [getToken('$black'), getToken('$black25')]
// Use theme colors so the gradient follows the active color preset
const darkGradientColors = [theme.background.val, theme.background25.val]
const darkGradientColors2 = [
getToken('$black25'),
getToken('$black75'),
getToken('$black'),
getToken('$black'),
theme.background25.val,
theme.background75.val,
theme.background.val,
theme.background.val,
]
// Define styles

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { Progress, XStack, YStack } from 'tamagui'
import { Progress, useTheme, XStack, YStack } from 'tamagui'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker'
@@ -35,6 +35,7 @@ export default function Miniplayer(): React.JSX.Element | null {
console.log('nowPlaying', nowPlaying)
const skip = useSkip()
const previous = usePrevious()
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
@@ -99,7 +100,7 @@ export default function Miniplayer(): React.JSX.Element | null {
pressStyle={pressStyle}
animation={'quick'}
onPress={openPlayer}
backgroundColor='$background'
backgroundColor={theme.background.val}
>
<MiniPlayerProgress />
<XStack alignItems='center' padding={'$2'}>

View File

@@ -1,6 +1,8 @@
import React, { Suspense, lazy } from 'react'
import { useColorScheme } from 'react-native'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import { getToken, getTokenValue, useTheme, Spinner, YStack } from 'tamagui'
import { getTokenValue, useTheme, Spinner, YStack } from 'tamagui'
import { useColorPresetSetting, useThemeSetting } from '../../stores/settings/app'
import SettingsTabBar from './tab-bar'
// Lazy load tab components to improve initial render
@@ -63,9 +65,16 @@ function LazyInfoTab() {
export default function Settings(): React.JSX.Element {
const theme = useTheme()
const [themeSetting] = useThemeSetting()
const [colorPreset] = useColorPresetSetting()
const isDarkMode = useColorScheme() === 'dark'
const resolvedMode = themeSetting === 'system' ? (isDarkMode ? 'dark' : 'light') : themeSetting
// Key forces navigator to remount when preset/mode changes so tab bar colors update
const themeKey = `${colorPreset}_${resolvedMode}`
return (
<SettingsTabsNavigator.Navigator
key={themeKey}
screenOptions={{
tabBarIndicatorStyle: {
borderColor: theme.background.val,

View File

@@ -2,7 +2,9 @@ import { YStack, XStack, Paragraph, SizableText } from 'tamagui'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import SettingsListGroup from './settings-list-group'
import {
ColorPreset,
ThemeSetting,
useColorPresetSetting,
useHideRunTimesSetting,
useReducedHapticsSetting,
useSendMetricsSetting,
@@ -18,6 +20,12 @@ type ThemeOptionConfig = {
icon: string
}
type ColorPresetOptionConfig = {
value: ColorPreset
label: string
icon: string
}
const THEME_OPTIONS: ThemeOptionConfig[] = [
{
value: 'system',
@@ -41,6 +49,34 @@ const THEME_OPTIONS: ThemeOptionConfig[] = [
},
]
const COLOR_PRESET_OPTIONS: ColorPresetOptionConfig[] = [
{
value: 'purple',
label: 'Purple',
icon: 'crown',
},
{
value: 'ocean',
label: 'Ocean',
icon: 'waves',
},
{
value: 'forest',
label: 'Forest',
icon: 'forest',
},
{
value: 'sunset',
label: 'Sunset',
icon: 'weather-sunset',
},
{
value: 'peanut',
label: 'Peanut',
icon: 'peanut',
},
]
function ActionChip({
active,
label,
@@ -113,6 +149,42 @@ function ThemeOptionCard({
)
}
function ColorPresetOptionCard({
option,
isSelected,
onPress,
}: {
option: ColorPresetOptionConfig
isSelected: boolean
onPress: () => void
}) {
return (
<YStack
onPress={onPress}
pressStyle={{ scale: 0.97 }}
animation='quick'
borderWidth={'$1'}
borderColor={isSelected ? '$primary' : '$borderColor'}
backgroundColor={isSelected ? '$background25' : '$background'}
borderRadius={'$9'}
padding='$3'
gap='$2'
hitSlop={8}
role='button'
aria-label={`${option.label} color preset option`}
aria-selected={isSelected}
>
<XStack alignItems='center' gap='$2'>
<Icon small name={option.icon} color={isSelected ? '$primary' : '$borderColor'} />
<SizableText size={'$4'} flex={1} fontWeight='600'>
{option.label}
</SizableText>
{isSelected && <Icon small name='check-circle-outline' color={'$primary'} />}
</XStack>
</YStack>
)
}
function getThemeSubtitle(themeSetting: ThemeSetting): string {
switch (themeSetting) {
case 'light':
@@ -126,10 +198,28 @@ function getThemeSubtitle(themeSetting: ThemeSetting): string {
}
}
function getColorPresetSubtitle(colorPreset: ColorPreset): string {
switch (colorPreset) {
case 'purple':
return 'Purple vibes'
case 'ocean':
return 'Oceanic vibes'
case 'forest':
return 'Foresty vibes'
case 'sunset':
return 'Sunset vibes'
case 'peanut':
return 'Sandbox vibes'
default:
return 'Default vibes'
}
}
export default function PreferencesTab(): React.JSX.Element {
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
const [themeSetting, setThemeSetting] = useThemeSetting()
const [colorPreset, setColorPreset] = useColorPresetSetting()
const [hideRunTimes, setHideRunTimes] = useHideRunTimesSetting()
const left = useSwipeSettingsStore((s) => s.left)
@@ -138,7 +228,7 @@ export default function PreferencesTab(): React.JSX.Element {
const toggleRight = useSwipeSettingsStore((s) => s.toggleRight)
const themeSubtitle = getThemeSubtitle(themeSetting)
const colorPresetSubtitle = getColorPresetSubtitle(colorPreset)
return (
<SettingsListGroup
settingsList={[
@@ -160,6 +250,24 @@ export default function PreferencesTab(): React.JSX.Element {
</YStack>
),
},
{
title: 'Color Preset',
subTitle: colorPresetSubtitle && `${colorPresetSubtitle}`,
iconName: 'palette',
iconColor: '$primary',
children: (
<YStack gap='$2' paddingVertical='$2'>
{COLOR_PRESET_OPTIONS.map((option) => (
<ColorPresetOptionCard
key={option.value}
option={option}
isSelected={colorPreset === option.value}
onPress={() => setColorPreset(option.value)}
/>
))}
</YStack>
),
},
{
title: 'Track Swipe Actions',
subTitle: 'Choose actions for left/right swipes',

View File

@@ -10,13 +10,17 @@ import {
} from '@typedigital/telemetrydeck-react'
import telemetryDeckConfig from '../../telemetrydeck.json'
import * as Sentry from '@sentry/react-native'
import { getToken, Theme, useTheme } from 'tamagui'
import { getToken, Theme, ThemeName, useTheme } from 'tamagui'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../configs/toast.config'
import { useColorScheme } from 'react-native'
import { StorageProvider } from '../providers/Storage'
import { useSelectPlayerEngine } from '../stores/player/engine'
import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app'
import {
useColorPresetSetting,
useSendMetricsSetting,
useThemeSetting,
} from '../stores/settings/app'
import { GLITCHTIP_DSN } from '../configs/config'
import useDownloadProcessor from '../hooks/use-download-processor'
/**
@@ -25,12 +29,17 @@ import useDownloadProcessor from '../hooks/use-download-processor'
*/
export default function Jellify(): React.JSX.Element {
const [theme] = useThemeSetting()
const [colorPreset] = useColorPresetSetting()
const isDarkMode = useColorScheme() === 'dark'
const resolvedMode = theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme
const themeName = `${colorPreset}_${resolvedMode}` // e.g. 'purple_dark'
useSelectPlayerEngine()
return (
<Theme name={theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme}>
<Theme name={themeName as ThemeName | null}>
<JellifyLoggingWrapper>
<DisplayProvider>
<App />

View File

@@ -1,5 +1,7 @@
import { DarkTheme, DefaultTheme } from '@react-navigation/native'
import { getToken, getTokens } from 'tamagui'
import type { Theme } from '@react-navigation/native'
import { PRESET_PALETTES } from '../../tamagui.config'
import type { ColorPreset } from '../stores/settings/app'
interface Fonts {
regular: FontStyle
@@ -43,38 +45,36 @@ const JellifyFonts: Fonts = {
},
}
export const JellifyDarkTheme: ReactNavigation.Theme = {
dark: true,
colors: {
...DarkTheme.colors,
card: getTokens().color.$darkBackground.val,
border: getTokens().color.$neutral.val,
background: getTokens().color.$darkBackground.val,
primary: getTokens().color.$primaryDark.val,
},
fonts: JellifyFonts,
function paletteToNavTheme(
palette: (typeof PRESET_PALETTES)['purple']['dark'],
dark: boolean,
): Theme {
const base = dark ? DarkTheme : DefaultTheme
return {
...base,
dark,
colors: {
...base.colors,
background: palette.background,
card: palette.background,
border: palette.borderColor,
primary: palette.primary,
},
fonts: JellifyFonts,
}
}
export const JellifyLightTheme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: getTokens().color.$primaryLight.val,
border: getTokens().color.$neutral.val,
background: getTokens().color.$white.val,
card: getTokens().color.$white.val,
},
fonts: JellifyFonts,
/** React Navigation theme for a given color preset and mode (purple_dark, ocean_light, etc.) */
export function getJellifyNavTheme(preset: ColorPreset, mode: 'light' | 'dark' | 'oled'): Theme {
const palette = PRESET_PALETTES[preset][mode]
return paletteToNavTheme(palette, mode !== 'light')
}
export const JellifyOLEDTheme: ReactNavigation.Theme = {
dark: true,
colors: {
...DarkTheme.colors,
card: getTokens().color.$black.val,
border: getTokens().color.$neutral.val,
background: getTokens().color.$black.val,
primary: getTokens().color.$primaryDark.val,
},
fonts: JellifyFonts,
}
/** Purple dark — matches Tamagui purple_dark (current JellifyDarkTheme) */
export const JellifyDarkTheme: Theme = paletteToNavTheme(PRESET_PALETTES.purple.dark, true)
/** Purple light — matches Tamagui purple_light (current JellifyLightTheme) */
export const JellifyLightTheme: Theme = paletteToNavTheme(PRESET_PALETTES.purple.light, false)
/** Purple oled — matches Tamagui purple_oled (current JellifyOLEDTheme) */
export const JellifyOLEDTheme: Theme = paletteToNavTheme(PRESET_PALETTES.purple.oled, true)

View File

@@ -1,4 +1,4 @@
import Config from 'react-native-config'
import Config from 'react-native-superconfig'
const OTA_UPDATE_ENABLED = Config.OTA_UPDATE_ENABLED === 'true'
const IS_MAESTRO_BUILD = Config.IS_MAESTRO_BUILD === 'true'

View File

@@ -28,6 +28,7 @@ export default function Tabs({ route, navigation }: TabProps): React.JSX.Element
animation: 'shift',
tabBarActiveTintColor: theme.primary.val,
tabBarInactiveTintColor: theme.borderColor.val,
tabBarStyle: { backgroundColor: theme.background.val },
lazy: true,
}}
tabBar={(props) => <TabBar {...props} />}

View File

@@ -1,17 +1,57 @@
import React, { useMemo } from 'react'
import Miniplayer from '../../components/Player/mini-player'
import InternetConnectionWatcher from '../../components/Network/internetConnectionWatcher'
import { BottomTabBar, BottomTabBarProps } from '@react-navigation/bottom-tabs'
import useIsMiniPlayerActive from '../../hooks/use-mini-player'
import { useTheme } from 'tamagui'
export default function TabBar({ ...props }: BottomTabBarProps): React.JSX.Element {
/**
* Merge theme-driven tab bar options into the focused route's descriptor
* so the bar updates immediately when color preset changes (the navigator
* often does not re-pass updated screenOptions to the tab bar).
*/
export default function TabBar(props: BottomTabBarProps): React.JSX.Element {
const isMiniPlayerActive = useIsMiniPlayerActive()
const theme = useTheme()
const descriptorsWithTheme = useMemo(() => {
const focusedRoute = props.state.routes[props.state.index]
const focusedDescriptor = props.descriptors[focusedRoute.key]
if (!focusedDescriptor) return props.descriptors
return {
...props.descriptors,
[focusedRoute.key]: {
...focusedDescriptor,
options: {
...focusedDescriptor.options,
tabBarStyle: {
...focusedDescriptor.options.tabBarStyle,
backgroundColor: theme.background.val,
},
tabBarActiveTintColor: theme.primary.val,
tabBarInactiveTintColor: theme.borderColor.val,
},
},
}
}, [
props.descriptors,
props.state.routes,
props.state.index,
theme.background.val,
theme.primary.val,
theme.borderColor.val,
])
// Key forces mini-player to remount when theme changes so colors update
// (avoids stale styles from Reanimated/Progress when preset changes without interaction)
const themeKey = `${theme.background.val}-${theme.primary.val}`
return (
<>
{isMiniPlayerActive && <Miniplayer />}
{isMiniPlayerActive && <Miniplayer key={themeKey} />}
<InternetConnectionWatcher />
<BottomTabBar {...props} />
<BottomTabBar {...props} descriptors={descriptorsWithTheme} />
</>
)
}

View File

@@ -4,6 +4,7 @@ import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'
export type ThemeSetting = 'system' | 'light' | 'dark' | 'oled'
export type ColorPreset = 'purple' | 'ocean' | 'forest' | 'sunset' | 'peanut'
type AppSettingsStore = {
sendMetrics: boolean
@@ -17,6 +18,9 @@ type AppSettingsStore = {
theme: ThemeSetting
setTheme: (theme: ThemeSetting) => void
colorPreset: ColorPreset
setColorPreset: (colorPreset: ColorPreset) => void
}
export const useAppSettingsStore = create<AppSettingsStore>()(
@@ -34,6 +38,9 @@ export const useAppSettingsStore = create<AppSettingsStore>()(
theme: 'system',
setTheme: (theme: ThemeSetting) => set({ theme }),
colorPreset: 'purple',
setColorPreset: (colorPreset: ColorPreset) => set({ colorPreset }),
}),
{
name: 'app-settings-storage',
@@ -50,6 +57,18 @@ export const useThemeSetting: () => [ThemeSetting, (theme: ThemeSetting) => void
return [theme, setTheme]
}
export const useColorPresetSetting: () => [
ColorPreset,
(colorPreset: ColorPreset) => void,
] = () => {
const colorPreset = useAppSettingsStore((state) => state.colorPreset)
const setColorPreset = useAppSettingsStore((state) => state.setColorPreset)
return [colorPreset, setColorPreset]
}
export const useReducedHapticsSetting: () => [boolean, (reducedHaptics: boolean) => void] = () => {
const reducedHaptics = useAppSettingsStore((state) => state.reducedHaptics)

View File

@@ -1,4 +1,4 @@
declare module 'react-native-config' {
declare module 'react-native-superconfig' {
export interface NativeConfig {
OTA_UPDATE_ENABLED?: string
IS_MAESTRO_BUILD?: string

View File

@@ -51,6 +51,275 @@ const tokens = createTokens({
},
})
/** Theme mode palette: semantic keys used by Tamagui and React Navigation */
type PresetModePalette = {
background: string
background75: string
background50: string
background25: string
borderColor: string
color: string
success: string
secondary: string
primary: string
danger: string
warning: string
neutral: string
translucent: string
}
/** Palettes per preset (purple = current Jellify themes), for Tamagui + nav */
export const PRESET_PALETTES: Record<
'purple' | 'ocean' | 'forest' | 'sunset' | 'peanut',
{ light: PresetModePalette; dark: PresetModePalette; oled: PresetModePalette }
> = {
purple: {
// Matches current JellifyDarkTheme / JellifyLightTheme / JellifyOLEDTheme
dark: {
background: 'rgba(25, 24, 28, 1)',
background75: 'rgba(25, 24, 28, 0.75)',
background50: 'rgba(25, 24, 28, 0.5)',
background25: 'rgba(25, 24, 28, 0.25)',
borderColor: '#77748E',
color: '#ffffff',
success: 'rgba(87, 233, 201, 1)',
secondary: 'rgba(75, 125, 215, 1)',
primary: '#887BFF',
danger: '#FF066F',
warning: '#FF6625',
neutral: '#77748E',
translucent: 'rgba(0, 0, 0, 0.5)',
},
light: {
background: '#ffffff',
background75: 'rgba(235, 221, 255, 0.75)',
background50: 'rgba(235, 221, 255, 0.5)',
background25: 'rgba(235, 221, 255, 0.25)',
borderColor: '#77748E',
color: '#0C0622',
success: 'rgba(16, 175, 141, 1)',
secondary: 'rgba(0, 58, 159, 1)',
primary: '#4b0fd6ff',
danger: '#B30077',
warning: '#a93300ff',
neutral: '#77748E',
translucent: 'rgba(255, 255, 255, 0.75)',
},
oled: {
background: '#000000',
background75: 'rgba(0, 0, 0, 0.75)',
background50: 'rgba(0, 0, 0, 0.5)',
background25: 'rgba(0, 0, 0, 0.25)',
borderColor: '#77748E',
color: '#ffffff',
success: 'rgba(87, 233, 201, 1)',
secondary: 'rgba(75, 125, 215, 1)',
primary: '#887BFF',
danger: '#FF066F',
warning: '#FF6625',
neutral: '#77748E',
translucent: 'rgba(0, 0, 0, 0.5)',
},
},
ocean: {
dark: {
background: 'rgba(25, 24, 28, 1)',
background75: 'rgba(25, 24, 28, 0.75)',
background50: 'rgba(25, 24, 28, 0.5)',
background25: 'rgba(25, 24, 28, 0.25)',
borderColor: '#78909C',
color: '#ffffff',
success: '#4DD0E1',
secondary: '#81D4FA',
primary: '#4FC3F7',
danger: '#FF7043',
warning: '#FFB74D',
neutral: '#78909C',
translucent: 'rgba(0, 0, 0, 0.5)',
},
light: {
background: '#E1F5FE',
background75: 'rgba(225, 245, 254, 0.75)',
background50: 'rgba(225, 245, 254, 0.5)',
background25: 'rgba(225, 245, 254, 0.25)',
borderColor: '#546E7A',
color: '#01579B',
success: '#00838F',
secondary: '#0277BD',
primary: '#0288D1',
danger: '#D84315',
warning: '#EF6C00',
neutral: '#546E7A',
translucent: 'rgba(255, 255, 255, 0.75)',
},
oled: {
background: '#000000',
background75: 'rgba(0, 0, 0, 0.75)',
background50: 'rgba(0, 0, 0, 0.5)',
background25: 'rgba(0, 0, 0, 0.25)',
borderColor: '#78909C',
color: '#ffffff',
success: '#4DD0E1',
secondary: '#81D4FA',
primary: '#4FC3F7',
danger: '#FF7043',
warning: '#FFB74D',
neutral: '#78909C',
translucent: 'rgba(0, 0, 0, 0.5)',
},
},
forest: {
dark: {
background: 'rgb(35, 47, 35)',
background75: 'rgba(35, 47, 35, 0.75)',
background50: 'rgba(35, 47, 35, 0.5)',
background25: 'rgba(35, 47, 35, 0.25)',
borderColor: '#8D9E8C',
color: '#ffffff',
success: '#66BB6A',
secondary: '#9CCC65',
primary: 'rgb(56, 105, 56)',
danger: '#E57373',
warning: '#FFB74D',
neutral: '#8D9E8C',
translucent: 'rgba(0, 0, 0, 0.5)',
},
light: {
background: '#E8F5E9',
background75: 'rgba(232, 245, 233, 0.75)',
background50: 'rgba(232, 245, 233, 0.5)',
background25: 'rgba(232, 245, 233, 0.25)',
borderColor: '#558B2F',
color: '#1B5E20',
success: '#2E7D32',
secondary: '#43A047',
primary: 'rgb(14, 143, 21)',
danger: '#C62828',
warning: '#E65100',
neutral: '#558B2F',
translucent: 'rgba(255, 255, 255, 0.75)',
},
oled: {
background: '#000000',
background75: 'rgba(0, 0, 0, 0.75)',
background50: 'rgba(0, 0, 0, 0.5)',
background25: 'rgba(0, 0, 0, 0.25)',
borderColor: '#8D9E8C',
color: '#ffffff',
success: '#66BB6A',
secondary: '#9CCC65',
primary: 'rgb(11, 128, 17)',
danger: '#E57373',
warning: '#FFB74D',
neutral: '#8D9E8C',
translucent: 'rgba(0, 0, 0, 0.5)',
},
},
sunset: {
dark: {
background: 'rgb(52, 34, 28)',
background75: 'rgba(52, 34, 28, 0.75)',
background50: 'rgba(52, 34, 28, 0.5)',
background25: 'rgba(52, 34, 28, 0.25)',
borderColor: '#A1887F',
color: '#ffffff',
success: '#FFAB91',
secondary: '#FF8A65',
primary: '#FF7043',
danger: '#EF5350',
warning: '#FFCA28',
neutral: '#A1887F',
translucent: 'rgba(0, 0, 0, 0.5)',
},
light: {
background: '#FFF3E0',
background75: 'rgba(255, 243, 224, 0.75)',
background50: 'rgba(255, 243, 224, 0.5)',
background25: 'rgba(255, 243, 224, 0.25)',
borderColor: '#BF360C',
color: '#3E2723',
success: '#E64A19',
secondary: '#FF5722',
primary: '#FF5722',
danger: '#B71C1C',
warning: '#F57C00',
neutral: '#BF360C',
translucent: 'rgba(255, 255, 255, 0.75)',
},
oled: {
background: '#000000',
background75: 'rgba(0, 0, 0, 0.75)',
background50: 'rgba(0, 0, 0, 0.5)',
background25: 'rgba(0, 0, 0, 0.25)',
borderColor: '#A1887F',
color: '#ffffff',
success: '#FFAB91',
secondary: '#FF8A65',
primary: '#FF7043',
danger: '#EF5350',
warning: '#FFCA28',
neutral: '#A1887F',
translucent: 'rgba(0, 0, 0, 0.5)',
},
},
peanut: {
dark: {
background: 'rgba(62, 39, 22, 1)',
background75: 'rgba(62, 39, 22, 0.75)',
background50: 'rgba(62, 39, 22, 0.5)',
background25: 'rgba(62, 39, 22, 0.25)',
borderColor: '#BCAAA4',
color: '#ffffff',
success: '#D7CCC8',
secondary: '#A1887F',
primary: '#D7CCC8',
danger: '#8D6E63',
warning: '#FFAB91',
neutral: '#BCAAA4',
translucent: 'rgba(0, 0, 0, 0.5)',
},
light: {
background: '#EFEBE9',
background75: 'rgba(239, 235, 233, 0.75)',
background50: 'rgba(239, 235, 233, 0.5)',
background25: 'rgba(239, 235, 233, 0.25)',
borderColor: '#6D4C41',
color: '#3E2723',
success: '#5D4037',
secondary: '#795548',
primary: '#8D6E63',
danger: '#4E342E',
warning: '#BF360C',
neutral: '#6D4C41',
translucent: 'rgba(255, 255, 255, 0.75)',
},
oled: {
background: '#000000',
background75: 'rgba(0, 0, 0, 0.75)',
background50: 'rgba(0, 0, 0, 0.5)',
background25: 'rgba(0, 0, 0, 0.25)',
borderColor: '#BCAAA4',
color: '#ffffff',
success: '#D7CCC8',
secondary: '#A1887F',
primary: '#D7CCC8',
danger: '#8D6E63',
warning: '#FFAB91',
neutral: '#BCAAA4',
translucent: 'rgba(0, 0, 0, 0.5)',
},
},
}
const presetNames = ['purple', 'ocean', 'forest', 'sunset', 'peanut'] as const
const themes: Record<string, PresetModePalette> = {}
for (const preset of presetNames) {
for (const mode of ['light', 'dark', 'oled'] as const) {
themes[`${preset}_${mode}`] = PRESET_PALETTES[preset][mode]
}
}
const jellifyConfig = createTamagui({
animations,
fonts: {
@@ -60,89 +329,7 @@ const jellifyConfig = createTamagui({
media,
shorthands,
tokens,
themes: {
dark: {
background: tokens.color.darkBackground,
background75: tokens.color.darkBackground75,
background50: tokens.color.darkBackground50,
background25: tokens.color.darkBackground25,
borderColor: tokens.color.neutral,
color: tokens.color.white,
success: tokens.color.tealDark,
secondary: tokens.color.secondaryDark,
primary: tokens.color.primaryDark,
danger: tokens.color.dangerDark,
warning: tokens.color.warningDark,
neutral: tokens.color.neutral,
translucent: tokens.color.darkTranslucent,
},
oled: {
// True black OLED theme
background: tokens.color.black,
background75: tokens.color.black75,
background50: tokens.color.black50,
background25: tokens.color.black25,
borderColor: tokens.color.neutral,
color: tokens.color.white,
success: tokens.color.tealDark,
secondary: tokens.color.secondaryDark,
primary: tokens.color.primaryDark,
danger: tokens.color.dangerDark,
warning: tokens.color.warningDark,
neutral: tokens.color.neutral,
translucent: tokens.color.darkTranslucent,
},
dark_inverted_purple: {
color: tokens.color.purpleDark,
borderColor: tokens.color.amethyst,
background: tokens.color.amethyst,
background25: tokens.color.amethyst25,
background50: tokens.color.amethyst50,
background75: tokens.color.amethyst75,
success: tokens.color.tealDark,
secondary: tokens.color.secondaryDark,
primary: tokens.color.primaryDark,
danger: tokens.color.dangerDark,
warning: tokens.color.warningDark,
neutral: tokens.color.neutral,
translucent: tokens.color.darkTranslucent,
},
light: {
background: tokens.color.white,
background75: tokens.color.lightBackground75,
background50: tokens.color.lightBackground50,
background25: tokens.color.lightBackground25,
borderColor: tokens.color.neutral,
color: tokens.color.purpleDark,
success: tokens.color.tealLight,
secondary: tokens.color.secondaryLight,
primary: tokens.color.primaryLight,
danger: tokens.color.dangerLight,
warning: tokens.color.warningLight,
neutral: tokens.color.neutral,
translucent: tokens.color.lightTranslucent,
},
light_inverted_purple: {
color: tokens.color.purpleDark,
borderColor: tokens.color.neutral,
background: tokens.color.amethyst,
background25: tokens.color.amethyst25,
background50: tokens.color.amethyst50,
background75: tokens.color.amethyst75,
success: tokens.color.tealLight,
secondary: tokens.color.secondaryLight,
primary: tokens.color.primaryLight,
danger: tokens.color.dangerLight,
warning: tokens.color.warningLight,
neutral: tokens.color.neutral,
translucent: tokens.color.lightTranslucent,
},
},
themes,
})
export type JellifyConfig = typeof jellifyConfig