@telemetryos/cli 1.7.1 → 1.7.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @telemetryos/cli
2
2
 
3
+ ## 1.7.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Added scale slider to default package and supporting features in SDK, CLI
8
+ - Updated dependencies
9
+ - @telemetryos/development-application-host-ui@1.7.3
10
+
11
+ ## 1.7.2
12
+
13
+ ### Patch Changes
14
+
15
+ - Add more React components for Settings, show the canvas ascpect ratio in the dev host
16
+ - Updated dependencies
17
+ - @telemetryos/development-application-host-ui@1.7.2
18
+
3
19
  ## 1.7.1
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetryos/cli",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "The official TelemetryOS application CLI package. Use it to build applications that run on the TelemetryOS platform",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "license": "",
26
26
  "repository": "github:TelemetryTV/Application-API",
27
27
  "dependencies": {
28
- "@telemetryos/development-application-host-ui": "^1.7.1",
28
+ "@telemetryos/development-application-host-ui": "^1.7.3",
29
29
  "@types/serve-handler": "^6.1.4",
30
30
  "commander": "^14.0.0",
31
31
  "inquirer": "^12.9.6",
@@ -40,7 +40,8 @@ project-root/
40
40
  │ ├── Settings.tsx # /settings mount point
41
41
  │ └── Render.tsx # /render mount point
42
42
  ├── components/ # Reusable components
43
- ├── hooks/ # Custom React hooks
43
+ ├── hooks/
44
+ │ └── store.ts # Store state hooks (createUseStoreState)
44
45
  ├── types/ # TypeScript interfaces
45
46
  └── utils/ # Helper functions
46
47
  ```
@@ -128,159 +129,145 @@ export default function App() {
128
129
  }
129
130
  ```
130
131
 
131
- ### src/views/Settings.tsx (Complete Reference)
132
+ ### src/hooks/store.ts (Store Hooks)
132
133
  ```typescript
133
- import { useEffect, useState, FormEvent } from 'react';
134
- import { store } from '@telemetryos/sdk';
134
+ import { createUseStoreState } from '@telemetryos/sdk/react'
135
135
 
136
- interface Config {
137
- city: string;
138
- units: 'celsius' | 'fahrenheit';
139
- }
140
-
141
- export default function Settings() {
142
- const [config, setConfig] = useState<Config>({ city: '', units: 'celsius' });
143
- const [loading, setLoading] = useState(false);
144
- const [error, setError] = useState<string | null>(null);
145
-
146
- // Load existing config on mount
147
- useEffect(() => {
148
- store().instance.get<Config>('config')
149
- .then(saved => { if (saved) setConfig(saved); })
150
- .catch(err => setError(err.message));
151
- }, []);
136
+ // Create typed hooks for each store key
137
+ export const useTeamStoreState = createUseStoreState<string>('team', '')
138
+ export const useLeagueStoreState = createUseStoreState<string>('league', 'nfl')
139
+ ```
152
140
 
153
- const handleSave = async (e: FormEvent) => {
154
- e.preventDefault();
155
- setLoading(true);
156
- setError(null);
141
+ ### src/views/Settings.tsx (Complete Reference)
142
+ ```typescript
143
+ import { store } from '@telemetryos/sdk'
144
+ import {
145
+ SettingsContainer,
146
+ SettingsField,
147
+ SettingsLabel,
148
+ SettingsInputFrame,
149
+ SettingsSelectFrame,
150
+ } from '@telemetryos/sdk/react'
151
+ import { useTeamStoreState, useLeagueStoreState } from '../hooks/store'
157
152
 
158
- try {
159
- const success = await store().instance.set('config', config);
160
- if (!success) throw new Error('Storage operation failed');
161
- } catch (err) {
162
- setError(err instanceof Error ? err.message : 'Unknown error');
163
- } finally {
164
- setLoading(false);
165
- }
166
- };
153
+ export default function Settings() {
154
+ const [isLoadingTeam, team, setTeam] = useTeamStoreState(store().instance)
155
+ const [isLoadingLeague, league, setLeague] = useLeagueStoreState(store().instance)
167
156
 
168
157
  return (
169
- <div>
170
- <h2>Settings</h2>
171
- {error && <div style={{ color: 'red' }}>{error}</div>}
172
- <form onSubmit={handleSave}>
173
- <div>
174
- <label htmlFor="city">City:</label>
158
+ <SettingsContainer>
159
+ <SettingsField>
160
+ <SettingsLabel>Team Name</SettingsLabel>
161
+ <SettingsInputFrame>
175
162
  <input
176
- id="city"
177
- value={config.city}
178
- onChange={(e) => setConfig({ ...config, city: e.target.value })}
179
- required
163
+ type="text"
164
+ placeholder="Enter team name..."
165
+ disabled={isLoadingTeam}
166
+ value={team}
167
+ onChange={(e) => setTeam(e.target.value)}
180
168
  />
181
- </div>
182
- <div>
183
- <label htmlFor="units">Units:</label>
169
+ </SettingsInputFrame>
170
+ </SettingsField>
171
+
172
+ <SettingsField>
173
+ <SettingsLabel>League</SettingsLabel>
174
+ <SettingsSelectFrame>
184
175
  <select
185
- id="units"
186
- value={config.units}
187
- onChange={(e) => setConfig({ ...config, units: e.target.value as Config['units'] })}
176
+ disabled={isLoadingLeague}
177
+ value={league}
178
+ onChange={(e) => setLeague(e.target.value)}
188
179
  >
189
- <option value="celsius">Celsius</option>
190
- <option value="fahrenheit">Fahrenheit</option>
180
+ <option value="nfl">NFL</option>
181
+ <option value="nba">NBA</option>
182
+ <option value="mlb">MLB</option>
183
+ <option value="nhl">NHL</option>
191
184
  </select>
192
- </div>
193
- <button type="submit" disabled={loading}>
194
- {loading ? 'Saving...' : 'Save'}
195
- </button>
196
- </form>
197
- </div>
198
- );
185
+ </SettingsSelectFrame>
186
+ </SettingsField>
187
+ </SettingsContainer>
188
+ )
199
189
  }
200
190
  ```
201
191
 
202
192
  ### src/views/Render.tsx (Complete Reference)
203
193
  ```typescript
204
- import { useEffect, useState } from 'react';
205
- import { store, proxy } from '@telemetryos/sdk';
206
-
207
- interface Config {
208
- city: string;
209
- units: 'celsius' | 'fahrenheit';
210
- }
211
-
212
- interface WeatherData {
213
- temperature: number;
214
- conditions: string;
194
+ import { useEffect, useState } from 'react'
195
+ import { proxy } from '@telemetryos/sdk'
196
+ import { store } from '@telemetryos/sdk'
197
+ import { useTeamStoreState, useLeagueStoreState } from '../hooks/store'
198
+
199
+ interface GameScore {
200
+ homeTeam: string
201
+ awayTeam: string
202
+ homeScore: number
203
+ awayScore: number
204
+ status: string
215
205
  }
216
206
 
217
207
  export default function Render() {
218
- const [config, setConfig] = useState<Config | null>(null);
219
- const [weather, setWeather] = useState<WeatherData | null>(null);
220
- const [loading, setLoading] = useState(false);
221
- const [error, setError] = useState<string | null>(null);
208
+ // Use same hooks as Settings - automatically syncs when Settings changes
209
+ const [isLoadingTeam, team] = useTeamStoreState(store().instance)
210
+ const [isLoadingLeague, league] = useLeagueStoreState(store().instance)
222
211
 
223
- // Subscribe to config changes from Settings
224
- useEffect(() => {
225
- store().instance.get<Config>('config').then(setConfig);
212
+ const [score, setScore] = useState<GameScore | null>(null)
213
+ const [loading, setLoading] = useState(false)
214
+ const [error, setError] = useState<string | null>(null)
226
215
 
227
- const handler = (newConfig: Config | undefined) => {
228
- if (newConfig) setConfig(newConfig);
229
- };
230
- store().instance.subscribe('config', handler);
231
-
232
- return () => {
233
- store().instance.unsubscribe('config', handler);
234
- };
235
- }, []);
236
-
237
- // Fetch weather when config changes
216
+ // Fetch scores when config changes
238
217
  useEffect(() => {
239
- if (!config?.city) return;
218
+ if (!team) return
240
219
 
241
- const fetchWeather = async () => {
242
- setLoading(true);
243
- setError(null);
220
+ const fetchScore = async () => {
221
+ setLoading(true)
222
+ setError(null)
244
223
 
245
224
  try {
225
+ // Platform handles caching automatically - no manual cache needed
246
226
  const response = await proxy().fetch(
247
- `https://api.example.com/weather?city=${config.city}&units=${config.units}`
248
- );
249
-
250
- if (!response.ok) throw new Error(`API error: ${response.status}`);
251
-
252
- const data = await response.json();
253
- setWeather({ temperature: data.temp, conditions: data.conditions });
254
-
255
- // Cache for offline
256
- await store().device.set('cached', { data, timestamp: Date.now() });
227
+ `https://api.sportsdata.io/v3/${league}/scores/json/GamesByTeam/${team}`
228
+ )
229
+
230
+ if (!response.ok) throw new Error(`API error: ${response.status}`)
231
+
232
+ const data = await response.json()
233
+ if (data.length > 0) {
234
+ const game = data[0]
235
+ setScore({
236
+ homeTeam: game.HomeTeam,
237
+ awayTeam: game.AwayTeam,
238
+ homeScore: game.HomeScore,
239
+ awayScore: game.AwayScore,
240
+ status: game.Status,
241
+ })
242
+ }
257
243
  } catch (err) {
258
- setError(err instanceof Error ? err.message : 'Unknown error');
259
-
260
- // Try cached data
261
- const cached = await store().device.get<any>('cached');
262
- if (cached) setWeather(cached.data);
244
+ setError(err instanceof Error ? err.message : 'Unknown error')
263
245
  } finally {
264
- setLoading(false);
246
+ setLoading(false)
265
247
  }
