@limrun/ui 0.4.0-rc.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.4.0-rc.9",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,13 +1,20 @@
1
1
  .rc-container {
2
2
  position: relative;
3
3
  display: inline-block;
4
- height: 100%;
5
4
  box-sizing: border-box;
6
5
  isolation: isolate;
7
6
 
8
7
  touch-action: none;
9
8
  }
10
9
 
10
+ .rc-container-portrait {
11
+ height: 100%;
12
+ }
13
+
14
+ .rc-container-landscape {
15
+ width: 100%;
16
+ }
17
+
11
18
  .rc-phone-frame {
12
19
  position: relative;
13
20
  z-index: 20;
@@ -18,19 +25,38 @@
18
25
 
19
26
  .rc-phone-frame-portrait {
20
27
  height: 100%;
28
+ max-width: 100%;
21
29
  }
22
30
 
23
31
  .rc-phone-frame-landscape {
24
32
  width: 100%;
33
+ max-height: 100%;
25
34
  }
26
35
 
27
36
  .rc-video {
28
37
  position: absolute;
29
- width: auto;
38
+ /* Size is controlled by inline style; defaults are for frameless mode */
39
+ width: 100%;
40
+ height: 100%;
30
41
  outline: none;
31
42
  pointer-events: none;
32
43
  cursor: none;
33
44
  background-color: black;
45
+ object-fit: contain;
46
+ object-position: center center;
47
+ inset: 0;
48
+
49
+ top: 50%;
50
+ left: 50%;
51
+ transform: translate(-50%, -50%);
52
+ right: auto;
53
+ bottom: auto;
54
+ }
55
+
56
+ .rc-video-ios-stretch {
57
+ /* iOS-only: intentionally stretch to fill the target screen box */
58
+ object-fit: fill;
59
+ z-index: 30;
34
60
  }
35
61
 
36
62
  .rc-video-frameless {
@@ -38,32 +64,9 @@
38
64
  height: 100%;
39
65
  top: auto;
40
66
  left: auto;
67
+ width: 100%;
41
68
  }
42
69
 
43
70
  .rc-video-loading {
44
71
  aspect-ratio: 9 / 19.5;
45
- }
46
-
47
- .rc-video-ios-portrait {
48
- top: 1.61%;
49
- left: 3.9%;
50
- height: 96.78%;
51
- }
52
-
53
- .rc-video-ios-landscape {
54
- top: 3.9%;
55
- left: 1.61%;
56
- width: 96.78%;
57
- }
58
-
59
- .rc-video-android-portrait {
60
- top: 2.25%;
61
- left: 4.5%;
62
- height: 95.9%;
63
- }
64
-
65
- .rc-video-android-landscape {
66
- left: 2.25%;
67
- top: 4.5%;
68
- width: 95.9%;
69
72
  }
@@ -4,10 +4,10 @@ import './remote-control.css';
4
4
 
5
5
  import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
6
6
 
7
- import iphoneFrameImage from '../assets/iphone16pro_black.webp';
7
+ import iphoneFrameImage from '../assets/iphone16pro_black_bg.webp';
8
8
  import pixelFrameImage from '../assets/pixel9_black.webp';
9
9
  import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
10
- import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape.webp';
10
+ import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
11
11
  import appleLogoSvg from '../assets/Apple_logo_white.svg';
12
12
  import androidBootImage from '../assets/android_boot.webp';
13
13
  import {
@@ -91,23 +91,50 @@ const detectPlatform = (url: string): DevicePlatform => {
91
91
  return 'ios';
92
92
  };
93
93
 
94
+ type DeviceConfig = {
95
+ videoBorderRadiusMultiplier: number;
96
+ loadingLogo: string;
97
+ loadingLogoSize: string;
98
+ videoPosition: {
99
+ portrait: { heightMultiplier?: number; widthMultiplier?: number; };
100
+ landscape: { heightMultiplier?: number; widthMultiplier?: number; };
101
+ };
102
+ frame: {
103
+ image: string;
104
+ imageLandscape: string;
105
+ }
106
+ }
107
+
94
108
  // Device-specific configuration for frame sizing and video positioning
95
- const deviceConfig = {
109
+ // Video position percentages are relative to the frame image dimensions
110
+ const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
96
111
  ios: {
97
- frameImage: iphoneFrameImage,
98
- frameImageLandscape: iphoneFrameImageLandscape,
99
- frameWidthMultiplier: 1.0841,
112
+ frame: {
113
+ image: iphoneFrameImage,
114
+ imageLandscape: iphoneFrameImageLandscape,
115
+ },
100
116
  videoBorderRadiusMultiplier: 0.15,
101
117
  loadingLogo: appleLogoSvg,
102
118
  loadingLogoSize: '20%',
119
+ // Video position as percentage of frame dimensions
120
+ videoPosition: {
121
+ portrait: { heightMultiplier: 0.9678 },
122
+ landscape: { widthMultiplier: 0.9678 },
123
+ },
103
124
  },
104
125
  android: {
105
- frameImage: pixelFrameImage,
106
- frameImageLandscape: pixelFrameImageLandscape,
107
- frameWidthMultiplier: 1.107,
126
+ frame: {
127
+ image: pixelFrameImage,
128
+ imageLandscape: pixelFrameImageLandscape,
129
+ },
108
130
  videoBorderRadiusMultiplier: 0.13,
109
131
  loadingLogo: androidBootImage,
110
132
  loadingLogoSize: '40%',
133
+ // Video position as percentage of frame dimensions
134
+ videoPosition: {
135
+ portrait: { heightMultiplier: 0.967 },
136
+ landscape: { widthMultiplier: 0.962 },
137
+ },
111
138
  },
112
139
  };
113
140
 
@@ -147,6 +174,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
147
174
  const frameRef = useRef<HTMLImageElement>(null);
148
175
  const [videoLoaded, setVideoLoaded] = useState(false);
149
176
  const [isLandscape, setIsLandscape] = useState(false);
177
+ const [videoStyle, setVideoStyle] = useState<React.CSSProperties>({});
150
178
  const wsRef = useRef<WebSocket | null>(null);
151
179
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
152
180
  const dataChannelRef = useRef<RTCDataChannel | null>(null);
@@ -787,33 +815,70 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
787
815
  };
788
816
  }, [url, token, propSessionId]);
