lobsterboard 0.1.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,1219 @@
1
+ /*!
2
+ * LobsterBoard v0.1.0
3
+ * Dashboard builder with customizable widgets
4
+ * https://github.com/curbob/LobsterBoard
5
+ * @license MIT
6
+ */
7
+ (function (global, factory) {
8
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
9
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
10
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.LobsterBoard = {}));
11
+ })(this, (function (exports) { 'use strict';
12
+
13
+ /**
14
+ * LobsterBoard - Widget Definitions
15
+ * Each widget defines its default size, properties, and generated code
16
+ *
17
+ * @module lobsterboard/widgets
18
+ */
19
+
20
+ const WIDGETS = {
21
+ // ─────────────────────────────────────────────
22
+ // SMALL CARDS (KPI style)
23
+ // ─────────────────────────────────────────────
24
+
25
+ 'weather': {
26
+ name: 'Local Weather',
27
+ icon: '🌡️',
28
+ category: 'small',
29
+ description: 'Shows current weather for a single location using wttr.in (no API key needed).',
30
+ defaultWidth: 200,
31
+ defaultHeight: 120,
32
+ hasApiKey: false,
33
+ properties: {
34
+ title: 'Local Weather',
35
+ location: 'Atlanta',
36
+ units: 'F',
37
+ refreshInterval: 600
38
+ },
39
+ preview: `<div style="text-align:center;padding:8px;">
40
+ <div style="font-size:24px;">72°F</div>
41
+ <div style="font-size:11px;color:#8b949e;">Atlanta</div>
42
+ </div>`,
43
+ generateHtml: (props) => `
44
+ <div class="dash-card" id="widget-${props.id}" style="height:100%;">
45
+ <div class="dash-card-head">
46
+ <span class="dash-card-title">🌡️ ${props.title || 'Local Weather'}</span>
47
+ </div>
48
+ <div class="dash-card-body" style="display:flex;align-items:center;justify-content:center;gap:10px;">
49
+ <span id="${props.id}-icon" style="font-size:24px;">🌡️</span>
50
+ <div>
51
+ <div class="kpi-value blue" id="${props.id}-value">—</div>
52
+ <div class="kpi-label" id="${props.id}-label">${props.location || 'Location'}</div>
53
+ </div>
54
+ </div>
55
+ </div>`,
56
+ generateJs: (props) => `
57
+ // Weather Widget: ${props.id} (uses free wttr.in API - no key needed)
58
+ async function update_${props.id.replace(/-/g, '_')}() {
59
+ try {
60
+ const location = encodeURIComponent('${props.location || 'Atlanta'}');
61
+ const res = await fetch('https://wttr.in/' + location + '?format=j1');
62
+ const data = await res.json();
63
+ const current = data.current_condition[0];
64
+ const temp = '${props.units}' === 'C' ? current.temp_C : current.temp_F;
65
+ const unit = '${props.units}' === 'C' ? '°C' : '°F';
66
+ document.getElementById('${props.id}-value').textContent = temp + unit;
67
+ document.getElementById('${props.id}-label').textContent = current.weatherDesc[0].value;
68
+ const code = parseInt(current.weatherCode);
69
+ let icon = '🌡️';
70
+ if (code === 113) icon = '☀️';
71
+ else if (code === 116 || code === 119) icon = '⛅';
72
+ else if (code >= 176 && code <= 359) icon = '🌧️';
73
+ else if (code >= 368 && code <= 395) icon = '❄️';
74
+ document.getElementById('${props.id}-icon').textContent = icon;
75
+ } catch (e) {
76
+ console.error('Weather widget error:', e);
77
+ document.getElementById('${props.id}-value').textContent = '—';
78
+ }
79
+ }
80
+ update_${props.id.replace(/-/g, '_')}();
81
+ setInterval(update_${props.id.replace(/-/g, '_')}, ${(props.refreshInterval || 600) * 1000});
82
+ `
83
+ },
84
+
85
+ 'clock': {
86
+ name: 'Clock',
87
+ icon: '🕐',
88
+ category: 'small',
89
+ description: 'Simple digital clock. Supports 12h or 24h format.',
90
+ defaultWidth: 200,
91
+ defaultHeight: 120,
92
+ hasApiKey: false,
93
+ properties: {
94
+ title: 'Clock',
95
+ timezone: 'local',
96
+ format24h: false
97
+ },
98
+ preview: `<div style="text-align:center;padding:8px;">
99
+ <div style="font-size:24px;">3:45 PM</div>
100
+ <div style="font-size:11px;color:#8b949e;">Wed, Feb 5</div>
101
+ </div>`,
102
+ generateHtml: (props) => `
103
+ <div class="dash-card" id="widget-${props.id}" style="height:100%;">
104
+ <div class="dash-card-head">
105
+ <span class="dash-card-title">🕐 ${props.title || 'Clock'}</span>
106
+ </div>
107
+ <div class="dash-card-body" style="display:flex;flex-direction:column;align-items:center;justify-content:center;">
108
+ <div class="kpi-value" id="${props.id}-time">—</div>
109
+ <div class="kpi-label" id="${props.id}-date">—</div>
110
+ </div>
111
+ </div>`,
112
+ generateJs: (props) => `
113
+ // Clock Widget: ${props.id}
114
+ function updateClock_${props.id.replace(/-/g, '_')}() {
115
+ const now = new Date();
116
+ const timeEl = document.getElementById('${props.id}-time');
117
+ const dateEl = document.getElementById('${props.id}-date');
118
+ const opts = { hour: 'numeric', minute: '2-digit', hour12: ${!props.format24h} };
119
+ timeEl.textContent = now.toLocaleTimeString('en-US', opts);
120
+ dateEl.textContent = now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
121
+ }
122
+ updateClock_${props.id.replace(/-/g, '_')}();
123
+ setInterval(updateClock_${props.id.replace(/-/g, '_')}, 1000);
124
+ `
125
+ },
126
+
127
+ 'auth-status': {
128
+ name: 'Auth Status',
129
+ icon: '🔐',
130
+ category: 'small',
131
+ description: 'Shows if OpenClaw is using Anthropic Max subscription (green) or API key fallback (yellow).',
132
+ defaultWidth: 180,
133
+ defaultHeight: 100,
134
+ hasApiKey: true,
135
+ apiKeyName: 'OPENCLAW_API',
136
+ properties: {
137
+ title: 'Auth Type',
138
+ endpoint: '/api/status',
139
+ refreshInterval: 30
140
+ },
141
+ preview: `<div style="text-align:center;padding:8px;">
142
+ <div style="width:10px;height:10px;background:#3fb950;border-radius:50%;margin:0 auto 4px;"></div>
143
+ <div style="font-size:13px;">OAuth</div>
144
+ <div style="font-size:11px;color:#8b949e;">Auth</div>
145
+ </div>`,
146
+ generateHtml: (props) => `
147
+ <div class="dash-card" id="widget-${props.id}" style="height:100%;">
148
+ <div class="dash-card-head">
149
+ <span class="dash-card-title">🔐 ${props.title || 'Auth Type'}</span>
150
+ </div>
151
+ <div class="dash-card-body" style="display:flex;align-items:center;justify-content:center;gap:10px;">
152
+ <div class="kpi-indicator" id="${props.id}-dot"></div>
153
+ <div class="kpi-value" id="${props.id}-value">—</div>
154
+ </div>
155
+ </div>`,
156
+ generateJs: (props) => `
157
+ // Auth Status Widget: ${props.id}
158
+ async function update_${props.id.replace(/-/g, '_')}() {
159
+ try {
160
+ const res = await fetch('${props.endpoint || '/api/status'}');
161
+ const json = await res.json();
162
+ const data = json.data || json;
163
+ const dot = document.getElementById('${props.id}-dot');
164
+ const val = document.getElementById('${props.id}-value');
165
+ val.textContent = data.authMode === 'oauth' ? 'Subscription' : 'API';
166
+ dot.className = 'kpi-indicator ' + (data.authMode === 'oauth' ? 'green' : 'yellow');
167
+ } catch (e) {
168
+ console.error('Auth status widget error:', e);
169
+ document.getElementById('${props.id}-value').textContent = '—';
170
+ }
171
+ }
172
+ update_${props.id.replace(/-/g, '_')}();
173
+ setInterval(update_${props.id.replace(/-/g, '_')}, ${(props.refreshInterval || 30) * 1000});
174
+ `
175
+ },
176
+
177
+ 'session-count': {
178
+ name: 'Active Sessions',
179
+ icon: '💬',
180
+ category: 'small',
181
+ description: 'Shows count of active OpenClaw sessions.',
182
+ defaultWidth: 160,
183
+ defaultHeight: 100,
184
+ hasApiKey: true,
185
+ apiKeyName: 'OPENCLAW_API',
186
+ properties: {
187
+ title: 'Sessions',
188
+ endpoint: '/api/sessions',
189
+ refreshInterval: 30
190
+ },
191
+ preview: `<div style="text-align:center;padding:8px;">
192
+ <div style="font-size:28px;color:#58a6ff;">3</div>
193
+ <div style="font-size:11px;color:#8b949e;">Active</div>
194
+ </div>`,
195
+ generateHtml: (props) => `
196
+ <div class="kpi-card kpi-sm" id="widget-${props.id}">
197
+ <div class="kpi-icon">💬</div>
198
+ <div class="kpi-data">
199
+ <div class="kpi-value blue" id="${props.id}-count">—</div>
200
+ <div class="kpi-label">Active</div>
201
+ </div>
202
+ </div>`,
203
+ generateJs: (props) => `
204
+ // Session Count Widget: ${props.id}
205
+ async function update_${props.id.replace(/-/g, '_')}() {
206
+ try {
207
+ const res = await fetch('${props.endpoint || '/api/sessions'}');
208
+ const json = await res.json();
209
+ const data = json.data || json;
210
+ document.getElementById('${props.id}-count').textContent = data.active || data.length || 0;
211
+ } catch (e) {
212
+ document.getElementById('${props.id}-count').textContent = '—';
213
+ }
214
+ }
215
+ update_${props.id.replace(/-/g, '_')}();
216
+ setInterval(update_${props.id.replace(/-/g, '_')}, ${(props.refreshInterval || 30) * 1000});
217
+ `
218
+ },
219
+
220
+ // ─────────────────────────────────────────────
221
+ // LARGE CARDS (Content)
222
+ // ─────────────────────────────────────────────
223
+
224
+ 'activity-list': {
225
+ name: 'Activity List',
226
+ icon: '📋',
227
+ category: 'large',
228
+ description: 'Shows recent OpenClaw activity from /api/activity endpoint.',
229
+ defaultWidth: 400,
230
+ defaultHeight: 300,
231
+ hasApiKey: true,
232
+ apiKeyName: 'OPENCLAW_API',
233
+ properties: {
234
+ title: 'Today',
235
+ endpoint: '/api/activity',
236
+ maxItems: 10,
237
+ refreshInterval: 60
238
+ },
239
+ preview: `<div style="padding:4px;font-size:11px;color:#8b949e;">
240
+ <div>• Meeting at 2pm</div>
241
+ <div>• Review PR #42</div>
242
+ <div>• Deploy v1.2</div>
243
+ </div>`,
244
+ generateHtml: (props) => `
245
+ <div class="dash-card" id="widget-${props.id}" style="height:100%;">
246
+ <div class="dash-card-head">
247
+ <span class="dash-card-title">📋 ${props.title || 'Today'}</span>
248
+ <span class="dash-card-badge" id="${props.id}-badge">—</span>
249
+ </div>
250
+ <div class="dash-card-body compact-list" id="${props.id}-list">
251
+ <div class="list-item">• Team standup at 10am</div>
252
+ <div class="list-item">• Review PR #42</div>
253
+ </div>
254
+ </div>`,
255
+ generateJs: (props) => `
256
+ // Activity List Widget: ${props.id}
257
+ async function update_${props.id.replace(/-/g, '_')}() {
258
+ try {
259
+ const res = await fetch('${props.endpoint || '/api/activity'}');
260
+ const json = await res.json();
261
+ const data = json.data || json;
262
+ const list = document.getElementById('${props.id}-list');
263
+ const badge = document.getElementById('${props.id}-badge');
264
+ const items = data.items || [];
265
+ list.innerHTML = items.slice(0, ${props.maxItems || 10}).map(item =>
266
+ '<div class="list-item">' + item.text + '</div>'
267
+ ).join('');
268
+ badge.textContent = items.length + ' items';
269
+ } catch (e) {
270
+ console.error('Activity list widget error:', e);
271
+ document.getElementById('${props.id}-list').innerHTML = '<div class="list-item">—</div>';
272
+ }
273
+ }
274
+ update_${props.id.replace(/-/g, '_')}();
275
+ setInterval(update_${props.id.replace(/-/g, '_')}, ${(props.refreshInterval || 60) * 1000});
276
+ `
277
+ },
278
+
279
+ 'cron-jobs': {
280
+ name: 'Cron Jobs',
281
+ icon: '⏰',
282
+ category: 'large',
283
+ description: 'Lists scheduled cron jobs from OpenClaw /api/cron endpoint.',
284
+ defaultWidth: 400,
285
+ defaultHeight: 250,
286
+ hasApiKey: true,
287
+ apiKeyName: 'OPENCLAW_API',
288
+ properties: {
289
+ title: 'Cron',
290
+ endpoint: '/api/cron',
291
+ refreshInterval: 30
292
+ },
293
+ preview: `<div style="padding:4px;font-size:11px;color:#8b949e;">
294
+ <div>⏰ Daily backup - 2am</div>
295
+ <div>⏰ Sync data - */5 *</div>
296
+ </div>`,
297
+ generateHtml: (props) => `
298
+ <div class="dash-card" id="widget-${props.id}" style="height:100%;">
299
+ <div class="dash-card-head">
300
+ <span class="dash-card-title">⏰ ${props.title || 'Cron'}</span>
301
+ <span class="dash-card-badge" id="${props.id}-badge">—</span>
302
+ </div>
303
+ <div class="dash-card-body" id="${props.id}-list">
304
+ <div class="cron-item"><span class="cron-name">Daily backup</span><span class="cron-next">2:00 AM</span></div>
305
+ </div>
306
+ </div>`,
307
+ generateJs: (props) => `
308
+ // Cron Jobs Widget: ${props.id}
309
+ async function update_${props.id.replace(/-/g, '_')}() {
310
+ try {
311
+ const res = await fetch('${props.endpoint || '/api/cron'}');
312
+ const json = await res.json();
313
+ const data = json.data || json;
314
+ const list = document.getElementById('${props.id}-list');
315
+ const badge = document.getElementById('${props.id}-badge');
316
+ const jobs = data.jobs || [];
317
+ list.innerHTML = jobs.map(job =>
318
+ '<div class="cron-item"><span class="cron-name">' + job.name + '</span><span class="cron-next">' + job.next + '</span></div>'
319
+ ).join('');
320
+ badge.textContent = jobs.length + ' jobs';
321
+ } catch (e) {
322
+ console.error('Cron jobs widget error:', e);
323
+ document.getElementById('${props.id}-list').innerHTML = '<div class="cron-item"><span class="cron-name">—</span></div>';
324
+ }
325
+ }
326
+ update_${props.id.replace(/-/g, '_')}();
327
+ setInterval(update_${props.id.replace(/-/g, '_')}, ${(props.refreshInterval || 30) * 1000});
328
+ `
329
+ },
330
+
331
+ 'system-log': {
332
+ name: 'System Log',
333
+ icon: '🔧',
334
+ category: 'large',
335
+ description: 'Shows recent system logs from OpenClaw /api/logs endpoint.',
336
+ defaultWidth: 500,
337
+ defaultHeight: 400,
338
+ hasApiKey: true,
339
+ apiKeyName: 'OPENCLAW_API',
340
+ properties: {
341
+ title: 'System Log',
342
+ endpoint: '/api/logs',
343
+ maxLines: 50,
344
+ refreshInterval: 10
345
+ },
346
+ preview: `<div style="padding:4px;font-size:10px;font-family:monospace;color:#8b949e;">
347
+ <div>[INFO] System started</div>
348
+ <div>[DEBUG] Loading config</div>
349
+ </div>`,
350
+ generateHtml: (props) => `
351
+ <div class="dash-card" id="widget-${props.id}" style="height:100%;">
352
+ <div class="dash-card-head">
353
+ <span class="dash-card-title">🔧 ${props.title || 'System Log'}</span>
354
+ <span class="dash-card-badge" id="${props.id}-badge">—</span>
355
+ </div>
356
+ <div class="dash-card-body compact-list syslog-scroll" id="${props.id}-log">
357
+ <div class="log-line">[INFO] System started successfully</div>
358
+ </div>
359
+ </div>`,
360
+ generateJs: (props) => `
361
+ // System Log Widget: ${props.id}
362
+ async function update_${props.id.replace(/-/g, '_')}() {
363
+ try {
364
+ const res = await fetch('${props.endpoint || '/api/logs'}');
365
+ const json = await res.json();
366
+ const data = json.data || json;
367
+ const log = document.getElementById('${props.id}-log');
368
+ const badge = document.getElementById('${props.id}-badge');
369
+ const lines = data.lines || [];
370
+ log.innerHTML = lines.slice(-${props.maxLines || 50}).map(line =>
371
+ '<div class="log-line">' + line + '</div>'
372
+ ).join('');
373
+ badge.textContent = lines.length + ' lines';
374
+ log.scrollTop = log.scrollHeight;
375
+ } catch (e) {
376
+ console.error('System log widget error:', e);
377
+ document.getElementById('${props.id}-log').innerHTML = '<div class="log-line">—</div>';
378
+ }
379
+ }
380
+ update_${props.id.replace(/-/g, '_')}();
381
+ setInterval(update_${props.id.replace(/-/g, '_')}, ${(props.refreshInterval || 10) * 1000});
382
+ `
383
+ },
384
+
385
+ // ─────────────────────────────────────────────
386
+ // BARS
387
+ // ─────────────────────────────────────────────
388
+
389
+ 'topbar': {
390
+ name: 'Top Nav Bar',
391
+ icon: '🔝',
392
+ category: 'bar',
393
+ description: 'Navigation bar with clock, weather, and system stats.',
394
+ defaultWidth: 1920,
395
+ defaultHeight: 48,
396
+ hasApiKey: false,
397
+ properties: {
398
+ title: 'OpenClaw',
399
+ links: 'Dashboard,Activity,Settings'
400
+ },
401
+ preview: `<div style="background:#161b22;padding:8px;font-size:11px;display:flex;gap:12px;">
402
+ <span>🤖 OpenClaw</span>
403
+ <span style="color:#58a6ff;">Dashboard</span>
404
+ </div>`,
405
+ generateHtml: (props) => `
406
+ <nav class="topbar" id="widget-${props.id}">
407
+ <div class="topbar-left">
408
+ <span class="topbar-brand">🤖 ${props.title || 'OpenClaw'}</span>
409
+ ${(props.links || 'Dashboard').split(',').map((link, i) =>
410
+ `<a href="#" class="topbar-link${i === 0 ? ' active' : ''}">${link.trim()}</a>`
411
+ ).join('')}
412
+ </div>
413
+ <div class="topbar-right">
414
+ <span class="topbar-meta" id="${props.id}-refresh">—</span>
415
+ <button class="topbar-refresh" onclick="location.reload()" title="Refresh">↻</button>
416
+ </div>
417
+ </nav>`,
418
+ generateJs: (props) => `
419
+ // Top Bar Widget: ${props.id}
420
+ document.getElementById('${props.id}-refresh').textContent =
421
+ new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
422
+ `
423
+ }
424
+ };
425
+
426
+ // Helper to get widget categories
427
+ function getWidgetCategories() {
428
+ const categories = {};
429
+ for (const [key, widget] of Object.entries(WIDGETS)) {
430
+ const cat = widget.category || 'other';
431
+ if (!categories[cat]) categories[cat] = [];
432
+ categories[cat].push({ key, ...widget });
433
+ }
434
+ return categories;
435
+ }
436
+
437
+ // Helper to get widget by type
438
+ function getWidget(type) {
439
+ return WIDGETS[type] || null;
440
+ }
441
+
442
+ // Helper to list all widget types
443
+ function getWidgetTypes() {
444
+ return Object.keys(WIDGETS);
445
+ }
446
+
447
+ /**
448
+ * LobsterBoard - Dashboard Builder Core
449
+ * Provides utilities for generating dashboard HTML, CSS, and JS
450
+ *
451
+ * @module lobsterboard/builder
452
+ */
453
+
454
+
455
+ // ─────────────────────────────────────────────
456
+ // SECURITY HELPERS
457
+ // ─────────────────────────────────────────────
458
+
459
+ /**
460
+ * Escape HTML to prevent XSS attacks
461
+ * @param {string} str - String to escape
462
+ * @returns {string} Escaped string
463
+ */
464
+ function escapeHtml(str) {
465
+ if (!str) return '';
466
+ if (typeof document !== 'undefined') {
467
+ const div = document.createElement('div');
468
+ div.textContent = str;
469
+ return div.innerHTML;
470
+ }
471
+ // Fallback for Node.js
472
+ return str
473
+ .replace(/&/g, '&amp;')
474
+ .replace(/</g, '&lt;')
475
+ .replace(/>/g, '&gt;')
476
+ .replace(/"/g, '&quot;')
477
+ .replace(/'/g, '&#039;');
478
+ }
479
+
480
+ // ─────────────────────────────────────────────
481
+ // HTML PROCESSING
482
+ // ─────────────────────────────────────────────
483
+
484
+ /**
485
+ * Process widget HTML to conditionally remove header
486
+ * @param {string} html - Widget HTML
487
+ * @param {boolean} showHeader - Whether to show the header
488
+ * @returns {string} Processed HTML
489
+ */
490
+ function processWidgetHtml(html, showHeader) {
491
+ if (showHeader !== false) return html;
492
+ const headerRegex = /<div\s+class="dash-card-head"[^>]*>[\s\S]*?<\/div>/i;
493
+ return html.replace(headerRegex, '');
494
+ }
495
+
496
+ // ─────────────────────────────────────────────
497
+ // CSS GENERATION
498
+ // ─────────────────────────────────────────────
499
+
500
+ /**
501
+ * Generate the base dashboard CSS
502
+ * @returns {string} CSS styles
503
+ */
504
+ function generateDashboardCss() {
505
+ return `/* LobsterBoard Dashboard - Generated Styles */
506
+
507
+ :root {
508
+ --bg-primary: #0d1117;
509
+ --bg-secondary: #161b22;
510
+ --bg-tertiary: #21262d;
511
+ --bg-hover: #30363d;
512
+ --border: #30363d;
513
+ --text-primary: #e6edf3;
514
+ --text-secondary: #8b949e;
515
+ --text-muted: #6e7681;
516
+ --accent-blue: #58a6ff;
517
+ --accent-green: #3fb950;
518
+ --accent-orange: #d29922;
519
+ --accent-red: #f85149;
520
+ --accent-purple: #a371f7;
521
+ }
522
+
523
+ * {
524
+ box-sizing: border-box;
525
+ margin: 0;
526
+ padding: 0;
527
+ }
528
+
529
+ body {
530
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
531
+ background: var(--bg-primary);
532
+ color: var(--text-primary);
533
+ min-height: 100vh;
534
+ }
535
+
536
+ .dashboard {
537
+ margin: 0 auto;
538
+ overflow: hidden;
539
+ }
540
+
541
+ .widget-container {
542
+ overflow: hidden;
543
+ }
544
+
545
+ /* KPI Cards */
546
+ .kpi-card {
547
+ background: var(--bg-secondary);
548
+ border: 1px solid var(--border);
549
+ border-radius: 8px;
550
+ padding: 16px;
551
+ display: flex;
552
+ align-items: center;
553
+ gap: 12px;
554
+ height: 100%;
555
+ }
556
+
557
+ .kpi-sm {
558
+ padding: 12px;
559
+ }
560
+
561
+ .kpi-icon {
562
+ font-size: 24px;
563
+ }
564
+
565
+ .kpi-data {
566
+ flex: 1;
567
+ }
568
+
569
+ .kpi-value {
570
+ font-size: 20px;
571
+ font-weight: 600;
572
+ }
573
+
574
+ .kpi-value.blue { color: var(--accent-blue); }
575
+ .kpi-value.green { color: var(--accent-green); }
576
+ .kpi-value.orange { color: var(--accent-orange); }
577
+ .kpi-value.red { color: var(--accent-red); }
578
+
579
+ .kpi-label {
580
+ font-size: 12px;
581
+ color: var(--text-secondary);
582
+ margin-top: 2px;
583
+ }
584
+
585
+ .kpi-indicator {
586
+ width: 10px;
587
+ height: 10px;
588
+ border-radius: 50%;
589
+ background: var(--text-muted);
590
+ }
591
+
592
+ .kpi-indicator.green { background: var(--accent-green); }
593
+ .kpi-indicator.yellow { background: var(--accent-orange); }
594
+ .kpi-indicator.red { background: var(--accent-red); }
595
+
596
+ /* Dash Cards */
597
+ .dash-card {
598
+ background: var(--bg-secondary);
599
+ border: 1px solid var(--border);
600
+ border-radius: 8px;
601
+ display: flex;
602
+ flex-direction: column;
603
+ height: 100%;
604
+ overflow: hidden;
605
+ }
606
+
607
+ .dash-card-head {
608
+ display: flex;
609
+ justify-content: space-between;
610
+ align-items: center;
611
+ padding: 12px 16px;
612
+ border-bottom: 1px solid var(--border);
613
+ background: var(--bg-tertiary);
614
+ }
615
+
616
+ .dash-card-title {
617
+ font-size: 13px;
618
+ font-weight: 600;
619
+ }
620
+
621
+ .dash-card-badge {
622
+ font-size: 11px;
623
+ color: var(--text-secondary);
624
+ background: var(--bg-primary);
625
+ padding: 2px 8px;
626
+ border-radius: 10px;
627
+ }
628
+
629
+ .dash-card-body {
630
+ flex: 1;
631
+ padding: 12px 16px;
632
+ overflow-y: auto;
633
+ }
634
+
635
+ .compact-list {
636
+ font-size: 12px;
637
+ }
638
+
639
+ .syslog-scroll {
640
+ font-family: 'SF Mono', Monaco, monospace;
641
+ font-size: 11px;
642
+ }
643
+
644
+ /* Top Bar */
645
+ .topbar {
646
+ display: flex;
647
+ justify-content: space-between;
648
+ align-items: center;
649
+ padding: 8px 20px;
650
+ background: var(--bg-secondary);
651
+ border-bottom: 1px solid var(--border);
652
+ height: 100%;
653
+ }
654
+
655
+ .topbar-left {
656
+ display: flex;
657
+ align-items: center;
658
+ gap: 20px;
659
+ }
660
+
661
+ .topbar-brand {
662
+ font-weight: 600;
663
+ font-size: 14px;
664
+ }
665
+
666
+ .topbar-link {
667
+ color: var(--text-secondary);
668
+ text-decoration: none;
669
+ font-size: 13px;
670
+ }
671
+
672
+ .topbar-link:hover,
673
+ .topbar-link.active {
674
+ color: var(--accent-blue);
675
+ }
676
+
677
+ .topbar-right {
678
+ display: flex;
679
+ align-items: center;
680
+ gap: 12px;
681
+ }
682
+
683
+ .topbar-meta {
684
+ font-size: 12px;
685
+ color: var(--text-muted);
686
+ }
687
+
688
+ .topbar-refresh {
689
+ background: var(--bg-tertiary);
690
+ border: 1px solid var(--border);
691
+ color: var(--text-secondary);
692
+ padding: 4px 8px;
693
+ border-radius: 4px;
694
+ cursor: pointer;
695
+ }
696
+
697
+ /* List Items */
698
+ .list-item {
699
+ padding: 6px 0;
700
+ border-bottom: 1px solid var(--border);
701
+ }
702
+
703
+ .list-item:last-child {
704
+ border-bottom: none;
705
+ }
706
+
707
+ .cron-item {
708
+ display: flex;
709
+ justify-content: space-between;
710
+ padding: 6px 0;
711
+ border-bottom: 1px solid var(--border);
712
+ }
713
+
714
+ .cron-name {
715
+ color: var(--text-primary);
716
+ }
717
+
718
+ .cron-next {
719
+ color: var(--text-muted);
720
+ font-size: 11px;
721
+ }
722
+
723
+ .log-line {
724
+ padding: 2px 0;
725
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
726
+ }
727
+
728
+ /* Weather */
729
+ .weather-row {
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 10px;
733
+ padding: 8px 0;
734
+ border-bottom: 1px solid var(--border);
735
+ }
736
+
737
+ .weather-row:last-child {
738
+ border-bottom: none;
739
+ }
740
+
741
+ .weather-icon {
742
+ font-size: 18px;
743
+ }
744
+
745
+ .weather-loc {
746
+ flex: 1;
747
+ color: var(--text-primary);
748
+ }
749
+
750
+ .weather-temp {
751
+ font-weight: 600;
752
+ color: var(--accent-blue);
753
+ }
754
+
755
+ /* Utilities */
756
+ .loading-sm {
757
+ display: flex;
758
+ align-items: center;
759
+ justify-content: center;
760
+ padding: 20px;
761
+ }
762
+
763
+ .spinner-sm {
764
+ width: 20px;
765
+ height: 20px;
766
+ border: 2px solid var(--bg-tertiary);
767
+ border-top-color: var(--accent-blue);
768
+ border-radius: 50%;
769
+ animation: spin 1s linear infinite;
770
+ }
771
+
772
+ @keyframes spin {
773
+ to { transform: rotate(360deg); }
774
+ }
775
+
776
+ .error {
777
+ color: var(--accent-red);
778
+ padding: 10px;
779
+ text-align: center;
780
+ }
781
+
782
+ ::-webkit-scrollbar {
783
+ width: 6px;
784
+ }
785
+
786
+ ::-webkit-scrollbar-track {
787
+ background: var(--bg-primary);
788
+ }
789
+
790
+ ::-webkit-scrollbar-thumb {
791
+ background: var(--bg-tertiary);
792
+ border-radius: 3px;
793
+ }
794
+
795
+ /* Post-Export Edit Mode */
796
+ .edit-mode .widget-container {
797
+ cursor: move;
798
+ outline: 2px dashed #3b82f6;
799
+ outline-offset: -2px;
800
+ }
801
+
802
+ .edit-mode .widget-container:hover {
803
+ outline-color: #60a5fa;
804
+ }
805
+
806
+ .edit-mode .widget-container.dragging {
807
+ opacity: 0.8;
808
+ z-index: 1000;
809
+ }
810
+
811
+ .resize-handle-edit {
812
+ display: none;
813
+ position: absolute;
814
+ bottom: 0;
815
+ right: 0;
816
+ width: 16px;
817
+ height: 16px;
818
+ cursor: se-resize;
819
+ background: #3b82f6;
820
+ border-radius: 2px 0 0 0;
821
+ z-index: 10;
822
+ }
823
+
824
+ .edit-mode .resize-handle-edit {
825
+ display: block;
826
+ }
827
+
828
+ #edit-toggle {
829
+ position: fixed;
830
+ bottom: 20px;
831
+ right: 20px;
832
+ z-index: 9999;
833
+ padding: 8px 16px;
834
+ background: #1e293b;
835
+ color: white;
836
+ border: none;
837
+ border-radius: 6px;
838
+ cursor: pointer;
839
+ font-size: 13px;
840
+ font-weight: 500;
841
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
842
+ }
843
+
844
+ #edit-toggle:hover {
845
+ background: #334155;
846
+ }
847
+
848
+ #edit-toggle.active {
849
+ background: #3b82f6;
850
+ }
851
+ `;
852
+ }
853
+
854
+ // ─────────────────────────────────────────────
855
+ // JS GENERATION
856
+ // ─────────────────────────────────────────────
857
+
858
+ /**
859
+ * Generate the post-export edit mode JS
860
+ * @returns {string} JavaScript code
861
+ */
862
+ function generateEditJs() {
863
+ return `
864
+ // ─────────────────────────────────────────────
865
+ // POST-EXPORT LAYOUT EDITING
866
+ // ─────────────────────────────────────────────
867
+
868
+ (function() {
869
+ const STORAGE_KEY = 'lobsterboard-layout';
870
+ const GRID_SIZE = 20;
871
+ const MIN_WIDTH = 100;
872
+ const MIN_HEIGHT = 60;
873
+
874
+ let editMode = false;
875
+ let activeWidget = null;
876
+ let startX, startY, origLeft, origTop, origWidth, origHeight;
877
+ let isResizing = false;
878
+
879
+ document.addEventListener('DOMContentLoaded', initEditMode);
880
+
881
+ function initEditMode() {
882
+ const btn = document.createElement('button');
883
+ btn.id = 'edit-toggle';
884
+ btn.textContent = '✏️ Edit Layout';
885
+ btn.onclick = toggleEditMode;
886
+ document.body.appendChild(btn);
887
+ document.querySelectorAll('.widget-container').forEach(initWidget);
888
+ loadPositions();
889
+ }
890
+
891
+ function initWidget(widget) {
892
+ const handle = document.createElement('div');
893
+ handle.className = 'resize-handle-edit';
894
+ widget.appendChild(handle);
895
+ widget.addEventListener('mousedown', onWidgetMouseDown);
896
+ handle.addEventListener('mousedown', onResizeMouseDown);
897
+ }
898
+
899
+ function toggleEditMode() {
900
+ editMode = !editMode;
901
+ document.body.classList.toggle('edit-mode', editMode);
902
+ document.getElementById('edit-toggle').classList.toggle('active', editMode);
903
+ document.getElementById('edit-toggle').textContent = editMode ? '💾 Save Layout' : '✏️ Edit Layout';
904
+ if (!editMode) savePositions();
905
+ }
906
+
907
+ function onWidgetMouseDown(e) {
908
+ if (!editMode) return;
909
+ if (e.target.classList.contains('resize-handle-edit')) return;
910
+ if (e.button !== 0) return;
911
+ e.preventDefault();
912
+ activeWidget = e.currentTarget;
913
+ isResizing = false;
914
+ startX = e.clientX;
915
+ startY = e.clientY;
916
+ origLeft = activeWidget.offsetLeft;
917
+ origTop = activeWidget.offsetTop;
918
+ activeWidget.classList.add('dragging');
919
+ document.addEventListener('mousemove', onMouseMove);
920
+ document.addEventListener('mouseup', onMouseUp);
921
+ }
922
+
923
+ function onResizeMouseDown(e) {
924
+ if (!editMode) return;
925
+ e.preventDefault();
926
+ e.stopPropagation();
927
+ activeWidget = e.target.parentElement;
928
+ isResizing = true;
929
+ startX = e.clientX;
930
+ startY = e.clientY;
931
+ origWidth = activeWidget.offsetWidth;
932
+ origHeight = activeWidget.offsetHeight;
933
+ activeWidget.classList.add('dragging');
934
+ document.addEventListener('mousemove', onMouseMove);
935
+ document.addEventListener('mouseup', onMouseUp);
936
+ }
937
+
938
+ function onMouseMove(e) {
939
+ if (!activeWidget) return;
940
+ const dx = e.clientX - startX;
941
+ const dy = e.clientY - startY;
942
+ if (isResizing) {
943
+ activeWidget.style.width = Math.max(MIN_WIDTH, origWidth + dx) + 'px';
944
+ activeWidget.style.height = Math.max(MIN_HEIGHT, origHeight + dy) + 'px';
945
+ } else {
946
+ activeWidget.style.left = Math.max(0, origLeft + dx) + 'px';
947
+ activeWidget.style.top = Math.max(0, origTop + dy) + 'px';
948
+ }
949
+ }
950
+
951
+ function onMouseUp() {
952
+ if (!activeWidget) return;
953
+ if (isResizing) {
954
+ activeWidget.style.width = snapToGrid(activeWidget.offsetWidth) + 'px';
955
+ activeWidget.style.height = snapToGrid(activeWidget.offsetHeight) + 'px';
956
+ } else {
957
+ activeWidget.style.left = snapToGrid(activeWidget.offsetLeft) + 'px';
958
+ activeWidget.style.top = snapToGrid(activeWidget.offsetTop) + 'px';
959
+ }
960
+ activeWidget.classList.remove('dragging');
961
+ activeWidget = null;
962
+ isResizing = false;
963
+ document.removeEventListener('mousemove', onMouseMove);
964
+ document.removeEventListener('mouseup', onMouseUp);
965
+ }
966
+
967
+ function snapToGrid(value) {
968
+ return Math.round(value / GRID_SIZE) * GRID_SIZE;
969
+ }
970
+
971
+ function savePositions() {
972
+ const positions = {};
973
+ document.querySelectorAll('.widget-container').forEach(widget => {
974
+ const id = widget.dataset.widgetId;
975
+ if (id) {
976
+ positions[id] = {
977
+ left: widget.offsetLeft,
978
+ top: widget.offsetTop,
979
+ width: widget.offsetWidth,
980
+ height: widget.offsetHeight
981
+ };
982
+ }
983
+ });
984
+ try {
985
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
986
+ } catch (e) {}
987
+ }
988
+
989
+ function loadPositions() {
990
+ try {
991
+ const saved = localStorage.getItem(STORAGE_KEY);
992
+ if (!saved) return;
993
+ const positions = JSON.parse(saved);
994
+ document.querySelectorAll('.widget-container').forEach(widget => {
995
+ const id = widget.dataset.widgetId;
996
+ const pos = positions[id];
997
+ if (pos) {
998
+ widget.style.left = pos.left + 'px';
999
+ widget.style.top = pos.top + 'px';
1000
+ widget.style.width = pos.width + 'px';
1001
+ widget.style.height = pos.height + 'px';
1002
+ }
1003
+ });
1004
+ } catch (e) {}
1005
+ }
1006
+ })();
1007
+ `;
1008
+ }
1009
+
1010
+ // ─────────────────────────────────────────────
1011
+ // DASHBOARD GENERATION
1012
+ // ─────────────────────────────────────────────
1013
+
1014
+ /**
1015
+ * Generate widget HTML for a widget configuration
1016
+ * @param {Object} widget - Widget configuration
1017
+ * @returns {string} Widget HTML
1018
+ */
1019
+ function generateWidgetHtml(widget) {
1020
+ const template = WIDGETS[widget.type];
1021
+ if (!template) return '';
1022
+
1023
+ const props = { ...widget.properties, id: widget.id };
1024
+ let html = processWidgetHtml(template.generateHtml(props), widget.properties.showHeader);
1025
+
1026
+ return `
1027
+ <div class="widget-container" data-widget-id="${widget.id}" style="position:absolute;left:${widget.x}px;top:${widget.y}px;width:${widget.width}px;height:${widget.height}px;">
1028
+ ${html}
1029
+ </div>`;
1030
+ }
1031
+
1032
+ /**
1033
+ * Generate widget JavaScript for a widget configuration
1034
+ * @param {Object} widget - Widget configuration
1035
+ * @returns {string} Widget JavaScript
1036
+ */
1037
+ function generateWidgetJs(widget) {
1038
+ const template = WIDGETS[widget.type];
1039
+ if (!template || !template.generateJs) return '';
1040
+
1041
+ const props = { ...widget.properties, id: widget.id };
1042
+ return template.generateJs(props);
1043
+ }
1044
+
1045
+ /**
1046
+ * Generate complete dashboard HTML
1047
+ * @param {Object} config - Dashboard configuration
1048
+ * @param {Object} config.canvas - Canvas dimensions { width, height }
1049
+ * @param {Array} config.widgets - Array of widget configurations
1050
+ * @returns {string} Complete HTML document
1051
+ */
1052
+ function generateDashboardHtml(config) {
1053
+ const { canvas, widgets } = config;
1054
+ const widgetHtml = widgets.map(generateWidgetHtml).join('\n');
1055
+
1056
+ return `<!DOCTYPE html>
1057
+ <html lang="en">
1058
+ <head>
1059
+ <meta charset="UTF-8">
1060
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1061
+ <title>My LobsterBoard Dashboard</title>
1062
+ <link rel="stylesheet" href="css/style.css">
1063
+ </head>
1064
+ <body>
1065
+ <main class="dashboard" style="width:${canvas.width}px;height:${canvas.height}px;position:relative;">
1066
+ ${widgetHtml}
1067
+ </main>
1068
+ <script src="js/dashboard.js"></script>
1069
+ </body>
1070
+ </html>`;
1071
+ }
1072
+
1073
+ /**
1074
+ * Generate complete dashboard JavaScript
1075
+ * @param {Array} widgets - Array of widget configurations
1076
+ * @returns {string} Complete JavaScript
1077
+ */
1078
+ function generateDashboardJs(widgets) {
1079
+ const widgetJs = widgets.map(generateWidgetJs).filter(Boolean).join('\n\n');
1080
+ const editJs = generateEditJs();
1081
+
1082
+ return `/**
1083
+ * LobsterBoard Dashboard - Generated JavaScript
1084
+ * Replace YOUR_*_API_KEY placeholders with your actual API keys
1085
+ */
1086
+
1087
+ document.addEventListener('DOMContentLoaded', () => {
1088
+ console.log('Dashboard loaded');
1089
+ });
1090
+
1091
+ ${widgetJs}
1092
+
1093
+ ${editJs}
1094
+ `;
1095
+ }
1096
+
1097
+ /**
1098
+ * Generate README for exported dashboard
1099
+ * @param {Array} widgets - Array of widget configurations
1100
+ * @returns {string} README markdown
1101
+ */
1102
+ function generateReadme(widgets) {
1103
+ const apiKeys = [];
1104
+ const needsOpenClaw = widgets.some(w =>
1105
+ ['openclaw-release', 'auth-status', 'activity-list', 'cron-jobs', 'system-log', 'session-count', 'token-gauge'].includes(w.type)
1106
+ );
1107
+
1108
+ widgets.forEach(widget => {
1109
+ const template = WIDGETS[widget.type];
1110
+ if (template?.hasApiKey && template.apiKeyName) {
1111
+ if (!apiKeys.includes(template.apiKeyName)) {
1112
+ apiKeys.push(template.apiKeyName);
1113
+ }
1114
+ }
1115
+ });
1116
+
1117
+ return `# LobsterBoard Dashboard
1118
+
1119
+ This dashboard was generated with LobsterBoard Dashboard Builder.
1120
+
1121
+ ## Quick Start
1122
+
1123
+ ${needsOpenClaw ? `### Running with OpenClaw widgets
1124
+
1125
+ Your dashboard includes widgets that connect to OpenClaw. Run the included server:
1126
+
1127
+ \`\`\`bash
1128
+ node server.js
1129
+ \`\`\`
1130
+
1131
+ Open http://localhost:8080 in your browser.
1132
+ ` : ''}
1133
+ ### Static mode
1134
+
1135
+ Open \`index.html\` directly in a browser.
1136
+
1137
+ ## Files
1138
+
1139
+ | File | Description |
1140
+ |------|-------------|
1141
+ | \`index.html\` | Dashboard page |
1142
+ | \`css/style.css\` | Styles |
1143
+ | \`js/dashboard.js\` | Widget logic |
1144
+ | \`server.js\` | Server with OpenClaw API proxy |
1145
+
1146
+ ${apiKeys.length > 0 ? `## API Keys
1147
+
1148
+ Edit \`js/dashboard.js\` and replace these placeholders:
1149
+ ${apiKeys.map(key => `- \`YOUR_${key}\``).join('\n')}
1150
+ ` : ''}
1151
+
1152
+ ---
1153
+
1154
+ Generated with LobsterBoard - https://github.com/curbob/LobsterBoard
1155
+ `;
1156
+ }
1157
+
1158
+ var builder = {
1159
+ escapeHtml,
1160
+ processWidgetHtml,
1161
+ generateDashboardCss,
1162
+ generateEditJs,
1163
+ generateWidgetHtml,
1164
+ generateWidgetJs,
1165
+ generateDashboardHtml,
1166
+ generateDashboardJs,
1167
+ generateReadme
1168
+ };
1169
+
1170
+ /**
1171
+ * LobsterBoard - Dashboard Builder Library
1172
+ *
1173
+ * A library for building and generating dashboard configurations
1174
+ * with customizable widgets.
1175
+ *
1176
+ * @module lobsterboard
1177
+ * @example
1178
+ * // ESM
1179
+ * import { WIDGETS, generateDashboardHtml, generateDashboardCss } from 'lobsterboard';
1180
+ *
1181
+ * // CommonJS
1182
+ * const { WIDGETS, generateDashboardHtml } = require('lobsterboard');
1183
+ *
1184
+ * // Browser (UMD)
1185
+ * <script src="https://unpkg.com/lobsterboard"></script>
1186
+ * const { WIDGETS } = LobsterBoard;
1187
+ */
1188
+
1189
+
1190
+ // Version (will be replaced during build)
1191
+ const VERSION = '0.1.0';
1192
+
1193
+ // Default export for convenience
1194
+ var index = {
1195
+ VERSION,
1196
+ WIDGETS,
1197
+ ...builder
1198
+ };
1199
+
1200
+ exports.VERSION = VERSION;
1201
+ exports.WIDGETS = WIDGETS;
1202
+ exports.default = index;
1203
+ exports.escapeHtml = escapeHtml;
1204
+ exports.generateDashboardCss = generateDashboardCss;
1205
+ exports.generateDashboardHtml = generateDashboardHtml;
1206
+ exports.generateDashboardJs = generateDashboardJs;
1207
+ exports.generateEditJs = generateEditJs;
1208
+ exports.generateReadme = generateReadme;
1209
+ exports.generateWidgetHtml = generateWidgetHtml;
1210
+ exports.generateWidgetJs = generateWidgetJs;
1211
+ exports.getWidget = getWidget;
1212
+ exports.getWidgetCategories = getWidgetCategories;
1213
+ exports.getWidgetTypes = getWidgetTypes;
1214
+ exports.processWidgetHtml = processWidgetHtml;
1215
+
1216
+ Object.defineProperty(exports, '__esModule', { value: true });
1217
+
1218
+ }));
1219
+ //# sourceMappingURL=lobsterboard.umd.js.map