266
- };
248
+ }
267
249
 
268
- fetchWeather();
269
- }, [config]);
250
+ fetchScore()
251
+ }, [team, league])
270
252
 
271
- // States
272
- if (!config) return <div>Configure in Settings</div>;
273
- if (loading && !weather) return <div>Loading...</div>;
274
- if (error && !weather) return <div>Error: {error}</div>;
253
+ // Loading state
254
+ if (isLoadingTeam || isLoadingLeague) return <div>Loading config...</div>
255
+ if (!team) return <div>Configure team in Settings</div>
256
+ if (loading && !score) return <div>Loading scores...</div>
257
+ if (error && !score) return <div>Error: {error}</div>
275
258
 
276
259
  return (
277
260
  <div>
278
- <h1>{config.city}</h1>
279
- <div>{weather?.temperature}°{config.units === 'celsius' ? 'C' : 'F'}</div>
280
- <div>{weather?.conditions}</div>
281
- {error && <div style={{ color: 'orange' }}>Showing cached data</div>}
261
+ <h1>{team} - {league.toUpperCase()}</h1>
262
+ {score && (
263
+ <div>
264
+ <div>{score.awayTeam} @ {score.homeTeam}</div>
265
+ <div>{score.awayScore} - {score.homeScore}</div>
266
+ <div>{score.status}</div>
267
+ </div>
268
+ )}
282
269
  </div>
283
- );
270
+ )
284
271
  }
285
272
  ```
286
273
 
@@ -361,9 +348,9 @@ proxy().fetch(url: string, options?: RequestInit): Promise<Response>
361
348
  ```
362
349
 
363
350
  - Same interface as standard fetch()
364
- - Use for ALL external API calls to avoid CORS errors
351
+ - Use when external APIs don't include CORS headers
365
352
  - Returns standard Response object
366
- - Handles CORS server-side
353
+ - Regular `fetch()` works fine when CORS is not an issue (and has advanced caching in the player)
367
354
 
368
355
  **Example:**
369
356
  ```typescript
@@ -535,6 +522,362 @@ if (result.ready.includes('app-specifier-hash')) {
535
522
  }
536
523
  ```
537
524
 
