diff --git a/.changeset/fresh-ways-deliver.md b/.changeset/fresh-ways-deliver.md new file mode 100644 index 00000000..59026f4c --- /dev/null +++ b/.changeset/fresh-ways-deliver.md @@ -0,0 +1,5 @@ +--- +'@rozenite/controls-plugin': minor +--- + +Adds a new Controls plugin for exposing app-owned text values, toggles, and actions directly in React Native DevTools. \ No newline at end of file diff --git a/apps/playground/package.json b/apps/playground/package.json index 450c70a2..a8dcfd38 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -24,6 +24,7 @@ "@rozenite/metro": "workspace:*", "@rozenite/mmkv-plugin": "workspace:*", "@rozenite/network-activity-plugin": "workspace:*", + "@rozenite/controls-plugin": "workspace:*", "@rozenite/overlay-plugin": "workspace:*", "@rozenite/performance-monitor-plugin": "workspace:*", "@rozenite/react-navigation-plugin": "workspace:*", diff --git a/apps/playground/src/app/App.tsx b/apps/playground/src/app/App.tsx index ea638e1b..f76cea8d 100644 --- a/apps/playground/src/app/App.tsx +++ b/apps/playground/src/app/App.tsx @@ -3,6 +3,7 @@ import { NavigationContainerRef, } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin'; import { useMMKVDevTools } from '@rozenite/mmkv-plugin'; import { useNetworkActivityDevTools } from '@rozenite/network-activity-plugin'; import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin'; @@ -13,11 +14,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useRef } from 'react'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; +import { usePlaygroundControlsSections } from './hooks/usePlaygroundControlsSections'; import { mmkvStorages } from './mmkv-storages'; import { BottomTabNavigator } from './navigation/BottomTabNavigator'; import { SuccessiveScreensNavigator } from './navigation/SuccessiveScreensNavigator'; import { RootStackParamList } from './navigation/types'; import { ConfigScreen } from './screens/ConfigScreen'; +import { ControlsPluginScreen } from './screens/ControlsPluginScreen'; import { LandingScreen } from './screens/LandingScreen'; import { MMKVPluginScreen } from './screens/MMKVPluginScreen'; import { NetworkTestScreen } from './screens/NetworkTestScreen'; @@ -39,7 +42,12 @@ const queryClient = new QueryClient(); const Stack = createNativeStackNavigator(); const Wrapper = () => { + const controlsSections = usePlaygroundControlsSections(); + useTanStackQueryDevTools(queryClient); + useRozeniteControlsPlugin({ + sections: controlsSections, + }); useNetworkActivityDevTools({ clientUISettings: { showUrlAsName: true, @@ -65,6 +73,7 @@ const Wrapper = () => { }} > + @@ -110,6 +119,7 @@ const linking = { config: { screens: { Landing: '', + ControlsPlugin: 'controls', MMKVPlugin: 'mmkv', StoragePlugin: 'storage', NetworkTest: 'network', diff --git a/apps/playground/src/app/hooks/usePlaygroundControlsSections.ts b/apps/playground/src/app/hooks/usePlaygroundControlsSections.ts new file mode 100644 index 00000000..260a0f59 --- /dev/null +++ b/apps/playground/src/app/hooks/usePlaygroundControlsSections.ts @@ -0,0 +1,257 @@ +import { createSection } from '@rozenite/controls-plugin'; +import { useMemo } from 'react'; +import { useControlsPluginStore } from '../stores/controlsPluginStore'; + +export const usePlaygroundControlsSections = () => { + const counter = useControlsPluginStore((state) => state.counter); + const releaseLabel = useControlsPluginStore((state) => state.releaseLabel); + const selectedEnvironment = useControlsPluginStore( + (state) => state.selectedEnvironment + ); + const status = useControlsPluginStore((state) => state.status); + const lastActionAt = useControlsPluginStore((state) => state.lastActionAt); + const notes = useControlsPluginStore((state) => state.notes); + const featureFlags = useControlsPluginStore((state) => state.featureFlags); + const updateReleaseLabel = useControlsPluginStore( + (state) => state.updateReleaseLabel + ); + const selectEnvironment = useControlsPluginStore( + (state) => state.selectEnvironment + ); + const toggleFlag = useControlsPluginStore((state) => state.toggleFlag); + const incrementCounter = useControlsPluginStore((state) => state.incrementCounter); + const markSynced = useControlsPluginStore((state) => state.markSynced); + const addCheckpoint = useControlsPluginStore((state) => state.addCheckpoint); + const resetDemo = useControlsPluginStore((state) => state.resetDemo); + + return useMemo( + () => [ + createSection({ + id: 'controls-status', + title: status === 'synced' ? 'Sync Status' : 'Runtime Status', + description: + 'This section reorders items when reverseDiagnostics changes, which makes HMR and full snapshot replacement easy to verify.', + items: featureFlags.reverseDiagnostics + ? [ + { + id: 'last-action', + type: 'text' as const, + title: 'Last Action', + value: lastActionAt ?? 'No actions yet', + }, + { + id: 'counter', + type: 'text' as const, + title: 'Counter', + value: String(counter), + }, + { + id: 'status', + type: 'text' as const, + title: 'Status', + value: status, + }, + { + id: 'environment', + type: 'text' as const, + title: 'Environment', + value: selectedEnvironment, + }, + { + id: 'release-label', + type: 'text' as const, + title: 'Release Label', + value: releaseLabel, + }, + ] + : [ + { + id: 'status', + type: 'text' as const, + title: 'Status', + value: status, + }, + { + id: 'counter', + type: 'text' as const, + title: 'Counter', + value: String(counter), + }, + { + id: 'environment', + type: 'text' as const, + title: 'Environment', + value: selectedEnvironment, + }, + { + id: 'release-label', + type: 'text' as const, + title: 'Release Label', + value: releaseLabel, + }, + { + id: 'last-action', + type: 'text' as const, + title: 'Last Action', + value: lastActionAt ?? 'No actions yet', + }, + ], + }), + createSection({ + id: 'feature-flags', + title: 'Feature Flags', + description: 'These toggles are handled on the device and mirrored into DevTools.', + items: [ + { + id: 'verbose-logging', + type: 'toggle' as const, + title: 'Verbose Logging', + value: featureFlags.verboseLogging, + description: 'Changes status to armed when enabled.', + onUpdate: (nextValue: boolean) => toggleFlag('verboseLogging', nextValue), + }, + { + id: 'mock-latency', + type: 'toggle' as const, + title: 'Mock Latency', + value: featureFlags.mockLatency, + description: 'Pure state toggle for validating round-trips.', + validate: (nextValue: boolean) => + status === 'synced' && nextValue + ? { + valid: false, + message: + 'Disable synced status before enabling mock latency.', + } + : { valid: true }, + onUpdate: (nextValue: boolean) => toggleFlag('mockLatency', nextValue), + }, + { + id: 'reverse-diagnostics', + type: 'toggle' as const, + title: 'Reverse Diagnostics', + value: featureFlags.reverseDiagnostics, + description: 'Reorders the diagnostics section to prove full snapshot replacement.', + onUpdate: (nextValue: boolean) => toggleFlag('reverseDiagnostics', nextValue), + }, + { + id: 'blocked-toggle', + type: 'toggle' as const, + title: 'Blocked Toggle', + value: false, + description: + 'This toggle is intentionally rejected to make validation errors easy to test.', + validate: (nextValue: boolean) => + nextValue + ? { + valid: false, + message: 'This demo toggle cannot be enabled.', + } + : { valid: true }, + onUpdate: () => undefined, + }, + { + id: 'environment-selector', + type: 'select' as const, + title: 'Environment', + value: selectedEnvironment, + description: 'Choose the backend target directly from DevTools.', + options: [ + { label: 'Local', value: 'local' }, + { label: 'Staging', value: 'staging' }, + { label: 'Production', value: 'production' }, + ], + validate: (nextValue: string) => + counter > 0 && nextValue === 'production' + ? { + valid: false, + message: + 'Reset the counter before switching to production.', + } + : { valid: true }, + onUpdate: (nextValue: string) => + selectEnvironment( + nextValue as 'local' | 'staging' | 'production' + ), + }, + { + id: 'release-label-input', + type: 'input' as const, + title: 'Release Label', + value: releaseLabel, + placeholder: 'build-001', + applyLabel: 'Apply', + description: + 'Apply is enabled only after editing. Labels must be at least 3 characters and contain no spaces.', + validate: (nextValue: string) => + nextValue.trim().length < 3 + ? { + valid: false, + message: 'Release label must be at least 3 characters.', + } + : nextValue.includes(' ') + ? { + valid: false, + message: 'Release label cannot contain spaces.', + } + : { valid: true }, + onUpdate: (nextValue: string) => updateReleaseLabel(nextValue), + }, + ], + }), + createSection({ + id: 'actions', + title: 'Actions', + description: `Recent checkpoints tracked: ${notes.length}`, + items: [ + { + id: 'increment-counter', + type: 'button' as const, + title: 'Increment Counter', + actionLabel: 'Increment', + onPress: incrementCounter, + }, + { + id: 'mark-synced', + type: 'button' as const, + title: 'Mark Synced', + actionLabel: 'Sync', + onPress: markSynced, + }, + { + id: 'add-checkpoint', + type: 'button' as const, + title: 'Add Checkpoint', + actionLabel: 'Add', + onPress: addCheckpoint, + }, + { + id: 'reset-demo', + type: 'button' as const, + title: 'Reset Demo', + actionLabel: 'Reset', + onPress: resetDemo, + }, + ], + }), + ], + [ + addCheckpoint, + counter, + featureFlags.mockLatency, + featureFlags.reverseDiagnostics, + featureFlags.verboseLogging, + incrementCounter, + lastActionAt, + markSynced, + notes.length, + releaseLabel, + resetDemo, + selectEnvironment, + selectedEnvironment, + status, + toggleFlag, + updateReleaseLabel, + ] + ); +}; diff --git a/apps/playground/src/app/navigation/types.ts b/apps/playground/src/app/navigation/types.ts index 1d23b18a..d9343ac5 100644 --- a/apps/playground/src/app/navigation/types.ts +++ b/apps/playground/src/app/navigation/types.ts @@ -2,6 +2,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; export type RootStackParamList = { Landing: undefined; + ControlsPlugin: undefined; MMKVPlugin: undefined; StoragePlugin: undefined; NetworkTest: undefined; diff --git a/apps/playground/src/app/screens/ControlsPluginScreen.tsx b/apps/playground/src/app/screens/ControlsPluginScreen.tsx new file mode 100644 index 00000000..fa2edc00 --- /dev/null +++ b/apps/playground/src/app/screens/ControlsPluginScreen.tsx @@ -0,0 +1,281 @@ +import { useMemo } from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useControlsPluginStore } from '../stores/controlsPluginStore'; + +export const ControlsPluginScreen = () => { + const insets = useSafeAreaInsets(); + const counter = useControlsPluginStore((state) => state.counter); + const releaseLabel = useControlsPluginStore((state) => state.releaseLabel); + const selectedEnvironment = useControlsPluginStore( + (state) => state.selectedEnvironment + ); + const status = useControlsPluginStore((state) => state.status); + const lastActionAt = useControlsPluginStore((state) => state.lastActionAt); + const notes = useControlsPluginStore((state) => state.notes); + const featureFlags = useControlsPluginStore((state) => state.featureFlags); + const updateReleaseLabel = useControlsPluginStore( + (state) => state.updateReleaseLabel + ); + const selectEnvironment = useControlsPluginStore( + (state) => state.selectEnvironment + ); + const toggleFlag = useControlsPluginStore((state) => state.toggleFlag); + const incrementCounter = useControlsPluginStore((state) => state.incrementCounter); + const markSynced = useControlsPluginStore((state) => state.markSynced); + const addCheckpoint = useControlsPluginStore((state) => state.addCheckpoint); + const resetDemo = useControlsPluginStore((state) => state.resetDemo); + + const diagnostics = useMemo( + () => [ + ['Status', status], + ['Counter', String(counter)], + ['Environment', selectedEnvironment], + ['Release label', releaseLabel], + ['Last action', lastActionAt ?? 'No actions yet'], + ], + [counter, lastActionAt, releaseLabel, selectedEnvironment, status] + ); + + return ( + + Controls Plugin Demo + + Change state locally and from DevTools. The Controls panel should always mirror + this screen because the device owns the source of truth. + + + + Diagnostics + {diagnostics.map(([label, value]) => ( + + {label} + {value} + + ))} + + + + Feature Flags + {( + Object.entries(featureFlags) as Array< + [keyof typeof featureFlags, boolean] + > + ).map(([flag, enabled]) => ( + + + {flag} + + Toggle locally or from DevTools to validate two-way updates. + + + toggleFlag(flag, nextValue)} + trackColor={{ false: '#374151', true: '#8232FF' }} + thumbColor="#ffffff" + /> + + ))} + + + + Environment + + Change it here or from the DevTools select control. + + + {(['local', 'staging', 'production'] as const).map((environment) => ( + selectEnvironment(environment)} + variant={ + selectedEnvironment === environment ? 'primary' : 'secondary' + } + /> + ))} + + + + + Release Label + + Edit it here or from the DevTools input control. + + + + + + Actions + + + + + + + + + + + + Recent Checkpoints + {notes.length === 0 ? ( + No checkpoints yet. + ) : ( + notes.map((note) => ( + + {note} + + )) + )} + + + ); +}; + +const DemoButton = ({ + label, + onPress, + variant = 'primary', +}: { + label: string; + onPress: () => void; + variant?: 'primary' | 'secondary'; +}) => ( + + + {label} + + +); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a0a0a', + }, + content: { + padding: 20, + gap: 16, + }, + title: { + color: '#ffffff', + fontSize: 28, + fontWeight: '700', + }, + subtitle: { + color: '#9ca3af', + fontSize: 15, + lineHeight: 22, + }, + card: { + backgroundColor: '#111827', + borderRadius: 16, + borderWidth: 1, + borderColor: '#1f2937', + padding: 16, + gap: 12, + }, + cardTitle: { + color: '#ffffff', + fontSize: 18, + fontWeight: '600', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + }, + rowText: { + flex: 1, + gap: 4, + }, + label: { + color: '#e5e7eb', + fontSize: 15, + fontWeight: '500', + textTransform: 'capitalize', + }, + value: { + color: '#c4b5fd', + fontSize: 14, + fontWeight: '600', + }, + helperText: { + color: '#6b7280', + fontSize: 13, + lineHeight: 18, + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + }, + button: { + flex: 1, + backgroundColor: '#8232FF', + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 14, + alignItems: 'center', + }, + secondaryButton: { + backgroundColor: '#111827', + borderWidth: 1, + borderColor: '#374151', + }, + buttonLabel: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + }, + secondaryButtonLabel: { + color: '#d1d5db', + }, + input: { + borderWidth: 1, + borderColor: '#374151', + backgroundColor: '#030712', + borderRadius: 12, + color: '#ffffff', + fontSize: 14, + paddingHorizontal: 14, + paddingVertical: 12, + }, + note: { + color: '#d1d5db', + fontSize: 13, + lineHeight: 18, + }, +}); diff --git a/apps/playground/src/app/screens/LandingScreen.tsx b/apps/playground/src/app/screens/LandingScreen.tsx index 9c98f3f3..5acf599a 100644 --- a/apps/playground/src/app/screens/LandingScreen.tsx +++ b/apps/playground/src/app/screens/LandingScreen.tsx @@ -1,4 +1,5 @@ import { + ScrollView, View, Text, StyleSheet, @@ -15,78 +16,90 @@ export const LandingScreen = () => { return ( - - Rozenite - - React Native DevTools Plugin Framework{'\n'} - Playground for testing plugins in real-world scenarios - + + + Rozenite + + React Native DevTools Plugin Framework{'\n'} + Playground for testing plugins in real-world scenarios + - - navigation.navigate('MMKVPlugin' as never)} - > - MMKV Plugin - + + navigation.navigate('ControlsPlugin' as never)} + > + Controls Plugin + - navigation.navigate('StoragePlugin' as never)} - > - Storage Plugin - + navigation.navigate('MMKVPlugin' as never)} + > + MMKV Plugin + - navigation.navigate('NetworkTest' as never)} - > - Network Activity - + navigation.navigate('StoragePlugin' as never)} + > + Storage Plugin + - navigation.navigate('ReduxTest' as never)} - > - Redux Test - + navigation.navigate('NetworkTest' as never)} + > + Network Activity + - navigation.navigate('PerformanceMonitor' as never)} - > - Performance Monitor - + navigation.navigate('ReduxTest' as never)} + > + Redux Test + - - navigation.navigate('RequireProfilerTest' as never) - } - > - Require Profiler Test - + navigation.navigate('PerformanceMonitor' as never)} + > + Performance Monitor + - navigation.navigate('BottomTabs' as never)} - > - React Navigation - + + navigation.navigate('RequireProfilerTest' as never) + } + > + Require Profiler Test + - navigation.navigate('Config' as never)} - > - ⚙️ Settings - - + navigation.navigate('BottomTabs' as never)} + > + React Navigation + + + navigation.navigate('Config' as never)} + > + ⚙️ Settings + + - - Test and explore Rozenite plugins with type-safe, isomorphic - communication between DevTools and React Native - - + + Test and explore Rozenite plugins with type-safe, isomorphic + communication between DevTools and React Native + + + @@ -101,9 +114,13 @@ const styles = StyleSheet.create({ backgroundGradient: { flex: 1, backgroundColor: '#0a0a0a', + position: 'relative', + }, + scrollContent: { + flexGrow: 1, justifyContent: 'center', alignItems: 'center', - position: 'relative', + paddingVertical: 40, }, content: { alignItems: 'center', diff --git a/apps/playground/src/app/stores/controlsPluginStore.ts b/apps/playground/src/app/stores/controlsPluginStore.ts new file mode 100644 index 00000000..d503dc31 --- /dev/null +++ b/apps/playground/src/app/stores/controlsPluginStore.ts @@ -0,0 +1,92 @@ +import { create } from 'zustand'; + +type FeatureFlags = { + verboseLogging: boolean; + mockLatency: boolean; + reverseDiagnostics: boolean; +}; + +type ControlsPluginState = { + counter: number; + releaseLabel: string; + selectedEnvironment: 'local' | 'staging' | 'production'; + status: 'idle' | 'armed' | 'synced'; + lastActionAt: string | null; + notes: string[]; + featureFlags: FeatureFlags; + updateReleaseLabel: (releaseLabel: string) => void; + selectEnvironment: ( + environment: ControlsPluginState['selectedEnvironment'] + ) => void; + toggleFlag: (flag: keyof FeatureFlags, nextValue: boolean) => void; + incrementCounter: () => void; + markSynced: () => void; + addCheckpoint: () => void; + resetDemo: () => void; +}; + +const formatTimestamp = (date: Date) => date.toLocaleTimeString(); + +const initialFeatureFlags: FeatureFlags = { + verboseLogging: true, + mockLatency: false, + reverseDiagnostics: false, +}; + +export const useControlsPluginStore = create((set) => ({ + counter: 0, + releaseLabel: 'build-001', + selectedEnvironment: 'local', + status: 'idle', + lastActionAt: null, + notes: [], + featureFlags: initialFeatureFlags, + updateReleaseLabel: (releaseLabel) => + set(() => ({ + releaseLabel, + lastActionAt: formatTimestamp(new Date()), + })), + selectEnvironment: (selectedEnvironment) => + set(() => ({ + selectedEnvironment, + lastActionAt: formatTimestamp(new Date()), + })), + toggleFlag: (flag, nextValue) => + set((state) => ({ + featureFlags: { + ...state.featureFlags, + [flag]: nextValue, + }, + status: nextValue ? 'armed' : state.status === 'armed' ? 'idle' : state.status, + lastActionAt: formatTimestamp(new Date()), + })), + incrementCounter: () => + set((state) => ({ + counter: state.counter + 1, + status: 'armed', + lastActionAt: formatTimestamp(new Date()), + })), + markSynced: () => + set(() => ({ + status: 'synced', + lastActionAt: formatTimestamp(new Date()), + })), + addCheckpoint: () => + set((state) => ({ + notes: [ + `Checkpoint ${state.notes.length + 1} at ${formatTimestamp(new Date())}`, + ...state.notes, + ].slice(0, 5), + lastActionAt: formatTimestamp(new Date()), + })), + resetDemo: () => + set(() => ({ + counter: 0, + releaseLabel: 'build-001', + selectedEnvironment: 'local', + status: 'idle', + lastActionAt: formatTimestamp(new Date()), + notes: [], + featureFlags: initialFeatureFlags, + })), +})); diff --git a/apps/playground/tsconfig.app.json b/apps/playground/tsconfig.app.json index a26fbdbe..73869231 100644 --- a/apps/playground/tsconfig.app.json +++ b/apps/playground/tsconfig.app.json @@ -12,6 +12,10 @@ "jsx": "react-jsx", "module": "esnext", "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@rozenite/controls-plugin": ["../../packages/controls-plugin/react-native.d.ts"] + }, "lib": ["dom", "es2022"], "types": ["node"], "noUnusedLocals": false, diff --git a/commitlint.config.js b/commitlint.config.js index 4afcf879..c1a92c91 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -27,6 +27,7 @@ export default { 'chrome-extension', 'web', 'storage-plugin', + 'controls-plugin', '' ], ], diff --git a/packages/controls-plugin/package.json b/packages/controls-plugin/package.json new file mode 100644 index 00000000..83811333 --- /dev/null +++ b/packages/controls-plugin/package.json @@ -0,0 +1,38 @@ +{ + "name": "@rozenite/controls-plugin", + "version": "0.0.0", + "description": "Device-owned controls for Rozenite.", + "type": "module", + "main": "./dist/react-native.cjs", + "module": "./dist/react-native.js", + "types": "./dist/react-native.d.ts", + "scripts": { + "build": "rozenite build", + "dev": "rozenite dev", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint ." + }, + "dependencies": { + "@rozenite/plugin-bridge": "workspace:*" + }, + "devDependencies": { + "@rozenite/vite-plugin": "workspace:*", + "@types/react": "catalog:", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "react": "catalog:", + "react-dom": "catalog:", + "react-native": "catalog:", + "react-native-web": "^0.21.2", + "rozenite": "workspace:*", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "~5.9.3", + "vite": "catalog:" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "license": "MIT" +} diff --git a/packages/controls-plugin/postcss.config.js b/packages/controls-plugin/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/packages/controls-plugin/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/controls-plugin/react-native.d.ts b/packages/controls-plugin/react-native.d.ts new file mode 100644 index 00000000..c4496055 --- /dev/null +++ b/packages/controls-plugin/react-native.d.ts @@ -0,0 +1,19 @@ +export { + createSection, + type ControlsButtonItem, + type ControlsInputItem, + type ControlsItem, + type ControlsSelectItem, + type ControlsSelectOption, + type ControlsSection, + type ControlsTextItem, + type ControlsToggleItem, + type ControlsValidationResult, + type RozeniteControlsPluginOptions, +} from './src/shared/types'; + +export declare const useRozeniteControlsPlugin: ( + options: import('./src/shared/types').RozeniteControlsPluginOptions +) => import('@rozenite/plugin-bridge').RozeniteDevToolsClient< + import('./src/shared/messaging').ControlsEventMap +> | null; diff --git a/packages/controls-plugin/react-native.ts b/packages/controls-plugin/react-native.ts new file mode 100644 index 00000000..969d7f2e --- /dev/null +++ b/packages/controls-plugin/react-native.ts @@ -0,0 +1,25 @@ +export { + createSection, + type ControlsButtonItem, + type ControlsInputItem, + type ControlsItem, + type ControlsSelectItem, + type ControlsSelectOption, + type ControlsSection, + type ControlsTextItem, + type ControlsToggleItem, + type ControlsValidationResult, + type RozeniteControlsPluginOptions, +} from './src/shared/types'; + +export let useRozeniteControlsPlugin: typeof import('./src/react-native/useRozeniteControlsPlugin').useRozeniteControlsPlugin; + +const isDev = process.env.NODE_ENV !== 'production'; +const isServer = typeof window === 'undefined'; + +if (isDev && !isServer) { + useRozeniteControlsPlugin = + require('./src/react-native/useRozeniteControlsPlugin').useRozeniteControlsPlugin; +} else { + useRozeniteControlsPlugin = () => null; +} diff --git a/packages/controls-plugin/rozenite.config.ts b/packages/controls-plugin/rozenite.config.ts new file mode 100644 index 00000000..2b3fb8ee --- /dev/null +++ b/packages/controls-plugin/rozenite.config.ts @@ -0,0 +1,8 @@ +export default { + panels: [ + { + name: 'Controls', + source: './src/ui/panel.tsx', + }, + ], +}; diff --git a/packages/controls-plugin/src/__tests__/serialization.test.ts b/packages/controls-plugin/src/__tests__/serialization.test.ts new file mode 100644 index 00000000..bfef9e43 --- /dev/null +++ b/packages/controls-plugin/src/__tests__/serialization.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildActionRegistry, getActionRegistryKey, serializeSections } from '../shared/serialization'; +import { createSection } from '../shared/types'; + +describe('controls serialization', () => { + it('omits callbacks from snapshots', () => { + const sections = [ + createSection({ + id: 'diagnostics', + title: 'Diagnostics', + items: [ + { + id: 'status', + type: 'text', + title: 'Status', + value: 'ready', + }, + { + id: 'enabled', + type: 'toggle', + title: 'Enabled', + value: true, + validate: vi.fn(() => ({ valid: true as const })), + onUpdate: vi.fn(), + }, + { + id: 'reset', + type: 'button', + title: 'Reset', + onPress: vi.fn(), + }, + { + id: 'environment', + type: 'select', + title: 'Environment', + value: 'staging', + options: [ + { label: 'Local', value: 'local' }, + { label: 'Staging', value: 'staging' }, + ], + validate: vi.fn(() => ({ valid: true as const })), + onUpdate: vi.fn(), + }, + { + id: 'release-label', + type: 'input', + title: 'Release Label', + value: 'build-001', + placeholder: 'build-001', + applyLabel: 'Apply', + validate: vi.fn(() => ({ valid: true as const })), + onUpdate: vi.fn(), + }, + ], + }), + ]; + + expect(serializeSections(sections)).toEqual([ + { + id: 'diagnostics', + title: 'Diagnostics', + description: undefined, + items: [ + { + id: 'status', + type: 'text', + title: 'Status', + value: 'ready', + }, + { + id: 'enabled', + type: 'toggle', + title: 'Enabled', + value: true, + description: undefined, + disabled: undefined, + }, + { + id: 'reset', + type: 'button', + title: 'Reset', + actionLabel: undefined, + description: undefined, + disabled: undefined, + }, + { + id: 'environment', + type: 'select', + title: 'Environment', + value: 'staging', + options: [ + { label: 'Local', value: 'local' }, + { label: 'Staging', value: 'staging' }, + ], + description: undefined, + disabled: undefined, + }, + { + id: 'release-label', + type: 'input', + title: 'Release Label', + value: 'build-001', + placeholder: 'build-001', + applyLabel: 'Apply', + description: undefined, + disabled: undefined, + }, + ], + }, + ]); + }); + + it('builds an action registry for interactive items', async () => { + const onUpdateToggle = vi.fn(); + const onPress = vi.fn(); + const onUpdateSelect = vi.fn(); + const onUpdateInput = vi.fn(); + const validateToggle = vi.fn(() => ({ valid: true as const })); + const validateSelect = vi.fn(() => ({ valid: true as const })); + const validateInput = vi.fn(() => ({ valid: true as const })); + + const sections = [ + createSection({ + id: 'controls', + title: 'Controls', + items: [ + { + id: 'flag', + type: 'toggle', + title: 'Flag', + value: false, + validate: validateToggle, + onUpdate: onUpdateToggle, + }, + { + id: 'refresh', + type: 'button', + title: 'Refresh', + onPress, + }, + { + id: 'environment', + type: 'select', + title: 'Environment', + value: 'local', + options: [ + { label: 'Local', value: 'local' }, + { label: 'Staging', value: 'staging' }, + ], + validate: validateSelect, + onUpdate: onUpdateSelect, + }, + { + id: 'release-label', + type: 'input', + title: 'Release Label', + value: 'build-001', + placeholder: 'build-001', + applyLabel: 'Apply', + validate: validateInput, + onUpdate: onUpdateInput, + }, + ], + }), + ]; + + const registry = buildActionRegistry(sections); + + const toggleEntry = registry.get(getActionRegistryKey('controls', 'flag')); + const buttonEntry = registry.get(getActionRegistryKey('controls', 'refresh')); + const selectEntry = registry.get( + getActionRegistryKey('controls', 'environment') + ); + const inputEntry = registry.get( + getActionRegistryKey('controls', 'release-label') + ); + + expect(toggleEntry?.type).toBe('toggle'); + expect(buttonEntry?.type).toBe('button'); + expect(selectEntry?.type).toBe('select'); + expect(inputEntry?.type).toBe('input'); + + if (toggleEntry?.type === 'toggle') { + expect(toggleEntry.validate?.(true)).toEqual({ valid: true }); + await toggleEntry.onUpdate(true); + } + + if (buttonEntry?.type === 'button') { + await buttonEntry.onPress(); + } + + if (selectEntry?.type === 'select') { + expect(selectEntry.validate?.('staging')).toEqual({ valid: true }); + await selectEntry.onUpdate('staging'); + } + + if (inputEntry?.type === 'input') { + expect(inputEntry.validate?.('build-002')).toEqual({ valid: true }); + await inputEntry.onUpdate('build-002'); + } + + expect(validateToggle).toHaveBeenCalledWith(true); + expect(onUpdateToggle).toHaveBeenCalledWith(true); + expect(onPress).toHaveBeenCalledTimes(1); + expect(validateSelect).toHaveBeenCalledWith('staging'); + expect(onUpdateSelect).toHaveBeenCalledWith('staging'); + expect(validateInput).toHaveBeenCalledWith('build-002'); + expect(onUpdateInput).toHaveBeenCalledWith('build-002'); + }); +}); diff --git a/packages/controls-plugin/src/react-native/useRozeniteControlsPlugin.ts b/packages/controls-plugin/src/react-native/useRozeniteControlsPlugin.ts new file mode 100644 index 00000000..8ede057b --- /dev/null +++ b/packages/controls-plugin/src/react-native/useRozeniteControlsPlugin.ts @@ -0,0 +1,215 @@ +import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { useEffect, useMemo, useRef } from 'react'; +import type { + ControlsEventMap, + ControlsInvokeActionEvent, + ControlsUpdateRequestEvent, +} from '../shared/messaging'; +import type { RozeniteControlsPluginOptions } from '../shared/types'; +import { + buildActionRegistry, + getActionRegistryKey, + serializeSections, + validateValue, +} from '../shared/serialization'; + +export const useRozeniteControlsPlugin = ({ + sections, +}: RozeniteControlsPluginOptions) => { + const client = useRozeniteDevToolsClient({ + pluginId: '@rozenite/controls-plugin', + }); + + const snapshot = useMemo(() => serializeSections(sections), [sections]); + const actionRegistry = useMemo(() => buildActionRegistry(sections), [sections]); + const actionRegistryRef = useRef(actionRegistry); + + useEffect(() => { + actionRegistryRef.current = actionRegistry; + }, [actionRegistry]); + + useEffect(() => { + if (!client) { + return; + } + + client.send('snapshot', { + type: 'snapshot', + sections: snapshot, + }); + }, [client, snapshot]); + + useEffect(() => { + if (!client) { + return; + } + + const handleUpdateRequest = async ({ + requestId, + sectionId, + itemId, + value, + }: ControlsUpdateRequestEvent) => { + const key = getActionRegistryKey(sectionId, itemId); + const entry = actionRegistryRef.current.get(key); + + if (!entry || entry.type === 'button') { + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'error', + message: 'Update target not found.', + }); + return; + } + + try { + if (entry.type === 'toggle') { + if (typeof value !== 'boolean') { + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'error', + message: 'Invalid toggle value.', + }); + return; + } + + const result = validateValue(entry.validate, value); + if (!result.valid) { + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'error', + message: result.message, + }); + return; + } + + await entry.onUpdate(value); + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'ok', + }); + return; + } + + if (typeof value !== 'string') { + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'error', + message: `Invalid ${entry.type} value.`, + }); + return; + } + + const result = validateValue(entry.validate, value); + if (!result.valid) { + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'error', + message: result.message, + }); + return; + } + + await entry.onUpdate(value); + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'ok', + }); + } catch (error) { + console.warn( + `[Rozenite] Controls Plugin: Update failed for ${sectionId}/${itemId}.`, + error + ); + client.send('update-result', { + type: 'update-result', + requestId, + sectionId, + itemId, + status: 'error', + message: 'Update failed on the device.', + }); + } + }; + + const handleInvokeAction = async ({ + sectionId, + itemId, + action, + }: ControlsInvokeActionEvent) => { + if (action !== 'press') { + console.warn( + `[Rozenite] Controls Plugin: Unsupported action "${action}" for ${sectionId}/${itemId}.` + ); + return; + } + + const key = getActionRegistryKey(sectionId, itemId); + const entry = actionRegistryRef.current.get(key); + + if (!entry) { + console.warn( + `[Rozenite] Controls Plugin: Action target not found for ${sectionId}/${itemId}.` + ); + return; + } + + try { + if (entry.type !== 'button') { + console.warn( + `[Rozenite] Controls Plugin: Invalid press action payload for ${sectionId}/${itemId}.` + ); + return; + } + + await entry.onPress(); + } catch (error) { + console.warn( + `[Rozenite] Controls Plugin: Action failed for ${sectionId}/${itemId}.`, + error + ); + } + }; + + const subscriptions = [ + client.onMessage('get-snapshot', () => { + client.send('snapshot', { + type: 'snapshot', + sections: snapshot, + }); + }), + client.onMessage('update-request', (event: ControlsUpdateRequestEvent) => { + void handleUpdateRequest(event); + }), + client.onMessage('invoke-action', (event: ControlsInvokeActionEvent) => { + void handleInvokeAction(event); + }), + ]; + + return () => { + subscriptions.forEach((subscription) => subscription.remove()); + }; + }, [client, snapshot]); + + return client; +}; diff --git a/packages/controls-plugin/src/shared/messaging.ts b/packages/controls-plugin/src/shared/messaging.ts new file mode 100644 index 00000000..71e685dd --- /dev/null +++ b/packages/controls-plugin/src/shared/messaging.ts @@ -0,0 +1,45 @@ +import type { ControlsSectionSnapshot } from './types'; + +export type ControlsSnapshotEvent = { + type: 'snapshot'; + sections: ControlsSectionSnapshot[]; +}; + +export type ControlsGetSnapshotEvent = { + type: 'get-snapshot'; +}; + +export type ControlsUpdateRequestEvent = { + type: 'update-request'; + requestId: string; + sectionId: string; + itemId: string; + value: boolean | string; +}; + +export type ControlsUpdateResultEvent = { + type: 'update-result'; + requestId: string; + sectionId: string; + itemId: string; + status: 'ok' | 'error'; + message?: string; +}; + +export type ControlsInvokeActionEvent = { + type: 'invoke-action'; + sectionId: string; + itemId: string; + action: 'press'; +}; + +export type ControlsEvent = + | ControlsSnapshotEvent + | ControlsGetSnapshotEvent + | ControlsUpdateRequestEvent + | ControlsUpdateResultEvent + | ControlsInvokeActionEvent; + +export type ControlsEventMap = { + [K in ControlsEvent['type']]: Extract; +}; diff --git a/packages/controls-plugin/src/shared/serialization.ts b/packages/controls-plugin/src/shared/serialization.ts new file mode 100644 index 00000000..2338317c --- /dev/null +++ b/packages/controls-plugin/src/shared/serialization.ts @@ -0,0 +1,153 @@ +import type { + ControlsButtonItem, + ControlsInputItem, + ControlsItem, + ControlsItemSnapshot, + ControlsMutableItemBase, + ControlsSelectItem, + ControlsSection, + ControlsSectionSnapshot, + ControlsToggleItem, + ControlsValidationResult, +} from './types'; + +export type ActionRegistryEntry = + | { + type: 'toggle'; + validate?: ControlsToggleItem['validate']; + onUpdate: ControlsToggleItem['onUpdate']; + } + | { + type: 'button'; + onPress: ControlsButtonItem['onPress']; + } + | { + type: 'select'; + validate?: ControlsSelectItem['validate']; + onUpdate: ControlsSelectItem['onUpdate']; + } + | { + type: 'input'; + validate?: ControlsInputItem['validate']; + onUpdate: ControlsInputItem['onUpdate']; + }; + +const validateValue = ( + validate: ControlsMutableItemBase['validate'], + value: TValue +): ControlsValidationResult => { + if (!validate) { + return { valid: true }; + } + + return validate(value); +}; + +const toSnapshotItem = (item: ControlsItem): ControlsItemSnapshot => { + if (item.type === 'text') { + return item; + } + + if (item.type === 'toggle') { + return { + id: item.id, + type: item.type, + title: item.title, + value: item.value, + description: item.description, + disabled: item.disabled, + }; + } + + if (item.type === 'button') { + return { + id: item.id, + type: item.type, + title: item.title, + actionLabel: item.actionLabel, + description: item.description, + disabled: item.disabled, + }; + } + + if (item.type === 'select') { + return { + id: item.id, + type: item.type, + title: item.title, + value: item.value, + options: item.options, + description: item.description, + disabled: item.disabled, + }; + } + + return { + id: item.id, + type: item.type, + title: item.title, + value: item.value, + placeholder: item.placeholder, + applyLabel: item.applyLabel, + description: item.description, + disabled: item.disabled, + }; +}; + +export const serializeSections = ( + sections: ControlsSection[] +): ControlsSectionSnapshot[] => + sections.map((section) => ({ + id: section.id, + title: section.title, + description: section.description, + items: section.items.map(toSnapshotItem), + })); + +export const buildActionRegistry = (sections: ControlsSection[]) => { + const registry = new Map(); + + sections.forEach((section) => { + section.items.forEach((item) => { + const key = `${section.id}:${item.id}`; + + if (item.type === 'toggle') { + registry.set(key, { + type: 'toggle', + validate: item.validate, + onUpdate: item.onUpdate, + }); + } + + if (item.type === 'button') { + registry.set(key, { + type: 'button', + onPress: item.onPress, + }); + } + + if (item.type === 'select') { + registry.set(key, { + type: 'select', + validate: item.validate, + onUpdate: item.onUpdate, + }); + } + + if (item.type === 'input') { + registry.set(key, { + type: 'input', + validate: item.validate, + onUpdate: item.onUpdate, + }); + } + }); + }); + + return registry; +}; + +export const getActionRegistryKey = (sectionId: string, itemId: string) => + `${sectionId}:${itemId}`; + +export { validateValue }; diff --git a/packages/controls-plugin/src/shared/types.ts b/packages/controls-plugin/src/shared/types.ts new file mode 100644 index 00000000..f53968df --- /dev/null +++ b/packages/controls-plugin/src/shared/types.ts @@ -0,0 +1,103 @@ +export type ControlsTextItem = { + id: string; + type: 'text'; + title: string; + value: string; + description?: string; +}; + +export type ControlsValidationResult = + | { valid: true } + | { valid: false; message: string }; + +export type ControlsMutableItemBase = { + id: string; + title: string; + value: TValue; + description?: string; + disabled?: boolean; + validate?: (nextValue: TValue) => ControlsValidationResult; + onUpdate: (nextValue: TValue) => void | Promise; +}; + +export type ControlsToggleItem = ControlsMutableItemBase & { + type: 'toggle'; +}; + +export type ControlsButtonItem = { + id: string; + type: 'button'; + title: string; + actionLabel?: string; + description?: string; + disabled?: boolean; + onPress: () => void | Promise; +}; + +export type ControlsSelectOption = { + label: string; + value: string; +}; + +export type ControlsSelectItem = ControlsMutableItemBase & { + type: 'select'; + options: ControlsSelectOption[]; +}; + +export type ControlsInputItem = ControlsMutableItemBase & { + type: 'input'; + placeholder?: string; + applyLabel?: string; +}; + +export type ControlsItem = + | ControlsTextItem + | ControlsToggleItem + | ControlsButtonItem + | ControlsSelectItem + | ControlsInputItem; + +export type ControlsSection = { + id: string; + title: string; + description?: string; + items: ControlsItem[]; +}; + +export type ControlsTextItemSnapshot = Omit; + +export type ControlsToggleItemSnapshot = Omit< + ControlsToggleItem, + 'validate' | 'onUpdate' +>; + +export type ControlsButtonItemSnapshot = Omit; + +export type ControlsSelectItemSnapshot = Omit< + ControlsSelectItem, + 'validate' | 'onUpdate' +>; + +export type ControlsInputItemSnapshot = Omit< + ControlsInputItem, + 'validate' | 'onUpdate' +>; + +export type ControlsItemSnapshot = + | ControlsTextItemSnapshot + | ControlsToggleItemSnapshot + | ControlsButtonItemSnapshot + | ControlsSelectItemSnapshot + | ControlsInputItemSnapshot; + +export type ControlsSectionSnapshot = Omit & { + items: ControlsItemSnapshot[]; +}; + +export type RozeniteControlsPluginOptions = { + sections: ControlsSection[]; +}; + +export const createSection = ( + section: TSection +): TSection => section; diff --git a/packages/controls-plugin/src/ui/globals.css b/packages/controls-plugin/src/ui/globals.css new file mode 100644 index 00000000..c42d28c0 --- /dev/null +++ b/packages/controls-plugin/src/ui/globals.css @@ -0,0 +1,86 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1f2937; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; + border: 1px solid #1f2937; +} + +::-webkit-scrollbar-thumb:hover { + background: #4b5563; +} + +::-webkit-scrollbar-thumb:active { + background: #6b7280; +} + +::-webkit-scrollbar-corner { + background: #1f2937; +} diff --git a/packages/controls-plugin/src/ui/panel.tsx b/packages/controls-plugin/src/ui/panel.tsx new file mode 100644 index 00000000..5f180a2d --- /dev/null +++ b/packages/controls-plugin/src/ui/panel.tsx @@ -0,0 +1,501 @@ +import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { + ControlsEventMap, + ControlsSnapshotEvent, + ControlsUpdateResultEvent, +} from '../shared/messaging'; +import type { ControlsItemSnapshot, ControlsSectionSnapshot } from '../shared/types'; +import './globals.css'; + +type ItemUiState = { + pending: boolean; + message?: string; +}; + +const getItemKey = (sectionId: string, itemId: string) => `${sectionId}:${itemId}`; + +const createRequestId = () => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + +const RowShell = ({ + title, + description, + errorMessage, + children, +}: { + title: string; + description?: string; + errorMessage?: string; + children: ReactNode; +}) => ( +
+
+
{title}
+ {description ? ( +
{description}
+ ) : null} + {errorMessage ? ( +
{errorMessage}
+ ) : null} +
+ {children} +
+); + +const ToggleRow = ({ + sectionId, + item, + uiState, + onToggle, +}: { + sectionId: string; + item: Extract; + uiState?: ItemUiState; + onToggle: (sectionId: string, itemId: string, value: boolean) => void; +}) => { + return ( + +