@limeade-labs/sparkui 1.0.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.
@@ -0,0 +1,510 @@
1
+ 'use strict';
2
+
3
+ const base = require('./base');
4
+
5
+ /**
6
+ * Workout Timer template.
7
+ * A polished, fitness-app-quality workout page with rounds, rest timer,
8
+ * checklists for warmup/cooldown, and completion tracking.
9
+ *
10
+ * @param {object} data
11
+ * @param {string} data.title - Workout title
12
+ * @param {string} [data.subtitle] - Subtitle (e.g. day/program)
13
+ * @param {Array} [data.warmup] - [{text}] warmup items
14
+ * @param {Array} data.exercises - [{name, reps, notes}]
15
+ * @param {number} [data.rounds=3] - Number of rounds
16
+ * @param {number} [data.restSeconds=60] - Rest duration between rounds
17
+ * @param {Array} [data.cooldown] - [{text}] cooldown items
18
+ * @param {number} [data.estimatedMinutes] - Estimated duration
19
+ * @param {number} [data.estimatedCalories] - Estimated calories
20
+ * @param {string} [data._pageId] - Injected by template engine
21
+ * @param {object} [data._og] - OG metadata
22
+ * @returns {string} Full HTML page
23
+ */
24
+ function workoutTimer(data) {
25
+ const {
26
+ title = 'Workout',
27
+ subtitle = '',
28
+ warmup = [],
29
+ exercises = [],
30
+ rounds = 3,
31
+ restSeconds = 60,
32
+ cooldown = [],
33
+ estimatedMinutes = 0,
34
+ estimatedCalories = 0,
35
+ _pageId = '',
36
+ _og = {},
37
+ } = data;
38
+
39
+ const totalExercises = exercises.length * rounds + warmup.length + cooldown.length;
40
+ const accentColor = '#00ff88';
41
+ const accentDim = '#00cc6a';
42
+
43
+ // ── Build the HTML body ──
44
+
45
+ const body = `
46
+ <style>
47
+ .wt-header { margin-bottom: 20px; }
48
+ .wt-header h1 {
49
+ font-size: 1.6rem; font-weight: 800; color: #e0e0e0; margin: 0; line-height: 1.3;
50
+ }
51
+ .wt-header .wt-sub {
52
+ color: #888; font-size: 0.9rem; margin-top: 4px;
53
+ }
54
+ .wt-stats {
55
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
56
+ margin-bottom: 24px;
57
+ }
58
+ .wt-stat {
59
+ background: #1a1a1a; border: 1px solid #333; border-radius: 8px;
60
+ padding: 12px 8px; text-align: center;
61
+ }
62
+ .wt-stat-icon { font-size: 1.2rem; margin-bottom: 2px; }
63
+ .wt-stat-val { font-size: 1.3rem; font-weight: 700; color: #e0e0e0; font-variant-numeric: tabular-nums; }
64
+ .wt-stat-label { font-size: 0.7rem; color: #888; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.5px; }
65
+
66
+ .wt-section { margin-bottom: 24px; }
67
+ .wt-section-title {
68
+ font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;
69
+ color: #888; margin-bottom: 10px; display: flex; align-items: center; gap: 8px;
70
+ }
71
+ .wt-section-title::after {
72
+ content: ''; flex: 1; height: 1px; background: #333;
73
+ }
74
+
75
+ /* Checklist items */
76
+ .wt-check-item {
77
+ display: flex; align-items: center; padding: 12px 14px; margin-bottom: 6px;
78
+ background: #1a1a1a; border: 1px solid #333; border-radius: 8px;
79
+ cursor: pointer; transition: all 0.2s; user-select: none;
80
+ -webkit-tap-highlight-color: transparent;
81
+ }
82
+ .wt-check-item:active { transform: scale(0.98); }
83
+ .wt-check-item.checked { opacity: 0.5; }
84
+ .wt-check-item.checked .wt-check-text { text-decoration: line-through; }
85
+ .wt-check-circle {
86
+ width: 22px; height: 22px; border-radius: 50%; border: 2px solid #444;
87
+ display: flex; align-items: center; justify-content: center;
88
+ margin-right: 12px; flex-shrink: 0; transition: all 0.2s;
89
+ font-size: 13px; color: transparent;
90
+ }
91
+ .wt-check-item.checked .wt-check-circle {
92
+ border-color: ${accentColor}; color: ${accentColor};
93
+ }
94
+ .wt-check-text { color: #e0e0e0; font-size: 0.95rem; transition: all 0.2s; }
95
+
96
+ /* Round counter */
97
+ .wt-round-bar {
98
+ display: flex; align-items: center; justify-content: center; gap: 10px;
99
+ margin-bottom: 16px; padding: 14px; background: #1a1a1a;
100
+ border: 1px solid #333; border-radius: 8px;
101
+ }
102
+ .wt-round-dot {
103
+ width: 32px; height: 32px; border-radius: 50%; border: 2px solid #444;
104
+ display: flex; align-items: center; justify-content: center;
105
+ font-size: 0.85rem; font-weight: 700; color: #888;
106
+ transition: all 0.3s;
107
+ }
108
+ .wt-round-dot.active {
109
+ border-color: ${accentColor}; color: #111; background: ${accentColor};
110
+ box-shadow: 0 0 12px ${accentColor}40;
111
+ }
112
+ .wt-round-dot.done {
113
+ border-color: ${accentDim}; color: ${accentDim}; background: transparent;
114
+ }
115
+ .wt-round-label {
116
+ font-size: 0.85rem; color: #888; margin-left: 8px; font-weight: 600;
117
+ }
118
+
119
+ /* Exercise cards */
120
+ .wt-exercise {
121
+ background: #1a1a1a; border: 1px solid #333; border-radius: 8px;
122
+ border-left: 3px solid ${accentColor}; padding: 16px 16px 16px 18px;
123
+ margin-bottom: 8px; transition: all 0.3s;
124
+ }
125
+ .wt-exercise-name {
126
+ font-size: 1.05rem; font-weight: 700; color: #e0e0e0; margin-bottom: 4px;
127
+ }
128
+ .wt-exercise-reps {
129
+ font-size: 0.95rem; color: ${accentColor}; font-weight: 600; margin-bottom: 4px;
130
+ }
131
+ .wt-exercise-notes {
132
+ font-size: 0.85rem; color: #888; font-style: italic;
133
+ }
134
+
135
+ /* Rest timer */
136
+ .wt-rest-panel {
137
+ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
138
+ padding: 24px; text-align: center; margin-bottom: 16px;
139
+ display: none;
140
+ }
141
+ .wt-rest-panel.visible { display: block; animation: wtFadeIn 0.3s ease; }
142
+ .wt-rest-display {
143
+ font-size: 3.5rem; font-weight: 800; font-variant-numeric: tabular-nums;
144
+ color: #e0e0e0; margin: 12px 0;
145
+ }
146
+ .wt-rest-label { font-size: 0.85rem; color: #888; text-transform: uppercase; letter-spacing: 1px; }
147
+ .wt-rest-progress {
148
+ height: 4px; background: #333; border-radius: 2px; margin: 16px 0;
149
+ overflow: hidden;
150
+ }
151
+ .wt-rest-bar {
152
+ height: 100%; background: ${accentColor}; width: 0%; transition: width 1s linear;
153
+ }
154
+
155
+ /* Buttons */
156
+ .wt-btn {
157
+ display: inline-flex; align-items: center; justify-content: center;
158
+ padding: 14px 24px; border-radius: 8px; font-size: 1rem;
159
+ font-weight: 600; cursor: pointer; border: none;
160
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
161
+ transition: all 0.15s; width: 100%; max-width: 320px;
162
+ -webkit-tap-highlight-color: transparent;
163
+ }
164
+ .wt-btn:active { transform: scale(0.97); }
165
+ .wt-btn-primary { background: ${accentColor}; color: #111; }
166
+ .wt-btn-secondary { background: transparent; color: #e0e0e0; border: 2px solid #444; }
167
+ .wt-btn-danger { background: #ff4444; color: #fff; }
168
+ .wt-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
169
+
170
+ .wt-btn-row { display: flex; gap: 10px; justify-content: center; margin-top: 12px; flex-wrap: wrap; }
171
+
172
+ /* Complete section */
173
+ .wt-complete-section {
174
+ text-align: center; margin-top: 32px; padding-top: 24px;
175
+ border-top: 1px solid #333;
176
+ }
177
+
178
+ /* Elapsed timer */
179
+ .wt-elapsed {
180
+ text-align: center; font-size: 0.85rem; color: #888;
181
+ margin-bottom: 20px; font-variant-numeric: tabular-nums;
182
+ }
183
+ .wt-elapsed-time { color: #e0e0e0; font-weight: 600; font-size: 1rem; }
184
+
185
+ /* Animations */
186
+ @keyframes wtFadeIn {
187
+ from { opacity: 0; transform: translateY(-8px); }
188
+ to { opacity: 1; transform: translateY(0); }
189
+ }
190
+ @keyframes wtPulse {
191
+ 0%, 100% { box-shadow: 0 0 0 0 ${accentColor}40; }
192
+ 50% { box-shadow: 0 0 0 8px ${accentColor}00; }
193
+ }
194
+
195
+ /* Mobile tweaks */
196
+ @media (max-width: 380px) {
197
+ .wt-stats { grid-template-columns: repeat(2, 1fr); }
198
+ .wt-rest-display { font-size: 2.8rem; }
199
+ }
200
+ </style>
201
+
202
+ <!-- Header -->
203
+ <div class="wt-header">
204
+ <h1>🏋️ ${esc(title)}</h1>
205
+ ${subtitle ? `<p class="wt-sub">${esc(subtitle)}</p>` : ''}
206
+ </div>
207
+
208
+ <!-- Stats Bar -->
209
+ <div class="wt-stats">
210
+ <div class="wt-stat">
211
+ <div class="wt-stat-icon">⏱️</div>
212
+ <div class="wt-stat-val">${estimatedMinutes || '—'}</div>
213
+ <div class="wt-stat-label">Minutes</div>
214
+ </div>
215
+ <div class="wt-stat">
216
+ <div class="wt-stat-icon">💪</div>
217
+ <div class="wt-stat-val">${exercises.length}</div>
218
+ <div class="wt-stat-label">Exercises</div>
219
+ </div>
220
+ <div class="wt-stat">
221
+ <div class="wt-stat-icon">🔥</div>
222
+ <div class="wt-stat-val">${estimatedCalories || '—'}</div>
223
+ <div class="wt-stat-label">Calories</div>
224
+ </div>
225
+ <div class="wt-stat">
226
+ <div class="wt-stat-icon">🔄</div>
227
+ <div class="wt-stat-val">${rounds}</div>
228
+ <div class="wt-stat-label">Rounds</div>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Elapsed Timer -->
233
+ <div class="wt-elapsed">
234
+ Elapsed: <span class="wt-elapsed-time" id="wt_elapsed">00:00</span>
235
+ </div>
236
+
237
+ <!-- Warm-up Section -->
238
+ ${warmup.length > 0 ? `
239
+ <div class="wt-section" id="wt_warmup">
240
+ <div class="wt-section-title">🔥 Warm-up</div>
241
+ ${warmup.map((w, i) => `
242
+ <div class="wt-check-item" data-group="warmup" data-idx="${i}" onclick="wtToggleCheck(this)">
243
+ <div class="wt-check-circle">✓</div>
244
+ <span class="wt-check-text">${esc(w.text)}</span>
245
+ </div>`).join('')}
246
+ </div>` : ''}
247
+
248
+ <!-- Round Counter -->
249
+ <div class="wt-section">
250
+ <div class="wt-section-title">🏋️ Workout</div>
251
+ <div class="wt-round-bar" id="wt_round_bar">
252
+ ${Array.from({ length: rounds }, (_, i) => `
253
+ <div class="wt-round-dot ${i === 0 ? 'active' : ''}" data-round="${i}" id="wt_rdot_${i}">${i + 1}</div>`).join('')}
254
+ <span class="wt-round-label" id="wt_round_label">Round 1 / ${rounds}</span>
255
+ </div>
256
+
257
+ <!-- Exercise Cards -->
258
+ <div id="wt_exercises">
259
+ ${exercises.map((ex, i) => `
260
+ <div class="wt-exercise" data-ex="${i}">
261
+ <div class="wt-exercise-name">${esc(ex.name)}</div>
262
+ <div class="wt-exercise-reps">${esc(ex.reps)}</div>
263
+ ${ex.notes ? `<div class="wt-exercise-notes">${esc(ex.notes)}</div>` : ''}
264
+ </div>`).join('')}
265
+ </div>
266
+
267
+ <!-- Round Controls -->
268
+ <div class="wt-btn-row" id="wt_round_controls">
269
+ <button class="wt-btn wt-btn-primary" id="wt_next_round" onclick="wtNextRound()">
270
+ Complete Round → Start Rest
271
+ </button>
272
+ </div>
273
+ </div>
274
+
275
+ <!-- Rest Timer -->
276
+ <div class="wt-rest-panel" id="wt_rest_panel">
277
+ <div class="wt-rest-label">Rest Period</div>
278
+ <div class="wt-rest-display" id="wt_rest_display">${fmtTime(restSeconds)}</div>
279
+ <div class="wt-rest-progress">
280
+ <div class="wt-rest-bar" id="wt_rest_bar"></div>
281
+ </div>
282
+ <div class="wt-btn-row">
283
+ <button class="wt-btn wt-btn-secondary" id="wt_skip_rest" onclick="wtSkipRest()">Skip Rest</button>
284
+ </div>
285
+ </div>
286
+
287
+ <!-- Cool-down Section -->
288
+ ${cooldown.length > 0 ? `
289
+ <div class="wt-section" id="wt_cooldown" style="display:none">
290
+ <div class="wt-section-title">🧘 Cool-down</div>
291
+ ${cooldown.map((c, i) => `
292
+ <div class="wt-check-item" data-group="cooldown" data-idx="${i}" onclick="wtToggleCheck(this)">
293
+ <div class="wt-check-circle">✓</div>
294
+ <span class="wt-check-text">${esc(c.text)}</span>
295
+ </div>`).join('')}
296
+ </div>` : ''}
297
+
298
+ <!-- Complete Button -->
299
+ <div class="wt-complete-section" id="wt_complete_section" style="display:none">
300
+ <button class="wt-btn wt-btn-primary" id="wt_complete_btn" onclick="wtComplete()" style="animation: wtPulse 2s infinite">
301
+ ✅ Complete Workout
302
+ </button>
303
+ </div>
304
+
305
+ <script>
306
+ (function() {
307
+ // ── State ──
308
+ var currentRound = 0;
309
+ var totalRounds = ${rounds};
310
+ var restDuration = ${restSeconds};
311
+ var restTimer = null;
312
+ var restRemaining = 0;
313
+ var elapsedTimer = null;
314
+ var elapsedSeconds = 0;
315
+ var startTime = null;
316
+ var exerciseCount = ${exercises.length};
317
+ var warmupCount = ${warmup.length};
318
+ var cooldownCount = ${cooldown.length};
319
+ var totalExercises = ${totalExercises};
320
+ var checkedItems = { warmup: {}, cooldown: {} };
321
+ var workoutTitle = ${JSON.stringify(title)};
322
+
323
+ // ── Elapsed timer ──
324
+ function startElapsed() {
325
+ if (elapsedTimer) return;
326
+ startTime = Date.now();
327
+ var el = document.getElementById('wt_elapsed');
328
+ elapsedTimer = setInterval(function() {
329
+ elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
330
+ el.textContent = fmtT(elapsedSeconds);
331
+ }, 1000);
332
+ }
333
+
334
+ function fmtT(s) {
335
+ var h = Math.floor(s / 3600);
336
+ var m = Math.floor((s % 3600) / 60);
337
+ var sec = s % 60;
338
+ if (h > 0) return h + ':' + (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
339
+ return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
340
+ }
341
+
342
+ // ── Checklist toggle ──
343
+ window.wtToggleCheck = function(el) {
344
+ startElapsed();
345
+ el.classList.toggle('checked');
346
+ };
347
+
348
+ // ── Round management ──
349
+ function updateRoundDots() {
350
+ for (var i = 0; i < totalRounds; i++) {
351
+ var dot = document.getElementById('wt_rdot_' + i);
352
+ dot.className = 'wt-round-dot';
353
+ if (i < currentRound) dot.classList.add('done');
354
+ else if (i === currentRound) dot.classList.add('active');
355
+ }
356
+ var label = document.getElementById('wt_round_label');
357
+ if (currentRound < totalRounds) {
358
+ label.textContent = 'Round ' + (currentRound + 1) + ' / ' + totalRounds;
359
+ } else {
360
+ label.textContent = 'All rounds complete!';
361
+ }
362
+ }
363
+
364
+ window.wtNextRound = function() {
365
+ startElapsed();
366
+ currentRound++;
367
+ updateRoundDots();
368
+
369
+ if (currentRound >= totalRounds) {
370
+ // All rounds done — show cooldown or complete
371
+ document.getElementById('wt_round_controls').style.display = 'none';
372
+ document.getElementById('wt_rest_panel').className = 'wt-rest-panel';
373
+ var cd = document.getElementById('wt_cooldown');
374
+ if (cd) {
375
+ cd.style.display = 'block';
376
+ cd.style.animation = 'wtFadeIn 0.3s ease';
377
+ }
378
+ document.getElementById('wt_complete_section').style.display = 'block';
379
+ document.getElementById('wt_complete_section').style.animation = 'wtFadeIn 0.3s ease';
380
+ return;
381
+ }
382
+
383
+ // Show rest timer
384
+ startRest();
385
+ };
386
+
387
+ // ── Rest timer ──
388
+ function startRest() {
389
+ var panel = document.getElementById('wt_rest_panel');
390
+ var display = document.getElementById('wt_rest_display');
391
+ var bar = document.getElementById('wt_rest_bar');
392
+
393
+ panel.className = 'wt-rest-panel visible';
394
+ restRemaining = restDuration;
395
+ bar.style.transition = 'none';
396
+ bar.style.width = '0%';
397
+
398
+ // Scroll to rest panel
399
+ panel.scrollIntoView({ behavior: 'smooth', block: 'center' });
400
+
401
+ clearInterval(restTimer);
402
+ restTimer = setInterval(function() {
403
+ restRemaining--;
404
+ display.textContent = fmtRestTime(restRemaining);
405
+ bar.style.transition = 'width 1s linear';
406
+ bar.style.width = (((restDuration - restRemaining) / restDuration) * 100) + '%';
407
+
408
+ if (restRemaining <= 0) {
409
+ clearInterval(restTimer);
410
+ restTimer = null;
411
+ endRest();
412
+ }
413
+ }, 1000);
414
+ }
415
+
416
+ function fmtRestTime(s) {
417
+ if (s < 0) s = 0;
418
+ var m = Math.floor(s / 60);
419
+ var sec = s % 60;
420
+ return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
421
+ }
422
+
423
+ function endRest() {
424
+ var panel = document.getElementById('wt_rest_panel');
425
+ panel.className = 'wt-rest-panel';
426
+
427
+ // Update button text for next round
428
+ var btn = document.getElementById('wt_next_round');
429
+ if (currentRound >= totalRounds - 1) {
430
+ btn.textContent = 'Complete Final Round';
431
+ } else {
432
+ btn.textContent = 'Complete Round → Start Rest';
433
+ }
434
+
435
+ // Scroll exercises back into view
436
+ document.getElementById('wt_exercises').scrollIntoView({ behavior: 'smooth', block: 'start' });
437
+ }
438
+
439
+ window.wtSkipRest = function() {
440
+ clearInterval(restTimer);
441
+ restTimer = null;
442
+ endRest();
443
+ };
444
+
445
+ // ── Completion ──
446
+ window.wtComplete = function() {
447
+ if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
448
+
449
+ var warmupChecked = document.querySelectorAll('[data-group="warmup"].checked').length;
450
+ var cooldownChecked = document.querySelectorAll('[data-group="cooldown"].checked').length;
451
+ var exercisesChecked = (currentRound * exerciseCount) + warmupChecked + cooldownChecked;
452
+
453
+ var payload = {
454
+ action: 'workout_complete',
455
+ title: workoutTitle,
456
+ roundsCompleted: currentRound,
457
+ exercisesChecked: exercisesChecked,
458
+ totalExercises: totalExercises,
459
+ duration: elapsedSeconds,
460
+ completedAt: new Date().toISOString()
461
+ };
462
+
463
+ // Visual feedback
464
+ var btn = document.getElementById('wt_complete_btn');
465
+ btn.textContent = '🎉 Workout Complete!';
466
+ btn.disabled = true;
467
+ btn.style.animation = 'none';
468
+ btn.style.background = '#444';
469
+ btn.style.color = '#e0e0e0';
470
+
471
+ if (window.sparkui) {
472
+ window.sparkui.send('completion', payload);
473
+ }
474
+ };
475
+
476
+ // ── Init ──
477
+ updateRoundDots();
478
+ var nextBtn = document.getElementById('wt_next_round');
479
+ if (totalRounds <= 1) {
480
+ nextBtn.textContent = 'Complete Final Round';
481
+ }
482
+ })();
483
+ </script>`;
484
+
485
+ return base({
486
+ title: title,
487
+ body: body,
488
+ id: _pageId,
489
+ og: {
490
+ title: _og.title || title,
491
+ description: _og.description || 'Workout powered by SparkUI',
492
+ image: _og.image || '',
493
+ url: _og.url || '',
494
+ },
495
+ });
496
+ }
497
+
498
+ // ── Utility ──
499
+ function esc(str) {
500
+ if (!str) return '';
501
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
502
+ }
503
+
504
+ function fmtTime(s) {
505
+ var m = Math.floor(s / 60);
506
+ var sec = s % 60;
507
+ return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
508
+ }
509
+
510
+ module.exports = workoutTimer;
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ const base = require('./base');
4
+
5
+ /**
6
+ * WebSocket test/demo template.
7
+ * Tests all WS bridge features: events, completion, status, server messages.
8
+ */
9
+ function wsTest(data = {}) {
10
+ const pageId = data._pageId || 'unknown';
11
+ const _og = data._og || {};
12
+
13
+ const body = `
14
+ <h1 style="font-size:1.5rem;margin-bottom:8px">⚡ SparkUI WebSocket Test</h1>
15
+ <p style="color:#888;margin-bottom:24px">Page ID: <code style="color:#6cf">${pageId}</code></p>
16
+
17
+ <!-- Connection Status -->
18
+ <div id="status" style="padding:12px;border-radius:8px;margin-bottom:20px;background:#1a1a1a;border:1px solid #333">
19
+ <span id="status-dot" style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f44;margin-right:8px;vertical-align:middle"></span>
20
+ <span id="status-text" style="vertical-align:middle">Connecting...</span>
21
+ </div>
22
+
23
+ <!-- Test Buttons -->
24
+ <div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:20px">
25
+ <button id="btn-click" style="padding:10px 20px;border-radius:6px;border:none;background:#2563eb;color:#fff;font-size:1rem;cursor:pointer;flex:1;min-width:120px">
26
+ 🖱️ Send Click Event
27
+ </button>
28
+ <button id="btn-custom" style="padding:10px 20px;border-radius:6px;border:none;background:#7c3aed;color:#fff;font-size:1rem;cursor:pointer;flex:1;min-width:120px">
29
+ ⚡ Send Custom Event
30
+ </button>
31
+ </div>
32
+
33
+ <!-- Form -->
34
+ <form id="test-form" style="background:#1a1a1a;padding:16px;border-radius:8px;margin-bottom:20px;border:1px solid #333">
35
+ <h3 style="margin-bottom:12px;font-size:1rem">📋 Test Form (sends completion)</h3>
36
+ <label style="display:block;margin-bottom:8px;color:#aaa;font-size:0.9rem">Name</label>
37
+ <input id="form-name" type="text" placeholder="Enter name" style="width:100%;padding:8px 12px;border-radius:4px;border:1px solid #444;background:#222;color:#eee;font-size:1rem;margin-bottom:12px">
38
+ <label style="display:block;margin-bottom:8px;color:#aaa;font-size:0.9rem">Rating</label>
39
+ <select id="form-rating" style="width:100%;padding:8px 12px;border-radius:4px;border:1px solid #444;background:#222;color:#eee;font-size:1rem;margin-bottom:16px">
40
+ <option value="5">⭐⭐⭐⭐⭐ Excellent</option>
41
+ <option value="4">⭐⭐⭐⭐ Good</option>
42
+ <option value="3">⭐⭐⭐ OK</option>
43
+ <option value="2">⭐⭐ Poor</option>
44
+ <option value="1">⭐ Bad</option>
45
+ </select>
46
+ <button type="submit" style="width:100%;padding:10px;border-radius:6px;border:none;background:#059669;color:#fff;font-size:1rem;cursor:pointer">
47
+ ✅ Submit (sends completion)
48
+ </button>
49
+ </form>
50
+
51
+ <!-- Message Log -->
52
+ <div style="background:#1a1a1a;padding:16px;border-radius:8px;border:1px solid #333">
53
+ <h3 style="margin-bottom:12px;font-size:1rem">📨 Message Log</h3>
54
+ <div id="log" style="font-family:monospace;font-size:0.85rem;max-height:300px;overflow-y:auto">
55
+ <div style="color:#555">Waiting for messages...</div>
56
+ </div>
57
+ </div>
58
+ `;
59
+
60
+ const extraHead = `
61
+ <script>
62
+ document.addEventListener('DOMContentLoaded', function() {
63
+ var log = document.getElementById('log');
64
+ var statusDot = document.getElementById('status-dot');
65
+ var statusText = document.getElementById('status-text');
66
+
67
+ function addLog(msg, color) {
68
+ var div = document.createElement('div');
69
+ div.style.cssText = 'padding:4px 0;border-bottom:1px solid #222;color:' + (color || '#ccc');
70
+ var time = new Date().toLocaleTimeString();
71
+ div.textContent = '[' + time + '] ' + msg;
72
+ if (log.firstChild && log.firstChild.textContent === 'Waiting for messages...') {
73
+ log.innerHTML = '';
74
+ }
75
+ log.appendChild(div);
76
+ log.scrollTop = log.scrollHeight;
77
+ }
78
+
79
+ // Connection status
80
+ document.addEventListener('sparkui:status', function(e) {
81
+ var s = e.detail.status;
82
+ statusText.textContent = s.charAt(0).toUpperCase() + s.slice(1);
83
+ statusDot.style.background = s === 'connected' ? '#4ade80' : '#f44';
84
+ addLog('Status: ' + s, s === 'connected' ? '#4ade80' : '#f44');
85
+ });
86
+
87
+ // Listen for server messages
88
+ if (window.sparkui) {
89
+ sparkui.onMessage(function(msg) {
90
+ addLog('← Server: ' + JSON.stringify(msg), '#6cf');
91
+ });
92
+ }
93
+
94
+ // Button: click event
95
+ document.getElementById('btn-click').addEventListener('click', function() {
96
+ sparkui.send('event', { action: 'click', button: 'test', timestamp: Date.now() });
97
+ addLog('→ Sent click event', '#fbbf24');
98
+ });
99
+
100
+ // Button: custom event
101
+ document.getElementById('btn-custom').addEventListener('click', function() {
102
+ sparkui.send('event', { action: 'custom', value: Math.random().toString(36).slice(2, 8) });
103
+ addLog('→ Sent custom event', '#c084fc');
104
+ });
105
+
106
+ // Form: completion
107
+ document.getElementById('test-form').addEventListener('submit', function(e) {
108
+ e.preventDefault();
109
+ var formData = {
110
+ name: document.getElementById('form-name').value,
111
+ rating: document.getElementById('form-rating').value,
112
+ };
113
+ sparkui.sendCompletion({ formData: formData, completedAt: Date.now() });
114
+ addLog('→ Sent completion: ' + JSON.stringify(formData), '#34d399');
115
+ });
116
+ });
117
+ </script>
118
+ `;
119
+
120
+ const og = {
121
+ title: _og.title || 'WebSocket Test',
122
+ description: _og.description || 'SparkUI WebSocket connectivity test ⚡',
123
+ image: _og.image,
124
+ url: _og.url,
125
+ };
126
+
127
+ return base({
128
+ title: 'SparkUI WS Test',
129
+ body,
130
+ id: pageId,
131
+ extraHead,
132
+ og,
133
+ });
134
+ }
135
+
136
+ module.exports = wsTest;