@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.
- package/.env.example +9 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/SKILL.md +242 -0
- package/bin/deploy +23 -0
- package/bin/sparkui.js +390 -0
- package/docs/README.md +51 -0
- package/docs/api-reference.md +428 -0
- package/docs/chatgpt-setup.md +206 -0
- package/docs/components.md +432 -0
- package/docs/getting-started.md +179 -0
- package/docs/mcp-setup.md +195 -0
- package/docs/openclaw-setup.md +177 -0
- package/docs/templates.md +289 -0
- package/lib/components.js +474 -0
- package/lib/store.js +193 -0
- package/lib/templates.js +48 -0
- package/lib/ws-client.js +197 -0
- package/mcp-server/README.md +189 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +15 -0
- package/package.json +52 -0
- package/server.js +620 -0
- package/templates/base.js +82 -0
- package/templates/checkout.js +271 -0
- package/templates/feedback-form.js +140 -0
- package/templates/macro-tracker.js +205 -0
- package/templates/workout-timer.js +510 -0
- package/templates/ws-test.js +136 -0
package/bin/sparkui.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const { spawn } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const command = args[0];
|
|
13
|
+
|
|
14
|
+
// Resolve the package root (where server.js lives)
|
|
15
|
+
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
16
|
+
|
|
17
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function getArg(name) {
|
|
20
|
+
const idx = args.indexOf(name);
|
|
21
|
+
return idx >= 0 && args[idx + 1] ? args[idx + 1] : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasFlag(name) {
|
|
25
|
+
return args.includes(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load .env from a given directory into an object (does NOT modify process.env).
|
|
30
|
+
*/
|
|
31
|
+
function loadEnvFile(dir) {
|
|
32
|
+
const envPath = path.join(dir, '.env');
|
|
33
|
+
const vars = {};
|
|
34
|
+
if (fs.existsSync(envPath)) {
|
|
35
|
+
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
|
|
36
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
37
|
+
if (m) vars[m[1]] = m[2];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return vars;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve config: CWD .env > PKG_ROOT .env > defaults
|
|
45
|
+
*/
|
|
46
|
+
function resolveConfig() {
|
|
47
|
+
const cwdEnv = loadEnvFile(process.cwd());
|
|
48
|
+
const pkgEnv = loadEnvFile(PKG_ROOT);
|
|
49
|
+
const env = { ...pkgEnv, ...cwdEnv };
|
|
50
|
+
|
|
51
|
+
// CLI overrides
|
|
52
|
+
const portOverride = getArg('--port');
|
|
53
|
+
if (portOverride) env.SPARKUI_PORT = portOverride;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
port: parseInt(env.SPARKUI_PORT, 10) || 3457,
|
|
57
|
+
pushToken: env.PUSH_TOKEN || '',
|
|
58
|
+
baseUrl: env.SPARKUI_BASE_URL || `http://localhost:${parseInt(env.SPARKUI_PORT, 10) || 3457}`,
|
|
59
|
+
envFile: fs.existsSync(path.join(process.cwd(), '.env'))
|
|
60
|
+
? path.join(process.cwd(), '.env')
|
|
61
|
+
: fs.existsSync(path.join(PKG_ROOT, '.env'))
|
|
62
|
+
? path.join(PKG_ROOT, '.env')
|
|
63
|
+
: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function usage() {
|
|
68
|
+
console.log(`
|
|
69
|
+
⚡ SparkUI CLI
|
|
70
|
+
|
|
71
|
+
Usage:
|
|
72
|
+
sparkui init Initialize SparkUI in current directory
|
|
73
|
+
sparkui start [--port <port>] Start the SparkUI server
|
|
74
|
+
sparkui stop Stop the SparkUI server
|
|
75
|
+
sparkui push --html <file> Push raw HTML
|
|
76
|
+
sparkui push --template <name> --data <json> Push from template
|
|
77
|
+
sparkui update <id> --html <file> Update a page
|
|
78
|
+
sparkui delete <id> Delete a page
|
|
79
|
+
sparkui status Check server health
|
|
80
|
+
|
|
81
|
+
Options:
|
|
82
|
+
--port <port> Override server port (start command)
|
|
83
|
+
--ttl <seconds> Page TTL (default: 3600)
|
|
84
|
+
--token <token> Push token (or set PUSH_TOKEN env)
|
|
85
|
+
|
|
86
|
+
Environment:
|
|
87
|
+
SPARKUI_URL Server base URL (default: http://localhost:3457)
|
|
88
|
+
PUSH_TOKEN Authentication token
|
|
89
|
+
`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Init Command ─────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function cmdInit() {
|
|
95
|
+
const cwd = process.cwd();
|
|
96
|
+
const envDest = path.join(cwd, '.env');
|
|
97
|
+
|
|
98
|
+
// Check if .env already exists
|
|
99
|
+
if (fs.existsSync(envDest)) {
|
|
100
|
+
console.log('⚠️ .env already exists in this directory. Skipping init.');
|
|
101
|
+
console.log(' Delete it first if you want to re-initialize.');
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Read .env.example from the package
|
|
106
|
+
const examplePath = path.join(PKG_ROOT, '.env.example');
|
|
107
|
+
if (!fs.existsSync(examplePath)) {
|
|
108
|
+
console.error('Error: .env.example not found in package. Something is wrong with the installation.');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let envContent = fs.readFileSync(examplePath, 'utf-8');
|
|
113
|
+
|
|
114
|
+
// Generate a random push token
|
|
115
|
+
const token = 'spk_' + crypto.randomBytes(24).toString('hex');
|
|
116
|
+
envContent = envContent.replace('your-random-token-here', token);
|
|
117
|
+
|
|
118
|
+
// Write .env
|
|
119
|
+
fs.writeFileSync(envDest, envContent);
|
|
120
|
+
|
|
121
|
+
console.log(`
|
|
122
|
+
⚡ SparkUI initialized!
|
|
123
|
+
|
|
124
|
+
📁 Config: ${envDest}
|
|
125
|
+
🔑 Push token: ${token.slice(0, 12)}...${token.slice(-4)}
|
|
126
|
+
🌐 Port: 3457
|
|
127
|
+
🔗 Base URL: http://localhost:3457
|
|
128
|
+
|
|
129
|
+
Next steps:
|
|
130
|
+
1. Review .env and adjust settings if needed
|
|
131
|
+
2. Run: sparkui start
|
|
132
|
+
3. Push a page:
|
|
133
|
+
curl -X POST http://localhost:3457/api/push \\
|
|
134
|
+
-H "Authorization: Bearer ${token}" \\
|
|
135
|
+
-H "Content-Type: application/json" \\
|
|
136
|
+
-d '{"template":"feedback-form","data":{"title":"Hello SparkUI!"}}'
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Start Command ────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function cmdStart() {
|
|
143
|
+
const config = resolveConfig();
|
|
144
|
+
const pidFile = path.join(process.cwd(), '.sparkui.pid');
|
|
145
|
+
|
|
146
|
+
// Check if already running
|
|
147
|
+
if (fs.existsSync(pidFile)) {
|
|
148
|
+
const existingPid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
149
|
+
try {
|
|
150
|
+
process.kill(existingPid, 0); // check if process exists
|
|
151
|
+
console.log(`⚠️ SparkUI is already running (PID ${existingPid}).`);
|
|
152
|
+
console.log(' Run "sparkui stop" first, or delete .sparkui.pid if stale.');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
} catch {
|
|
155
|
+
// Process doesn't exist — stale PID file, clean it up
|
|
156
|
+
fs.unlinkSync(pidFile);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!config.envFile) {
|
|
161
|
+
console.log('⚠️ No .env file found. Run "sparkui init" first, or create a .env file.');
|
|
162
|
+
console.log(' Starting with defaults...');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Build env for the child process
|
|
166
|
+
const childEnv = { ...process.env };
|
|
167
|
+
if (config.envFile) {
|
|
168
|
+
const envVars = loadEnvFile(path.dirname(config.envFile));
|
|
169
|
+
Object.assign(childEnv, envVars);
|
|
170
|
+
}
|
|
171
|
+
// CLI overrides
|
|
172
|
+
if (getArg('--port')) childEnv.SPARKUI_PORT = getArg('--port');
|
|
173
|
+
|
|
174
|
+
const serverScript = path.join(PKG_ROOT, 'server.js');
|
|
175
|
+
const child = spawn(process.execPath, [serverScript], {
|
|
176
|
+
env: childEnv,
|
|
177
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
178
|
+
detached: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Write PID file
|
|
182
|
+
fs.writeFileSync(pidFile, String(child.pid));
|
|
183
|
+
|
|
184
|
+
// Capture initial output briefly to confirm startup
|
|
185
|
+
let started = false;
|
|
186
|
+
const timeout = setTimeout(() => {
|
|
187
|
+
if (!started) {
|
|
188
|
+
console.log(`⚡ SparkUI server starting (PID ${child.pid})...`);
|
|
189
|
+
console.log(` Port: ${config.port}`);
|
|
190
|
+
console.log(` PID file: ${pidFile}`);
|
|
191
|
+
console.log(` Stop with: sparkui stop`);
|
|
192
|
+
child.unref();
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
}, 3000);
|
|
196
|
+
|
|
197
|
+
child.stdout.on('data', (data) => {
|
|
198
|
+
const output = data.toString();
|
|
199
|
+
if (output.includes('SparkUI server running')) {
|
|
200
|
+
started = true;
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
console.log(`⚡ SparkUI server started (PID ${child.pid})`);
|
|
203
|
+
console.log(` 🌐 http://localhost:${config.port}/`);
|
|
204
|
+
if (config.pushToken) {
|
|
205
|
+
console.log(` 🔑 Token: ${config.pushToken.slice(0, 12)}...${config.pushToken.slice(-4)}`);
|
|
206
|
+
}
|
|
207
|
+
console.log(` 📄 PID file: ${pidFile}`);
|
|
208
|
+
console.log(` Stop with: sparkui stop`);
|
|
209
|
+
child.unref();
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
child.stderr.on('data', (data) => {
|
|
215
|
+
const output = data.toString().trim();
|
|
216
|
+
if (output) {
|
|
217
|
+
console.error(`[server] ${output}`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
child.on('error', (err) => {
|
|
222
|
+
clearTimeout(timeout);
|
|
223
|
+
console.error(`Error starting server: ${err.message}`);
|
|
224
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
225
|
+
process.exit(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
child.on('exit', (code) => {
|
|
229
|
+
if (!started) {
|
|
230
|
+
clearTimeout(timeout);
|
|
231
|
+
console.error(`Server exited with code ${code} before starting.`);
|
|
232
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Stop Command ─────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function cmdStop() {
|
|
241
|
+
const pidFile = path.join(process.cwd(), '.sparkui.pid');
|
|
242
|
+
|
|
243
|
+
if (!fs.existsSync(pidFile)) {
|
|
244
|
+
console.log('⚠️ No .sparkui.pid file found. Is SparkUI running from this directory?');
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
process.kill(pid, 'SIGTERM');
|
|
252
|
+
console.log(`⚡ SparkUI server stopped (PID ${pid}).`);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err.code === 'ESRCH') {
|
|
255
|
+
console.log(`⚠️ Process ${pid} not found. Cleaning up stale PID file.`);
|
|
256
|
+
} else {
|
|
257
|
+
console.error(`Error stopping server: ${err.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Client Commands (push/update/delete/status) ─────────────────────────────
|
|
265
|
+
|
|
266
|
+
const BASE_URL = process.env.SPARKUI_URL || 'http://localhost:3457';
|
|
267
|
+
const TOKEN = process.env.PUSH_TOKEN || getArg('--token') || '';
|
|
268
|
+
|
|
269
|
+
function request(method, urlPath, body) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const url = new URL(urlPath, BASE_URL);
|
|
272
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
273
|
+
const opts = {
|
|
274
|
+
method,
|
|
275
|
+
hostname: url.hostname,
|
|
276
|
+
port: url.port,
|
|
277
|
+
path: url.pathname,
|
|
278
|
+
headers: {
|
|
279
|
+
'Content-Type': 'application/json',
|
|
280
|
+
'Authorization': `Bearer ${TOKEN}`,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const req = mod.request(opts, (res) => {
|
|
285
|
+
let data = '';
|
|
286
|
+
res.on('data', c => data += c);
|
|
287
|
+
res.on('end', () => {
|
|
288
|
+
try {
|
|
289
|
+
resolve({ status: res.statusCode, body: JSON.parse(data) });
|
|
290
|
+
} catch {
|
|
291
|
+
resolve({ status: res.statusCode, body: data });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
req.on('error', reject);
|
|
296
|
+
if (body) req.write(JSON.stringify(body));
|
|
297
|
+
req.end();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function cmdStatus() {
|
|
302
|
+
const res = await request('GET', '/');
|
|
303
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function cmdPush() {
|
|
307
|
+
const htmlFile = getArg('--html');
|
|
308
|
+
const template = getArg('--template');
|
|
309
|
+
const dataStr = getArg('--data');
|
|
310
|
+
const ttl = parseInt(getArg('--ttl') || '3600', 10);
|
|
311
|
+
|
|
312
|
+
const body = { ttl };
|
|
313
|
+
if (htmlFile) {
|
|
314
|
+
body.html = fs.readFileSync(path.resolve(htmlFile), 'utf-8');
|
|
315
|
+
} else if (template) {
|
|
316
|
+
body.template = template;
|
|
317
|
+
body.data = dataStr ? JSON.parse(dataStr) : {};
|
|
318
|
+
} else {
|
|
319
|
+
console.error('Error: provide --html <file> or --template <name>');
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const res = await request('POST', '/api/push', body);
|
|
324
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function cmdUpdate() {
|
|
328
|
+
const id = args[1];
|
|
329
|
+
if (!id) { console.error('Error: provide page ID'); process.exit(1); }
|
|
330
|
+
const htmlFile = getArg('--html');
|
|
331
|
+
const template = getArg('--template');
|
|
332
|
+
const dataStr = getArg('--data');
|
|
333
|
+
const body = {};
|
|
334
|
+
if (htmlFile) body.html = fs.readFileSync(path.resolve(htmlFile), 'utf-8');
|
|
335
|
+
if (template) { body.template = template; body.data = dataStr ? JSON.parse(dataStr) : {}; }
|
|
336
|
+
const ttl = getArg('--ttl');
|
|
337
|
+
if (ttl) body.ttl = parseInt(ttl, 10);
|
|
338
|
+
|
|
339
|
+
const res = await request('PATCH', `/api/pages/${id}`, body);
|
|
340
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function cmdDelete() {
|
|
344
|
+
const id = args[1];
|
|
345
|
+
if (!id) { console.error('Error: provide page ID'); process.exit(1); }
|
|
346
|
+
const res = await request('DELETE', `/api/pages/${id}`);
|
|
347
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
async function main() {
|
|
353
|
+
if (!command || command === '--help' || command === '-h') {
|
|
354
|
+
usage();
|
|
355
|
+
process.exit(0);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
switch (command) {
|
|
359
|
+
case 'init':
|
|
360
|
+
cmdInit();
|
|
361
|
+
break;
|
|
362
|
+
case 'start':
|
|
363
|
+
cmdStart();
|
|
364
|
+
break;
|
|
365
|
+
case 'stop':
|
|
366
|
+
cmdStop();
|
|
367
|
+
break;
|
|
368
|
+
case 'status':
|
|
369
|
+
await cmdStatus();
|
|
370
|
+
break;
|
|
371
|
+
case 'push':
|
|
372
|
+
await cmdPush();
|
|
373
|
+
break;
|
|
374
|
+
case 'update':
|
|
375
|
+
await cmdUpdate();
|
|
376
|
+
break;
|
|
377
|
+
case 'delete':
|
|
378
|
+
await cmdDelete();
|
|
379
|
+
break;
|
|
380
|
+
default:
|
|
381
|
+
console.error(`Unknown command: ${command}`);
|
|
382
|
+
usage();
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
main().catch(err => {
|
|
388
|
+
console.error('Error:', err.message);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
});
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# ⚡ SparkUI Documentation
|
|
2
|
+
|
|
3
|
+
**Ephemeral interactive UIs for AI agents.** Generate rich web pages from chat — no app install required.
|
|
4
|
+
|
|
5
|
+
SparkUI lets AI agents create polished, interactive web pages on demand. Instead of walls of text, your agent generates a URL the user clicks to see dashboards, forms, timers, and more. Pages auto-expire after a configurable TTL.
|
|
6
|
+
|
|
7
|
+
## Quick Navigation
|
|
8
|
+
|
|
9
|
+
| Guide | Description |
|
|
10
|
+
|-------|-------------|
|
|
11
|
+
| [Getting Started](./getting-started.md) | Install, configure, and push your first page |
|
|
12
|
+
| [API Reference](./api-reference.md) | Full REST API and WebSocket protocol documentation |
|
|
13
|
+
| [Templates](./templates.md) | 5 built-in templates with schemas and examples |
|
|
14
|
+
| [Components](./components.md) | 8 composable components for custom pages |
|
|
15
|
+
| [MCP Setup](./mcp-setup.md) | Use SparkUI from Claude Desktop, Cursor, or Windsurf |
|
|
16
|
+
| [OpenClaw Setup](./openclaw-setup.md) | Integrate SparkUI as an OpenClaw agent skill |
|
|
17
|
+
| [ChatGPT Setup](./chatgpt-setup.md) | Use SparkUI with ChatGPT Custom GPTs via Actions |
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
┌─────────┐ POST /api/push ┌──────────────┐ GET /s/:id ┌─────────┐
|
|
23
|
+
│ Agent │ ──────────────────────▶│ SparkUI │◀────────────────── │ Browser │
|
|
24
|
+
│ (AI/MCP) │ │ Server │ ──────────────────▶│ (User) │
|
|
25
|
+
│ │◀── WebSocket ──────────│ :3457 │──── WebSocket ────▶│ │
|
|
26
|
+
└─────────┘ completion events └──────────────┘ user actions └─────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Flow:**
|
|
30
|
+
1. Agent pushes a page (template, composed, or raw HTML)
|
|
31
|
+
2. Server generates HTML, stores in memory, returns URL
|
|
32
|
+
3. User opens URL in any browser
|
|
33
|
+
4. User interacts (checks items, fills forms, clicks buttons)
|
|
34
|
+
5. Actions flow back to the agent via WebSocket
|
|
35
|
+
6. Page auto-expires after TTL
|
|
36
|
+
|
|
37
|
+
## Key Features
|
|
38
|
+
|
|
39
|
+
- 🎯 **No app install** — just a URL that works in any browser
|
|
40
|
+
- ⏱️ **Ephemeral** — pages auto-expire (default 1 hour)
|
|
41
|
+
- 🔄 **Bidirectional** — user actions flow back to the agent via WebSocket
|
|
42
|
+
- 🧩 **Composable** — 8 components you can mix and match
|
|
43
|
+
- 📱 **Mobile-first** — designed for phones, works everywhere
|
|
44
|
+
- 🔌 **MCP compatible** — works with Claude Desktop, Cursor, Windsurf
|
|
45
|
+
- 🌙 **Dark theme** — easy on the eyes, polished look
|
|
46
|
+
|
|
47
|
+
## Links
|
|
48
|
+
|
|
49
|
+
- **GitHub:** [github.com/Limeade-Labs/sparkui](https://github.com/Limeade-Labs/sparkui)
|
|
50
|
+
- **npm:** [npmjs.com/package/sparkui](https://www.npmjs.com/package/sparkui)
|
|
51
|
+
- **Homepage:** [sparkui.dev](https://sparkui.dev)
|