525
+ ## React Hooks for Store
526
+
527
+ Import from `@telemetryos/sdk/react`. These hooks simplify store interactions by handling subscriptions, loading states, and cleanup automatically.
528
+
529
+ ### useStoreState
530
+
531
+ Syncs React state with a store key. Handles subscription/unsubscription automatically.
532
+
533
+ ```typescript
534
+ import { useStoreState } from '@telemetryos/sdk/react'
535
+ import { store } from '@telemetryos/sdk'
536
+
537
+ function MyComponent() {
538
+ const [isLoading, value, setValue] = useStoreState<string>(
539
+ store().instance, // Store slice
540
+ 'myKey', // Key name
541
+ 'default value', // Initial state (optional)
542
+ 300 // Debounce delay in ms (optional)
543
+ )
544
+
545
+ return (
546
+ <input
547
+ disabled={isLoading}
548
+ value={value}
549
+ onChange={(e) => setValue(e.target.value)}
550
+ />
551
+ )
552
+ }
553
+ ```
554
+
555
+ **Returns:** `[isLoading: boolean, value: T, setValue: Dispatch<SetStateAction<T>>]`
556
+
557
+ - `isLoading` - `true` until first value received from store
558
+ - `value` - Current value (from store or local optimistic update)
559
+ - `setValue` - Updates both local state and store (with optional debounce)
560
+
561
+ ### createUseStoreState
562
+
563
+ Factory function to create reusable, typed hooks for specific store keys. **This is the recommended pattern.**
564
+
565
+ ```typescript
566
+ // hooks/store.ts
567
+ import { createUseStoreState } from '@telemetryos/sdk/react'
568
+
569
+ // Create typed hooks for each store key
570
+ export const useTeamStoreState = createUseStoreState<string>('team', '')
571
+ export const useLeagueStoreState = createUseStoreState<string>('league', 'nfl')
572
+ export const useRefreshIntervalStoreState = createUseStoreState<number>('refreshInterval', 30)
573
+ ```
574
+
575
+ ```typescript
576
+ // views/Settings.tsx
577
+ import { store } from '@telemetryos/sdk'
578
+ import { useTeamStoreState, useLeagueStoreState } from '../hooks/store'
579
+
580
+ function Settings() {
581
+ const [isLoadingTeam, team, setTeam] = useTeamStoreState(store().instance)
582
+ const [isLoadingLeague, league, setLeague] = useLeagueStoreState(store().instance)
583
+ // ... use in components
584
+ }
585
+ ```
586
+
587
+ **Benefits:**
588
+ - Type-safe: TypeScript knows the exact type of each store key
589
+ - Reusable: Same hook works in Settings and Render
590
+ - Automatic cleanup: No manual subscribe/unsubscribe needed
591
+ - Immediate sync: Changes sync to store automatically (no save button needed)
592
+
593
+ ### Store Data Patterns
594
+
595
+ **Recommended: Separate store entry per field**
596
+ ```typescript
597
+ // hooks/store.ts
598
+ export const useTeamStoreState = createUseStoreState<string>('team', '')
599
+ export const useLeagueStoreState = createUseStoreState<string>('league', 'nfl')
600
+ export const useShowScoresStoreState = createUseStoreState<boolean>('showScores', true)
601
+ ```
602
+
603
+ **Alternative: Rich data object** (for tightly related data like slideshow items)
604
+ ```typescript
605
+ // hooks/store.ts
606
+ interface SportsSlide {
607
+ team: string
608
+ league: string
609
+ displaySeconds: number
610
+ }
611
+
612
+ export const useSlidesStoreState = createUseStoreState<SportsSlide[]>('slides', [])
613
+ ```
614
+
615
+ ## Settings Components
616
+
617
+ Import from `@telemetryos/sdk/react`. These components ensure your Settings UI matches the Studio design system.
618
+
619
+ **Important:** Always use these components for Settings. Raw HTML won't look correct in Studio.
620
+
621
+ ### Container & Layout
622
+
623
+ #### SettingsContainer
624
+
625
+ Root wrapper for all Settings content. Handles color scheme synchronization with Studio.
626
+
627
+ ```typescript
628
+ import { SettingsContainer } from '@telemetryos/sdk/react'
629
+
630
+ function Settings() {
631
+ return (
632
+ <SettingsContainer>
633
+ {/* All settings content goes here */}
634
+ </SettingsContainer>
635
+ )
636
+ }
637
+ ```
638
+
639
+ #### SettingsBox
640
+
641
+ Container with border for grouping related settings.
642
+
643
+ ```typescript
644
+ import { SettingsBox } from '@telemetryos/sdk/react'
645
+
646
+ <SettingsBox>
647
+ {/* Group of related fields */}
648
+ </SettingsBox>
649
+ ```
650
+
651
+ #### SettingsDivider
652
+
653
+ Horizontal rule separator between sections.
654
+
655
+ ```typescript
656
+ import { SettingsDivider } from '@telemetryos/sdk/react'
657
+
658
+ <SettingsDivider />
659
+ ```
660
+
661
+ ### Field Structure
662
+
663
+ #### SettingsField, SettingsLabel
664
+
665
+ Wrapper for each form field with its label.
666
+
667
+ ```typescript
668
+ import { SettingsField, SettingsLabel } from '@telemetryos/sdk/react'
669
+
670
+ <SettingsField>
671
+ <SettingsLabel>Field Label</SettingsLabel>
672
+ {/* Input component goes here */}
673
+ </SettingsField>
674
+ ```
675
+
676
+ ### Text Inputs
677
+
678
+ #### SettingsInputFrame (Text Input)
679
+
680
+ ```typescript
681
+ import { store } from '@telemetryos/sdk'
682
+ import {
683
+ SettingsContainer,
684
+ SettingsField,
685
+ SettingsLabel,
686
+ SettingsInputFrame,
687
+ } from '@telemetryos/sdk/react'
688
+ import { useTeamStoreState } from '../hooks/store'
689
+
690
+ function Settings() {
691
+ const [isLoading, team, setTeam] = useTeamStoreState(store().instance)
692
+
693
+ return (
694
+ <SettingsContainer>
695
+ <SettingsField>
696
+ <SettingsLabel>Team Name</SettingsLabel>
697
+ <SettingsInputFrame>
698
+ <input
699
+ type="text"
700
+ placeholder="Enter team name..."
701
+ disabled={isLoading}
702
+ value={team}
703
+ onChange={(e) => setTeam(e.target.value)}
704
+ />
705
+ </SettingsInputFrame>
706
+ </SettingsField>
707
+ </SettingsContainer>
708
+ )
709
+ }
710
+ ```
711
+
712
+ #### SettingsTextAreaFrame (Multiline Text)
713
+
714
+ ```typescript
715
+ import { SettingsTextAreaFrame } from '@telemetryos/sdk/react'
716
+ import { useDescriptionStoreState } from '../hooks/store'
717
+
718
+ const [isLoading, description, setDescription] = useDescriptionStoreState(store().instance)
719
+
720
+ <SettingsField>
721
+ <SettingsLabel>Description</SettingsLabel>
722
+ <SettingsTextAreaFrame>
723
+ <textarea
724
+ placeholder="Enter description..."
725
+ disabled={isLoading}
726
+ value={description}
727
+ onChange={(e) => setDescription(e.target.value)}
728
+ rows={4}
729
+ />
730
+ </SettingsTextAreaFrame>
731
+ </SettingsField>
732
+ ```
733
+
734
+ ### Selection Inputs
735
+
736
+ #### SettingsSelectFrame (Dropdown)
737
+
738
+ ```typescript
739
+ import { SettingsSelectFrame } from '@telemetryos/sdk/react'
740
+ import { useLeagueStoreState } from '../hooks/store'
741
+
742
+ const [isLoading, league, setLeague] = useLeagueStoreState(store().instance)
743
+
744
+ <SettingsField>
745
+ <SettingsLabel>League</SettingsLabel>
746
+ <SettingsSelectFrame>
747
+ <select
748
+ disabled={isLoading}
749
+ value={league}
750
+ onChange={(e) => setLeague(e.target.value)}
751
+ >
752
+ <option value="nfl">NFL</option>
753
+ <option value="nba">NBA</option>
754
+ <option value="mlb">MLB</option>
755
+ <option value="nhl">NHL</option>
756
+ </select>
757
+ </SettingsSelectFrame>
758
+ </SettingsField>
759
+ ```
760
+
761
+ #### SettingsSliderFrame (Range Slider)
762
+
763
+ ```typescript
764
+ import { SettingsSliderFrame } from '@telemetryos/sdk/react'
765
+ import { useVolumeStoreState } from '../hooks/store'
766
+
767
+ const [isLoading, volume, setVolume] = useVolumeStoreState(store().instance)
768
+
769
+ <SettingsField>
770
+ <SettingsLabel>Volume</SettingsLabel>
771
+ <SettingsSliderFrame>
772
+ <input
773
+ type="range"
774
+ min="0"
775
+ max="100"
776
+ disabled={isLoading}
777
+ value={volume}
778
+ onChange={(e) => setVolume(Number(e.target.value))}
779
+ />
780
+ <span>{volume}%</span> {/* Optional value label */}
781
+ </SettingsSliderFrame>
782
+ </SettingsField>
783
+ ```
784
+
785
+ The frame uses flexbox layout, so you can optionally add a `<span>` after the input to display the current value.
786
+
787
+ ### Toggle Inputs
788
+
789
+ #### SettingsSwitchFrame, SettingsSwitchLabel (Toggle Switch)
790
+
791
+ ```typescript
792
+ import { SettingsSwitchFrame, SettingsSwitchLabel } from '@telemetryos/sdk/react'
793
+ import { useShowScoresStoreState } from '../hooks/store'
794
+
795
+ const [isLoading, showScores, setShowScores] = useShowScoresStoreState(store().instance)
796
+
797
+ <SettingsField>
798
+ <SettingsSwitchFrame>
799
+ <input
800
+ type="checkbox"
801
+ role="switch"
802
+ disabled={isLoading}
803
+ checked={showScores}
804
+ onChange={(e) => setShowScores(e.target.checked)}
805
+ />
806
+ <SettingsSwitchLabel>Show Live Scores</SettingsSwitchLabel>
807
+ </SettingsSwitchFrame>
808
+ </SettingsField>
809
+ ```
810
+
811
+ #### SettingsCheckboxFrame, SettingsCheckboxLabel (Checkbox)
812
+
813
+ ```typescript
814
+ import { SettingsCheckboxFrame, SettingsCheckboxLabel } from '@telemetryos/sdk/react'
815
+ import { useAutoRefreshStoreState } from '../hooks/store'
816
+
817
+ const [isLoading, autoRefresh, setAutoRefresh] = useAutoRefreshStoreState(store().instance)
818
+
819
+ <SettingsField>
820
+ <SettingsCheckboxFrame>
821
+ <input
822
+ type="checkbox"
823
+ disabled={isLoading}
824
+ checked={autoRefresh}
825
+ onChange={(e) => setAutoRefresh(e.target.checked)}
826
+ />
827
+ <SettingsCheckboxLabel>Enable Auto-Refresh</SettingsCheckboxLabel>
828
+ </SettingsCheckboxFrame>
829
+ </SettingsField>
830
+ ```
831
+
832
+ #### SettingsRadioFrame, SettingsRadioLabel (Radio Buttons)
833
+
834
+ ```typescript
835
+ import { SettingsRadioFrame, SettingsRadioLabel } from '@telemetryos/sdk/react'
836
+ import { useDisplayModeStoreState } from '../hooks/store'
837
+
838
+ const [isLoading, displayMode, setDisplayMode] = useDisplayModeStoreState(store().instance)
839
+
840
+ <SettingsField>
841
+ <SettingsLabel>Display Mode</SettingsLabel>
842
+ <SettingsRadioFrame>
843
+ <input
844
+ type="radio"
845
+ name="displayMode"
846
+ value="compact"
847
+ disabled={isLoading}
848
+ checked={displayMode === 'compact'}
849
+ onChange={(e) => setDisplayMode(e.target.value)}
850
+ />
851
+ <SettingsRadioLabel>Compact</SettingsRadioLabel>
852
+ </SettingsRadioFrame>
853
+ <SettingsRadioFrame>
854
+ <input
855
+ type="radio"
856
+ name="displayMode"
857
+ value="expanded"
858
+ disabled={isLoading}
859
+ checked={displayMode === 'expanded'}
860
+ onChange={(e) => setDisplayMode(e.target.value)}
861
+ />
862
+ <SettingsRadioLabel>Expanded</SettingsRadioLabel>
863
+ </SettingsRadioFrame>
864
+ </SettingsField>
865
+ ```
866
+
867
+ ### Actions
868
+
869
+ #### SettingsButtonFrame
870
+
871
+ ```typescript
872
+ import { SettingsButtonFrame } from '@telemetryos/sdk/react'
873
+
874
+ <SettingsField>
875
+ <SettingsButtonFrame>
876
+ <button onClick={handleReset}>Reset to Defaults</button>
877
+ </SettingsButtonFrame>
878
+ </SettingsField>
879
+ ```
880
+
538
881
  ## Hard Constraints
