diff --git a/src/features/rnbw-membership/components/MembershipTierButton/MembershipTierButtonSurface.tsx b/src/features/rnbw-membership/components/MembershipTierButton/MembershipTierButtonSurface.tsx index 734391b8204..7cc77ab53cf 100644 --- a/src/features/rnbw-membership/components/MembershipTierButton/MembershipTierButtonSurface.tsx +++ b/src/features/rnbw-membership/components/MembershipTierButton/MembershipTierButtonSurface.tsx @@ -7,6 +7,7 @@ import { GradientBorderView } from '@/components/gradient-border/GradientBorderV import { useColorMode } from '@/design-system'; import { getValueForColorMode } from '@/design-system/color/palettes'; import { InnerShadow } from '@/features/polymarket/components/InnerShadow'; +import { RainbowShimmerFill } from '@/features/rnbw-membership/components/RainbowShimmerFill'; import { ShadowLayers } from '@/features/rnbw-membership/components/ShadowLayers'; import { MEMBERSHIP_CARD_BACKGROUND_COLOR } from '@/features/rnbw-membership/membershipCardTheme'; import { getTierPrimaryButtonTheme, getTierSecondaryButtonTheme } from '@/features/rnbw-membership/tierVisuals'; @@ -39,7 +40,7 @@ export const MembershipTierButtonSurface = memo(function MembershipTierButtonSur const borderRadius = height / 2; const shadowLayerBackgroundColor = getValueForColorMode(MEMBERSHIP_CARD_BACKGROUND_COLOR, colorMode); - const { borderGradient, surfaceGradient, surfaceHighlight, shadows } = useMemo(() => { + const { borderGradient, surfaceGradient, surfaceHighlight, shadows, overlay } = useMemo(() => { const surfaceTheme = variant === 'primary' ? getTierPrimaryButtonTheme(tier.level).surface : getTierSecondaryButtonTheme(tier.level).surface; @@ -48,6 +49,7 @@ export const MembershipTierButtonSurface = memo(function MembershipTierButtonSur surfaceGradient: getValueForColorMode(surfaceTheme.fill, colorMode), surfaceHighlight: surfaceTheme.highlight ? getValueForColorMode(surfaceTheme.highlight, colorMode) : null, shadows: getValueForColorMode(surfaceTheme.shadows, colorMode), + overlay: surfaceTheme.overlay ? getValueForColorMode(surfaceTheme.overlay, colorMode) : null, }; }, [tier.level, variant, colorMode]); @@ -66,13 +68,24 @@ export const MembershipTierButtonSurface = memo(function MembershipTierButtonSur end={borderGradient.end ?? GRADIENT_END} style={resolvedContainerStyle} > - + {overlay ? ( + + ) : ( + + )} {surfaceHighlight && ( (null); + + const onLayout = useCallback((e: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width: w, height: h } = e.nativeEvent.layout; + setMeasured(prev => (prev && prev.width === w && prev.height === h ? prev : { width: w, height: h })); + }, []); + + const width = measured?.width ?? 0; + const height = measured?.height ?? 0; + const canDisplay = width > 0 && height > 0; + + // Skia wants mutable arrays + const skiaFillColors = useMemo(() => [...fillColors], [fillColors]); + const skiaFillLocations = useMemo(() => (fillLocations ? [...fillLocations] : undefined), [fillLocations]); + const skiaOverlayColors = useMemo(() => [...overlay.colors], [overlay.colors]); + const skiaOverlayLocations = useMemo(() => [...overlay.locations], [overlay.locations]); + + return ( + + {canDisplay && ( + + + + + + }> + + + + + + + } + > + + + + + + + )} + + ); +}); diff --git a/src/features/rnbw-membership/components/ShadowLayers.tsx b/src/features/rnbw-membership/components/ShadowLayers.tsx index f301b1ced82..60ede92d8c5 100644 --- a/src/features/rnbw-membership/components/ShadowLayers.tsx +++ b/src/features/rnbw-membership/components/ShadowLayers.tsx @@ -13,6 +13,7 @@ type ShadowLayersProps = { borderRadius: number; backgroundColor: string; children: ReactNode; + outerGlowLayer?: ReactNode; style?: StyleProp; }; @@ -30,9 +31,17 @@ function getShadowLayerStyle(shadowStyle: ShadowStyle): ViewStyle { return shadowStyle; } -export const ShadowLayers = memo(function ShadowLayers({ shadows, borderRadius, backgroundColor, children, style }: ShadowLayersProps) { +export const ShadowLayers = memo(function ShadowLayers({ + shadows, + borderRadius, + backgroundColor, + children, + outerGlowLayer, + style, +}: ShadowLayersProps) { return ( + {outerGlowLayer} {shadows.map((shadowStyle, index) => ( (null); + + const dx = shadow.dx ?? 0; + const dy = shadow.dy ?? 0; + const start = shadow.start ?? DEFAULT_START; + const end = shadow.end ?? DEFAULT_END; + const padding = useMemo( + () => PixelRatio.roundToNearestPixel(shadow.blur * 2 + Math.max(Math.abs(dx), Math.abs(dy))), + [shadow.blur, dx, dy] + ); + + const skiaColors = useMemo(() => [...shadow.colors], [shadow.colors]); + const skiaLocations = useMemo(() => (shadow.locations ? [...shadow.locations] : undefined), [shadow.locations]); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const nextLayout = { + width: PixelRatio.roundToNearestPixel(event.nativeEvent.layout.width), + height: PixelRatio.roundToNearestPixel(event.nativeEvent.layout.height), + }; + + setLayout(current => (current && current.width === nextLayout.width && current.height === nextLayout.height ? current : nextLayout)); + }, []); + + const width = layout?.width ?? 0; + const height = layout?.height ?? 0; + const canRender = width > 0 && height > 0; + + return ( + + {canRender && ( + + + + + + + )} + + ); +}); + +const styles = StyleSheet.create({ + canvas: { + position: 'absolute', + }, +}); diff --git a/src/features/rnbw-membership/components/TierBadge.tsx b/src/features/rnbw-membership/components/TierBadge.tsx index 411e1b46118..b83b5a8a5a9 100644 --- a/src/features/rnbw-membership/components/TierBadge.tsx +++ b/src/features/rnbw-membership/components/TierBadge.tsx @@ -9,7 +9,9 @@ import { Text, useColorMode } from '@/design-system'; import { getValueForColorMode, globalColors } from '@/design-system/color/palettes'; import type { TextSize, TextWeight } from '@/design-system/components/Text/Text'; import { InnerShadow } from '@/features/polymarket/components/InnerShadow'; +import { RainbowShimmerFill } from '@/features/rnbw-membership/components/RainbowShimmerFill'; import { ShadowLayers } from '@/features/rnbw-membership/components/ShadowLayers'; +import { SkiaGradientShadow } from '@/features/rnbw-membership/components/SkiaGradientShadow'; import { getTierBadgeTheme } from '@/features/rnbw-membership/tierVisuals'; import type { Tier as TierType } from '@/features/rnbw-membership/types'; import { opacity } from '@/framework/ui/utils/opacity'; @@ -36,7 +38,14 @@ export const TierBadge = memo(function TierBadge({ }) { const { colorMode } = useColorMode(); const borderRadius = height / 2; - const { fill: badgeGradient, text, shadows: badgeShadows, border: badgeBorderGradient } = getTierBadgeTheme(tier.level); + const { + fill: badgeGradient, + text, + shadows: badgeShadows, + border: badgeBorderGradient, + overlay, + backgroundShadow, + } = getTierBadgeTheme(tier.level); const { colors: badgeGradientColors, locations: badgeGradientLocations, @@ -57,6 +66,8 @@ export const TierBadge = memo(function TierBadge({ } = getValueForColorMode(badgeBorderGradient, colorMode); const shadowStyles = getValueForColorMode(badgeShadows, colorMode); const textShadowStyle = getValueForColorMode(text.shadow, colorMode); + const resolvedOverlay = overlay ? getValueForColorMode(overlay, colorMode) : null; + const resolvedBackgroundShadow = backgroundShadow ? getValueForColorMode(backgroundShadow, colorMode) : null; const [firstBadgeGradientColor] = badgeGradientColors; const shadowLayerBackgroundColor = typeof firstBadgeGradientColor === 'string' ? firstBadgeGradientColor : '#FFFFFF'; @@ -65,6 +76,9 @@ export const TierBadge = memo(function TierBadge({ shadows={shadowStyles} borderRadius={borderRadius} backgroundColor={shadowLayerBackgroundColor} + outerGlowLayer={ + resolvedBackgroundShadow ? : null + } style={styles.shadowStack} > - + {resolvedOverlay ? ( + + ) : ( + + )} { + const { gradient, borderGradient, shadow, highlightGradient, backgroundShadow } = useMemo(() => { const progressTheme = getTierProgressBarTheme(tier.level); return { gradient: getValueForColorMode(progressTheme.fill, colorMode), borderGradient: getValueForColorMode(progressTheme.border, colorMode), shadow: getValueForColorMode(progressTheme.shadow, colorMode), highlightGradient: getValueForColorMode(progressTheme.highlight, colorMode), + backgroundShadow: progressTheme.backgroundShadow ? getValueForColorMode(progressTheme.backgroundShadow, colorMode) : null, }; }, [tier.level, colorMode]); @@ -82,6 +84,13 @@ export const TierProgressBar = memo(function TierProgressBar({ }; }, [fillWidth]); + // Skia wants mutable arrays + const skiaHighlightColors = useMemo(() => [...highlightGradient.colors], [highlightGradient.colors]); + const skiaHighlightLocations = useMemo( + () => (highlightGradient.locations ? [...highlightGradient.locations] : undefined), + [highlightGradient.locations] + ); + return ( - - - : null} + - + - - - - - + + + + + + + = { light: T; dark: T }; +export type GradientColors = readonly [string, string, ...string[]]; +export type GradientLocations = readonly [number, number, ...number[]]; type Gradient = { - colors: LinearGradientProps['colors']; - locations?: LinearGradientProps['locations']; + colors: GradientColors; + locations?: GradientLocations; start?: Point; end?: Point; }; @@ -30,6 +30,27 @@ type TextShadow = { textShadowRadius: number; }; +export type HardLightOverlay = { + colors: GradientColors; + locations: GradientLocations; + maskShadow: { + blur: number; + dx: number; + dy: number; + }; +}; + +export type GradientShadow = { + colors: GradientColors; + locations?: GradientLocations; + start?: Point; + end?: Point; + opacity: number; + blur: number; + dx?: number; + dy?: number; +}; + type GradientTextTheme = { gradient: Themed; shadow: Themed; @@ -40,6 +61,8 @@ type BadgeTheme = { border: Themed; shadows: Themed; text: GradientTextTheme; + overlay?: Themed; + backgroundShadow?: Themed; }; type ButtonSurfaceTheme = { @@ -47,6 +70,7 @@ type ButtonSurfaceTheme = { border: Themed; shadows: Themed; highlight?: Themed; + overlay?: Themed; }; type PrimaryButtonTheme = { @@ -64,6 +88,7 @@ type ProgressBarTheme = { border: Themed; shadow: Themed; highlight: Themed; + backgroundShadow?: Themed; }; type TierTheme = { @@ -80,6 +105,7 @@ type PrimaryButtonConfig = { border?: Themed; shadows?: Themed; highlight?: Themed; + overlay?: Themed; text?: { gradient?: Themed; shadow?: Themed; @@ -90,6 +116,7 @@ type SecondaryButtonConfig = { fill: Themed; border?: Themed; shadows?: Themed; + overlay?: Themed; textColor: Themed; }; @@ -119,12 +146,16 @@ const textShadow = (textShadowColor: string, textShadowOffset: TextShadow['textS textShadowRadius, }); +function isShadowArray(value: Shadow | readonly Shadow[]): value is readonly Shadow[] { + return Array.isArray(value); +} + function toShadowArray(value: Shadow | readonly Shadow[]): readonly Shadow[] { - if (Array.isArray(value)) { - return value as readonly Shadow[]; + if (isShadowArray(value)) { + return value; } - return [value as Shadow]; + return [value]; } const themedShadows = (light: Shadow | readonly Shadow[], dark?: Shadow | readonly Shadow[]): Themed => @@ -133,27 +164,34 @@ const themedShadows = (light: Shadow | readonly Shadow[], dark?: Shadow | readon const background = (lightColor: string, darkColor = lightColor): Themed => themed(gradient([opacity(lightColor, 0.4), opacity(lightColor, 0)]), gradient([opacity(darkColor, 0.16), opacity(darkColor, 0)])); -const badgePrimaryButton = (badge: BadgeTheme, config: PrimaryButtonConfig = {}): PrimaryButtonTheme => ({ - surface: { - fill: config.fill ?? badge.fill, - border: config.border ?? badge.border, - shadows: config.shadows ?? badge.shadows, - ...(config.highlight ? { highlight: config.highlight } : {}), - }, - text: { - gradient: config.text?.gradient ?? badge.text.gradient, - shadow: config.text?.shadow ?? badge.text.shadow, - }, -}); +const badgePrimaryButton = (badge: BadgeTheme, config: PrimaryButtonConfig = {}): PrimaryButtonTheme => { + const overlay = config.overlay ?? badge.overlay; + return { + surface: { + fill: config.fill ?? badge.fill, + border: config.border ?? badge.border, + shadows: config.shadows ?? badge.shadows, + ...(config.highlight ? { highlight: config.highlight } : {}), + ...(overlay ? { overlay } : {}), + }, + text: { + gradient: config.text?.gradient ?? badge.text.gradient, + shadow: config.text?.shadow ?? badge.text.shadow, + }, + }; +}; -const secondaryButton = (badge: BadgeTheme, config: SecondaryButtonConfig): SecondaryButtonTheme => ({ - surface: { - fill: config.fill, - border: config.border ?? badge.border, - shadows: config.shadows ?? badge.shadows, - }, - textColor: config.textColor, -}); +const secondaryButton = (badge: BadgeTheme, config: SecondaryButtonConfig): SecondaryButtonTheme => { + return { + surface: { + fill: config.fill, + border: config.border ?? badge.border, + shadows: config.shadows ?? badge.shadows, + ...(config.overlay ? { overlay: config.overlay } : {}), + }, + textColor: config.textColor, + }; +}; // ============ Shared Tokens ================================================== // @@ -161,6 +199,30 @@ const LIGHT_BADGE_BORDER_COLORS: Gradient['colors'] = [opacity(globalColors.grey const DEFAULT_BADGE_SHADOW = shadow('#000000', { width: 0, height: 4 }, 0.06, 6); const DEFAULT_PROGRESS_HIGHLIGHT = themed(solid('#FFFFFF')); const MONO_LABEL_GRADIENT = themed(solid('#000000'), solid('#FFFFFF')); +const TRANSPARENT_BORDER_COLORS: Gradient['colors'] = ['rgba(0,0,0,0)', 'rgba(0,0,0,0)']; + +const BLACK_TIER_RAINBOW_OVERLAY_COLORS: HardLightOverlay['colors'] = ['#A78BFF', '#8AD7FF', '#70D59E', '#FADA5D', '#FF9C92', '#FBAFD0']; +const BLACK_TIER_RAINBOW_OVERLAY_LOCATIONS: HardLightOverlay['locations'] = [0, 0.147, 0.362, 0.566, 0.785, 1]; + +const BLACK_TIER_BADGE_RAINBOW_OVERLAY: Themed = themed({ + colors: BLACK_TIER_RAINBOW_OVERLAY_COLORS, + locations: BLACK_TIER_RAINBOW_OVERLAY_LOCATIONS, + maskShadow: { + blur: 2, + dx: 0, + dy: 2, + }, +}); + +const BLACK_TIER_PRIMARY_BUTTON_RAINBOW_OVERLAY: Themed = themed({ + colors: BLACK_TIER_RAINBOW_OVERLAY_COLORS, + locations: BLACK_TIER_RAINBOW_OVERLAY_LOCATIONS, + maskShadow: { + blur: 2.5, + dx: 0, + dy: 4, + }, +}); // ============ Badges ========================================================= // @@ -215,12 +277,20 @@ const DIAMOND_BADGE: BadgeTheme = { const BLACK_BADGE: BadgeTheme = { fill: themed(gradient(['#444444', '#000000'])), - border: themed(gradient(LIGHT_BADGE_BORDER_COLORS)), + border: themed(gradient(TRANSPARENT_BORDER_COLORS)), shadows: themedShadows(DEFAULT_BADGE_SHADOW), text: { gradient: themed(solid('#FFFFFF')), shadow: themed(textShadow('rgba(0, 0, 0, 0)', { width: 0, height: 0 }, 0)), }, + overlay: BLACK_TIER_BADGE_RAINBOW_OVERLAY, + backgroundShadow: themed({ + colors: BLACK_TIER_RAINBOW_OVERLAY_COLORS, + locations: BLACK_TIER_RAINBOW_OVERLAY_LOCATIONS, + opacity: 0.5, + blur: 16, + dy: 0, + }), }; // ============ Buttons ======================================================== // @@ -333,7 +403,8 @@ const TIER_THEMES: Record = { labelGradient: MONO_LABEL_GRADIENT, badge: BLACK_BADGE, primaryButton: badgePrimaryButton(BLACK_BADGE, { - fill: themed(solid('#1A1716')), + fill: themed(gradient(['#2A2A2A', '#141414']), gradient(['#272727', '#141414'])), + overlay: BLACK_TIER_PRIMARY_BUTTON_RAINBOW_OVERLAY, }), secondaryButton: secondaryButton(BLACK_BADGE, { fill: themed( @@ -348,6 +419,13 @@ const TIER_THEMES: Record = { fill: themed(gradient(['#444444', '#000000'])), border: themed(solid('rgba(0,0,0,0)')), shadow: themed(shadow('#000000', { width: 0, height: 4 }, 0.06, 6)), + backgroundShadow: themed({ + colors: BLACK_TIER_RAINBOW_OVERLAY_COLORS, + locations: BLACK_TIER_RAINBOW_OVERLAY_LOCATIONS, + opacity: 0.24, + blur: 12, + dy: 0, + }), highlight: themed( gradient(['#7D53FF', '#3FBDFF', '#4BFF9D', '#FFD73A', '#FF5E4D', '#FF3C91'], { locations: [0, 0.15, 0.36, 0.57, 0.79, 1], @@ -361,8 +439,12 @@ const TIER_THEMES: Record = { const FALLBACK_TIER_THEME = TIER_THEMES.STAKING_TIER_LEVEL_BASIC; +function isTierId(level: string): level is TierId { + return level in TIER_THEMES; +} + function resolveTierTheme(level: TierId | string): TierTheme { - return TIER_THEMES[level as TierId] ?? FALLBACK_TIER_THEME; + return isTierId(level) ? TIER_THEMES[level] : FALLBACK_TIER_THEME; } export function getTierBackgroundTheme(level: TierId | string): { gradient: Themed } {