789
817
 
790
- // Resize phone frame and video border-radius relative to video size
818
+ // Calculate video position and border-radius based on frame dimensions
791
819
  useEffect(() => {
792
820
  const video = videoRef.current;
793
821
  const frame = frameRef.current;
794
- if (!video || !frame || !showFrame) return;
795
-
796
- const resizeObserver = new ResizeObserver((entries) => {
797
- for (const entry of entries) {
798
- const landscape = entry.contentRect.width > entry.contentRect.height;
799
- setIsLandscape(landscape);
800
- const videoWidth = entry.contentRect.width;
801
- const videoHeight = entry.contentRect.height;
802
- const originalFrameWidth = frame.clientWidth;
803
- const newFrameWidth = videoWidth * config.frameWidthMultiplier;
804
- // The video is too small up until the first frame is visible, so we need to make sure we don't scale the frame down too much.
805
- if (!landscape && newFrameWidth > 0.9*originalFrameWidth) {
806
- // This is mostly for iOS where the video size is slightly inconsistent.
807
- frame.style.width = `${videoWidth * config.frameWidthMultiplier}px`;
808
- }
809
- video.style.borderRadius = `${(entry.contentRect.width > entry.contentRect.height ? videoHeight : videoWidth) * config.videoBorderRadiusMultiplier}px`;
822
+
823
+ if (!video) return;
824
+
825
+ // If no frame, no positioning needed
826
+ if (!showFrame || !frame) {
827
+ setVideoStyle({});
828
+ return;
829
+ }
830
+
831
+ const updateVideoPosition = () => {
832
+ const frameWidth = frame.clientWidth;
833
+ const frameHeight = frame.clientHeight;
834
+
835
+ if (frameWidth === 0 || frameHeight === 0) return;
836
+
837
+ // Determine landscape based on video's intrinsic dimensions
838
+ const landscape = video.videoWidth > video.videoHeight;
839
+ setIsLandscape(landscape);
840
+
841
+ const pos = landscape ? config.videoPosition.landscape : config.videoPosition.portrait;
842
+ let newStyle: React.CSSProperties = {};
843
+ if (pos.heightMultiplier) {
844
+ newStyle.height = `${frameHeight * pos.heightMultiplier}px`;
845
+ // Let the other dimension follow the video stream's intrinsic aspect ratio.
846
+ newStyle.width = 'auto';
847
+ } else if (pos.widthMultiplier) {
848
+ newStyle.width = `${frameWidth * pos.widthMultiplier}px`;
849
+ // Let the other dimension follow the video stream's intrinsic aspect ratio.
850
+ newStyle.height = 'auto';
810
851
  }
852
+ newStyle.borderRadius = `${landscape ? frameHeight * config.videoBorderRadiusMultiplier : frameWidth * config.videoBorderRadiusMultiplier}px`;
853
+ setVideoStyle(newStyle);
854
+ };
855
+
856
+ const resizeObserver = new ResizeObserver(() => {
857
+ updateVideoPosition();
811
858
  });
812
859
 
860
+ resizeObserver.observe(frame);
813
861
  resizeObserver.observe(video);
862
+
863
+ // Also update when the frame image loads
864
+ frame.addEventListener('load', updateVideoPosition);
865
+
866
+ // Update when video metadata loads (to get correct intrinsic dimensions)
867
+ video.addEventListener('loadedmetadata', updateVideoPosition);
868
+
869
+ // IMPORTANT: When the WebRTC stream changes orientation, the intrinsic video size
870
+ // (videoWidth/videoHeight) can change without re-firing 'loadedmetadata'.
871
+ // The <video> element emits 'resize' in that case.
872
+ video.addEventListener('resize', updateVideoPosition);
873
+
874
+ // Initial calculation
875
+ updateVideoPosition();
814
876
 
815
877
  return () => {
816
878
  resizeObserver.disconnect();
879
+ video.removeEventListener('loadedmetadata', updateVideoPosition);
880
+ video.removeEventListener('resize', updateVideoPosition);
881
+ frame.removeEventListener('load', updateVideoPosition);
817
882
  };
818
883
  }, [config, showFrame]);
819
884
 
@@ -928,7 +993,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
928
993
  return (
929
994
  <div
930
995
  className={clsx(
931
- 'rc-container', // Use custom CSS class instead of Tailwind
996
+ 'rc-container',
997
+ isLandscape ? 'rc-container-landscape' : 'rc-container-portrait', // Use custom CSS class instead of Tailwind
932
998
  className,
933
999
  )}
934
1000
  style={{ touchAction: 'none' }} // Keep touchAction none for the container
@@ -946,7 +1012,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
946
1012
  {showFrame && (
947
1013
  <img
948
1014
  ref={frameRef}
949
- src={isLandscape ? config.frameImageLandscape : config.frameImage}
1015
+ src={isLandscape ? config.frame.imageLandscape : config.frame.image}
950
1016
  alt=""
951
1017
  className={clsx('rc-phone-frame', isLandscape ? 'rc-phone-frame-landscape' : 'rc-phone-frame-portrait')}
952
1018
  draggable={false}
@@ -956,19 +1022,21 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
956
1022
  ref={videoRef}
957
1023
  className={clsx(
958
1024
  'rc-video',
959
- showFrame ? (platform === 'ios' ? (isLandscape ? 'rc-video-ios-landscape' : 'rc-video-ios-portrait') : isLandscape ? 'rc-video-android-landscape' : 'rc-video-android-portrait') : 'rc-video-frameless',
1025
+ !showFrame && 'rc-video-frameless',
1026
+ showFrame && platform === 'ios' && 'rc-video-ios-stretch',
960
1027
  !videoLoaded && 'rc-video-loading',
961
1028
  )}
962
- style={
963
- config.loadingLogo
1029
+ style={{
1030
+ ...videoStyle,
1031
+ ...(config.loadingLogo
964
1032
  ? {
965
1033
  backgroundImage: `url("${config.loadingLogo}")`,
966
1034
  backgroundRepeat: 'no-repeat',
967
1035
  backgroundPosition: 'center',
968
1036
  backgroundSize: config.loadingLogoSize,
969
1037
  }
970
- : {}
971
- }
1038
+ : {}),
1039
+ }}
972
1040
  autoPlay
973
1041
  playsInline
974
1042
  muted
package/dist/demo.d.ts DELETED
@@ -1 +0,0 @@
1
- export {};
package/index.html DELETED
@@ -1,181 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>RemoteControl Demo - @limrun/ui</title>
7
- <style>
8
- * {
9
- margin: 0;
10
- padding: 0;
11
- box-sizing: border-box;
12
- }
13
-
14
- body {
15
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
16
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
- min-height: 100vh;
18
- padding: 20px;
19
- }
20
-
21
- #root {
22
- display: flex;
23
- flex-direction: column;
24
- gap: 20px;
25
- max-width: 1400px;
26
- margin: 0 auto;
27
- }
28
-
29
- .header {
30
- text-align: center;
31
- color: white;
32
- padding: 20px;
33
- }
34
-
35
- .header h1 {
36
- font-size: 2.5rem;
37
- margin-bottom: 10px;
38
- text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
39
- }
40
-
41
- .header p {
42
- font-size: 1.1rem;
43
- opacity: 0.9;
44
- }
45
-
46
- .demo-container {
47
- background: white;
48
- border-radius: 20px;
49
- padding: 30px;
50
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
51
- }
52
-
53
- .controls {
54
- display: flex;
55
- flex-direction: column;
56
- gap: 15px;
57
- margin-bottom: 30px;
58
- }
59
-
60
- .control-group {
61
- display: flex;
62
- flex-direction: column;
63
- gap: 8px;
64
- }
65
-
66
- .control-group label {
67
- font-weight: 600;
68
- font-size: 0.9rem;
69
- color: #374151;
70
- }
71
-
72
- .control-group input,
73
- .control-group select {
74
- padding: 10px 15px;
75
- border: 2px solid #e5e7eb;
76
- border-radius: 8px;
77
- font-size: 0.95rem;
78
- transition: border-color 0.2s;
79
- }
80
-
81
- .control-group input:focus,
82
- .control-group select:focus {
83
- outline: none;
84
- border-color: #667eea;
85
- }
86
-
87
- .button-group {
88
- display: flex;
89
- gap: 10px;
90
- }
91
-
92
- button {
93
- padding: 12px 24px;
94
- border: none;
95
- border-radius: 8px;
96
- font-size: 1rem;
97
- font-weight: 600;
98
- cursor: pointer;
99
- transition: all 0.2s;
100
- }
101
-
102
- button.primary {
103
- background: #667eea;
104
- color: white;
105
- }
106
-
107
- button.primary:hover {
108
- background: #5568d3;
109
- transform: translateY(-1px);
110
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
111
- }
112
-
113
- button.secondary {
114
- background: #e5e7eb;
115
- color: #374151;
116
- }
117
-
118
- button.secondary:hover {
119
- background: #d1d5db;
120
- }
121
-
122
- button:disabled {
123
- opacity: 0.5;
124
- cursor: not-allowed;
125
- }
126
-
127
- .device-preview {
128
- display: flex;
129
- gap: 30px;
130
- justify-content: center;
131
- flex-wrap: wrap;
132
- }
133
-
134
- .preview-item {
135
- flex: 1;
136
- min-width: 300px;
137
- max-width: 800px;
138
- max-height: 800px;
139
- }
140
-
141
- .preview-item h3 {
142
- text-align: center;
143
- margin-bottom: 15px;
144
- font-size: 1.2rem;
145
- color: #374151;
146
- }
147
-
148
- .device-wrapper {
149
- height: 100%;
150
- width: 100%;
151
- background: #f9fafb;
152
- border-radius: 12px;
153
- overflow: hidden;
154
- }
155
-
156
- .info-box {
157
- background: #fef3c7;
158
- border: 2px solid #fbbf24;
159
- border-radius: 8px;
160
- padding: 15px;
161
- margin-bottom: 20px;
162
- }
163
-
164
- .info-box h4 {
165
- color: #92400e;
166
- margin-bottom: 8px;
167
- font-size: 0.95rem;
168
- }
169
-
170
- .info-box p {
171
- color: #78350f;
172
- font-size: 0.9rem;
173
- line-height: 1.5;
174
- }
175
- </style>
176
- </head>
177
- <body>
178
- <div id="root"></div>
179
- <script type="module" src="/src/demo.tsx"></script>
180
- </body>
181
- </html>
package/src/demo.tsx DELETED
@@ -1,192 +0,0 @@
1
- import { useState, useRef } from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { RemoteControl, RemoteControlHandle } from './components/remote-control';
4
-
5
- function Demo() {
6
- const [url, setUrl] = useState('wss://eu-hel1-5-staging.limrun.dev/v1/android_eustg_01kd887q1vedb9nt1xkp0s1nan/endpointWebSocket');
7
- const [token, setToken] = useState('lim_6ffd2b5de18c6dcc59e047b7b8d923024fb45d59c802ca5d');
8
- const [platform, setPlatform] = useState<'ios' | 'android'>('ios');
9
- const [isConnected, setIsConnected] = useState(false);
10
- const [key, setKey] = useState(0);
11
- const [showDebugInfo, setShowDebugInfo] = useState(false);
12
-
13
- const remoteControlRef = useRef<RemoteControlHandle>(null);
14
-
15
- const handleConnect = () => {
16
- if (url) {
17
- setIsConnected(true);
18
- // Force remount by changing key
19
- setKey(prev => prev + 1);
20
- }
21
- };
22
-
23
- const handleDisconnect = () => {
24
- setIsConnected(false);
25
- setKey(prev => prev + 1);
26
- };
27
-
28
- const handleScreenshot = async () => {
29
- if (remoteControlRef.current) {
30
- try {
31
- const screenshot = await remoteControlRef.current.screenshot();
32
- // Open screenshot in new window
33
- const win = window.open();
34
- if (win) {
35
- win.document.write(`<img src="${screenshot.dataUri}" style="max-width: 100%;" />`);
36
- }
37
- } catch (error) {
38
- console.error('Screenshot failed:', error);
39
- alert('Screenshot failed: ' + (error as Error).message);
40
- }
41
- }
42
- };
43
-
44
- return (
45
- <>
46
- <div className="header">
47
- <h1>📱 RemoteControl Component Demo</h1>
48
- <p>Test the iOS device frame and remote control features</p>
49
- </div>
50
-
51
- <div className="demo-container">
52
- <div className="info-box">
53
- <h4>â„šī¸ How to Use:</h4>
54
- <p>
55
- Enter your WebSocket URL and authentication token below, select iOS or Android platform,
56
- then click Connect to see the remote control in action. The iOS platform will display
57
- a realistic iPhone frame around the stream.
58
- </p>
59
- <p style={{ marginTop: '10px', fontWeight: 600 }}>
60
- ✨ iOS Feature: Touches can start from the bottom bezel area (below the screen, near the
61
- home indicator) to enable authentic iOS swipe-up gestures for going home or switching apps!
62
- </p>
63
- </div>
64
-
65
- <div className="controls">
66
- <div className="control-group">
67
- <label htmlFor="url">WebSocket URL</label>
68
- <input
69
- id="url"
70
- type="text"
71
- value={url}
72
- onChange={(e) => setUrl(e.target.value)}
73
- placeholder="wss://your-instance.limrun.com/control"
74
- disabled={isConnected}
75
- />
76
- </div>
77
-
78
- <div className="control-group">
79
- <label htmlFor="token">Authentication Token</label>
80
- <input
81
- id="token"
82
- type="password"
83
- value={token}
84
- onChange={(e) => setToken(e.target.value)}
85
- placeholder="Enter your token"
86
- disabled={isConnected}
87
- />
88
- </div>
89
-
90
- <div className="control-group">
91
- <label htmlFor="platform">Platform</label>
92
- <select
93
- id="platform"
94
- value={platform}
95
- onChange={(e) => setPlatform(e.target.value as 'ios' | 'android')}
96
- disabled={isConnected}
97
- >
98
- <option value="ios">iOS (with frame)</option>
99
- <option value="android">Android (no frame)</option>
100
- </select>
101
- </div>
102
-
103
- <div className="control-group">
104
- <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
105
- <input
106
- type="checkbox"
107
- checked={showDebugInfo}
108
- onChange={(e) => setShowDebugInfo(e.target.checked)}
109
- style={{ cursor: 'pointer' }}
110
- />
111
- Show iOS Extended Touch Area Info
112
- </label>
113
- </div>
114
-
115
- <div className="button-group">
116
- {!isConnected ? (
117
- <button
118
- className="primary"
119
- onClick={handleConnect}
120
- disabled={!url}
121
- >
122
- Connect
123
- </button>
124
- ) : (
125
- <>
126
- <button className="secondary" onClick={handleDisconnect}>
127
- Disconnect
128
- </button>
129
- <button className="primary" onClick={handleScreenshot}>
130
- Take Screenshot
131
- </button>
132
- </>
133
- )}
134
- </div>
135
- </div>
136
-
137
- {showDebugInfo && platform === 'ios' && (
138
- <div style={{
139
- background: '#e0f2fe',
140
- border: '2px solid #0284c7',
141
- borderRadius: '8px',
142
- padding: '15px',
143
- marginBottom: '20px'
144
- }}>
145
- <h4 style={{ color: '#0c4a6e', marginBottom: '8px', fontSize: '0.95rem' }}>
146
- 🔧 iOS Extended Touch Area
147
- </h4>
148
- <p style={{ color: '#075985', fontSize: '0.9rem', lineHeight: '1.5', margin: 0 }}>
149
- The iOS frame includes a <strong>60-pixel extended touch area</strong> below the visible screen.
150
- This area (where the home indicator is located) can receive touch events and sends coordinates
151
- beyond the screen bounds (y &gt; screenHeight), allowing iOS to properly detect gestures that
152
- start from outside the screen - just like on a real iPhone. Try starting a swipe gesture from
153
- the home indicator area!
154
- </p>
155
- </div>
156
- )}
157
-
158
- {isConnected ? (
159
- <div className="device-preview">
160
- <div className="preview-item">
161
- <h3>{platform === 'ios' ? '📱 iOS with Frame' : '🤖 Android (No Frame)'}</h3>
162
- <div className="device-wrapper">
163
- <RemoteControl
164
- key={key}
165
- ref={remoteControlRef}
166
- url={url}
167
- token={token}
168
- />
169
- </div>
170
- </div>
171
- </div>
172
- ) : (
173
- <div style={{
174
- textAlign: 'center',
175
- padding: '60px 20px',
176
- color: '#9ca3af',
177
- fontSize: '1.1rem'
178
- }}>
179
- Enter your connection details above and click Connect to start
180
- </div>
181
- )}
182
- </div>
183
- </>
184
- );
185
- }
186
-
187
- // Mount the demo app
188
- const container = document.getElementById('root');
189
- if (container) {
190
- const root = createRoot(container);
191
- root.render(<Demo />);
192
- }