539
882
 
540
883
  **These cause runtime errors:**
@@ -544,9 +887,10 @@ if (result.ready.includes('app-specifier-hash')) {
544
887
  - `store().device.*` throws Error in Settings
545
888
  - Use `store().instance` or `store().application` instead
546
889
 
547
- 2. **External API without proxy**
548
- - Direct `fetch()` to external domains fails with CORS error
549
- - Must use `proxy().fetch()` for all external requests
890
+ 2. **CORS errors on external APIs**
891
+ - Some external APIs don't include CORS headers
892
+ - Use `proxy().fetch()` when you encounter CORS errors
893
+ - Regular `fetch()` is fine when CORS is not an issue (and has advanced caching in the player)
550
894
 
551
895
  3. **Missing configure()**
552
896
  - SDK methods throw "SDK not configured" Error
@@ -592,50 +936,205 @@ export default function WeatherCard({ data, onRefresh }: Props) {
592
936
 
593
937
  ## React Patterns
594
938
 
939
+ **Prefer SDK hooks over manual store operations:**
940
+ ```typescript
941
+ // RECOMMENDED: Use SDK hooks
942
+ import { useTeamStoreState } from '../hooks/store'
943
+ const [isLoading, team, setTeam] = useTeamStoreState(store().instance)
944
+
945
+ // AVOID: Manual subscription (only for special cases)
946
+ const [team, setTeam] = useState('')
947
+ useEffect(() => {
948
+ const handler = (value) => setTeam(value)
949
+ store().instance.subscribe('team', handler)
950
+ return () => store().instance.unsubscribe('team', handler)
951
+ }, [])
952
+ ```
953
+
954
+ **Subscription behavior:**
955
+ When you call `subscribe()`, the handler is immediately called with the current value. No separate `get()` call is needed:
956
+ ```typescript
957
+ // WRONG - unnecessary get() call
958
+ useEffect(() => {
959
+ store().instance.get('config').then(setConfig) // Not needed!
960
+ store().instance.subscribe('config', handler)
961
+ // ...
962
+ }, [])
963
+
964
+ // CORRECT - subscribe handles initial value
965
+ useEffect(() => {
966
+ const handler = (value) => setConfig(value)
967
+ store().instance.subscribe('config', handler) // Immediately receives current value
968
+ return () => store().instance.unsubscribe('config', handler)
969
+ }, [])
970
+ ```
971
+
972
+ **No manual caching needed:**
973
+ The platform automatically caches SDK API calls, `fetch()`, and `proxy().fetch()` requests. Don't implement your own cache:
974
+ ```typescript
975
+ // WRONG - manual caching
976
+ const response = await fetch(url)
977
+ await store().device.set('cached', data) // Don't do this!
978
+
979
+ // CORRECT - just fetch, platform handles caching
980
+ const response = await fetch(url)
981
+ ```
982
+
595
983
  **Error handling:**
596
984
  ```typescript
597
- const [error, setError] = useState<string | null>(null);
985
+ const [error, setError] = useState<string | null>(null)
598
986
 
599
987
  try {
600
- await store().instance.set('key', value);
988
+ await store().instance.set('key', value)
601
989
  } catch (err) {
602
- setError(err instanceof Error ? err.message : 'Unknown error');
990
+ setError(err instanceof Error ? err.message : 'Unknown error')
603
991
  }
604
992
  ```
605
993
 
606
994
  **Loading states:**
607
995
  ```typescript
608
- const [loading, setLoading] = useState(false);
996
+ const [loading, setLoading] = useState(false)
609
997
 
610
998
  const handleAction = async () => {
611
- setLoading(true);
999
+ setLoading(true)
612
1000
  try {
613
- await someAsyncOperation();
1001
+ await someAsyncOperation()
614
1002
  } finally {
615
- setLoading(false);
1003
+ setLoading(false)
616
1004
  }
617
- };
1005
+ }
618
1006
  ```
619
1007
 
620
- **Subscription cleanup:**
1008
+ ## Low-Level Store API (Alternative)
1009
+
1010
+ For special cases where SDK hooks don't fit, you can use the store API directly. This requires manual subscription management.
1011
+
1012
+ **Manual subscription pattern:**
621
1013
  ```typescript
622
- useEffect(() => {
623
- const handler = (value) => { /* handle value */ };
624
- store().instance.subscribe('key', handler);
625
- return () => {
626
- store().instance.unsubscribe('key', handler);
627
- };
628
- }, []);
1014
+ import { useEffect, useState } from 'react'
1015
+ import { store } from '@telemetryos/sdk'
1016
+ import {
1017
+ SettingsContainer,
1018
+ SettingsField,
1019
+ SettingsLabel,
1020
+ SettingsInputFrame,
1021
+ } from '@telemetryos/sdk/react'
1022
+
1023
+ interface Config {
1024
+ team: string
1025
+ league: string
1026
+ }
1027
+
1028
+ export default function Settings() {
1029
+ const [config, setConfig] = useState<Config>({ team: '', league: 'nfl' })
1030
+ const [isLoading, setIsLoading] = useState(true)
1031
+
1032
+ // Subscribe on mount - subscribe() immediately sends current value
1033
+ useEffect(() => {
1034
+ const handler = (value: Config | undefined) => {
1035
+ if (value) setConfig(value)
1036
+ setIsLoading(false)
1037
+ }
1038
+ store().instance.subscribe<Config>('config', handler)
1039
+
1040
+ return () => {
1041
+ store().instance.unsubscribe('config', handler)
1042
+ }
1043
+ }, [])
1044
+
1045
+ // Update store on change
1046
+ const updateConfig = (updates: Partial<Config>) => {
1047
+ const newConfig = { ...config, ...updates }
1048
+ setConfig(newConfig)
1049
+ store().instance.set('config', newConfig)
1050
+ }
1051
+
1052
+ return (
1053
+ <SettingsContainer>
1054
+ <SettingsField>
1055
+ <SettingsLabel>Team Name</SettingsLabel>
1056
+ <SettingsInputFrame>
1057
+ <input
1058
+ type="text"
1059
+ value={config.team}
1060
+ onChange={(e) => updateConfig({ team: e.target.value })}
1061
+ disabled={isLoading}
1062
+ />
1063
+ </SettingsInputFrame>
1064
+ </SettingsField>
1065
+ </SettingsContainer>
1066
+ )
1067
+ }
629
1068
  ```
630
1069
 
631
- **Empty deps for mount-only effects:**
1070
+ **Form submission pattern** (for cases requiring validation before save):
632
1071
  ```typescript
633
- useEffect(() => {
634
- // Runs once on mount
635
- store().instance.get('config').then(setConfig);
636
- }, []); // Empty deps array
1072
+ import { useState, FormEvent } from 'react'
1073
+ import { store } from '@telemetryos/sdk'
1074
+ import {
1075
+ SettingsContainer,
1076
+ SettingsField,
1077
+ SettingsLabel,
1078
+ SettingsInputFrame,
1079
+ } from '@telemetryos/sdk/react'
1080
+
1081
+ export default function Settings() {
1082
+ const [team, setTeam] = useState('')
1083
+ const [saving, setSaving] = useState(false)
1084
+ const [error, setError] = useState<string | null>(null)
1085
+
1086
+ const handleSubmit = async (e: FormEvent) => {
1087
+ e.preventDefault()
1088
+ setSaving(true)
1089
+ setError(null)
1090
+
1091
+ try {
1092
+ // Validate before saving
1093
+ if (team.length < 2) throw new Error('Team name too short')
1094
+
1095
+ const success = await store().instance.set('team', team)
1096
+ if (!success) throw new Error('Storage operation failed')
1097
+ } catch (err) {
1098
+ setError(err instanceof Error ? err.message : 'Unknown error')
1099
+ } finally {
1100
+ setSaving(false)
1101
+ }
1102
+ }
1103
+
1104
+ return (
1105
+ <SettingsContainer>
1106
+ <form onSubmit={handleSubmit}>
1107
+ {error && <div style={{ color: 'red' }}>{error}</div>}
1108
+ <SettingsField>
1109
+ <SettingsLabel>Team Name</SettingsLabel>
1110
+ <SettingsInputFrame>
1111
+ <input
1112
+ type="text"
1113
+ value={team}
1114
+ onChange={(e) => setTeam(e.target.value)}
1115
+ />
1116
+ </SettingsInputFrame>
1117
+ </SettingsField>
1118
+ <button type="submit" disabled={saving}>
1119
+ {saving ? 'Saving...' : 'Save'}
1120
+ </button>
1121
+ </form>
1122
+ </SettingsContainer>
1123
+ )
1124
+ }
637
1125
  ```
638
1126
 
1127
+ **When to use low-level API:**
1128
+ - Complex validation logic before saving
1129
+ - Batching multiple store operations
1130
+ - Custom debouncing behavior
1131
+ - Integration with form libraries
1132
+
1133
+ **When to use SDK hooks (recommended):**
1134
+ - Most Settings components
1135
+ - Simple form fields
1136
+ - Real-time sync between Settings and Render
1137
+
639
1138
  ## Code Style
640
1139
 
641
1140
  **Naming:**
@@ -647,14 +1146,21 @@ useEffect(() => {
647
1146
  **Imports order:**
648
1147
  ```typescript
649
1148
  // 1. SDK imports
650
- import { configure, store, proxy } from '@telemetryos/sdk';
1149
+ import { configure, store, proxy } from '@telemetryos/sdk'
1150
+ import {
1151
+ SettingsContainer,
1152
+ SettingsField,
1153
+ SettingsLabel,
1154
+ SettingsInputFrame,
1155
+ } from '@telemetryos/sdk/react'
651
1156
 
652
1157
  // 2. React imports
653
- import { useEffect, useState } from 'react';
1158
+ import { useEffect, useState } from 'react'
654
1159
 
655
- // 3. Local imports
656
- import WeatherCard from '@/components/WeatherCard';
657
- import type { WeatherData } from '@/types';
1160
+ // 3. Local imports (hooks, components, types)
1161
+ import { useTeamStoreState } from '../hooks/store'
1162
+ import ScoreCard from '@/components/ScoreCard'
1163
+ import type { GameScore } from '@/types'
658
1164
  ```
659
1165
 
660
1166
  **TypeScript:**
@@ -707,7 +1213,7 @@ git push origin main
707
1213
  → Using `store().device` in Settings - use `store().instance` instead
708
1214
 
709
1215
  **CORS error**
710
- Using direct `fetch()` - use `proxy().fetch()` instead
1216
+ External API doesn't include CORS headers - use `proxy().fetch()` for that API
711
1217
 
712
1218
  **"Request timeout"**
713
1219
  → SDK operation exceeded 30 seconds - handle with try/catch
@@ -1,3 +1,5 @@
1
1
  import { createUseStoreState } from '@telemetryos/sdk/react'
2
2
 
3
+ export const useUiScaleStoreState = createUseStoreState<number>('ui-scale', 1)
4
+
3
5
  export const useSubtitleStoreState = createUseStoreState<string>('subtitle', 'Change this line in settings ⚙️ ↗️')
@@ -3,20 +3,18 @@
3
3
  }
4
4
 
5
5
  html {
6
- font-size: 10px;
6
+ font-size: 1vmax;
7
7
  height: 100%;
8
+ overflow: hidden;
8
9
  }
9
10
 
10
11
  body {
11
- display: flex;
12
- flex-direction: column;
13
- font-size: 1.6rem;
14
12
  margin: 0;
15
- min-height: 100%;
13
+ height: 100%;
16
14
  }
17
15
 
18
16
  #app {
19
- flex: 1;
17
+ height: 100%;
20
18
  display: flex;
21
19
  flex-direction: column;
22
20
 
@@ -4,50 +4,50 @@
4
4
  flex-direction: column;
5
5
  align-items: center;
6
6
  justify-content: space-between;
7
- padding: 3rem 0 5rem;
7
+ gap: 2rem;
8
+ padding: 5vmin;
8
9
  color: hsl(210 40% 88%);
9
10
  background: hsl(212 28% 10%);
10
11
  }
11
12
 
12
13
  .render__logo {
13
- max-width: 35rem;
14
+ width: 30rem;
15
+ max-width: 50%;
14
16
  }
15
17
 
16
18
  .render__hero {
17
19
  display: flex;
18
20
  flex-direction: column;
19
21
  align-items: center;
22
+ gap: 2rem;
20
23
  }
21
24
 
22
25
  .render__hero-title {
23
- font-size: 4rem;
24
- line-height: 1.8em;
25
- margin-bottom: 4rem;
26
+ font-size: 5rem;
26
27
  font-weight: 600;
28
+ text-align: center;
27
29
  }
28
30
 
29
31
  .render__hero-subtitle {
30
- font-size: 2.5rem;
32
+ font-size: 3rem;
33
+ text-align: center;
31
34
  }
32
35
 
33
36
  .render__docs-information {
34
37
  display: flex;
35
38
  flex-direction: column;
36
39
  align-items: center;
40
+ gap: 2rem;
37
41
  }
38
42
 
39
43
  .render__docs-information-title {
40
- font-size: 1.6rem;
41
- line-height: 1.8em;
42
- margin-bottom: 0.8rem;
44
+ font-size: 2rem;
43
45
  font-weight: 600;
44
46
  text-align: center;
45
47
  }
46
48
 
47
49
  .render__docs-information-text {
48
- font-size: 1.3rem;
49
- line-height: 1.4em;
50
- margin-bottom: 2.4rem;
50
+ font-size: 2rem;
51
51
  max-width: 60rem;
52
52
  text-align: center;
53
53
  }
@@ -57,11 +57,10 @@
57
57
  align-items: center;
58
58
  background: rgb(248, 180, 53);
59
59
  text-transform: uppercase;
60
- font-size: 1.5rem;
61
- height: 3.1rem;
60
+ font-size: 2rem;
62
61
  text-decoration: none;
63
62
  color: black;
64
63
  font-weight: bold;
65
- padding: 0 1.75rem;
66
- border-radius: 0.5rem;
64
+ padding: 1rem 2rem;
65
+ border-radius: 1rem;
67
66
  }
@@ -1,33 +1,44 @@
1
1
  import { store } from '@telemetryos/sdk'
2
- import './Render.css'
2
+ import { useUiScaleFactorToSetRem } from '@telemetryos/sdk/react'
3
3
  import wordMarkPath from '../../assets/telemetryos-wordmark.svg'
4
- import { useSubtitleStoreState } from '../hooks/store'
4
+ import { useSubtitleStoreState, useUiScaleStoreState } from '../hooks/store'
5
+ import './Render.css'
5
6
 
6
7
  export function Render() {
8
+ const [_isUiScaleLoading, uiScale] = useUiScaleStoreState(store().instance)
9
+ useUiScaleFactorToSetRem(uiScale)
7
10
  const [isLoading, subtitle] = useSubtitleStoreState(store().instance)
8
11
 
9
12
  return (
10
13
  <div className="render">
11
14
  <img src={wordMarkPath} alt="TelemetryOS" className="render__logo" />
12
15
  <div className="render__hero">
13
- <div className="render__hero-title">Welcome to TelemetryOS SDK</div>
16
+ {uiScale < 1.5 && (
17
+ <div className="render__hero-title">Welcome to TelemetryOS SDK</div>
18
+ )}
14
19
  <div className="render__hero-subtitle">{isLoading ? 'Loading...' : subtitle}</div>
15
20
  </div>
16
21
  <div className="render__docs-information">
17
- <div className="render__docs-information-title">
18
- To get started, edit the Render.tsx and Settings.tsx files
19
- </div>
20
- <div className="render__docs-information-text">
21
- Visit our documentation on building applications to learn more
22
- </div>
23
- <a
24
- className="render__docs-information-button"
25
- href="https://docs.telemetryos.com/docs/sdk-getting-started"
26
- target="_blank"
27
- rel="noreferrer"
28
- >
29
- Documentation
30
- </a>
22
+ {uiScale < 1.2 && (
23
+ <>
24
+ <div className="render__docs-information-title">
25
+ To get started, edit the Render.tsx and Settings.tsx files
26
+ </div>
27
+ <div className="render__docs-information-text">
28
+ Visit our documentation on building applications to learn more
29
+ </div>
30
+ </>
31
+ )}
32
+ {uiScale < 1.35 && (
33
+ <a
34
+ className="render__docs-information-button"
35
+ href="https://docs.telemetryos.com/docs/sdk-getting-started"
36
+ target="_blank"
37
+ rel="noreferrer"
38
+ >
39
+ Documentation
40
+ </a>
41
+ )}
31
42
  </div>
32
43
  </div>
33
44
  )
@@ -1,18 +1,39 @@
1
1
  import { store } from '@telemetryos/sdk'
2
2
  import {
3
3
  SettingsContainer,
4
+ SettingsDivider,
4
5
  SettingsField,
5
- SettingsLabel,
6
6
  SettingsInputFrame,
7
+ SettingsLabel,
8
+ SettingsSliderFrame,
7
9
  } from '@telemetryos/sdk/react'
8
- import { useSubtitleStoreState } from '../hooks/store'
10
+ import { useSubtitleStoreState, useUiScaleStoreState } from '../hooks/store'
9
11
 
10
12
  export function Settings() {
13
+ const [isLoadingUiScale, uiScale, setUiScale] = useUiScaleStoreState(store().instance)
11
14
  const [isLoading, subtitle, setSubtitle] = useSubtitleStoreState(store().instance)
12
15
 
13
16
  return (
14
17
  <SettingsContainer>
15
18
 
19
+ <SettingsField>
20
+ <SettingsLabel>UI Scale</SettingsLabel>
21
+ <SettingsSliderFrame>
22
+ <input
23
+ type="range"
24
+ min={1}
25
+ max={3}
26
+ step={0.01}
27
+ disabled={isLoadingUiScale}
28
+ value={uiScale}
29
+ onChange={(e) => setUiScale(parseFloat(e.target.value))}
30
+ />
31
+ <span>{uiScale}x</span>
32
+ </SettingsSliderFrame>
33
+ </SettingsField>
34
+
35
+ <SettingsDivider />
36
+
16
37
  <SettingsField>
17
38
  <SettingsLabel>Subtitle Text</SettingsLabel>
18
39
  <SettingsInputFrame>
@@ -1,10 +0,0 @@
1
- export type GenerateApplicationOptions = {
2
- name: string;
3
- description: string;
4
- author: string;
5
- version: string;
6
- template: string;
7
- projectPath: string;
8
- progressFn: (createdFilePath: string) => void;
9
- };
10
- export declare function generateApplication(options: GenerateApplicationOptions): Promise<void>;
@@ -1,45 +0,0 @@
1
- import fs from "fs/promises";
2
- import path from "path";
3
- const ignoredTelemplateFiles = [
4
- '.DS_Store',
5
- 'thumbs.db',
6
- 'node_modules',
7
- '.git',
8
- 'dist'
9
- ];
10
- const templatesDir = path.join(import.meta.dirname, '../templates');
11
- export async function generateApplication(options) {
12
- const { name, description, author, version, template, projectPath, progressFn } = options;
13
- await fs.mkdir(projectPath, { recursive: true });
14
- await copyDir(path.join(templatesDir, template), projectPath, {
15
- name,
16
- description,
17
- author,
18
- version
19
- }, progressFn);
20
- }
21
- async function copyDir(source, destination, replacements, progressFn) {
22
- const dirListing = await fs.readdir(source);
23
- for (const dirEntry of dirListing) {
24
- if (ignoredTelemplateFiles.includes(dirEntry))
25
- continue;
26
- const sourcePath = path.join(source, dirEntry);
27
- const destinationPath = path.join(destination, dirEntry);
28
- const stats = await fs.stat(sourcePath);
29
- if (stats.isDirectory()) {
30
- await fs.mkdir(destinationPath, { recursive: true });
31
- await copyDir(sourcePath, destinationPath, replacements, progressFn);
32
- }
33
- else if (stats.isFile()) {
34
- await copyFile(sourcePath, destinationPath, replacements, progressFn);
35
- }
36
- }
37
- }
38
- async function copyFile(source, destination, replacements, progressFn) {
39
- let contents = await fs.readFile(source, 'utf-8');
40
- for (const [key, value] of Object.entries(replacements)) {
41
- contents = contents.replace(new RegExp(`{{${key}}}`, 'g'), value);
42
- }
43
- await fs.writeFile(destination, contents, 'utf-8');
44
- progressFn(destination);
45
- }
@@ -1,5 +0,0 @@
1
- type Flags = {
2
- port: number;
3
- };
4
- export declare function runServer(projectPath: string, flags: Flags): Promise<void>;
5
- export {};
@@ -1,104 +0,0 @@
1
- import { spawn } from 'child_process';
2
- import { readFile } from 'fs/promises';
3
- import http from 'http';
4
- import path from 'path';
5
- import readable from 'readline/promises';
6
- import serveHandler from 'serve-handler';
7
- const ansiYellow = '\u001b[33m';
8
- const ansiCyan = '\u001b[36m';
9
- const ansiBold = '\u001b[1m';
10
- const ansiReset = '\u001b[0m';
11
- export async function runServer(projectPath, flags) {
12
- printSplashScreen();
13
- projectPath = path.resolve(process.cwd(), projectPath);
14
- const telemetryConfig = await loadConfigFile(projectPath);
15
- if (!telemetryConfig) {
16
- console.error('No telemetry configuration found. Are you in the right directory?');
17
- process.exit(1);
18
- }
19
- await serveDevelopmentApplicationHostUI(flags.port, telemetryConfig);
20
- await serveTelemetryApplication(projectPath, telemetryConfig);
21
- }
22
- async function serveDevelopmentApplicationHostUI(port, telemetryConfig) {
23
- const hostUiPath = await import.meta.resolve('@telemetryos/development-application-host-ui/dist');
24
- const serveConfig = { public: hostUiPath.replace('file://', '') };
25
- const server = http.createServer();
26
- server.on('request', (req, res) => {
27
- const url = new URL(req.url, `http://${req.headers.origin}`);
28
- if (url.pathname === '/__tos-config__') {
29
- res.setHeader('Content-Type', 'application/json');
30
- res.end(JSON.stringify(telemetryConfig));
31
- return;
32
- }
33
- serveHandler(req, res, serveConfig).catch((err) => {
34
- console.error('Error handling request:', err);
35
- res.statusCode = 500;
36
- res.end('Internal Server Error');
37
- });
38
- });
39
- printServerInfo(port);
40
- server.listen(port);
41
- }
42
- async function serveTelemetryApplication(rootPath, telemetryConfig) {
43
- var _a;
44
- if (!((_a = telemetryConfig === null || telemetryConfig === void 0 ? void 0 : telemetryConfig.devServer) === null || _a === void 0 ? void 0 : _a.runCommand))
45
- return;
46
- const runCommand = telemetryConfig.devServer.runCommand;
47
- const binPath = path.join(rootPath, 'node_modules', '.bin');
48
- const childProcess = spawn(runCommand, {
49
- shell: true,
50
- env: { ...process.env, FORCE_COLOR: '1', PATH: `${binPath}:${process.env.PATH}` },
51
- stdio: ['ignore', 'pipe', 'pipe'],
52
- cwd: rootPath,
53
- });
54
- const stdoutReadline = readable.createInterface({
55
- input: childProcess.stdout,
56
- crlfDelay: Infinity,
57
- });
58
- const stderrReadline = readable.createInterface({
59
- input: childProcess.stderr,
60
- crlfDelay: Infinity,
61
- });
62
- stdoutReadline.on('line', (line) => {
63
- console.log(`[application]: ${line}`);
64
- });
65
- stderrReadline.on('line', (line) => {
66
- console.error(`[application]: ${line}`);
67
- });
68
- process.on('exit', () => {
69
- childProcess.kill();
70
- });
71
- }
72
- async function loadConfigFile(rootPath) {
73
- const configFilePath = path.join(rootPath, 'telemetry.config.json');
74
- try {
75
- const fileContent = await readFile(configFilePath, 'utf-8');
76
- const config = JSON.parse(fileContent);
77
- return config;
78
- }
79
- catch {
80
- return null;
81
- }
82
- }
83
- function printSplashScreen() {
84
- console.log(`${ansiYellow}
85
- ▄ ▄
86
- █ █ ▄▀▐
87
- █ █ █ ▄▀ ▐
88
- █ █ █ █ █ █ █ ▀ ▄▀
89
- █ █ █ █ ▛ ▄ █ ▀
90
- ▀█▀ ▄▀▀▄ █ ▄▀▀▄ █▀▄▀▄ ▄▀▀▄ ▀█▀ █▄▀ █ █ ▄▀ █ █
91
- █ █▀▀▀ █ █▀▀▀ █ █ █ █▀▀▀ █ █ █ █ ▄▀ █ █ █
92
- ▀▄ ▀▄▄▀ █ ▀▄▄▀ █ █ █ ▀▄▄▀ ▀▄ █ █ ▄▀ ▄▀ ▀ █ █ █
93
- ▄▀ ▌ ▄▀ ▀ █ █
94
- ▌▄▀ ▀ █
95
- ▀ ▀
96
- ${ansiReset}`);
97
- }
98
- function printServerInfo(port) {
99
- console.log(`
100
- ╔═══════════════════════════════════════════════════════════╗
101
- ║ ${ansiBold}Development environment running at: ${ansiCyan}http://localhost:${port}${ansiReset} ║
102
- ╚═══════════════════════════════════════════════════════════╝
103
- `);
104
- }