levante 0.1.4 → 0.2.1

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,65 @@
1
+ // Self-contained IIFE injected into the tested page.
2
+ // __COMPANION_PORT__ is replaced before injection.
3
+ (function () {
4
+ const PORT = '__COMPANION_PORT__';
5
+ const wsUrl = `ws://localhost:${PORT}`;
6
+
7
+ const host = document.createElement('div');
8
+ host.id = '__levante-pill';
9
+ host.style.cssText = 'position:fixed;top:12px;right:12px;z-index:2147483647;pointer-events:none;';
10
+ document.body.appendChild(host);
11
+
12
+ const shadow = host.attachShadow({ mode: 'closed' });
13
+
14
+ const style = document.createElement('style');
15
+ style.textContent = `
16
+ :host { all: initial; }
17
+ .pill {
18
+ display: inline-flex; align-items: center; gap: 6px;
19
+ padding: 6px 14px; border-radius: 20px;
20
+ font-family: system-ui, -apple-system, sans-serif;
21
+ font-size: 12px; font-weight: 600;
22
+ backdrop-filter: blur(8px); transition: all 0.3s ease;
23
+ }
24
+ .pill.recording { background: rgba(26,10,10,0.9); border: 1px solid #ff4444; color: #ff6666; }
25
+ .pill.paused { background: rgba(26,20,10,0.9); border: 1px solid #f0a030; color: #f0a030; }
26
+ .pill.stopped { background: rgba(20,20,20,0.9); border: 1px solid #666; color: #888; opacity:0; transition: opacity 2s ease 3s; }
27
+ .dot { width: 8px; height: 8px; border-radius: 50%; }
28
+ .recording .dot { background: #ff4444; box-shadow: 0 0 8px #ff4444; animation: pulse 1.5s infinite; }
29
+ .paused .dot { background: #f0a030; }
30
+ .stopped .dot { background: #666; }
31
+ .timer { font-size: 11px; opacity: 0.7; font-variant-numeric: tabular-nums; }
32
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } }
33
+ `;
34
+ shadow.appendChild(style);
35
+
36
+ const pill = document.createElement('div');
37
+ pill.className = 'pill recording';
38
+ pill.innerHTML = '<div class="dot"></div><span class="label">REC</span><span class="timer">00:00</span>';
39
+ shadow.appendChild(pill);
40
+
41
+ const labelEl = pill.querySelector('.label');
42
+ const timerEl = pill.querySelector('.timer');
43
+
44
+ function fmt(sec) {
45
+ const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
46
+ return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
47
+ }
48
+
49
+ function updateUI(state) {
50
+ pill.className = `pill ${state.status}`;
51
+ timerEl.textContent = fmt(state.elapsed);
52
+ labelEl.textContent = state.status === 'recording' ? 'REC' : state.status === 'paused' ? 'PAUSED' : 'STOPPED';
53
+ }
54
+
55
+ let ws, reconnectTimer;
56
+ function connect() {
57
+ try {
58
+ ws = new WebSocket(wsUrl);
59
+ ws.onmessage = (e) => { try { const m = JSON.parse(e.data); if (m.type === 'state:update') updateUI(m.state); } catch {} };
60
+ ws.onclose = () => { reconnectTimer = setTimeout(connect, 2000); };
61
+ ws.onerror = () => {};
62
+ } catch {}
63
+ }
64
+ connect();
65
+ })();
package/scripts/ui.mjs ADDED
@@ -0,0 +1,98 @@
1
+ import pc from 'picocolors';
2
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Print a bordered session header box to stderr.
7
+ * @param {string} title e.g. "levante record"
8
+ * @param {string} key e.g. "KAN-56"
9
+ * @param {Array<{label: string, value: string}>} items
10
+ */
11
+ export function printHeader(title, key, items = []) {
12
+ const heading = key ? `${title} ${pc.bold(key)}` : title;
13
+ const labelWidth = items.length ? Math.max(...items.map((i) => i.label.length)) : 0;
14
+ const contentLines = items.map((i) => ` ${i.label.padEnd(labelWidth)} ${i.value}`);
15
+ const innerWidth = Math.max(
16
+ stripAnsi(heading).length + 2,
17
+ ...contentLines.map(stripAnsi).map((l) => l.length),
18
+ ) + 2;
19
+
20
+ const top = pc.cyan('┌' + '─'.repeat(innerWidth) + '┐');
21
+ const titleLine =
22
+ pc.cyan('│') + ' ' + pc.bold(heading) +
23
+ ' '.repeat(innerWidth - stripAnsi(heading).length - 1) + pc.cyan('│');
24
+ const divider = pc.cyan('│') + pc.dim('─'.repeat(innerWidth)) + pc.cyan('│');
25
+ const rows = contentLines.map((line) => {
26
+ const raw = stripAnsi(line);
27
+ return pc.cyan('│') + line + ' '.repeat(innerWidth - raw.length) + pc.cyan('│');
28
+ });
29
+ const bottom = pc.cyan('└' + '─'.repeat(innerWidth) + '┘');
30
+
31
+ process.stderr.write('\n');
32
+ process.stderr.write(top + '\n');
33
+ process.stderr.write(titleLine + '\n');
34
+ if (items.length > 0) process.stderr.write(divider + '\n');
35
+ for (const row of rows) process.stderr.write(row + '\n');
36
+ process.stderr.write(bottom + '\n');
37
+ }
38
+
39
+ /**
40
+ * Print a ✓ success card to stderr.
41
+ * @param {string} message
42
+ * @param {Array<{label: string, value: string}>} items
43
+ */
44
+ export function printSuccess(message, items = []) {
45
+ process.stderr.write('\n' + pc.green('✓') + ' ' + pc.bold(message) + '\n');
46
+ _printCardItems(items);
47
+ process.stderr.write('\n');
48
+ }
49
+
50
+ /**
51
+ * Print a ✗ error card to stderr.
52
+ * @param {string} message
53
+ * @param {Array<{label: string, value: string}>} items
54
+ */
55
+ export function printError(message, items = []) {
56
+ process.stderr.write('\n' + pc.red('✗') + ' ' + pc.bold(message) + '\n');
57
+ _printCardItems(items);
58
+ process.stderr.write('\n');
59
+ }
60
+
61
+ /**
62
+ * Write a log file. Falls back to printing entries to stderr if write fails.
63
+ * @param {string} dir Absolute directory path
64
+ * @param {string} filename e.g. "record-error.log"
65
+ * @param {string[]} entries Pre-formatted lines
66
+ * @returns {string|null} Absolute path of written file, or null on failure
67
+ */
68
+ export function saveErrorLog(dir, filename, entries) {
69
+ if (!entries.length) return null;
70
+ try {
71
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
72
+ const filePath = join(dir, filename);
73
+ writeFileSync(filePath, entries.join('\n') + '\n', 'utf-8');
74
+ return filePath;
75
+ } catch (err) {
76
+ process.stderr.write('[log write failed] ' + err.message + '\n');
77
+ for (const entry of entries) process.stderr.write(entry + '\n');
78
+ return null;
79
+ }
80
+ }
81
+
82
+ // ---- internal helpers ----
83
+
84
+ function _printCardItems(items) {
85
+ if (!items.length) return;
86
+ const labelWidth = Math.max(...items.map((i) => i.label.length));
87
+ for (const item of items) {
88
+ process.stderr.write(
89
+ ' ' + pc.dim(item.label.padEnd(labelWidth)) + ' ' + item.value + '\n'
90
+ );
91
+ }
92
+ }
93
+
94
+ /** Strip ANSI SGR and OSC escape codes for length calculation. */
95
+ function stripAnsi(str) {
96
+ // eslint-disable-next-line no-control-regex
97
+ return str.replace(/(\x1b\[[0-9;]*m|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\)/g, '');
98
+ }