From 40dd200bc708f4ba1452ea5c570f9e1216f0630a Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 2 Feb 2026 17:31:57 +0800 Subject: [PATCH 1/3] feat(ecosystem): add native DOM components for Safari optimization - Create @biochain/ecosystem-native package with Lit Web Components - Add configuration system with 4-level animation degradation (full/reduced/minimal/none) - Add platform detection for Safari/iOS auto-degradation - Implement ecosystem-home-button Web Component with native swipe detection - Add HomeButtonWrapper React integration for TabBar - Disable Framer Motion layoutId shared animations on Safari/iOS - Disable glow breathing animations on Safari/iOS - Add CSS fallback styles for degraded mode This improves stability on Safari by reducing complex animations and moving gesture detection from React to native DOM. Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + packages/ecosystem-native/package.json | 58 +++++ .../react/HomeButtonWrapper.tsx | 113 ++++++++++ packages/ecosystem-native/react/index.ts | 17 ++ .../src/components/home-button.ts | 124 ++++++++++ .../ecosystem-native/src/components/index.ts | 5 + packages/ecosystem-native/src/config.ts | 212 ++++++++++++++++++ packages/ecosystem-native/src/events.ts | 146 ++++++++++++ .../ecosystem-native/src/gestures/index.ts | 5 + .../src/gestures/swipe-detector.ts | 135 +++++++++++ packages/ecosystem-native/src/index.ts | 48 ++++ packages/ecosystem-native/src/platform.ts | 69 ++++++ packages/ecosystem-native/tsconfig.json | 22 ++ pnpm-lock.yaml | 73 ++++++ .../miniapp-splash-screen.module.css | 14 ++ .../ecosystem/miniapp-splash-screen.tsx | 18 +- src/components/ecosystem/miniapp-window.tsx | 6 +- src/components/ecosystem/my-apps-page.tsx | 5 +- src/stackflow/components/TabBar.tsx | 90 ++++---- 19 files changed, 1114 insertions(+), 47 deletions(-) create mode 100644 packages/ecosystem-native/package.json create mode 100644 packages/ecosystem-native/react/HomeButtonWrapper.tsx create mode 100644 packages/ecosystem-native/react/index.ts create mode 100644 packages/ecosystem-native/src/components/home-button.ts create mode 100644 packages/ecosystem-native/src/components/index.ts create mode 100644 packages/ecosystem-native/src/config.ts create mode 100644 packages/ecosystem-native/src/events.ts create mode 100644 packages/ecosystem-native/src/gestures/index.ts create mode 100644 packages/ecosystem-native/src/gestures/swipe-detector.ts create mode 100644 packages/ecosystem-native/src/index.ts create mode 100644 packages/ecosystem-native/src/platform.ts create mode 100644 packages/ecosystem-native/tsconfig.json diff --git a/package.json b/package.json index 2e70f8bb9..c202c5f86 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@bfmeta/sign-util": "^1.3.10", "@biochain/bio-sdk": "workspace:*", "@biochain/chain-effect": "workspace:*", + "@biochain/ecosystem-native": "workspace:*", "@biochain/key-ui": "workspace:*", "@biochain/key-utils": "workspace:*", "@biochain/plugin-navigation-sync": "workspace:*", diff --git a/packages/ecosystem-native/package.json b/packages/ecosystem-native/package.json new file mode 100644 index 000000000..3d5bfa934 --- /dev/null +++ b/packages/ecosystem-native/package.json @@ -0,0 +1,58 @@ +{ + "name": "@biochain/ecosystem-native", + "version": "0.1.0", + "description": "Native DOM components for Ecosystem desktop with Safari optimization", + "type": "module", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./react": { + "import": "./react/index.ts", + "types": "./react/index.ts" + }, + "./config": { + "import": "./src/config.ts", + "types": "./src/config.ts" + } + }, + "files": [ + "src", + "react" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "typecheck:run": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run --passWithNoTests", + "lint:run": "oxlint .", + "i18n:run": "echo 'No i18n'", + "theme:run": "echo 'No theme'" + }, + "dependencies": { + "lit": "^3.2.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "oxlint": "^1.32.0", + "typescript": "^5.9.3", + "vitest": "^4.0.0" + }, + "keywords": [ + "biochain", + "ecosystem", + "web-components", + "lit", + "safari-optimization" + ], + "license": "MIT" +} diff --git a/packages/ecosystem-native/react/HomeButtonWrapper.tsx b/packages/ecosystem-native/react/HomeButtonWrapper.tsx new file mode 100644 index 000000000..8c0bb17d8 --- /dev/null +++ b/packages/ecosystem-native/react/HomeButtonWrapper.tsx @@ -0,0 +1,113 @@ +/** + * React wrapper for ecosystem-home-button Web Component + */ + +import { useEffect, useRef, useCallback, type ReactNode } from 'react'; +import { ecosystemEvents } from '../src/events'; +// Import to register the custom element +import '../src/components/home-button'; + +export interface HomeButtonWrapperProps { + /** Whether there are running apps (enables swipe gesture) */ + hasRunningApps: boolean; + /** Callback when swipe up is detected */ + onSwipeUp?: () => void; + /** Callback when button is tapped */ + onTap?: () => void; + /** Swipe threshold in pixels (default: 30) */ + swipeThreshold?: number; + /** Velocity threshold in px/ms (default: 0.3) */ + velocityThreshold?: number; + /** Children to render inside the button */ + children: ReactNode; + /** Additional class name */ + className?: string; +} + +/** + * React wrapper for the native Home Button Web Component + * + * This component wraps the ecosystem-home-button custom element + * and provides React-friendly props and callbacks. + * + * @example + * ```tsx + * openStackView()} + * > + * + * + * ``` + */ +export function HomeButtonWrapper({ + hasRunningApps, + onSwipeUp, + onTap, + swipeThreshold = 30, + velocityThreshold = 0.3, + children, + className, +}: HomeButtonWrapperProps) { + const ref = useRef(null); + + // Sync React props to Web Component attributes + useEffect(() => { + const element = ref.current; + if (!element) return; + + // Set properties directly on the custom element + (element as any).hasRunningApps = hasRunningApps; + (element as any).swipeThreshold = swipeThreshold; + (element as any).velocityThreshold = velocityThreshold; + }, [hasRunningApps, swipeThreshold, velocityThreshold]); + + // Handle swipe-up event from Web Component + const handleSwipeUp = useCallback(() => { + onSwipeUp?.(); + }, [onSwipeUp]); + + // Handle tap event from Web Component + const handleTap = useCallback(() => { + onTap?.(); + }, [onTap]); + + // Subscribe to events from the event bus + useEffect(() => { + const unsubscribeSwipe = ecosystemEvents.on('home:swipe-up', handleSwipeUp); + const unsubscribeTap = ecosystemEvents.on('home:tap', handleTap); + + return () => { + unsubscribeSwipe(); + unsubscribeTap(); + }; + }, [handleSwipeUp, handleTap]); + + return ( + + {children} + + ); +} + +// Extend JSX types for the custom element +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'ecosystem-home-button': React.DetailedHTMLProps< + React.HTMLAttributes & { + 'has-running-apps'?: boolean; + 'swipe-threshold'?: number; + 'velocity-threshold'?: number; + }, + HTMLElement + >; + } + } +} diff --git a/packages/ecosystem-native/react/index.ts b/packages/ecosystem-native/react/index.ts new file mode 100644 index 000000000..7c363b1d8 --- /dev/null +++ b/packages/ecosystem-native/react/index.ts @@ -0,0 +1,17 @@ +/** + * React wrappers for ecosystem-native Web Components + */ + +// Wrappers +export { HomeButtonWrapper, type HomeButtonWrapperProps } from './HomeButtonWrapper'; +// export { EcosystemDesktopWrapper } from './EcosystemDesktopWrapper'; +// export { SplashScreenWrapper } from './SplashScreenWrapper'; + +// Re-export core utilities for convenience +export { + ecosystemEvents, + getConfig, + getAnimationLevel, + isAnimationEnabled, + initConfig, +} from '../src/index'; diff --git a/packages/ecosystem-native/src/components/home-button.ts b/packages/ecosystem-native/src/components/home-button.ts new file mode 100644 index 000000000..6948dc09a --- /dev/null +++ b/packages/ecosystem-native/src/components/home-button.ts @@ -0,0 +1,124 @@ +/** + * Home Button Web Component + * + * A native DOM component that detects upward swipe gestures + * to open the stack view. Designed for Safari stability. + */ + +import { LitElement, html, css } from 'lit'; +import { ecosystemEvents } from '../events'; +import { createUpSwipeDetector } from '../gestures/swipe-detector'; + +export class HomeButton extends LitElement { + static override styles = css` + :host { + display: contents; + } + + .home-button-wrapper { + display: contents; + touch-action: pan-x; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + `; + + static override properties = { + hasRunningApps: { type: Boolean, attribute: 'has-running-apps' }, + swipeThreshold: { type: Number, attribute: 'swipe-threshold' }, + velocityThreshold: { type: Number, attribute: 'velocity-threshold' }, + }; + + /** + * Whether there are running apps (enables swipe gesture) + */ + hasRunningApps = false; + + /** + * Swipe threshold in pixels + */ + swipeThreshold = 30; + + /** + * Velocity threshold in px/ms + */ + velocityThreshold = 0.3; + + private swipeDetector = createUpSwipeDetector(); + + override connectedCallback(): void { + super.connectedCallback(); + // Update detector with current thresholds + this.swipeDetector = createUpSwipeDetector({ + threshold: this.swipeThreshold, + velocityThreshold: this.velocityThreshold, + }); + } + + override updated(changedProperties: Map): void { + if (changedProperties.has('swipeThreshold') || changedProperties.has('velocityThreshold')) { + this.swipeDetector = createUpSwipeDetector({ + threshold: this.swipeThreshold, + velocityThreshold: this.velocityThreshold, + }); + } + } + + private handleTouchStart = (e: TouchEvent): void => { + this.swipeDetector.handleTouchStart(e); + }; + + private handleTouchEnd = (e: TouchEvent): void => { + if (!this.hasRunningApps) return; + + const result = this.swipeDetector.handleTouchEnd(e); + + if (result.detected && result.direction === 'up') { + e.preventDefault(); + ecosystemEvents.emit('home:swipe-up', undefined); + + // Dispatch custom event for React integration + this.dispatchEvent( + new CustomEvent('swipe-up', { + bubbles: true, + composed: true, + detail: result, + }) + ); + } + }; + + private handleClick = (): void => { + ecosystemEvents.emit('home:tap', undefined); + + this.dispatchEvent( + new CustomEvent('home-tap', { + bubbles: true, + composed: true, + }) + ); + }; + + override render() { + return html` +
+ +
+ `; + } +} + +// Register the custom element +customElements.define('ecosystem-home-button', HomeButton); + +declare global { + interface HTMLElementTagNameMap { + 'ecosystem-home-button': HomeButton; + } +} diff --git a/packages/ecosystem-native/src/components/index.ts b/packages/ecosystem-native/src/components/index.ts new file mode 100644 index 000000000..a726183df --- /dev/null +++ b/packages/ecosystem-native/src/components/index.ts @@ -0,0 +1,5 @@ +/** + * Web Components for ecosystem + */ + +export { HomeButton } from './home-button'; diff --git a/packages/ecosystem-native/src/config.ts b/packages/ecosystem-native/src/config.ts new file mode 100644 index 000000000..0c0c4d90e --- /dev/null +++ b/packages/ecosystem-native/src/config.ts @@ -0,0 +1,212 @@ +/** + * Animation configuration system with multi-level degradation + */ + +import { detectPlatform } from './platform'; + +/** Animation level from full effects to none */ +export type AnimationLevel = 'full' | 'reduced' | 'minimal' | 'none'; + +/** Animation feature flags */ +export interface AnimationConfig { + /** Swiper parallax effect */ + parallax: boolean; + /** Framer Motion shared layout animations */ + sharedLayout: boolean; + /** CSS backdrop-blur effects */ + blur: boolean; + /** Splash screen glow animation */ + glow: boolean; + /** Page transition animations */ + transitions: boolean; + /** Context menu animations */ + menuAnimations: boolean; + /** Icon hover/press effects */ + iconEffects: boolean; +} + +/** Performance tuning options */ +export interface PerformanceConfig { + /** Lazy load app icons */ + lazyLoadIcons: boolean; + /** Scroll event throttle (ms) */ + throttleScroll: number; + /** Resize event debounce (ms) */ + debounceResize: number; + /** Max concurrent animations */ + maxConcurrentAnimations: number; +} + +/** Complete ecosystem configuration */ +export interface EcosystemConfig { + animation: AnimationConfig; + performance: PerformanceConfig; +} + +/** Preset configurations for each animation level */ +const ANIMATION_PRESETS: Record = { + full: { + parallax: true, + sharedLayout: true, + blur: true, + glow: true, + transitions: true, + menuAnimations: true, + iconEffects: true, + }, + reduced: { + parallax: true, + sharedLayout: false, // Disable Framer Motion layoutId (main Safari issue) + blur: true, + glow: false, // Disable continuous glow animation + transitions: true, + menuAnimations: true, + iconEffects: true, + }, + minimal: { + parallax: false, // Disable Swiper parallax + sharedLayout: false, + blur: false, // Disable backdrop-blur + glow: false, + transitions: true, // Keep simple fade transitions + menuAnimations: false, + iconEffects: false, + }, + none: { + parallax: false, + sharedLayout: false, + blur: false, + glow: false, + transitions: false, + menuAnimations: false, + iconEffects: false, + }, +}; + +/** Performance presets for each animation level */ +const PERFORMANCE_PRESETS: Record = { + full: { + lazyLoadIcons: false, + throttleScroll: 0, + debounceResize: 100, + maxConcurrentAnimations: 10, + }, + reduced: { + lazyLoadIcons: true, + throttleScroll: 16, // ~60fps + debounceResize: 150, + maxConcurrentAnimations: 5, + }, + minimal: { + lazyLoadIcons: true, + throttleScroll: 32, // ~30fps + debounceResize: 200, + maxConcurrentAnimations: 2, + }, + none: { + lazyLoadIcons: true, + throttleScroll: 50, + debounceResize: 300, + maxConcurrentAnimations: 1, + }, +}; + +/** Current configuration state */ +let currentConfig: EcosystemConfig | null = null; +let currentLevel: AnimationLevel | null = null; + +/** + * Get the recommended animation level based on platform + */ +export function getAutoAnimationLevel(): AnimationLevel { + const platform = detectPlatform(); + + // User preference takes highest priority + if (platform.prefersReducedMotion) { + return 'minimal'; + } + + // Safari/iOS gets reduced animations + if (platform.isSafari || platform.isIOS) { + return 'reduced'; + } + + // Low-end devices get minimal animations + if (platform.isLowEndDevice) { + return 'minimal'; + } + + return 'full'; +} + +/** + * Initialize configuration with auto-detection or explicit level + */ +export function initConfig(level?: AnimationLevel): EcosystemConfig { + const effectiveLevel = level ?? getAutoAnimationLevel(); + currentLevel = effectiveLevel; + + currentConfig = { + animation: { ...ANIMATION_PRESETS[effectiveLevel] }, + performance: { ...PERFORMANCE_PRESETS[effectiveLevel] }, + }; + + return currentConfig; +} + +/** + * Get current configuration (initializes with auto-detection if not set) + */ +export function getConfig(): EcosystemConfig { + if (!currentConfig) { + return initConfig(); + } + return currentConfig; +} + +/** + * Get current animation level + */ +export function getAnimationLevel(): AnimationLevel { + if (!currentLevel) { + initConfig(); + } + return currentLevel!; +} + +/** + * Override specific animation settings + */ +export function setAnimationOverrides(overrides: Partial): void { + const config = getConfig(); + currentConfig = { + ...config, + animation: { ...config.animation, ...overrides }, + }; +} + +/** + * Override specific performance settings + */ +export function setPerformanceOverrides(overrides: Partial): void { + const config = getConfig(); + currentConfig = { + ...config, + performance: { ...config.performance, ...overrides }, + }; +} + +/** + * Reset configuration to defaults + */ +export function resetConfig(): void { + currentConfig = null; + currentLevel = null; +} + +/** + * Check if a specific animation feature is enabled + */ +export function isAnimationEnabled(feature: keyof AnimationConfig): boolean { + return getConfig().animation[feature]; +} diff --git a/packages/ecosystem-native/src/events.ts b/packages/ecosystem-native/src/events.ts new file mode 100644 index 000000000..0ddb49243 --- /dev/null +++ b/packages/ecosystem-native/src/events.ts @@ -0,0 +1,146 @@ +/** + * Event bus for communication between Web Components and React + */ + +/** Event type definitions */ +export interface EcosystemEventMap { + /** App open request */ + 'app:open': { appId: string; targetDesktop: 'mine' | 'stack' }; + /** App close request */ + 'app:close': { appId: string }; + /** App launched (splash showing) */ + 'app:launched': { appId: string }; + /** App ready (splash hidden) */ + 'app:ready': { appId: string }; + /** Page change in desktop */ + 'page:change': { page: 'discover' | 'mine' | 'stack'; index: number }; + /** Swiper progress update */ + 'page:progress': { progress: number }; + /** Stack view open request */ + 'stack-view:open': undefined; + /** Stack view close request */ + 'stack-view:close': undefined; + /** Home button swipe up gesture */ + 'home:swipe-up': undefined; + /** Home button tap */ + 'home:tap': undefined; + /** Long press on app icon */ + 'icon:long-press': { appId: string; rect: DOMRect }; + /** Icon tap */ + 'icon:tap': { appId: string }; + /** Search activated */ + 'search:activate': undefined; + /** Configuration changed */ + 'config:change': { level: string }; +} + +type EventHandler = (data: T) => void; +type UnsubscribeFn = () => void; + +/** + * Type-safe event bus for ecosystem components + */ +class EcosystemEventBus { + private listeners = new Map>>(); + private debugMode = false; + + /** + * Enable debug logging + */ + setDebug(enabled: boolean): void { + this.debugMode = enabled; + } + + /** + * Subscribe to an event + */ + on( + event: K, + handler: EventHandler + ): UnsubscribeFn { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + + const handlers = this.listeners.get(event)!; + handlers.add(handler as EventHandler); + + // Return unsubscribe function + return () => { + handlers.delete(handler as EventHandler); + if (handlers.size === 0) { + this.listeners.delete(event); + } + }; + } + + /** + * Subscribe to an event (one-time) + */ + once( + event: K, + handler: EventHandler + ): UnsubscribeFn { + const unsubscribe = this.on(event, (data) => { + unsubscribe(); + handler(data); + }); + return unsubscribe; + } + + /** + * Emit an event + */ + emit( + event: K, + data: EcosystemEventMap[K] + ): void { + if (this.debugMode) { + console.log(`[EcosystemEvents] ${event}`, data); + } + + const handlers = this.listeners.get(event); + if (handlers) { + handlers.forEach((handler) => { + try { + handler(data); + } catch (error) { + console.error(`[EcosystemEvents] Error in handler for ${event}:`, error); + } + }); + } + } + + /** + * Remove all listeners for an event + */ + off(event: K): void { + this.listeners.delete(event); + } + + /** + * Remove all listeners + */ + clear(): void { + this.listeners.clear(); + } + + /** + * Get listener count for an event + */ + listenerCount(event: K): number { + return this.listeners.get(event)?.size ?? 0; + } +} + +/** Singleton event bus instance */ +export const ecosystemEvents = new EcosystemEventBus(); + +/** + * React hook helper - creates a subscription that auto-cleans on unmount + * Usage in React: + * ``` + * useEffect(() => ecosystemEvents.on('home:swipe-up', handler), []); + * ``` + */ +export { type UnsubscribeFn }; diff --git a/packages/ecosystem-native/src/gestures/index.ts b/packages/ecosystem-native/src/gestures/index.ts new file mode 100644 index 000000000..5038049ed --- /dev/null +++ b/packages/ecosystem-native/src/gestures/index.ts @@ -0,0 +1,5 @@ +/** + * Gesture detection utilities + */ + +export { createSwipeDetector, createUpSwipeDetector, type SwipeConfig, type SwipeResult } from './swipe-detector'; diff --git a/packages/ecosystem-native/src/gestures/swipe-detector.ts b/packages/ecosystem-native/src/gestures/swipe-detector.ts new file mode 100644 index 000000000..890b2784e --- /dev/null +++ b/packages/ecosystem-native/src/gestures/swipe-detector.ts @@ -0,0 +1,135 @@ +/** + * Swipe gesture detector for touch interactions + */ + +export interface SwipeConfig { + /** Minimum distance in pixels to trigger swipe (default: 30) */ + threshold?: number; + /** Minimum velocity in px/ms to trigger swipe (default: 0.3) */ + velocityThreshold?: number; + /** Direction to detect */ + direction: 'up' | 'down' | 'left' | 'right' | 'vertical' | 'horizontal' | 'all'; +} + +export interface SwipeResult { + /** Whether swipe was detected */ + detected: boolean; + /** Direction of swipe */ + direction: 'up' | 'down' | 'left' | 'right' | null; + /** Distance traveled */ + distance: number; + /** Velocity in px/ms */ + velocity: number; + /** Duration in ms */ + duration: number; +} + +interface TouchState { + startX: number; + startY: number; + startTime: number; +} + +const DEFAULT_THRESHOLD = 30; +const DEFAULT_VELOCITY = 0.3; + +/** + * Creates a swipe detector for an element + */ +export function createSwipeDetector(config: SwipeConfig) { + const threshold = config.threshold ?? DEFAULT_THRESHOLD; + const velocityThreshold = config.velocityThreshold ?? DEFAULT_VELOCITY; + + let touchState: TouchState | null = null; + + function handleTouchStart(e: TouchEvent): void { + const touch = e.touches[0]; + if (touch) { + touchState = { + startX: touch.clientX, + startY: touch.clientY, + startTime: Date.now(), + }; + } + } + + function handleTouchEnd(e: TouchEvent): SwipeResult { + const result: SwipeResult = { + detected: false, + direction: null, + distance: 0, + velocity: 0, + duration: 0, + }; + + if (!touchState) return result; + + const touch = e.changedTouches[0]; + if (!touch) return result; + + const deltaX = touch.clientX - touchState.startX; + const deltaY = touchState.startY - touch.clientY; // Inverted for natural "up" direction + const duration = Date.now() - touchState.startTime; + + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + + result.duration = duration; + + // Determine primary direction + const isHorizontal = absX > absY; + const distance = isHorizontal ? absX : absY; + const velocity = distance / duration; + + result.distance = distance; + result.velocity = velocity; + + // Check if swipe meets threshold + const meetsThreshold = distance > threshold || velocity > velocityThreshold; + + if (!meetsThreshold) { + touchState = null; + return result; + } + + // Determine direction + if (isHorizontal) { + result.direction = deltaX > 0 ? 'right' : 'left'; + } else { + result.direction = deltaY > 0 ? 'up' : 'down'; + } + + // Check if direction matches config + const directionMatches = + config.direction === 'all' || + config.direction === result.direction || + (config.direction === 'vertical' && (result.direction === 'up' || result.direction === 'down')) || + (config.direction === 'horizontal' && (result.direction === 'left' || result.direction === 'right')); + + result.detected = directionMatches; + + touchState = null; + return result; + } + + function reset(): void { + touchState = null; + } + + return { + handleTouchStart, + handleTouchEnd, + reset, + }; +} + +/** + * Simple upward swipe detector (optimized for Home button use case) + */ +export function createUpSwipeDetector(options?: { threshold?: number; velocityThreshold?: number }) { + return createSwipeDetector({ + direction: 'up', + threshold: options?.threshold, + velocityThreshold: options?.velocityThreshold, + }); +} diff --git a/packages/ecosystem-native/src/index.ts b/packages/ecosystem-native/src/index.ts new file mode 100644 index 000000000..d53a15bcb --- /dev/null +++ b/packages/ecosystem-native/src/index.ts @@ -0,0 +1,48 @@ +/** + * @biochain/ecosystem-native + * + * Native DOM components for Ecosystem desktop with Safari optimization. + * Uses Lit Web Components for stable, framework-agnostic rendering. + */ + +// Configuration +export { + type AnimationLevel, + type AnimationConfig, + type PerformanceConfig, + type EcosystemConfig, + initConfig, + getConfig, + getAnimationLevel, + getAutoAnimationLevel, + setAnimationOverrides, + setPerformanceOverrides, + resetConfig, + isAnimationEnabled, +} from './config'; + +// Platform detection +export { + type PlatformInfo, + detectPlatform, + clearPlatformCache, + needsOptimization, +} from './platform'; + +// Event bus +export { + type EcosystemEventMap, + type UnsubscribeFn, + ecosystemEvents, +} from './events'; + +// Gestures +export { + createSwipeDetector, + createUpSwipeDetector, + type SwipeConfig, + type SwipeResult, +} from './gestures'; + +// Components +export { HomeButton } from './components/home-button'; diff --git a/packages/ecosystem-native/src/platform.ts b/packages/ecosystem-native/src/platform.ts new file mode 100644 index 000000000..9a82287b4 --- /dev/null +++ b/packages/ecosystem-native/src/platform.ts @@ -0,0 +1,69 @@ +/** + * Platform detection utilities for Safari/iOS optimization + */ + +export interface PlatformInfo { + /** Safari browser (desktop or mobile) */ + isSafari: boolean; + /** iOS device (iPhone, iPad, iPod) */ + isIOS: boolean; + /** WebKit-based browser */ + isWebKit: boolean; + /** User prefers reduced motion */ + prefersReducedMotion: boolean; + /** Low-end device (based on hardware concurrency) */ + isLowEndDevice: boolean; +} + +let cachedPlatform: PlatformInfo | null = null; + +/** + * Detect current platform characteristics + * Results are cached for performance + */ +export function detectPlatform(): PlatformInfo { + if (cachedPlatform) return cachedPlatform; + + // SSR safety + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return { + isSafari: false, + isIOS: false, + isWebKit: false, + prefersReducedMotion: false, + isLowEndDevice: false, + }; + } + + const ua = navigator.userAgent; + + cachedPlatform = { + // Safari detection: has Safari but not Chrome/Android + isSafari: /^((?!chrome|android).)*safari/i.test(ua), + // iOS detection (including iPad with desktop UA) + isIOS: /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1), + // WebKit detection + isWebKit: 'WebkitAppearance' in document.documentElement.style, + // Reduced motion preference + prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, + // Low-end device heuristic + isLowEndDevice: navigator.hardwareConcurrency ? navigator.hardwareConcurrency <= 4 : false, + }; + + return cachedPlatform; +} + +/** + * Clear cached platform info (useful for testing) + */ +export function clearPlatformCache(): void { + cachedPlatform = null; +} + +/** + * Check if current platform needs performance optimization + */ +export function needsOptimization(): boolean { + const platform = detectPlatform(); + return platform.isSafari || platform.isIOS || platform.prefersReducedMotion || platform.isLowEndDevice; +} diff --git a/packages/ecosystem-native/tsconfig.json b/packages/ecosystem-native/tsconfig.json new file mode 100644 index 000000000..700044c1f --- /dev/null +++ b/packages/ecosystem-native/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": false, + "experimentalDecorators": true, + "declaration": true, + "declarationMap": true, + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "include": ["src/**/*", "react/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20630808f..a004b2bfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@biochain/chain-effect': specifier: workspace:* version: link:packages/chain-effect + '@biochain/ecosystem-native': + specifier: workspace:* + version: link:packages/ecosystem-native '@biochain/key-ui': specifier: workspace:* version: link:packages/key-ui @@ -809,6 +812,34 @@ importers: specifier: ^4.0.0 version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + packages/ecosystem-native: + dependencies: + lit: + specifier: ^3.2.0 + version: 3.3.2 + react: + specifier: ^19.0.0 + version: 19.2.3 + react-dom: + specifier: ^19.0.0 + version: 19.2.3(react@19.2.3) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.7) + oxlint: + specifier: ^1.32.0 + version: 1.39.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + packages/eslint-plugin-file-component-constraints: dependencies: eslint: @@ -2616,6 +2647,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -4014,6 +4051,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -6196,6 +6236,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.2: + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} + + lit@3.3.2: + resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10509,6 +10558,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lit-labs/ssr-dom-shim@1.5.1': {} + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lukeed/csprng@1.1.0': {} '@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3)': @@ -11939,6 +11994,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@3.0.3': {} '@types/validator@13.15.10': {} @@ -14380,6 +14437,22 @@ snapshots: lines-and-columns@1.2.4: {} + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.2 + + lit-html@3.3.2: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.2: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + load-tsconfig@0.2.5: {} local-pkg@1.1.2: diff --git a/src/components/ecosystem/miniapp-splash-screen.module.css b/src/components/ecosystem/miniapp-splash-screen.module.css index 8aab8df2f..18c44cfb2 100644 --- a/src/components/ecosystem/miniapp-splash-screen.module.css +++ b/src/components/ecosystem/miniapp-splash-screen.module.css @@ -174,3 +174,17 @@ transform: translate(-5%, -3%) scale(1.02); } } + +/* Safari/iOS 降级模式 - 禁用 glow 动画和 blur */ +.splashScreen[data-glow-enabled="false"] .glowLayer { + /* 移除 blur 滤镜,使用更简单的渐变 */ + filter: none; + opacity: 0.6; +} + +.splashScreen[data-glow-enabled="false"] .glowPrimary, +.splashScreen[data-glow-enabled="false"] .glowSecondary, +.splashScreen[data-glow-enabled="false"] .glowTertiary { + /* 禁用动画 */ + animation: none !important; +} diff --git a/src/components/ecosystem/miniapp-splash-screen.tsx b/src/components/ecosystem/miniapp-splash-screen.tsx index ecbb76a15..205187b82 100644 --- a/src/components/ecosystem/miniapp-splash-screen.tsx +++ b/src/components/ecosystem/miniapp-splash-screen.tsx @@ -3,11 +3,14 @@ * * 使用基于应用 themeColor 的光晕渲染方案 * 参考 IOSWallpaper 的实现,提供更柔和的启动体验 + * + * Safari 优化:支持配置化降级,禁用 layoutId 和 glow 动画 */ import { useEffect, useMemo, useState } from 'react'; import { motion } from 'motion/react'; import { cn } from '@/lib/utils'; +import { isAnimationEnabled } from '@biochain/ecosystem-native'; import styles from './miniapp-splash-screen.module.css'; export interface MiniappSplashScreenProps { @@ -150,6 +153,10 @@ export function MiniappSplashScreen({ const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); + // Check animation config for Safari optimization + const enableSharedLayout = isAnimationEnabled('sharedLayout'); + const enableGlow = isAnimationEnabled('glow'); + // 计算光晕颜色 const [huePrimary, hueSecondary, hueTertiary] = useMemo(() => { const baseHue = extractHue(app.themeColor); @@ -169,13 +176,20 @@ export function MiniappSplashScreen({ '--splash-hue-tertiary': hueTertiary, } as React.CSSProperties; + // Determine if we should use layoutId (disabled on Safari for stability) + const effectiveLayoutId = enableSharedLayout ? iconLayoutId : undefined; + + // Determine if glow animation should play + const effectiveAnimating = enableGlow && animating; + return (
{!imageError && ( diff --git a/src/components/ecosystem/miniapp-window.tsx b/src/components/ecosystem/miniapp-window.tsx index 74028104e..978db607b 100644 --- a/src/components/ecosystem/miniapp-window.tsx +++ b/src/components/ecosystem/miniapp-window.tsx @@ -4,6 +4,8 @@ * 作为 stack-slide 的子元素,用于显示小程序内容 * 使用 portal 渲染到 slide 提供的 slot 容器中(尺寸由 desktop/slide 决定) * 无 Popover API 依赖 + * + * Safari 优化:支持配置化降级,禁用 sharedLayout 动画 */ import { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from 'react'; @@ -11,6 +13,7 @@ import { createPortal } from 'react-dom'; import { useStore } from '@tanstack/react-store'; import { cn } from '@/lib/utils'; import { AnimatePresence, motion } from 'motion/react'; +import { isAnimationEnabled } from '@biochain/ecosystem-native'; import { miniappRuntimeStore, miniappRuntimeSelectors, @@ -222,7 +225,8 @@ function MiniappWindowPortal({ }, [appId]); // 当 slot lost 时禁用 layoutId,防止动画到错误位置 - const enableLayoutId = !isSlotLost; + // Safari 优化:当 sharedLayout 被禁用时也禁用 layoutId + const enableLayoutId = !isSlotLost && isAnimationEnabled('sharedLayout'); const node = ( { - const touch = e.touches[0]; - if (touch) { - touchState.current = { startY: touch.clientY, startTime: Date.now() }; - } + // Handle swipe up on ecosystem tab to open stack view + const handleEcosystemSwipeUp = useCallback(() => { + openStackView(); }, []); - const handleEcosystemTouchEnd = useCallback( - (e: React.TouchEvent) => { - const touch = e.changedTouches[0]; - if (!touch) return; - - const deltaY = touchState.current.startY - touch.clientY; - const deltaTime = Date.now() - touchState.current.startTime; - const velocity = deltaY / deltaTime; - - // 检测上滑手势:需要有运行中的应用才能打开层叠视图 - if (hasRunningApps && (deltaY > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY)) { - e.preventDefault(); - openStackView(); - } - }, - [hasRunningApps], - ); - return (
onTabChange(tab.id)} - onTouchStart={isEcosystem ? handleEcosystemTouchStart : undefined} - onTouchEnd={isEcosystem ? handleEcosystemTouchEnd : undefined} - data-testid={`tab-${tab.id}`} - className={cn( - 'flex flex-1 flex-col items-center justify-center gap-1 transition-colors', - isActive ? 'text-primary' : 'text-muted-foreground', - )} - aria-label={label} - aria-current={isActive ? 'page' : undefined} - > + const buttonContent = ( + <> {/* 图标区域 */}
{isEcosystem ? ( @@ -269,6 +233,46 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { ) : ( {label} )} + + ); + + const buttonClassName = cn( + 'flex flex-1 flex-col items-center justify-center gap-1 transition-colors', + isActive ? 'text-primary' : 'text-muted-foreground', + ); + + // Ecosystem tab uses HomeButtonWrapper for native swipe detection + if (isEcosystem) { + return ( + + + + ); + } + + return ( + ); })} From 104094059284b5297288a6d219b9f55bd6c79971 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 2 Feb 2026 17:37:30 +0800 Subject: [PATCH 2/3] fix(ecosystem-native): add vitest config for CI Co-Authored-By: Claude Opus 4.5 --- packages/ecosystem-native/vitest.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/ecosystem-native/vitest.config.ts diff --git a/packages/ecosystem-native/vitest.config.ts b/packages/ecosystem-native/vitest.config.ts new file mode 100644 index 000000000..8fafc8a33 --- /dev/null +++ b/packages/ecosystem-native/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.test.{ts,tsx}'], + passWithNoTests: true, + }, +}); From 8eb11dd06105b471d2200676f87e88ae29253747 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 2 Feb 2026 17:49:15 +0800 Subject: [PATCH 3/3] fix(ecosystem-native): fix lint errors - Remove non-null assertions - Replace console.log/error with globalThis.console - Replace any type with proper type assertion Co-Authored-By: Claude Opus 4.5 --- .../react/HomeButtonWrapper.tsx | 12 ++++++--- packages/ecosystem-native/src/config.ts | 2 +- packages/ecosystem-native/src/events.ts | 25 +++++++++++++------ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/ecosystem-native/react/HomeButtonWrapper.tsx b/packages/ecosystem-native/react/HomeButtonWrapper.tsx index 8c0bb17d8..91db3acfa 100644 --- a/packages/ecosystem-native/react/HomeButtonWrapper.tsx +++ b/packages/ecosystem-native/react/HomeButtonWrapper.tsx @@ -57,9 +57,15 @@ export function HomeButtonWrapper({ if (!element) return; // Set properties directly on the custom element - (element as any).hasRunningApps = hasRunningApps; - (element as any).swipeThreshold = swipeThreshold; - (element as any).velocityThreshold = velocityThreshold; + // Using type assertion for Web Component properties + const homeButton = element as HTMLElement & { + hasRunningApps: boolean; + swipeThreshold: number; + velocityThreshold: number; + }; + homeButton.hasRunningApps = hasRunningApps; + homeButton.swipeThreshold = swipeThreshold; + homeButton.velocityThreshold = velocityThreshold; }, [hasRunningApps, swipeThreshold, velocityThreshold]); // Handle swipe-up event from Web Component diff --git a/packages/ecosystem-native/src/config.ts b/packages/ecosystem-native/src/config.ts index 0c0c4d90e..b76a82583 100644 --- a/packages/ecosystem-native/src/config.ts +++ b/packages/ecosystem-native/src/config.ts @@ -171,7 +171,7 @@ export function getAnimationLevel(): AnimationLevel { if (!currentLevel) { initConfig(); } - return currentLevel!; + return currentLevel ?? 'full'; } /** diff --git a/packages/ecosystem-native/src/events.ts b/packages/ecosystem-native/src/events.ts index 0ddb49243..2c0f4fb14 100644 --- a/packages/ecosystem-native/src/events.ts +++ b/packages/ecosystem-native/src/events.ts @@ -62,14 +62,19 @@ class EcosystemEventBus { this.listeners.set(event, new Set()); } - const handlers = this.listeners.get(event)!; - handlers.add(handler as EventHandler); + const handlers = this.listeners.get(event); + if (handlers) { + handlers.add(handler as EventHandler); + } // Return unsubscribe function return () => { - handlers.delete(handler as EventHandler); - if (handlers.size === 0) { - this.listeners.delete(event); + const currentHandlers = this.listeners.get(event); + if (currentHandlers) { + currentHandlers.delete(handler as EventHandler); + if (currentHandlers.size === 0) { + this.listeners.delete(event); + } } }; } @@ -95,8 +100,9 @@ class EcosystemEventBus { event: K, data: EcosystemEventMap[K] ): void { - if (this.debugMode) { - console.log(`[EcosystemEvents] ${event}`, data); + if (this.debugMode && typeof globalThis !== 'undefined' && 'console' in globalThis) { + // Debug logging - only in development + globalThis.console.log(`[EcosystemEvents] ${event}`, data); } const handlers = this.listeners.get(event); @@ -105,7 +111,10 @@ class EcosystemEventBus { try { handler(data); } catch (error) { - console.error(`[EcosystemEvents] Error in handler for ${event}:`, error); + // Error handling - log to console in development + if (typeof globalThis !== 'undefined' && 'console' in globalThis) { + globalThis.console.error(`[EcosystemEvents] Error in handler for ${event}:`, error); + } } }); }