@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,474 @@
1
+ 'use strict';
2
+
3
+ const base = require('../templates/base');
4
+
5
+ /**
6
+ * SparkUI Composable Component Library
7
+ *
8
+ * Each component returns an HTML string with inline styles and JS.
9
+ * Components are self-contained, dark-themed, responsive, and accessible.
10
+ * Interactive components emit events via window.sparkui.send(type, data).
11
+ */
12
+
13
+ // ── Unique ID generator for component instances ──
14
+ let _idCounter = 0;
15
+ function uid(prefix = 'sui') {
16
+ return `${prefix}_${++_idCounter}_${Date.now().toString(36)}`;
17
+ }
18
+
19
+ // ── Style constants ──
20
+ const THEME = {
21
+ bg: '#111',
22
+ cardBg: '#1a1a1a',
23
+ border: '#333',
24
+ text: '#e0e0e0',
25
+ textMuted: '#888',
26
+ accent: '#00ff88',
27
+ secondary: '#444',
28
+ danger: '#ff4444',
29
+ radius: '8px',
30
+ font: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif",
31
+ };
32
+
33
+ // ── 1. Header Component ──
34
+
35
+ function header(config = {}) {
36
+ const { title = '', subtitle = '', icon = '', badge = '' } = config;
37
+ const badgeHtml = badge
38
+ ? `<span style="background:${THEME.accent};color:#111;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:12px;margin-left:8px;vertical-align:middle">${esc(badge)}</span>`
39
+ : '';
40
+ const iconHtml = icon
41
+ ? `<span style="font-size:1.8rem;margin-right:8px;vertical-align:middle">${icon}</span>`
42
+ : '';
43
+ const subtitleHtml = subtitle
44
+ ? `<p style="color:${THEME.textMuted};font-size:0.9rem;margin-top:4px">${esc(subtitle)}</p>`
45
+ : '';
46
+
47
+ return `<div style="margin-bottom:20px">
48
+ <h1 style="font-size:1.5rem;font-weight:700;color:${THEME.text};margin:0;line-height:1.3">
49
+ ${iconHtml}${esc(title)}${badgeHtml}
50
+ </h1>
51
+ ${subtitleHtml}
52
+ </div>`;
53
+ }
54
+
55
+ // ── 2. Button Component ──
56
+
57
+ function button(config = {}) {
58
+ const { label = 'Button', action = 'click', style = 'primary', icon = '', disabled = false } = config;
59
+ const id = uid('btn');
60
+
61
+ const styles = {
62
+ primary: `background:${THEME.accent};color:#111;border:none;font-weight:600`,
63
+ secondary: `background:transparent;color:${THEME.text};border:2px solid ${THEME.secondary}`,
64
+ danger: `background:${THEME.danger};color:#fff;border:none;font-weight:600`,
65
+ };
66
+
67
+ const btnStyle = styles[style] || styles.primary;
68
+ const disabledAttr = disabled ? 'disabled' : '';
69
+ const disabledStyle = disabled ? 'opacity:0.5;cursor:not-allowed;' : 'cursor:pointer;';
70
+ const iconHtml = icon ? `<span style="margin-right:6px">${icon}</span>` : '';
71
+
72
+ return `<button id="${id}" ${disabledAttr} style="${btnStyle};${disabledStyle}padding:12px 24px;border-radius:${THEME.radius};font-size:1rem;font-family:${THEME.font};display:inline-flex;align-items:center;justify-content:center;transition:opacity 0.15s;width:100%;max-width:320px" onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">${iconHtml}${esc(label)}</button>
73
+ <script>(function(){var b=document.getElementById('${id}');b&&b.addEventListener('click',function(){if(!b.disabled&&window.sparkui)window.sparkui.send('event',{action:'${escJs(action)}'})});})()</script>`;
74
+ }
75
+
76
+ // ── 3. Timer Component ──
77
+
78
+ function timer(config = {}) {
79
+ const { mode = 'countdown', duration = 60, intervals = [], autoStart = false, onComplete = '' } = config;
80
+ const id = uid('tmr');
81
+
82
+ // Pre-compute intervals JSON
83
+ const intervalsJson = JSON.stringify(intervals.map(i => ({
84
+ label: i.label || 'Work',
85
+ seconds: i.seconds || 30,
86
+ })));
87
+
88
+ return `<div id="${id}" style="background:${THEME.cardBg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:24px;text-align:center;margin-bottom:16px">
89
+ <div id="${id}_label" style="font-size:0.85rem;color:${THEME.textMuted};margin-bottom:8px;min-height:1.2em"></div>
90
+ <div id="${id}_display" style="font-size:3rem;font-weight:700;font-variant-numeric:tabular-nums;color:${THEME.text};margin-bottom:16px">00:00</div>
91
+ <div id="${id}_progress" style="height:4px;background:${THEME.secondary};border-radius:2px;margin-bottom:16px;overflow:hidden;display:none">
92
+ <div id="${id}_bar" style="height:100%;background:${THEME.accent};width:0%;transition:width 0.3s"></div>
93
+ </div>
94
+ <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
95
+ <button id="${id}_start" style="background:${THEME.accent};color:#111;border:none;padding:10px 20px;border-radius:${THEME.radius};font-size:0.9rem;font-weight:600;cursor:pointer;font-family:${THEME.font}">Start</button>
96
+ <button id="${id}_pause" style="background:${THEME.secondary};color:${THEME.text};border:none;padding:10px 20px;border-radius:${THEME.radius};font-size:0.9rem;cursor:pointer;font-family:${THEME.font};display:none">Pause</button>
97
+ <button id="${id}_reset" style="background:transparent;color:${THEME.textMuted};border:1px solid ${THEME.border};padding:10px 20px;border-radius:${THEME.radius};font-size:0.9rem;cursor:pointer;font-family:${THEME.font}">Reset</button>
98
+ </div>
99
+ </div>
100
+ <script>(function(){
101
+ var mode='${escJs(mode)}',dur=${parseInt(duration)||60},intervals=${intervalsJson},autoStart=${!!autoStart};
102
+ var el=document.getElementById('${id}'),display=document.getElementById('${id}_display');
103
+ var label=document.getElementById('${id}_label'),progWrap=document.getElementById('${id}_progress');
104
+ var bar=document.getElementById('${id}_bar');
105
+ var startBtn=document.getElementById('${id}_start'),pauseBtn=document.getElementById('${id}_pause');
106
+ var resetBtn=document.getElementById('${id}_reset');
107
+ var timer=null,elapsed=0,running=false,curInterval=0,intervalElapsed=0;
108
+
109
+ function totalDur(){
110
+ if(mode==='interval'){var t=0;for(var i=0;i<intervals.length;i++)t+=intervals[i].seconds;return t||dur;}
111
+ if(mode==='countdown')return dur;
112
+ return 0;
113
+ }
114
+
115
+ function fmt(s){var m=Math.floor(s/60);var sec=s%60;return(m<10?'0':'')+m+':'+(sec<10?'0':'')+sec;}
116
+
117
+ function currentTarget(){
118
+ if(mode==='countdown')return dur;
119
+ if(mode==='interval'&&intervals.length>0)return intervals[curInterval]?intervals[curInterval].seconds:0;
120
+ return 0;
121
+ }
122
+
123
+ function render(){
124
+ if(mode==='stopwatch'){display.textContent=fmt(elapsed);return;}
125
+ if(mode==='countdown'){display.textContent=fmt(Math.max(0,dur-elapsed));progWrap.style.display='block';bar.style.width=(elapsed/dur*100)+'%';return;}
126
+ if(mode==='interval'&&intervals.length>0){
127
+ var ci=intervals[curInterval];
128
+ if(ci){label.textContent=ci.label;display.textContent=fmt(Math.max(0,ci.seconds-intervalElapsed));progWrap.style.display='block';bar.style.width=(intervalElapsed/ci.seconds*100)+'%';}
129
+ }
130
+ }
131
+
132
+ function tick(){
133
+ elapsed++;intervalElapsed++;
134
+ if(mode==='countdown'&&elapsed>=dur){stop();done();return;}
135
+ if(mode==='interval'){
136
+ var ci=intervals[curInterval];
137
+ if(ci&&intervalElapsed>=ci.seconds){curInterval++;intervalElapsed=0;if(curInterval>=intervals.length){stop();done();return;}}
138
+ }
139
+ render();
140
+ }
141
+
142
+ function start(){if(running)return;running=true;timer=setInterval(tick,1000);startBtn.style.display='none';pauseBtn.style.display='inline-block';render();}
143
+ function pause(){running=false;clearInterval(timer);timer=null;startBtn.style.display='inline-block';startBtn.textContent='Resume';pauseBtn.style.display='none';}
144
+ function stop(){running=false;clearInterval(timer);timer=null;startBtn.style.display='none';pauseBtn.style.display='none';}
145
+ function reset(){stop();elapsed=0;curInterval=0;intervalElapsed=0;startBtn.style.display='inline-block';startBtn.textContent='Start';pauseBtn.style.display='none';label.textContent='';render();}
146
+ function done(){
147
+ display.style.color='${THEME.accent}';
148
+ if(window.sparkui)window.sparkui.send('timer',{action:'complete',mode:mode,elapsed:elapsed});
149
+ }
150
+
151
+ startBtn.addEventListener('click',start);
152
+ pauseBtn.addEventListener('click',pause);
153
+ resetBtn.addEventListener('click',reset);
154
+ render();
155
+ if(autoStart)start();
156
+ })()</script>`;
157
+ }
158
+
159
+ // ── 4. Checklist Component ──
160
+
161
+ function checklist(config = {}) {
162
+ const { items = [], allowAdd = false, showProgress = false } = config;
163
+ const id = uid('chk');
164
+ const itemsJson = JSON.stringify(items.map(i => ({
165
+ text: i.text || '',
166
+ checked: !!i.checked,
167
+ })));
168
+
169
+ return `<div id="${id}" style="background:${THEME.cardBg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:16px;margin-bottom:16px">
170
+ ${showProgress ? `<div style="margin-bottom:12px">
171
+ <div style="display:flex;justify-content:space-between;font-size:0.8rem;color:${THEME.textMuted};margin-bottom:4px">
172
+ <span id="${id}_count">0 / 0</span><span id="${id}_pct">0%</span>
173
+ </div>
174
+ <div style="height:6px;background:${THEME.secondary};border-radius:3px;overflow:hidden">
175
+ <div id="${id}_bar" style="height:100%;background:${THEME.accent};width:0%;transition:width 0.3s ease"></div>
176
+ </div>
177
+ </div>` : ''}
178
+ <div id="${id}_list"></div>
179
+ ${allowAdd ? `<div style="margin-top:12px;display:flex;gap:8px">
180
+ <input id="${id}_input" type="text" placeholder="Add item..." style="flex:1;background:${THEME.bg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:8px 12px;color:${THEME.text};font-size:0.9rem;font-family:${THEME.font};outline:none" />
181
+ <button id="${id}_add" style="background:${THEME.accent};color:#111;border:none;padding:8px 16px;border-radius:${THEME.radius};font-weight:600;cursor:pointer;font-family:${THEME.font}">+</button>
182
+ </div>` : ''}
183
+ </div>
184
+ <script>(function(){
185
+ var id='${id}',items=${itemsJson},showProgress=${!!showProgress},allowAdd=${!!allowAdd};
186
+ var list=document.getElementById(id+'_list');
187
+ function render(){
188
+ list.innerHTML='';
189
+ var done=0;
190
+ items.forEach(function(it,i){
191
+ if(it.checked)done++;
192
+ var row=document.createElement('div');
193
+ row.style.cssText='display:flex;align-items:center;padding:10px 0;border-bottom:1px solid ${THEME.border};cursor:pointer;transition:opacity 0.2s';
194
+ row.innerHTML='<span style="width:22px;height:22px;border-radius:50%;border:2px solid '+(it.checked?'${THEME.accent}':'${THEME.secondary}')+';display:flex;align-items:center;justify-content:center;margin-right:12px;flex-shrink:0;transition:all 0.2s">'+(it.checked?'<span style="color:${THEME.accent};font-size:14px">✓</span>':'')+'</span><span style="'+(it.checked?'text-decoration:line-through;color:${THEME.textMuted}':'color:${THEME.text}')+';transition:all 0.2s;font-size:0.95rem">'+escH(it.text)+'</span>';
195
+ row.addEventListener('click',function(){items[i].checked=!items[i].checked;render();
196
+ if(window.sparkui)window.sparkui.send('event',{action:'checklist_toggle',index:i,checked:items[i].checked,text:items[i].text});
197
+ var allDone=items.length>0&&items.every(function(x){return x.checked});
198
+ if(allDone&&window.sparkui)window.sparkui.send('completion',{action:'checklist_complete',items:items});
199
+ });
200
+ list.appendChild(row);
201
+ });
202
+ if(showProgress){
203
+ var pct=items.length?Math.round(done/items.length*100):0;
204
+ var ce=document.getElementById(id+'_count');if(ce)ce.textContent=done+' / '+items.length;
205
+ var pe=document.getElementById(id+'_pct');if(pe)pe.textContent=pct+'%';
206
+ var be=document.getElementById(id+'_bar');if(be)be.style.width=pct+'%';
207
+ }
208
+ }
209
+ function escH(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;}
210
+ render();
211
+ if(allowAdd){
212
+ var inp=document.getElementById(id+'_input'),addBtn=document.getElementById(id+'_add');
213
+ function addItem(){var t=inp.value.trim();if(!t)return;items.push({text:t,checked:false});inp.value='';render();}
214
+ addBtn.addEventListener('click',addItem);
215
+ inp.addEventListener('keydown',function(e){if(e.key==='Enter')addItem();});
216
+ }
217
+ })()</script>`;
218
+ }
219
+
220
+ // ── 5. Progress Component ──
221
+
222
+ function progress(config = {}) {
223
+ const { value = 0, max = 100, label = '', color = THEME.accent, showPercent = true, segments = [] } = config;
224
+ const id = uid('prg');
225
+
226
+ if (segments && segments.length > 0) {
227
+ // Multi-segment mode
228
+ const segHtml = segments.map((seg, i) => {
229
+ const segId = `${id}_s${i}`;
230
+ const pct = seg.max ? Math.min(100, Math.round((seg.value / seg.max) * 100)) : 0;
231
+ const segColor = seg.color || THEME.accent;
232
+ return `<div style="margin-bottom:12px">
233
+ <div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:4px">
234
+ <span style="color:${THEME.text}">${esc(seg.label || '')}</span>
235
+ <span style="color:${THEME.textMuted}">${seg.value}/${seg.max}${showPercent ? ' ('+pct+'%)' : ''}</span>
236
+ </div>
237
+ <div style="height:8px;background:${THEME.secondary};border-radius:4px;overflow:hidden">
238
+ <div id="${segId}" style="height:100%;background:${segColor};width:0%;border-radius:4px;transition:width 0.8s ease"></div>
239
+ </div>
240
+ </div>`;
241
+ }).join('');
242
+
243
+ const animScript = segments.map((seg, i) => {
244
+ const pct = seg.max ? Math.min(100, Math.round((seg.value / seg.max) * 100)) : 0;
245
+ return `setTimeout(function(){var e=document.getElementById('${id}_s${i}');if(e)e.style.width='${pct}%';},50);`;
246
+ }).join('');
247
+
248
+ return `<div style="background:${THEME.cardBg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:16px;margin-bottom:16px">
249
+ ${segHtml}
250
+ </div>
251
+ <script>(function(){${animScript}})()</script>`;
252
+ }
253
+
254
+ // Single bar mode
255
+ const pct = max ? Math.min(100, Math.round((value / max) * 100)) : 0;
256
+ return `<div style="background:${THEME.cardBg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:16px;margin-bottom:16px">
257
+ <div style="display:flex;justify-content:space-between;font-size:0.85rem;margin-bottom:6px">
258
+ <span style="color:${THEME.text}">${esc(label)}</span>
259
+ ${showPercent ? `<span style="color:${THEME.textMuted}">${pct}%</span>` : ''}
260
+ </div>
261
+ <div style="height:8px;background:${THEME.secondary};border-radius:4px;overflow:hidden">
262
+ <div id="${id}_bar" style="height:100%;background:${color};width:0%;border-radius:4px;transition:width 0.8s ease"></div>
263
+ </div>
264
+ </div>
265
+ <script>(function(){setTimeout(function(){var e=document.getElementById('${id}_bar');if(e)e.style.width='${pct}%';},50);})()</script>`;
266
+ }
267
+
268
+ // ── 6. Stats Grid Component ──
269
+
270
+ function stats(config = {}) {
271
+ const { items = [] } = config;
272
+
273
+ const cards = items.map(item => {
274
+ const trendMap = { up: '↑', down: '↓', flat: '→' };
275
+ const trendColor = { up: THEME.accent, down: THEME.danger, flat: THEME.textMuted };
276
+ const trend = item.trend ? `<span style="color:${trendColor[item.trend] || THEME.textMuted};font-size:0.9rem;margin-left:4px">${trendMap[item.trend] || ''}</span>` : '';
277
+ const iconHtml = item.icon ? `<span style="font-size:1.3rem;margin-bottom:4px;display:block">${item.icon}</span>` : '';
278
+ const unitHtml = item.unit ? `<span style="font-size:0.85rem;color:${THEME.textMuted};margin-left:2px">${esc(item.unit)}</span>` : '';
279
+
280
+ return `<div style="background:${THEME.cardBg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:16px;text-align:center">
281
+ ${iconHtml}
282
+ <div style="font-size:1.6rem;font-weight:700;color:${THEME.text};font-variant-numeric:tabular-nums">${esc(String(item.value || '0'))}${unitHtml}${trend}</div>
283
+ <div style="font-size:0.8rem;color:${THEME.textMuted};margin-top:4px">${esc(item.label || '')}</div>
284
+ </div>`;
285
+ }).join('');
286
+
287
+ return `<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:16px">
288
+ ${cards}
289
+ </div>`;
290
+ }
291
+
292
+ // ── 7. Form Component ──
293
+
294
+ function form(config = {}) {
295
+ const { fields = [], submitLabel = 'Submit' } = config;
296
+ const id = uid('frm');
297
+ const fieldsJson = JSON.stringify(fields);
298
+
299
+ const fieldHtml = fields.map((f, i) => {
300
+ const fid = `${id}_f${i}`;
301
+ const req = f.required ? '<span style="color:' + THEME.danger + '">*</span>' : '';
302
+ const labelHtml = f.label ? `<label for="${fid}" style="display:block;font-size:0.85rem;color:${THEME.textMuted};margin-bottom:4px">${esc(f.label)} ${req}</label>` : '';
303
+ const inputStyle = `width:100%;background:${THEME.bg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:10px 12px;color:${THEME.text};font-size:0.95rem;font-family:${THEME.font};outline:none;transition:border-color 0.2s`;
304
+
305
+ let input = '';
306
+ switch (f.type) {
307
+ case 'textarea':
308
+ input = `<textarea id="${fid}" name="${esc(f.name || '')}" placeholder="${esc(f.placeholder || '')}" ${f.required ? 'required' : ''} style="${inputStyle};min-height:80px;resize:vertical" onfocus="this.style.borderColor='${THEME.accent}'" onblur="this.style.borderColor='${THEME.border}'"></textarea>`;
309
+ break;
310
+ case 'select':
311
+ const opts = (f.options || []).map(o => `<option value="${esc(typeof o === 'string' ? o : o.value)}">${esc(typeof o === 'string' ? o : o.label)}</option>`).join('');
312
+ input = `<select id="${fid}" name="${esc(f.name || '')}" ${f.required ? 'required' : ''} style="${inputStyle};cursor:pointer"><option value="">Select...</option>${opts}</select>`;
313
+ break;
314
+ case 'rating':
315
+ input = `<div id="${fid}" data-name="${esc(f.name || '')}" style="display:flex;gap:4px;font-size:1.5rem;cursor:pointer">
316
+ ${[1,2,3,4,5].map(n => `<span data-val="${n}" style="color:${THEME.secondary};transition:color 0.15s" onmouseover="this.parentNode._hover(${n})" onmouseout="this.parentNode._unhover()" onclick="this.parentNode._select(${n})">★</span>`).join('')}
317
+ </div>`;
318
+ break;
319
+ default:
320
+ input = `<input id="${fid}" type="${f.type || 'text'}" name="${esc(f.name || '')}" placeholder="${esc(f.placeholder || '')}" ${f.required ? 'required' : ''} style="${inputStyle}" onfocus="this.style.borderColor='${THEME.accent}'" onblur="this.style.borderColor='${THEME.border}'" />`;
321
+ }
322
+
323
+ return `<div style="margin-bottom:16px">${labelHtml}${input}</div>`;
324
+ }).join('');
325
+
326
+ // Rating fields init script
327
+ const ratingFields = fields.map((f, i) => f.type === 'rating' ? i : -1).filter(i => i >= 0);
328
+ const ratingScript = ratingFields.map(i => {
329
+ const fid = `${id}_f${i}`;
330
+ return `(function(){
331
+ var el=document.getElementById('${fid}'),val=0,stars=el.querySelectorAll('span');
332
+ function paint(n,c){for(var j=0;j<5;j++)stars[j].style.color=j<n?c:'${THEME.secondary}';}
333
+ el._hover=function(n){paint(n,'${THEME.accent}');};
334
+ el._unhover=function(){paint(val,'#f5c518');};
335
+ el._select=function(n){val=n;paint(n,'#f5c518');el.dataset.value=n;};
336
+ })();`;
337
+ }).join('');
338
+
339
+ return `<form id="${id}" style="background:${THEME.cardBg};border:1px solid ${THEME.border};border-radius:${THEME.radius};padding:20px;margin-bottom:16px" onsubmit="return false">
340
+ ${fieldHtml}
341
+ <button type="submit" style="background:${THEME.accent};color:#111;border:none;padding:12px 24px;border-radius:${THEME.radius};font-size:1rem;font-weight:600;cursor:pointer;font-family:${THEME.font};width:100%;transition:opacity 0.15s" onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">${esc(submitLabel)}</button>
342
+ </form>
343
+ <script>(function(){
344
+ ${ratingScript}
345
+ var form=document.getElementById('${id}');
346
+ var fields=${fieldsJson};
347
+ form.addEventListener('submit',function(e){
348
+ e.preventDefault();
349
+ var data={};
350
+ fields.forEach(function(f,i){
351
+ var fid='${id}_f'+i;
352
+ var el=document.getElementById(fid);
353
+ if(!el)return;
354
+ if(f.type==='rating'){data[f.name||'rating']=parseInt(el.dataset.value)||0;}
355
+ else{data[f.name||'field_'+i]=el.value;}
356
+ });
357
+ if(window.sparkui)window.sparkui.send('completion',{formData:data});
358
+ // Visual feedback
359
+ var btn=form.querySelector('button[type=submit]');
360
+ if(btn){btn.textContent='✓ Sent';btn.style.background='${THEME.secondary}';btn.disabled=true;}
361
+ });
362
+ })()</script>`;
363
+ }
364
+
365
+ // ── 8. Tabs Component ──
366
+
367
+ function tabs(config = {}) {
368
+ const { tabs: tabList = [], activeIndex = 0 } = config;
369
+ const id = uid('tabs');
370
+
371
+ const tabBtns = tabList.map((t, i) => {
372
+ const active = i === activeIndex;
373
+ return `<button class="${id}_tab" data-idx="${i}" style="background:${active ? THEME.cardBg : 'transparent'};color:${active ? THEME.accent : THEME.textMuted};border:none;border-bottom:2px solid ${active ? THEME.accent : 'transparent'};padding:10px 16px;font-size:0.9rem;cursor:pointer;font-family:${THEME.font};transition:all 0.2s;flex:1">${esc(t.label || 'Tab ' + (i + 1))}</button>`;
374
+ }).join('');
375
+
376
+ const panels = tabList.map((t, i) => {
377
+ return `<div class="${id}_panel" data-idx="${i}" style="display:${i === activeIndex ? 'block' : 'none'};padding:16px 0">${t.content || ''}</div>`;
378
+ }).join('');
379
+
380
+ return `<div style="margin-bottom:16px">
381
+ <div style="display:flex;border-bottom:1px solid ${THEME.border};margin-bottom:0">${tabBtns}</div>
382
+ <div style="background:${THEME.cardBg};border:1px solid ${THEME.border};border-top:none;border-radius:0 0 ${THEME.radius} ${THEME.radius};padding:16px">${panels}</div>
383
+ </div>
384
+ <script>(function(){
385
+ var tabs=document.querySelectorAll('.${id}_tab'),panels=document.querySelectorAll('.${id}_panel');
386
+ tabs.forEach(function(tab){
387
+ tab.addEventListener('click',function(){
388
+ var idx=parseInt(this.dataset.idx);
389
+ tabs.forEach(function(t,i){
390
+ t.style.background=i===idx?'${THEME.cardBg}':'transparent';
391
+ t.style.color=i===idx?'${THEME.accent}':'${THEME.textMuted}';
392
+ t.style.borderBottomColor=i===idx?'${THEME.accent}':'transparent';
393
+ });
394
+ panels.forEach(function(p,i){p.style.display=i===idx?'block':'none';});
395
+ });
396
+ });
397
+ })()</script>`;
398
+ }
399
+
400
+ // ── Compose Function ──
401
+
402
+ /**
403
+ * Compose a full page from a layout config.
404
+ * @param {object} layout
405
+ * @param {string} layout.title - Page title
406
+ * @param {Array} layout.sections - Array of { type, config } objects
407
+ * @param {object} [layout.openclaw] - OpenClaw webhook config
408
+ * @returns {{ html: string, pushBody: object }}
409
+ */
410
+ function compose(layout = {}) {
411
+ const { title = 'SparkUI', sections = [], openclaw = null } = layout;
412
+
413
+ const componentMap = {
414
+ header,
415
+ button,
416
+ timer,
417
+ checklist,
418
+ progress,
419
+ stats,
420
+ form,
421
+ tabs,
422
+ };
423
+
424
+ // Reset ID counter for deterministic output
425
+ _idCounter = 0;
426
+
427
+ const bodyParts = sections.map(section => {
428
+ const fn = componentMap[section.type];
429
+ if (!fn) return `<!-- unknown component: ${esc(section.type)} -->`;
430
+ return fn(section.config || {});
431
+ });
432
+
433
+ const bodyHtml = bodyParts.join('\n');
434
+
435
+ // Use placeholder for page ID - will be replaced with actual UUID on push
436
+ const html = base({
437
+ title,
438
+ body: bodyHtml,
439
+ id: '__PAGE_ID__',
440
+ });
441
+
442
+ const pushBody = {
443
+ html,
444
+ meta: { title, template: 'composed' },
445
+ openclaw: openclaw || undefined,
446
+ };
447
+
448
+ return { html, pushBody };
449
+ }
450
+
451
+ // ── Utility ──
452
+
453
+ function esc(str) {
454
+ if (!str) return '';
455
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
456
+ }
457
+
458
+ function escJs(str) {
459
+ if (!str) return '';
460
+ return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n');
461
+ }
462
+
463
+ module.exports = {
464
+ header,
465
+ button,
466
+ timer,
467
+ checklist,
468
+ progress,
469
+ stats,
470
+ form,
471
+ tabs,
472
+ compose,
473
+ THEME,
474
+ };
package/lib/store.js ADDED
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_TTL = 3600; // 1 hour in seconds
4
+
5
+ class PageStore {
6
+ constructor() {
7
+ this.pages = new Map();
8
+ // Sweep expired pages every 60s
9
+ this._sweepInterval = setInterval(() => this._sweep(), 60_000);
10
+ this._sweepInterval.unref();
11
+ }
12
+
13
+ /**
14
+ * Store a page.
15
+ * @param {string} id - UUID
16
+ * @param {object} opts - { html, ttl, callbackUrl, callbackToken, meta }
17
+ */
18
+ set(id, { html, ttl = DEFAULT_TTL, callbackUrl, callbackToken, meta, openclaw }) {
19
+ const expiresAt = Date.now() + ttl * 1000;
20
+ this.pages.set(id, {
21
+ html,
22
+ ttl,
23
+ expiresAt,
24
+ createdAt: Date.now(),
25
+ updatedAt: Date.now(),
26
+ callbackUrl: callbackUrl || null,
27
+ callbackToken: callbackToken || null,
28
+ meta: meta || {},
29
+ openclaw: openclaw || null,
30
+ viewCount: 0,
31
+ lastViewedAt: null,
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Get a page by id. Returns null if not found or expired.
37
+ * @param {string} id
38
+ * @returns {object|null}
39
+ */
40
+ get(id) {
41
+ const page = this.pages.get(id);
42
+ if (!page) return null;
43
+ if (Date.now() > page.expiresAt) {
44
+ this.pages.delete(id);
45
+ return null;
46
+ }
47
+ return page;
48
+ }
49
+
50
+ /**
51
+ * Update an existing page's HTML content. Resets TTL.
52
+ * @param {string} id
53
+ * @param {object} opts - { html, ttl }
54
+ * @returns {boolean} true if updated, false if not found/expired
55
+ */
56
+ update(id, { html, ttl }) {
57
+ const existing = this.get(id);
58
+ if (!existing) return false;
59
+ const newTtl = ttl ?? existing.ttl;
60
+ existing.html = html;
61
+ existing.ttl = newTtl;
62
+ existing.expiresAt = Date.now() + newTtl * 1000;
63
+ existing.updatedAt = Date.now();
64
+ return true;
65
+ }
66
+
67
+ /**
68
+ * Delete a page immediately.
69
+ * @param {string} id
70
+ * @returns {boolean}
71
+ */
72
+ delete(id) {
73
+ return this.pages.delete(id);
74
+ }
75
+
76
+ /**
77
+ * Record a page view. Increments viewCount and updates lastViewedAt.
78
+ * @param {string} id
79
+ */
80
+ recordView(id) {
81
+ const page = this.get(id);
82
+ if (!page) return;
83
+ page.viewCount = (page.viewCount || 0) + 1;
84
+ page.lastViewedAt = Date.now();
85
+ }
86
+
87
+ /**
88
+ * List all pages, optionally filtered.
89
+ * @param {object} opts - { status: 'active'|'expired'|'all', template: string }
90
+ * @returns {Array}
91
+ */
92
+ list({ status = 'active', template } = {}) {
93
+ const now = Date.now();
94
+ const results = [];
95
+ for (const [id, page] of this.pages) {
96
+ const isExpired = now > page.expiresAt;
97
+ const pageStatus = isExpired ? 'expired' : 'active';
98
+
99
+ if (status !== 'all' && pageStatus !== status) continue;
100
+ if (template && (page.meta && page.meta.template) !== template) continue;
101
+
102
+ results.push({
103
+ id,
104
+ template: (page.meta && page.meta.template) || null,
105
+ title: (page.meta && (page.meta.title || page.meta.og && page.meta.og.title)) || null,
106
+ createdAt: new Date(page.createdAt).toISOString(),
107
+ expiresAt: new Date(page.expiresAt).toISOString(),
108
+ viewCount: page.viewCount || 0,
109
+ lastViewedAt: page.lastViewedAt ? new Date(page.lastViewedAt).toISOString() : null,
110
+ status: pageStatus,
111
+ });
112
+ }
113
+ return results;
114
+ }
115
+
116
+ /**
117
+ * Get full page details (for API, not just HTML).
118
+ * @param {string} id
119
+ * @returns {object|null}
120
+ */
121
+ getDetails(id) {
122
+ const page = this.pages.get(id);
123
+ if (!page) return null;
124
+ const now = Date.now();
125
+ const isExpired = now > page.expiresAt;
126
+ return {
127
+ id,
128
+ template: (page.meta && page.meta.template) || null,
129
+ data: (page.meta && page.meta.data) || null,
130
+ createdAt: new Date(page.createdAt).toISOString(),
131
+ expiresAt: new Date(page.expiresAt).toISOString(),
132
+ updatedAt: new Date(page.updatedAt).toISOString(),
133
+ viewCount: page.viewCount || 0,
134
+ lastViewedAt: page.lastViewedAt ? new Date(page.lastViewedAt).toISOString() : null,
135
+ status: isExpired ? 'expired' : 'active',
136
+ meta: page.meta || {},
137
+ openclaw: page.openclaw || null,
138
+ ttl: page.ttl,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Check if a page ever existed (even if expired).
144
+ * For distinguishing 404 vs 410.
145
+ */
146
+ has(id) {
147
+ return this.pages.has(id);
148
+ }
149
+
150
+ /**
151
+ * Get callback info for a page.
152
+ * @param {string} id
153
+ * @returns {{ callbackUrl: string|null, callbackToken: string|null }|null}
154
+ */
155
+ getCallback(id) {
156
+ const page = this.get(id);
157
+ if (!page) return null;
158
+ return { callbackUrl: page.callbackUrl, callbackToken: page.callbackToken };
159
+ }
160
+
161
+ /**
162
+ * Get OpenClaw config for a page.
163
+ * @param {string} id
164
+ * @returns {object|null}
165
+ */
166
+ getOpenclaw(id) {
167
+ const page = this.get(id);
168
+ if (!page) return null;
169
+ return page.openclaw || null;
170
+ }
171
+
172
+ /** Sweep expired entries */
173
+ _sweep() {
174
+ const now = Date.now();
175
+ for (const [id, page] of this.pages) {
176
+ if (now > page.expiresAt) {
177
+ this.pages.delete(id);
178
+ }
179
+ }
180
+ }
181
+
182
+ /** For graceful shutdown */
183
+ destroy() {
184
+ clearInterval(this._sweepInterval);
185
+ this.pages.clear();
186
+ }
187
+
188
+ get size() {
189
+ return this.pages.size;
190
+ }
191
+ }
192
+
193
+ module.exports = { PageStore, DEFAULT_TTL };