@telemetryos/cli 1.11.0 → 1.12.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/CHANGELOG.md +11 -0
- package/dist/services/run-server.js +139 -36
- package/dist/utils/ansi.d.ts +1 -0
- package/dist/utils/ansi.js +1 -0
- package/package.json +4 -4
- package/templates/vite-react-typescript/_claude/skills/tos-media-api/SKILL.md +94 -7
- package/templates/vite-react-typescript/_claude/skills/tos-render-design/SKILL.md +0 -624
package/CHANGELOG.md
CHANGED
|
@@ -3,11 +3,11 @@ import { readFile } from 'fs/promises';
|
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import http from 'http';
|
|
5
5
|
import path from 'path';
|
|
6
|
-
import
|
|
6
|
+
import { createInterface } from 'readline/promises';
|
|
7
7
|
import serveHandler from 'serve-handler';
|
|
8
8
|
import pkg from '../../package.json' with { type: 'json' };
|
|
9
9
|
import { loadProjectConfig } from './project-config.js';
|
|
10
|
-
import { ansi } from '../utils/ansi.js';
|
|
10
|
+
import { ansi, ansiRegex } from '../utils/ansi.js';
|
|
11
11
|
export async function runServer(projectPath, flags) {
|
|
12
12
|
printSplashScreen();
|
|
13
13
|
projectPath = path.resolve(process.cwd(), projectPath);
|
|
@@ -21,6 +21,8 @@ export async function runServer(projectPath, flags) {
|
|
|
21
21
|
}
|
|
22
22
|
await serveDevelopmentApplicationHostUI(projectPath, flags.port, projectConfig);
|
|
23
23
|
await serveTelemetryApplication(projectPath, projectConfig);
|
|
24
|
+
// Print ready message after Vite is confirmed ready
|
|
25
|
+
printServerInfo(flags.port);
|
|
24
26
|
}
|
|
25
27
|
async function serveDevelopmentApplicationHostUI(projectPath, port, projectConfig) {
|
|
26
28
|
const hostUiPath = await import.meta.resolve('@telemetryos/development-application-host-ui/dist');
|
|
@@ -122,49 +124,150 @@ async function serveDevelopmentApplicationHostUI(projectPath, port, projectConfi
|
|
|
122
124
|
}
|
|
123
125
|
return;
|
|
124
126
|
}
|
|
127
|
+
// TODO: Publish endpoint disabled until auth is working
|
|
128
|
+
// if (url.pathname === '/__publish__') {
|
|
129
|
+
// if (req.method !== 'GET') {
|
|
130
|
+
// res.statusCode = 405
|
|
131
|
+
// res.end('Method not allowed')
|
|
132
|
+
// return
|
|
133
|
+
// }
|
|
134
|
+
//
|
|
135
|
+
// // Set SSE headers
|
|
136
|
+
// res.setHeader('Content-Type', 'text/event-stream')
|
|
137
|
+
// res.setHeader('Cache-Control', 'no-cache')
|
|
138
|
+
// res.setHeader('Connection', 'keep-alive')
|
|
139
|
+
//
|
|
140
|
+
// const sendEvent = (event: string, data: any) => {
|
|
141
|
+
// res.write(`event: ${event}\n`)
|
|
142
|
+
// res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
143
|
+
// }
|
|
144
|
+
//
|
|
145
|
+
// try {
|
|
146
|
+
// sendEvent('state', { state: 'starting' })
|
|
147
|
+
//
|
|
148
|
+
// await handlePublishCommand(
|
|
149
|
+
// projectPath,
|
|
150
|
+
// {},
|
|
151
|
+
// {
|
|
152
|
+
// onLog: (line: string) => {
|
|
153
|
+
// sendEvent('log', { message: line })
|
|
154
|
+
// },
|
|
155
|
+
// onStateChange: (state: string) => {
|
|
156
|
+
// sendEvent('state', { state })
|
|
157
|
+
// },
|
|
158
|
+
// onComplete: (data: { success: boolean; buildIndex?: number; duration?: string }) => {
|
|
159
|
+
// sendEvent('complete', data)
|
|
160
|
+
// res.end()
|
|
161
|
+
// },
|
|
162
|
+
// onError: (error: Error) => {
|
|
163
|
+
// const isAuthError =
|
|
164
|
+
// error.message.includes('authenticate') ||
|
|
165
|
+
// error.message.includes('Not authenticated')
|
|
166
|
+
// sendEvent('error', {
|
|
167
|
+
// error: error.message,
|
|
168
|
+
// code: isAuthError ? 'AUTH_REQUIRED' : 'PUBLISH_FAILED',
|
|
169
|
+
// message: isAuthError
|
|
170
|
+
// ? "Run 'tos auth' in the terminal to authenticate"
|
|
171
|
+
// : error.message,
|
|
172
|
+
// })
|
|
173
|
+
// res.end()
|
|
174
|
+
// },
|
|
175
|
+
// },
|
|
176
|
+
// )
|
|
177
|
+
// } catch (error) {
|
|
178
|
+
// sendEvent('error', {
|
|
179
|
+
// error: (error as Error).message,
|
|
180
|
+
// code: 'UNEXPECTED_ERROR',
|
|
181
|
+
// message: (error as Error).message,
|
|
182
|
+
// })
|
|
183
|
+
// res.end()
|
|
184
|
+
// }
|
|
185
|
+
// return
|
|
186
|
+
// }
|
|
125
187
|
serveHandler(req, res, serveConfig).catch((err) => {
|
|
126
188
|
console.error('Error handling request:', err);
|
|
127
189
|
res.statusCode = 500;
|
|
128
190
|
res.end('Internal Server Error');
|
|
129
191
|
});
|
|
130
192
|
});
|
|
131
|
-
printServerInfo(port);
|
|
132
193
|
server.listen(port);
|
|
133
194
|
}
|
|
134
195
|
async function serveTelemetryApplication(rootPath, projectConfig) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
var _a;
|
|
198
|
+
if (!((_a = projectConfig === null || projectConfig === void 0 ? void 0 : projectConfig.devServer) === null || _a === void 0 ? void 0 : _a.runCommand)) {
|
|
199
|
+
console.log('No value in config at devServer.runCommand');
|
|
200
|
+
resolve();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const runCommand = projectConfig.devServer.runCommand;
|
|
204
|
+
const binPath = path.join(rootPath, 'node_modules', '.bin');
|
|
205
|
+
const childProcess = spawn(runCommand, {
|
|
206
|
+
shell: true,
|
|
207
|
+
env: {
|
|
208
|
+
...process.env,
|
|
209
|
+
FORCE_COLOR: '1',
|
|
210
|
+
PATH: `${binPath}${path.delimiter}${process.env.PATH}`,
|
|
211
|
+
},
|
|
212
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
213
|
+
cwd: rootPath,
|
|
214
|
+
});
|
|
215
|
+
const stdoutReadline = createInterface({
|
|
216
|
+
input: childProcess.stdout,
|
|
217
|
+
crlfDelay: Infinity,
|
|
218
|
+
});
|
|
219
|
+
const stderrReadline = createInterface({
|
|
220
|
+
input: childProcess.stderr,
|
|
221
|
+
crlfDelay: Infinity,
|
|
222
|
+
});
|
|
223
|
+
let cleaned = false;
|
|
224
|
+
const cleanup = () => {
|
|
225
|
+
if (cleaned)
|
|
226
|
+
return;
|
|
227
|
+
cleaned = true;
|
|
228
|
+
process.removeListener('exit', onParentExit);
|
|
229
|
+
clearTimeout(timeoutHandle);
|
|
230
|
+
childProcess.kill();
|
|
231
|
+
stdoutReadline.close();
|
|
232
|
+
stderrReadline.close();
|
|
233
|
+
};
|
|
234
|
+
const onParentExit = () => {
|
|
235
|
+
console.log('Shutting down development server...');
|
|
236
|
+
cleanup();
|
|
237
|
+
};
|
|
238
|
+
process.on('exit', onParentExit);
|
|
239
|
+
const timeoutHandle = setTimeout(() => {
|
|
240
|
+
cleanup();
|
|
241
|
+
reject(new Error('Vite dev server did not start within 30 seconds'));
|
|
242
|
+
}, 30000);
|
|
243
|
+
let viteReady = false;
|
|
244
|
+
stdoutReadline.on('line', (line) => {
|
|
245
|
+
console.log(`[application]: ${line}`);
|
|
246
|
+
// Detect Vite ready signal
|
|
247
|
+
if (!viteReady) {
|
|
248
|
+
const cleanLine = line.replace(ansiRegex, '');
|
|
249
|
+
if (cleanLine.includes('VITE') && cleanLine.includes('ready in')) {
|
|
250
|
+
viteReady = true;
|
|
251
|
+
clearTimeout(timeoutHandle);
|
|
252
|
+
resolve();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
stderrReadline.on('line', (line) => {
|
|
257
|
+
console.error(`[application]: ${line}`);
|
|
258
|
+
});
|
|
259
|
+
childProcess.on('error', (error) => {
|
|
260
|
+
if (!viteReady) {
|
|
261
|
+
cleanup();
|
|
262
|
+
reject(error);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
childProcess.on('close', (code) => {
|
|
266
|
+
if (!viteReady) {
|
|
267
|
+
cleanup();
|
|
268
|
+
reject(new Error(`Dev server process exited with code ${code} before becoming ready`));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
168
271
|
});
|
|
169
272
|
}
|
|
170
273
|
function printSplashScreen() {
|
package/dist/utils/ansi.d.ts
CHANGED
package/dist/utils/ansi.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telemetryos/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
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.
|
|
28
|
+
"@telemetryos/development-application-host-ui": "^1.12.0",
|
|
29
29
|
"@types/serve-handler": "^6.1.4",
|
|
30
30
|
"commander": "^14.0.0",
|
|
31
31
|
"ignore": "^6.0.2",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"eslint-plugin-prettier": "^5.4.0",
|
|
44
44
|
"globals": "^16.0.0",
|
|
45
45
|
"prettier": "^3.5.3",
|
|
46
|
-
"typescript
|
|
47
|
-
"typescript": "^
|
|
46
|
+
"typescript": "^5.8.3",
|
|
47
|
+
"typescript-eslint": "^8.49.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"tos": "pnpm build && node dist/index.js",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tos-media-api
|
|
3
|
-
description: Access TelemetryOS Media Library for images, videos, and files. Use when building apps that display user-uploaded media content.
|
|
3
|
+
description: Access TelemetryOS Media Library for images, videos, and files. Use when building apps that display or select user-uploaded media content.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# TelemetryOS Media API
|
|
@@ -23,6 +23,10 @@ const tagged = await media().getAllByTag('banner')
|
|
|
23
23
|
|
|
24
24
|
// Get single item by ID
|
|
25
25
|
const item = await media().getById('content-id')
|
|
26
|
+
|
|
27
|
+
// Open media picker (user selects from full-screen dialog)
|
|
28
|
+
const selection = await media().openPicker()
|
|
29
|
+
const imagesOnly = await media().openPicker({ accept: ['image/*'] })
|
|
26
30
|
```
|
|
27
31
|
|
|
28
32
|
## Response Types
|
|
@@ -61,6 +65,29 @@ interface MediaContent {
|
|
|
61
65
|
}
|
|
62
66
|
```
|
|
63
67
|
|
|
68
|
+
### MediaSelection
|
|
69
|
+
|
|
70
|
+
Returned by `openPicker()` and used by `SettingsMediaSelect`.
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
interface MediaSelection {
|
|
74
|
+
id: string
|
|
75
|
+
name: string
|
|
76
|
+
thumbnailUrl: string // Thumbnail for preview
|
|
77
|
+
url: string // Full content URL
|
|
78
|
+
contentType: string // MIME type
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### MediaPickerOptions
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface MediaPickerOptions {
|
|
86
|
+
accept?: string[] // Content type filters: ['image/*', 'video/*']
|
|
87
|
+
currentValue?: MediaSelection // Pre-select current value in picker
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
64
91
|
## Common Patterns
|
|
65
92
|
|
|
66
93
|
### Folder Picker in Settings
|
|
@@ -123,6 +150,64 @@ export default function Settings() {
|
|
|
123
150
|
}
|
|
124
151
|
```
|
|
125
152
|
|
|
153
|
+
### Media Picker in Settings (SettingsMediaSelect)
|
|
154
|
+
|
|
155
|
+
Use the built-in `SettingsMediaSelect` component to let users pick a media item. Opens a full-screen picker dialog in the host window.
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// hooks/store.ts
|
|
159
|
+
import { createUseInstanceStoreState } from '@telemetryos/sdk/react'
|
|
160
|
+
import type { MediaSelection } from '@telemetryos/sdk/react'
|
|
161
|
+
|
|
162
|
+
export const useMediaState = createUseInstanceStoreState<MediaSelection | null>('selectedMedia', null)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// views/Settings.tsx
|
|
167
|
+
import {
|
|
168
|
+
SettingsContainer,
|
|
169
|
+
SettingsField,
|
|
170
|
+
SettingsLabel,
|
|
171
|
+
SettingsMediaSelect,
|
|
172
|
+
} from '@telemetryos/sdk/react'
|
|
173
|
+
import { useMediaState } from '../hooks/store'
|
|
174
|
+
|
|
175
|
+
export default function Settings() {
|
|
176
|
+
const [isLoading, selectedMedia, setSelectedMedia] = useMediaState()
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<SettingsContainer>
|
|
180
|
+
<SettingsField>
|
|
181
|
+
<SettingsLabel>Background Image</SettingsLabel>
|
|
182
|
+
<SettingsMediaSelect
|
|
183
|
+
value={selectedMedia}
|
|
184
|
+
onChange={setSelectedMedia}
|
|
185
|
+
accept={['image/*']}
|
|
186
|
+
disabled={isLoading}
|
|
187
|
+
/>
|
|
188
|
+
</SettingsField>
|
|
189
|
+
</SettingsContainer>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Using openPicker() Directly
|
|
195
|
+
|
|
196
|
+
For custom UI, call `media().openPicker()` directly instead of using the component.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { media } from '@telemetryos/sdk'
|
|
200
|
+
|
|
201
|
+
const handleSelectMedia = async () => {
|
|
202
|
+
const selection = await media().openPicker({ accept: ['image/*'] })
|
|
203
|
+
if (selection) {
|
|
204
|
+
// selection has: id, name, thumbnailUrl, url, contentType
|
|
205
|
+
console.log('Selected:', selection.name, selection.url)
|
|
206
|
+
}
|
|
207
|
+
// selection is null if user cancelled
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
126
211
|
### Image Gallery in Render
|
|
127
212
|
|
|
128
213
|
```typescript
|
|
@@ -327,9 +412,11 @@ const activeContent = content.filter(item => {
|
|
|
327
412
|
|
|
328
413
|
## Tips
|
|
329
414
|
|
|
330
|
-
1. **Use
|
|
331
|
-
2. **Use
|
|
332
|
-
3. **
|
|
333
|
-
4. **
|
|
334
|
-
5. **
|
|
335
|
-
6. **
|
|
415
|
+
1. **Use `SettingsMediaSelect` for settings** - Preferred way to let users pick media in Settings views
|
|
416
|
+
2. **Use `openPicker()` for custom UI** - When you need full control over the selection flow
|
|
417
|
+
3. **Use publicUrls[0]** - First URL is the primary CDN URL
|
|
418
|
+
4. **Use thumbnailUrl for previews** - Smaller, faster loading
|
|
419
|
+
5. **Filter by contentType** - Ensure you're displaying compatible content
|
|
420
|
+
6. **Handle empty folders** - Show appropriate message when no content
|
|
421
|
+
7. **Lazy load images** - Use `loading="lazy"` for galleries
|
|
422
|
+
8. **Respect hidden flag** - Filter out hidden items unless intentional
|
|
@@ -1,624 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: tos-render-design
|
|
3
|
-
description: Design patterns for TelemetryOS Render views. Use when building display-only digital signage OR interactive kiosks. Covers responsive scaling, UI patterns, and interaction handling for both models.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Render View Design
|
|
7
|
-
|
|
8
|
-
TelemetryOS Render views support both **digital signage** (display-only) and **interactive kiosks** (touch-enabled). Understanding which pattern you're building determines how you handle user interaction and state management.
|
|
9
|
-
|
|
10
|
-
> **Note:** The init project already provides base styles in `index.css` (viewport scaling, box-sizing) and `Render.css` (`.render` class with padding, overflow, flexbox). Build on these—don't override them.
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## Interaction Models
|
|
15
|
-
|
|
16
|
-
TelemetryOS render views support two interaction models. Both use `@telemetryos/sdk` with the same architecture—the difference is whether the render view includes onClick handlers.
|
|
17
|
-
|
|
18
|
-
### Digital Signage (Display-Only)
|
|
19
|
-
|
|
20
|
-
**Use for:** Information displays, dashboards, menu boards, announcements
|
|
21
|
-
|
|
22
|
-
- Content updates automatically via store subscriptions
|
|
23
|
-
- No user interaction (no mouse, keyboard, touch input)
|
|
24
|
-
- No onClick handlers in render view
|
|
25
|
-
- Viewed from a distance
|
|
26
|
-
- Updates driven by timers, external data, or Settings changes
|
|
27
|
-
|
|
28
|
-
### Interactive Kiosk (Touch-Enabled)
|
|
29
|
-
|
|
30
|
-
**Use for:** Wayfinding, directories, check-in systems, search interfaces
|
|
31
|
-
|
|
32
|
-
- Users can touch/click elements on screen
|
|
33
|
-
- onClick handlers for buttons, navigation, forms
|
|
34
|
-
- Idle timeout returns to home screen after inactivity
|
|
35
|
-
- Touch feedback with :active states (not :hover)
|
|
36
|
-
- Manages interaction state (current screen, navigation history)
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## Digital Signage Pattern (Display-Only)
|
|
41
|
-
|
|
42
|
-
For apps where users only **view** content from a distance.
|
|
43
|
-
|
|
44
|
-
### No User Interaction
|
|
45
|
-
|
|
46
|
-
Assume **no mouse, keyboard, or touch input**:
|
|
47
|
-
|
|
48
|
-
```css
|
|
49
|
-
/* WRONG - No one will hover on digital signage */
|
|
50
|
-
.button:hover {
|
|
51
|
-
background: blue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/* WRONG - No one will focus elements */
|
|
55
|
-
.input:focus {
|
|
56
|
-
outline: 2px solid blue;
|
|
57
|
-
}
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Avoid `:hover`, `:focus`, `:active`, and similar interaction pseudo-classes for display-only apps.
|
|
61
|
-
|
|
62
|
-
### No Scrolling
|
|
63
|
-
|
|
64
|
-
Content **must fit the viewport**. There's no user to scroll:
|
|
65
|
-
|
|
66
|
-
```css
|
|
67
|
-
/* WRONG - Creates scrollbar no one can use */
|
|
68
|
-
.container {
|
|
69
|
-
overflow-y: scroll;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/* WRONG - Content disappears off-screen */
|
|
73
|
-
.content {
|
|
74
|
-
height: 150vh;
|
|
75
|
-
}
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
```css
|
|
79
|
-
/* CORRECT - Content contained */
|
|
80
|
-
.container {
|
|
81
|
-
height: 100vh;
|
|
82
|
-
overflow: hidden;
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
If content might overflow, truncate it or conditionally hide elements—never show a scrollbar.
|
|
87
|
-
|
|
88
|
-
---
|
|
89
|
-
|
|
90
|
-
## Interactive Kiosk Pattern (Touch-Enabled)
|
|
91
|
-
|
|
92
|
-
For apps where users **interact** with the screen via touch or click.
|
|
93
|
-
|
|
94
|
-
### onClick Handlers
|
|
95
|
-
|
|
96
|
-
Add click handlers to interactive elements:
|
|
97
|
-
|
|
98
|
-
```typescript
|
|
99
|
-
function Render() {
|
|
100
|
-
const [screen, setScreen] = useState('home')
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<div className="render">
|
|
104
|
-
{screen === 'home' && (
|
|
105
|
-
<button
|
|
106
|
-
className="kiosk-button"
|
|
107
|
-
onClick={() => setScreen('search')}
|
|
108
|
-
>
|
|
109
|
-
Search Directory
|
|
110
|
-
</button>
|
|
111
|
-
)}
|
|
112
|
-
{screen === 'search' && (
|
|
113
|
-
<SearchScreen onBack={() => setScreen('home')} />
|
|
114
|
-
)}
|
|
115
|
-
</div>
|
|
116
|
-
)
|
|
117
|
-
}
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
### Touch Feedback (:active states)
|
|
121
|
-
|
|
122
|
-
Use `:active` pseudo-class for touch feedback (NOT `:hover`):
|
|
123
|
-
|
|
124
|
-
```css
|
|
125
|
-
.kiosk-button {
|
|
126
|
-
padding: 2rem 4rem;
|
|
127
|
-
font-size: 3rem;
|
|
128
|
-
background: blue;
|
|
129
|
-
color: white;
|
|
130
|
-
border: none;
|
|
131
|
-
border-radius: 1rem;
|
|
132
|
-
transition: transform 0.1s, background 0.1s;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/* Touch feedback - user sees visual response when tapping */
|
|
136
|
-
.kiosk-button:active {
|
|
137
|
-
transform: scale(0.95);
|
|
138
|
-
background: darkblue;
|
|
139
|
-
}
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
**Why :active instead of :hover?**
|
|
143
|
-
- Touch devices don't have hover
|
|
144
|
-
- :active triggers on touch/click
|
|
145
|
-
- Provides immediate visual feedback
|
|
146
|
-
|
|
147
|
-
### Idle Timeout Pattern
|
|
148
|
-
|
|
149
|
-
Return to home screen after inactivity:
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
function Render() {
|
|
153
|
-
const [screen, setScreen] = useState('home')
|
|
154
|
-
const [lastInteraction, setLastInteraction] = useState(Date.now())
|
|
155
|
-
|
|
156
|
-
// Return to home after 30 seconds of inactivity
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
const timeout = setTimeout(() => {
|
|
159
|
-
const elapsed = Date.now() - lastInteraction
|
|
160
|
-
if (elapsed > 30000 && screen !== 'home') {
|
|
161
|
-
setScreen('home')
|
|
162
|
-
}
|
|
163
|
-
}, 30000)
|
|
164
|
-
|
|
165
|
-
return () => clearTimeout(timeout)
|
|
166
|
-
}, [lastInteraction, screen])
|
|
167
|
-
|
|
168
|
-
const handleInteraction = (newScreen: string) => {
|
|
169
|
-
setScreen(newScreen)
|
|
170
|
-
setLastInteraction(Date.now())
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return (
|
|
174
|
-
<div className="render">
|
|
175
|
-
{screen === 'home' && (
|
|
176
|
-
<button onClick={() => handleInteraction('search')}>
|
|
177
|
-
Search
|
|
178
|
-
</button>
|
|
179
|
-
)}
|
|
180
|
-
</div>
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
### Touch Target Sizing
|
|
186
|
-
|
|
187
|
-
Make touch targets large enough to tap accurately:
|
|
188
|
-
|
|
189
|
-
```css
|
|
190
|
-
/* Minimum sizes for touch targets */
|
|
191
|
-
.kiosk-button {
|
|
192
|
-
min-width: 15rem; /* Large enough to tap */
|
|
193
|
-
min-height: 8rem;
|
|
194
|
-
padding: 2rem 4rem;
|
|
195
|
-
font-size: 3rem; /* Large, readable text */
|
|
196
|
-
}
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
**Guidelines:**
|
|
200
|
-
- Minimum 8rem height for buttons
|
|
201
|
-
- 2rem padding minimum
|
|
202
|
-
- 3rem font size minimum for buttons
|
|
203
|
-
- Gap of at least 1rem between interactive elements
|
|
204
|
-
|
|
205
|
-
### Store State for Navigation
|
|
206
|
-
|
|
207
|
-
Use device store to persist state across composition changes:
|
|
208
|
-
|
|
209
|
-
```typescript
|
|
210
|
-
// hooks/store.ts
|
|
211
|
-
import { createUseDeviceStoreState } from '@telemetryos/sdk/react'
|
|
212
|
-
|
|
213
|
-
export const useKioskScreenState = createUseDeviceStoreState<string>(
|
|
214
|
-
'kiosk-screen',
|
|
215
|
-
'home'
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
// Render.tsx
|
|
219
|
-
const [_isLoading, screen, setScreen] = useKioskScreenState()
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
**Why device store?**
|
|
223
|
-
- Persists state on the device (survives composition changes)
|
|
224
|
-
- Doesn't sync to other devices (screen state is device-local)
|
|
225
|
-
|
|
226
|
-
---
|
|
227
|
-
|
|
228
|
-
## UI Scale Hooks
|
|
229
|
-
|
|
230
|
-
**Applies to both digital signage and interactive kiosks.**
|
|
231
|
-
|
|
232
|
-
Displays range from tablets to 8K video walls. Standard CSS pixels create inconsistent sizing. The SDK provides hooks that redefine `rem` as viewport-relative:
|
|
233
|
-
|
|
234
|
-
### useUiScaleToSetRem(uiScale)
|
|
235
|
-
|
|
236
|
-
Sets the document's root font-size based on viewport. **Call once in your Render view:**
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
import { useUiScaleToSetRem } from '@telemetryos/sdk/react'
|
|
240
|
-
import { useUiScaleStoreState } from '../hooks/store'
|
|
241
|
-
|
|
242
|
-
export function Render() {
|
|
243
|
-
const [_isLoading, uiScale] = useUiScaleStoreState()
|
|
244
|
-
useUiScaleToSetRem(uiScale)
|
|
245
|
-
|
|
246
|
-
return <div className="content">...</div>
|
|
247
|
-
}
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
**How it works:**
|
|
251
|
-
- At scale 1: `1rem` = 1% of viewport's longest dimension
|
|
252
|
-
- At scale 2: `1rem` = 2% of viewport's longest dimension
|
|
253
|
-
- A 2rem font occupies identical screen percentage on Full HD and 4K
|
|
254
|
-
|
|
255
|
-
### useUiAspectRatio()
|
|
256
|
-
|
|
257
|
-
Returns current aspect ratio, updating on resize:
|
|
258
|
-
|
|
259
|
-
```typescript
|
|
260
|
-
import { useUiAspectRatio } from '@telemetryos/sdk/react'
|
|
261
|
-
|
|
262
|
-
export function Render() {
|
|
263
|
-
const aspectRatio = useUiAspectRatio()
|
|
264
|
-
|
|
265
|
-
// > 1 = landscape, < 1 = portrait, = 1 = square
|
|
266
|
-
const isPortrait = aspectRatio < 1
|
|
267
|
-
|
|
268
|
-
return (
|
|
269
|
-
<div className={isPortrait ? 'portrait-layout' : 'landscape-layout'}>
|
|
270
|
-
...
|
|
271
|
-
</div>
|
|
272
|
-
)
|
|
273
|
-
}
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
---
|
|
277
|
-
|
|
278
|
-
## Best Practices
|
|
279
|
-
|
|
280
|
-
**Applies to both digital signage and interactive kiosks.**
|
|
281
|
-
|
|
282
|
-
### Use rem for Everything
|
|
283
|
-
|
|
284
|
-
All sizing should use `rem` to scale with the UI scale setting:
|
|
285
|
-
|
|
286
|
-
```css
|
|
287
|
-
/* CORRECT - Scales with viewport */
|
|
288
|
-
.title {
|
|
289
|
-
font-size: 4rem;
|
|
290
|
-
margin-bottom: 1rem;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
.card {
|
|
294
|
-
padding: 2rem;
|
|
295
|
-
border-radius: 0.5rem;
|
|
296
|
-
}
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
```css
|
|
300
|
-
/* WRONG - Fixed pixels don't scale */
|
|
301
|
-
.title {
|
|
302
|
-
font-size: 48px;
|
|
303
|
-
margin-bottom: 12px;
|
|
304
|
-
}
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
### Title Safe Zone
|
|
308
|
-
|
|
309
|
-
The init project's `.render` class already applies ~3rem padding from screen edges (SMPTE ST 2046-1 standard for avoiding bezel cutoff). Keep this padding when building your layout.
|
|
310
|
-
|
|
311
|
-
### Minimum Text Size
|
|
312
|
-
|
|
313
|
-
Text should be no smaller than ~2rem for comfortable viewing at typical distances (approximately 4% of screen height):
|
|
314
|
-
|
|
315
|
-
```css
|
|
316
|
-
.body-text {
|
|
317
|
-
font-size: 2rem; /* Minimum readable size */
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
.headline {
|
|
321
|
-
font-size: 4rem;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
.small-label {
|
|
325
|
-
font-size: 1.5rem; /* Use sparingly */
|
|
326
|
-
}
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
### Constrain Layouts
|
|
330
|
-
|
|
331
|
-
The init project's `index.css` and `.render` class already set up the base layout with `overflow: hidden` and flexbox. When adding child elements, use `min-height: 0` or `min-width: 0` on flex children to allow them to shrink:
|
|
332
|
-
|
|
333
|
-
```css
|
|
334
|
-
.my-content {
|
|
335
|
-
flex: 1;
|
|
336
|
-
min-height: 0; /* Allows flex children to shrink below content size */
|
|
337
|
-
}
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
### Text Truncation
|
|
341
|
-
|
|
342
|
-
When text might overflow, truncate gracefully:
|
|
343
|
-
|
|
344
|
-
```css
|
|
345
|
-
.title {
|
|
346
|
-
white-space: nowrap;
|
|
347
|
-
overflow: hidden;
|
|
348
|
-
text-overflow: ellipsis;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/* Multi-line truncation */
|
|
352
|
-
.description {
|
|
353
|
-
display: -webkit-box;
|
|
354
|
-
-webkit-line-clamp: 3;
|
|
355
|
-
-webkit-box-orient: vertical;
|
|
356
|
-
overflow: hidden;
|
|
357
|
-
}
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
### Adaptive Content
|
|
361
|
-
|
|
362
|
-
Use `useUiAspectRatio()` to adapt layouts for portrait vs landscape:
|
|
363
|
-
|
|
364
|
-
```typescript
|
|
365
|
-
function Dashboard() {
|
|
366
|
-
const aspectRatio = useUiAspectRatio()
|
|
367
|
-
const isPortrait = aspectRatio < 1
|
|
368
|
-
|
|
369
|
-
return (
|
|
370
|
-
<div className={`dashboard ${isPortrait ? 'dashboard--portrait' : ''}`}>
|
|
371
|
-
<PrimaryContent />
|
|
372
|
-
{/* Hide sidebar in portrait mode */}
|
|
373
|
-
{!isPortrait && <Sidebar />}
|
|
374
|
-
</div>
|
|
375
|
-
)
|
|
376
|
-
}
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
---
|
|
380
|
-
|
|
381
|
-
## Complete Examples
|
|
382
|
-
|
|
383
|
-
### Digital Signage Example (Display-Only)
|
|
384
|
-
|
|
385
|
-
```typescript
|
|
386
|
-
// Render.tsx - Display-only dashboard
|
|
387
|
-
import { useUiScaleToSetRem, useUiAspectRatio } from '@telemetryos/sdk/react'
|
|
388
|
-
import { useUiScaleStoreState } from '../hooks/store'
|
|
389
|
-
import './Render.css'
|
|
390
|
-
|
|
391
|
-
export function Render() {
|
|
392
|
-
const [isLoading, uiScale] = useUiScaleStoreState()
|
|
393
|
-
const aspectRatio = useUiAspectRatio()
|
|
394
|
-
|
|
395
|
-
useUiScaleToSetRem(uiScale)
|
|
396
|
-
|
|
397
|
-
if (isLoading) return null
|
|
398
|
-
|
|
399
|
-
const isPortrait = aspectRatio < 1
|
|
400
|
-
|
|
401
|
-
return (
|
|
402
|
-
<div className="render">
|
|
403
|
-
<header className="render__header">
|
|
404
|
-
<h1 className="render__title">Dashboard</h1>
|
|
405
|
-
</header>
|
|
406
|
-
|
|
407
|
-
<main className={`render__content ${isPortrait ? 'render__content--portrait' : ''}`}>
|
|
408
|
-
<div className="render__primary">
|
|
409
|
-
<MainDisplay />
|
|
410
|
-
</div>
|
|
411
|
-
|
|
412
|
-
{!isPortrait && (
|
|
413
|
-
<aside className="render__sidebar">
|
|
414
|
-
<SecondaryInfo />
|
|
415
|
-
</aside>
|
|
416
|
-
)}
|
|
417
|
-
</main>
|
|
418
|
-
</div>
|
|
419
|
-
)
|
|
420
|
-
}
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
```css
|
|
424
|
-
/* Render.css - Display-only styles */
|
|
425
|
-
.render__header {
|
|
426
|
-
flex-shrink: 0;
|
|
427
|
-
margin-bottom: 2rem;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
.render__title {
|
|
431
|
-
font-size: 4rem;
|
|
432
|
-
margin: 0;
|
|
433
|
-
white-space: nowrap;
|
|
434
|
-
overflow: hidden;
|
|
435
|
-
text-overflow: ellipsis;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
.render__content {
|
|
439
|
-
flex: 1;
|
|
440
|
-
min-height: 0;
|
|
441
|
-
display: flex;
|
|
442
|
-
gap: 2rem;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
.render__content--portrait {
|
|
446
|
-
flex-direction: column;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
.render__primary {
|
|
450
|
-
flex: 1;
|
|
451
|
-
min-width: 0;
|
|
452
|
-
min-height: 0;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
.render__sidebar {
|
|
456
|
-
width: 25rem;
|
|
457
|
-
flex-shrink: 0;
|
|
458
|
-
}
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
### Interactive Kiosk Example (Touch-Enabled)
|
|
462
|
-
|
|
463
|
-
```typescript
|
|
464
|
-
// Render.tsx - Interactive kiosk with navigation
|
|
465
|
-
import { useState, useEffect } from 'react'
|
|
466
|
-
import { useUiScaleToSetRem } from '@telemetryos/sdk/react'
|
|
467
|
-
import { useUiScaleStoreState } from '../hooks/store'
|
|
468
|
-
import './Render.css'
|
|
469
|
-
|
|
470
|
-
export function Render() {
|
|
471
|
-
const [isLoading, uiScale] = useUiScaleStoreState()
|
|
472
|
-
const [screen, setScreen] = useState('home')
|
|
473
|
-
const [lastInteraction, setLastInteraction] = useState(Date.now())
|
|
474
|
-
|
|
475
|
-
useUiScaleToSetRem(uiScale)
|
|
476
|
-
|
|
477
|
-
// Idle timeout - return to home after 30 seconds
|
|
478
|
-
useEffect(() => {
|
|
479
|
-
const timeout = setTimeout(() => {
|
|
480
|
-
const elapsed = Date.now() - lastInteraction
|
|
481
|
-
if (elapsed > 30000 && screen !== 'home') {
|
|
482
|
-
setScreen('home')
|
|
483
|
-
}
|
|
484
|
-
}, 30000)
|
|
485
|
-
|
|
486
|
-
return () => clearTimeout(timeout)
|
|
487
|
-
}, [lastInteraction, screen])
|
|
488
|
-
|
|
489
|
-
const handleInteraction = (newScreen: string) => {
|
|
490
|
-
setScreen(newScreen)
|
|
491
|
-
setLastInteraction(Date.now())
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
if (isLoading) return null
|
|
495
|
-
|
|
496
|
-
return (
|
|
497
|
-
<div className="render">
|
|
498
|
-
{screen === 'home' && (
|
|
499
|
-
<div className="kiosk-home">
|
|
500
|
-
<h1 className="kiosk-home__title">Welcome</h1>
|
|
501
|
-
<button
|
|
502
|
-
className="kiosk-button"
|
|
503
|
-
onClick={() => handleInteraction('search')}
|
|
504
|
-
>
|
|
505
|
-
Search Directory
|
|
506
|
-
</button>
|
|
507
|
-
<button
|
|
508
|
-
className="kiosk-button"
|
|
509
|
-
onClick={() => handleInteraction('map')}
|
|
510
|
-
>
|
|
511
|
-
View Map
|
|
512
|
-
</button>
|
|
513
|
-
</div>
|
|
514
|
-
)}
|
|
515
|
-
|
|
516
|
-
{screen === 'search' && (
|
|
517
|
-
<SearchScreen onBack={() => handleInteraction('home')} />
|
|
518
|
-
)}
|
|
519
|
-
|
|
520
|
-
{screen === 'map' && (
|
|
521
|
-
<MapScreen onBack={() => handleInteraction('home')} />
|
|
522
|
-
)}
|
|
523
|
-
</div>
|
|
524
|
-
)
|
|
525
|
-
}
|
|
526
|
-
```
|
|
527
|
-
|
|
528
|
-
```css
|
|
529
|
-
/* Render.css - Interactive kiosk styles */
|
|
530
|
-
.kiosk-home {
|
|
531
|
-
display: flex;
|
|
532
|
-
flex-direction: column;
|
|
533
|
-
align-items: center;
|
|
534
|
-
justify-content: center;
|
|
535
|
-
gap: 3rem;
|
|
536
|
-
height: 100%;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
.kiosk-home__title {
|
|
540
|
-
font-size: 6rem;
|
|
541
|
-
margin: 0;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/* Touch-friendly button with :active feedback */
|
|
545
|
-
.kiosk-button {
|
|
546
|
-
min-width: 20rem;
|
|
547
|
-
min-height: 8rem;
|
|
548
|
-
padding: 2rem 4rem;
|
|
549
|
-
font-size: 3rem;
|
|
550
|
-
background: #0066cc;
|
|
551
|
-
color: white;
|
|
552
|
-
border: none;
|
|
553
|
-
border-radius: 1rem;
|
|
554
|
-
cursor: pointer;
|
|
555
|
-
transition: transform 0.1s, background 0.1s;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/* Touch feedback - scales down when tapped */
|
|
559
|
-
.kiosk-button:active {
|
|
560
|
-
transform: scale(0.95);
|
|
561
|
-
background: #0052a3;
|
|
562
|
-
}
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
---
|
|
566
|
-
|
|
567
|
-
## Store Hook for UI Scale
|
|
568
|
-
|
|
569
|
-
Create a store hook to let admins adjust the UI scale:
|
|
570
|
-
|
|
571
|
-
```typescript
|
|
572
|
-
// hooks/store.ts
|
|
573
|
-
import { createUseInstanceStoreState } from '@telemetryos/sdk/react'
|
|
574
|
-
|
|
575
|
-
export const useUiScaleStoreState = createUseInstanceStoreState<number>('ui-scale', 1)
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
```typescript
|
|
579
|
-
// Settings.tsx - Add slider control
|
|
580
|
-
import { SettingsSliderFrame, SettingsField, SettingsLabel } from '@telemetryos/sdk/react'
|
|
581
|
-
import { useUiScaleStoreState } from '../hooks/store'
|
|
582
|
-
|
|
583
|
-
export function Settings() {
|
|
584
|
-
// Pass 0 debounce for instant slider updates
|
|
585
|
-
const [isLoading, uiScale, setUiScale] = useUiScaleStoreState(0)
|
|
586
|
-
|
|
587
|
-
return (
|
|
588
|
-
<SettingsField>
|
|
589
|
-
<SettingsLabel>UI Scale</SettingsLabel>
|
|
590
|
-
<SettingsSliderFrame>
|
|
591
|
-
<input
|
|
592
|
-
type="range"
|
|
593
|
-
min={1}
|
|
594
|
-
max={3}
|
|
595
|
-
step={0.01}
|
|
596
|
-
disabled={isLoading}
|
|
597
|
-
value={uiScale}
|
|
598
|
-
onChange={(e) => setUiScale(parseFloat(e.target.value))}
|
|
599
|
-
/>
|
|
600
|
-
<span>{uiScale}x</span>
|
|
601
|
-
</SettingsSliderFrame>
|
|
602
|
-
</SettingsField>
|
|
603
|
-
)
|
|
604
|
-
}
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
---
|
|
608
|
-
|
|
609
|
-
## Common Mistakes
|
|
610
|
-
|
|
611
|
-
| Mistake | Problem | Fix |
|
|
612
|
-
|---------|---------|-----|
|
|
613
|
-
| Using `px` units | Won't scale across resolutions | Use `rem` everywhere |
|
|
614
|
-
| Adding `:hover` styles on digital signage | No mouse on display-only apps | Remove interaction states for display-only |
|
|
615
|
-
| Using `:hover` on interactive kiosks | No hover on touch devices | Use `:active` instead for touch feedback |
|
|
616
|
-
| Using `overflow: scroll` | No user to scroll | Use `overflow: hidden`, truncate content |
|
|
617
|
-
| Fixed heights in `px` | Breaks on different aspect ratios | Use `vh`, `%`, or flex |
|
|
618
|
-
| Forgetting `useUiScaleToSetRem()` | `rem` units won't scale properly | Call it once in Render view with uiScale |
|
|
619
|
-
| Text below 2rem | Unreadable from viewing distance | Minimum 2rem for body text |
|
|
620
|
-
| Small touch targets on kiosks | Hard to tap accurately | Minimum 8rem height, 2rem padding, 3rem font |
|
|
621
|
-
| No idle timeout on kiosks | Kiosk stays on user's screen | Add useEffect timeout logic |
|
|
622
|
-
| No touch feedback on kiosks | User unsure if tap registered | Add :active state animations |
|
|
623
|
-
| Removing `.render` padding | Content cut off by bezels | Keep the ~3rem padding from init project |
|
|
624
|
-
| Overriding `index.css` base styles | Breaks viewport scaling | Add new styles, don't modify base setup |
|