soxogvv commited on
Commit
b64654b
Β·
verified Β·
1 Parent(s): 441ac51

Upload 4 files

Browse files
Files changed (4) hide show
  1. public/app.js +438 -0
  2. public/index.html +201 -0
  3. public/qrcode.min.js +1 -0
  4. public/style.css +508 -0
public/app.js ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── State ───────────────────────────────────────────────────────────────── */
2
+ let token = sessionStorage.getItem('shellular_token') || null;
3
+ let evtSource = null;
4
+ let fullOutput = '';
5
+ let shellStatus = 'stopped';
6
+ let qrRendered = false;
7
+
8
+ /* ── DOM helpers ─────────────────────────────────────────────────────────── */
9
+ const $ = (id) => document.getElementById(id);
10
+
11
+ const loginPage = $('login-page');
12
+ const dashPage = $('dashboard-page');
13
+ const loginForm = $('login-form');
14
+ const keyInput = $('key-input');
15
+ const loginBtn = $('login-btn');
16
+ const loginLabel = $('login-label');
17
+ const loginSpinner = $('login-spinner');
18
+ const loginError = $('login-error');
19
+ const toggleVisBtn = $('toggle-vis');
20
+ const eyeOpen = $('eye-open');
21
+ const eyeClosed = $('eye-closed');
22
+ const statusBadge = $('status-badge');
23
+ const restartBtn = $('restart-btn');
24
+ const logoutBtn = $('logout-btn');
25
+ const clearLogBtn = $('clear-log-btn');
26
+ const qrLoading = $('qr-loading');
27
+ const qrReady = $('qr-ready');
28
+ const qrError = $('qr-error');
29
+ const qrErrorMsg = $('qr-error-msg');
30
+ const qrCanvas = $('qr-canvas');
31
+ const logPre = $('log-pre');
32
+
33
+ /* ── Routing ─────────────────────────────────────────────────────────────── */
34
+ function showLogin() {
35
+ loginPage.classList.remove('hidden');
36
+ dashPage.classList.add('hidden');
37
+ keyInput.focus();
38
+ }
39
+
40
+ function showDashboard() {
41
+ loginPage.classList.add('hidden');
42
+ dashPage.classList.remove('hidden');
43
+ connectStream();
44
+ ensureShellularRunning(); // always start shellular, whether fresh login or returning session
45
+ }
46
+
47
+ /* ── Password visibility toggle ──────────────────────────────────────────── */
48
+ toggleVisBtn.addEventListener('click', () => {
49
+ const isPassword = keyInput.type === 'password';
50
+ keyInput.type = isPassword ? 'text' : 'password';
51
+ eyeOpen.classList.toggle('hidden', isPassword);
52
+ eyeClosed.classList.toggle('hidden', !isPassword);
53
+ });
54
+
55
+ /* ── Login ───────────────────────────────────────────────────────────────── */
56
+ loginForm.addEventListener('submit', async (e) => {
57
+ e.preventDefault();
58
+ const key = keyInput.value.trim();
59
+ if (!key) return;
60
+
61
+ loginBtn.disabled = true;
62
+ loginLabel.classList.add('hidden');
63
+ loginSpinner.classList.remove('hidden');
64
+ loginError.classList.add('hidden');
65
+
66
+ try {
67
+ const res = await fetch('/api/login', {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ key }),
71
+ });
72
+
73
+ const data = await res.json();
74
+
75
+ if (!res.ok) {
76
+ throw new Error(data.error || 'Login failed');
77
+ }
78
+
79
+ token = data.token;
80
+ sessionStorage.setItem('shellular_token', token);
81
+ keyInput.value = '';
82
+ showDashboard();
83
+ } catch (err) {
84
+ loginError.textContent = err.message;
85
+ loginError.classList.remove('hidden');
86
+ } finally {
87
+ loginBtn.disabled = false;
88
+ loginLabel.classList.remove('hidden');
89
+ loginSpinner.classList.add('hidden');
90
+ }
91
+ });
92
+
93
+ /* ── Logout ──────────────────────────────────────────────────────────────── */
94
+ logoutBtn.addEventListener('click', async () => {
95
+ if (evtSource) { evtSource.close(); evtSource = null; }
96
+ await fetch('/api/logout', {
97
+ method: 'POST',
98
+ headers: { Authorization: `Bearer ${token}` },
99
+ }).catch(() => {});
100
+ token = null;
101
+ sessionStorage.removeItem('shellular_token');
102
+ fullOutput = '';
103
+ logPre.textContent = '';
104
+ qrPre.textContent = '';
105
+ showLogin();
106
+ });
107
+
108
+ /* ── SSE stream ──────────────────────────────────────────────────────────── */
109
+ function connectStream() {
110
+ if (evtSource) { evtSource.close(); }
111
+
112
+ evtSource = new EventSource(`/api/stream?t=${Date.now()}`, {});
113
+
114
+ // EventSource doesn't support custom headers, so we pass the token
115
+ // as a cookie or query param workaround via a short-lived fetch first.
116
+ // Instead, we do: close the native EventSource approach and use fetch-based SSE.
117
+ evtSource.close();
118
+ evtSource = null;
119
+ startFetchSSE();
120
+ }
121
+
122
+ async function startFetchSSE() {
123
+ try {
124
+ const res = await fetch('/api/stream', {
125
+ headers: { Authorization: `Bearer ${token}` },
126
+ cache: 'no-store',
127
+ });
128
+
129
+ if (res.status === 401) {
130
+ sessionStorage.removeItem('shellular_token');
131
+ token = null;
132
+ showLogin();
133
+ return;
134
+ }
135
+
136
+ if (!res.ok || !res.body) {
137
+ setTimeout(startFetchSSE, 3000);
138
+ return;
139
+ }
140
+
141
+ const reader = res.body.getReader();
142
+ const decoder = new TextDecoder();
143
+ let partial = '';
144
+
145
+ while (true) {
146
+ const { value, done } = await reader.read();
147
+ if (done) break;
148
+
149
+ partial += decoder.decode(value, { stream: true });
150
+ const parts = partial.split('\n\n');
151
+ partial = parts.pop(); // keep incomplete last chunk
152
+
153
+ for (const part of parts) {
154
+ const line = part.trim();
155
+ if (!line.startsWith('data:')) continue;
156
+ try {
157
+ const payload = JSON.parse(line.slice(5).trim());
158
+ handleEvent(payload);
159
+ } catch { /* ignore parse errors */ }
160
+ }
161
+ }
162
+ } catch {
163
+ // Connection dropped β€” retry after 2s
164
+ }
165
+
166
+ setTimeout(startFetchSSE, 2000);
167
+ }
168
+
169
+ /* ── Event handler ───────────────────────────────────────────────────────── */
170
+ function handleEvent(payload) {
171
+ if (payload.type === 'status') {
172
+ updateStatus(payload.status);
173
+ } else if (payload.type === 'output') {
174
+ appendOutput(payload.text);
175
+ } else if (payload.type === 'clear') {
176
+ fullOutput = '';
177
+ logPre.textContent = '';
178
+ qrRendered = false;
179
+ setQrState('loading');
180
+ }
181
+ }
182
+
183
+ /* ── Status display ──────────────────────────────────────────────────────── */
184
+ function updateStatus(status) {
185
+ shellStatus = status;
186
+
187
+ const labels = {
188
+ running: 'Running',
189
+ starting: 'Starting',
190
+ retrying: 'Retrying…',
191
+ stopped: 'Stopped',
192
+ error: 'Error',
193
+ };
194
+ statusBadge.textContent = labels[status] || status;
195
+ statusBadge.className = `badge badge-${status}`;
196
+
197
+ if (status === 'retrying') {
198
+ if (!qrRendered) setQrState('loading');
199
+ return; // keep showing the spinner while we wait
200
+ }
201
+
202
+ if (status === 'stopped' || status === 'error') {
203
+ if (!qrRendered) {
204
+ setQrState('error');
205
+ qrErrorMsg.textContent =
206
+ status === 'error'
207
+ ? 'Shellular failed to start. Check the output log for details.'
208
+ : 'Shellular stopped. Click "Try again" to restart.';
209
+ }
210
+ }
211
+
212
+ if (status === 'starting') {
213
+ if (!qrRendered) setQrState('loading');
214
+ }
215
+
216
+ // Once shellular is running, fetch the QR data and render it
217
+ if (status === 'running' && !qrRendered) {
218
+ fetchAndRenderQR();
219
+ }
220
+ }
221
+
222
+ /* ── QR state machine ────────────────────────────────────────────────────── */
223
+ function setQrState(state) {
224
+ qrLoading.classList.toggle('hidden', state !== 'loading');
225
+ qrReady.classList.toggle('hidden', state !== 'ready');
226
+ qrError.classList.toggle('hidden', state !== 'error');
227
+ }
228
+
229
+ /* ── QR rendering ────────────────────────────────────────────────────────── */
230
+ async function fetchAndRenderQR() {
231
+ for (let i = 0; i < 8; i++) {
232
+ await new Promise(r => setTimeout(r, i === 0 ? 1000 : 2500));
233
+ try {
234
+ const res = await authFetch('/api/shellular/qr-data');
235
+ if (!res || !res.ok) continue;
236
+ const json = await res.json();
237
+ const qrData = json.qrData;
238
+ if (!qrData) continue;
239
+
240
+ // Show the container FIRST so the div has real dimensions when QRCode renders
241
+ setQrState('ready');
242
+
243
+ // Wipe any previous render
244
+ qrCanvas.innerHTML = '';
245
+
246
+ new QRCode(qrCanvas, {
247
+ text: qrData,
248
+ width: 220,
249
+ height: 220,
250
+ colorDark: '#000000',
251
+ colorLight: '#ffffff',
252
+ correctLevel: QRCode.CorrectLevel.M,
253
+ });
254
+ qrRendered = true;
255
+ return;
256
+ } catch (err) {
257
+ console.warn('fetchAndRenderQR attempt', i, err);
258
+ }
259
+ }
260
+ if (!qrRendered) {
261
+ setQrState('error');
262
+ qrErrorMsg.textContent = 'Could not render QR. Try restarting.';
263
+ }
264
+ }
265
+
266
+ /* ── Output processing ───────────────────────────────────────────────────── */
267
+ function appendOutput(text) {
268
+ if (!text) return;
269
+ fullOutput += text;
270
+ logPre.textContent = fullOutput;
271
+ logPre.scrollTop = logPre.scrollHeight;
272
+
273
+ // Show manual registration card as soon as rate-limit message appears
274
+ if (text.includes('rate-limited') || text.includes('Registration rate-limited')) {
275
+ rateLimitCount++;
276
+ if (rateLimitCount >= 1) loadManualCard();
277
+ }
278
+ }
279
+
280
+ /* ── Controls ───────────────────────���────────────────────────────────────── */
281
+ restartBtn.addEventListener('click', restartShellular);
282
+
283
+ async function restartShellular() {
284
+ fullOutput = '';
285
+ logPre.textContent = '';
286
+ qrRendered = false;
287
+ setQrState('loading');
288
+ await authFetch('/api/shellular/restart', 'POST');
289
+ }
290
+
291
+ clearLogBtn.addEventListener('click', () => {
292
+ logPre.textContent = '';
293
+ });
294
+
295
+ async function authFetch(url, method = 'GET') {
296
+ const res = await fetch(url, {
297
+ method,
298
+ headers: { Authorization: `Bearer ${token}` },
299
+ });
300
+ if (res.status === 401) {
301
+ token = null;
302
+ sessionStorage.removeItem('shellular_token');
303
+ showLogin();
304
+ }
305
+ return res;
306
+ }
307
+
308
+ /* ── Manual registration fallback ───────────────────────────────────────── */
309
+ const manualCard = $('manual-reg-card');
310
+ const manualCurlCmd = $('manual-curl-cmd');
311
+ const manualHostInput = $('manual-host-id');
312
+ const manualSubmitBtn = $('manual-submit-btn');
313
+ const manualError = $('manual-error');
314
+
315
+ let rateLimitCount = 0;
316
+ let machineIdLoaded = false;
317
+
318
+ async function loadManualCard() {
319
+ if (machineIdLoaded) { manualCard.classList.remove('hidden'); return; }
320
+ try {
321
+ const res = await fetch('/api/shellular/machine-id');
322
+ const { machineId } = await res.json();
323
+ const cmd = `curl -s -X POST "https://api.shellular.dev/register" -H "Content-Type: application/json" -d '{"machineId":"${machineId}","platform":"linux"}'`;
324
+ manualCurlCmd.textContent = cmd;
325
+ machineIdLoaded = true;
326
+ manualCard.classList.remove('hidden');
327
+ } catch { /* silent */ }
328
+ }
329
+
330
+ manualSubmitBtn.addEventListener('click', async () => {
331
+ const hostId = manualHostInput.value.trim();
332
+ if (!hostId) {
333
+ manualError.textContent = 'Please enter the hostId from the curl response.';
334
+ manualError.classList.remove('hidden');
335
+ return;
336
+ }
337
+ manualError.classList.add('hidden');
338
+ manualSubmitBtn.disabled = true;
339
+ manualSubmitBtn.textContent = 'Connecting…';
340
+
341
+ try {
342
+ const r = await fetch('/api/shellular/seed-host', {
343
+ method: 'POST',
344
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
345
+ body: JSON.stringify({ hostId }),
346
+ });
347
+ const data = await r.json();
348
+ if (!r.ok) throw new Error(data.error || 'Failed');
349
+
350
+ manualCard.classList.add('hidden');
351
+ rateLimitCount = 0;
352
+ // shellular is restarting β€” clear output and show loading
353
+ fullOutput = '';
354
+ logPre.textContent = '';
355
+ qrRendered = false;
356
+ setQrState('loading');
357
+ } catch (err) {
358
+ manualError.textContent = err.message;
359
+ manualError.classList.remove('hidden');
360
+ } finally {
361
+ manualSubmitBtn.disabled = false;
362
+ manualSubmitBtn.textContent = 'Connect';
363
+ }
364
+ });
365
+
366
+ /* ── First-time setup panel ──────────────────────────────────────────────── */
367
+ const setupCard = $('setup-card');
368
+ let setupDone = false; // true once panel is shown OR secrets are already seeded
369
+
370
+ async function checkSetup() {
371
+ if (setupDone || !token) return;
372
+ try {
373
+ const r1 = await authFetch('/api/setup-status');
374
+ if (!r1 || !r1.ok) return;
375
+ const { seeded } = await r1.json();
376
+ if (seeded) { setupDone = true; return; } // secrets already saved
377
+
378
+ // Try to read credentials (shellular must have registered first)
379
+ const r2 = await authFetch('/api/shellular/credentials');
380
+ if (!r2 || !r2.ok) return; // not ready yet β€” poll will retry
381
+ const data = await r2.json();
382
+ if (!data.hostId) return;
383
+
384
+ // Populate and show the panel
385
+ $('val-host-id').textContent = data.hostId;
386
+ $('val-machine-id').textContent = data.machineId;
387
+ $('val-key').textContent = data.keyB64;
388
+ setupCard.classList.remove('hidden');
389
+ setupDone = true;
390
+ } catch { /* retry on next poll */ }
391
+ }
392
+
393
+ // Poll every 4 s so the panel appears as soon as credentials are available,
394
+ // regardless of whether the QR has rendered yet.
395
+ setInterval(checkSetup, 4000);
396
+
397
+ // Copy-to-clipboard buttons
398
+ document.addEventListener('click', (e) => {
399
+ const btn = e.target.closest('.btn-copy');
400
+ if (!btn) return;
401
+ const val = $( btn.dataset.target )?.textContent || '';
402
+ navigator.clipboard.writeText(val).then(() => {
403
+ btn.textContent = 'Copied!';
404
+ btn.classList.add('copied');
405
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
406
+ });
407
+ });
408
+
409
+ /* ── Kick off shellular automatically after login ────────────────────────── */
410
+ async function ensureShellularRunning() {
411
+ const res = await authFetch('/api/status');
412
+ if (!res) return;
413
+ const { running } = await res.json();
414
+ if (!running) {
415
+ await authFetch('/api/shellular/start', 'POST');
416
+ }
417
+ }
418
+
419
+ /* ── Init ────────────────────────────────────────────────────────────────── */
420
+ // If we have a stored token, try to go straight to the dashboard
421
+ if (token) {
422
+ fetch('/api/status', { headers: { Authorization: `Bearer ${token}` } })
423
+ .then((r) => {
424
+ if (r.status === 401) {
425
+ token = null;
426
+ sessionStorage.removeItem('shellular_token');
427
+ showLogin();
428
+ } else {
429
+ showDashboard();
430
+ }
431
+ })
432
+ .catch(() => showLogin());
433
+ } else {
434
+ showLogin();
435
+ }
436
+
437
+ // Expose for inline onclick in HTML
438
+ window.restartShellular = restartShellular;
public/index.html ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Shellular</title>
7
+ <link rel="stylesheet" href="style.css" />
8
+ </head>
9
+ <body>
10
+
11
+ <!-- ── Login page ─────────────────────────────────────────── -->
12
+ <div id="login-page" class="page">
13
+ <div class="login-card">
14
+ <div class="brand">
15
+ <svg class="brand-icon" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
16
+ <rect width="40" height="40" rx="10" fill="#00c4cc"/>
17
+ <path d="M8 14l8 6-8 6" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
18
+ <line x1="20" y1="26" x2="32" y2="26" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>
19
+ </svg>
20
+ <h1>Shellular</h1>
21
+ </div>
22
+ <p class="tagline">Enter your access key to get the QR code</p>
23
+
24
+ <form id="login-form" autocomplete="off">
25
+ <div class="field">
26
+ <label for="key-input">Access Key</label>
27
+ <div class="input-wrap">
28
+ <input
29
+ id="key-input"
30
+ type="password"
31
+ placeholder="β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’"
32
+ autocomplete="current-password"
33
+ spellcheck="false"
34
+ />
35
+ <button type="button" id="toggle-vis" class="eye-btn" aria-label="Toggle visibility">
36
+ <svg id="eye-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
37
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
38
+ <circle cx="12" cy="12" r="3"/>
39
+ </svg>
40
+ <svg id="eye-closed" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
41
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
42
+ <path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
43
+ <line x1="1" y1="1" x2="23" y2="23"/>
44
+ </svg>
45
+ </button>
46
+ </div>
47
+ </div>
48
+
49
+ <button type="submit" id="login-btn">
50
+ <span id="login-label">Login</span>
51
+ <span id="login-spinner" class="spinner hidden"></span>
52
+ </button>
53
+
54
+ <p id="login-error" class="error-msg hidden"></p>
55
+ </form>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- ── Dashboard page ─────────────────────────────────────── -->
60
+ <div id="dashboard-page" class="page hidden">
61
+ <header class="topbar">
62
+ <div class="topbar-brand">
63
+ <svg class="brand-icon small" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
64
+ <rect width="40" height="40" rx="10" fill="#00c4cc"/>
65
+ <path d="M8 14l8 6-8 6" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
66
+ <line x1="20" y1="26" x2="32" y2="26" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>
67
+ </svg>
68
+ <span>Shellular</span>
69
+ </div>
70
+ <div class="topbar-actions">
71
+ <span id="status-badge" class="badge badge-stopped">Stopped</span>
72
+ <button id="restart-btn" class="btn btn-secondary" title="Restart shellular">&#8635; Restart</button>
73
+ <button id="logout-btn" class="btn btn-ghost">Logout</button>
74
+ </div>
75
+ </header>
76
+
77
+ <main class="dashboard">
78
+
79
+ <!-- QR card -->
80
+ <section class="card qr-card">
81
+ <div class="card-header">
82
+ <h2>QR Code</h2>
83
+ <p>Scan with the <strong>Shellular app</strong> to connect your device</p>
84
+ </div>
85
+
86
+ <div id="qr-area" class="qr-area">
87
+ <!-- Loading state -->
88
+ <div id="qr-loading" class="qr-state">
89
+ <div class="loader"></div>
90
+ <p>Starting Shellular&hellip;</p>
91
+ </div>
92
+
93
+ <!-- QR code rendered on canvas via qrcode.js -->
94
+ <div id="qr-ready" class="qr-state hidden">
95
+ <div class="qr-frame">
96
+ <div id="qr-canvas"></div>
97
+ </div>
98
+ <p class="qr-hint">Point your Shellular app camera at the code above</p>
99
+ </div>
100
+
101
+ <!-- Error / stopped state -->
102
+ <div id="qr-error" class="qr-state hidden">
103
+ <p class="error-icon">&#9888;</p>
104
+ <p id="qr-error-msg">Shellular stopped unexpectedly.</p>
105
+ <button class="btn btn-primary" onclick="restartShellular()">Try again</button>
106
+ </div>
107
+ </div>
108
+ </section>
109
+
110
+ <!-- First-time setup card (hidden once secrets are saved) -->
111
+ <section id="setup-card" class="card setup-card hidden">
112
+ <div class="card-header">
113
+ <h2>&#9889; One-time Setup</h2>
114
+ <p>Save these as HF Secrets so restarts never need to re-register</p>
115
+ </div>
116
+ <div class="setup-body">
117
+ <p class="setup-intro">
118
+ Shellular just registered successfully. To make this permanent
119
+ (and avoid rate-limit errors after container restarts), add the
120
+ three secrets below to your Space:
121
+ <a href="https://huggingface.co/spaces/settings" target="_blank" class="setup-link">
122
+ Space Settings β†’ Variables and secrets
123
+ </a>
124
+ </p>
125
+
126
+ <div class="secret-row">
127
+ <span class="secret-name">SHELLULAR_HOST_ID</span>
128
+ <code id="val-host-id" class="secret-val"></code>
129
+ <button class="btn-copy" data-target="val-host-id">Copy</button>
130
+ </div>
131
+ <div class="secret-row">
132
+ <span class="secret-name">SHELLULAR_MACHINE_ID</span>
133
+ <code id="val-machine-id" class="secret-val"></code>
134
+ <button class="btn-copy" data-target="val-machine-id">Copy</button>
135
+ </div>
136
+ <div class="secret-row">
137
+ <span class="secret-name">SHELLULAR_KEY</span>
138
+ <code id="val-key" class="secret-val"></code>
139
+ <button class="btn-copy" data-target="val-key">Copy</button>
140
+ </div>
141
+
142
+ <p class="setup-note">
143
+ After adding all three, restart this Space. This panel will disappear once the secrets are detected.
144
+ </p>
145
+ </div>
146
+ </section>
147
+
148
+ <!-- Manual registration fallback (shown when rate-limited) -->
149
+ <section id="manual-reg-card" class="card manual-card hidden">
150
+ <div class="card-header">
151
+ <h2>&#9888; Rate Limited β€” Manual Registration</h2>
152
+ <p>The shellular relay rejected automatic registration. Run one command from your terminal.</p>
153
+ </div>
154
+ <div class="manual-body">
155
+ <p class="manual-intro">
156
+ The Shellular registration API is temporarily rate-limiting this server's IP.
157
+ You can bypass it by registering from <strong>your own machine</strong> β€” it only takes 10 seconds.
158
+ </p>
159
+
160
+ <div class="manual-step">
161
+ <span class="step-num">1</span>
162
+ <div>
163
+ <p>Run this in your terminal (Mac / Linux / Windows WSL):</p>
164
+ <div class="code-block">
165
+ <code id="manual-curl-cmd">Loading…</code>
166
+ <button class="btn-copy" data-target="manual-curl-cmd">Copy</button>
167
+ </div>
168
+ </div>
169
+ </div>
170
+
171
+ <div class="manual-step">
172
+ <span class="step-num">2</span>
173
+ <div>
174
+ <p>You'll get back something like <code class="inline-code">{"success":true,"data":{"hostId":"<strong>XXXX</strong>"}}</code></p>
175
+ <p>Paste the <strong>hostId</strong> value below and click <strong>Connect</strong>:</p>
176
+ <div class="manual-input-row">
177
+ <input id="manual-host-id" type="text" placeholder='e.g. M58FBHn3YzbN' spellcheck="false" />
178
+ <button id="manual-submit-btn" class="btn btn-primary manual-btn">Connect</button>
179
+ </div>
180
+ <p id="manual-error" class="error-msg hidden"></p>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </section>
185
+
186
+ <!-- Log card -->
187
+ <section class="card log-card">
188
+ <div class="card-header">
189
+ <h2>Output</h2>
190
+ <button id="clear-log-btn" class="btn btn-ghost small">Clear</button>
191
+ </div>
192
+ <pre id="log-pre" class="log-pre"></pre>
193
+ </section>
194
+
195
+ </main>
196
+ </div>
197
+
198
+ <script src="qrcode.min.js"></script>
199
+ <script src="app.js"></script>
200
+ </body>
201
+ </html>
public/qrcode.min.js ADDED
@@ -0,0 +1 @@
 
 
1
+ var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
public/style.css ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset & base ─────────────────────────────────────────────────────────── */
2
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
+
4
+ :root {
5
+ --bg: #0d0f14;
6
+ --surface: #161b25;
7
+ --surface2: #1e2533;
8
+ --border: #2a3347;
9
+ --accent: #00c4cc;
10
+ --accent-dk: #009ba1;
11
+ --text: #e2e8f0;
12
+ --muted: #8896ab;
13
+ --error: #ff5f6d;
14
+ --success: #4ade80;
15
+ --warning: #facc15;
16
+ --radius: 12px;
17
+ --radius-sm: 8px;
18
+ --font-mono: 'Cascadia Code', 'Fira Code', 'Courier New', Courier, monospace;
19
+ }
20
+
21
+ html, body {
22
+ height: 100%;
23
+ background: var(--bg);
24
+ color: var(--text);
25
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
26
+ font-size: 15px;
27
+ line-height: 1.6;
28
+ -webkit-font-smoothing: antialiased;
29
+ }
30
+
31
+ /* ── Pages ────────────────────────────────────────────────────────────────── */
32
+ .page { min-height: 100vh; }
33
+ .hidden { display: none !important; }
34
+
35
+ /* ── Login page ───────────────────────────────────────────────────────────── */
36
+ #login-page {
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: center;
40
+ padding: 24px;
41
+ background:
42
+ radial-gradient(ellipse at 20% 60%, rgba(0,196,204,.08) 0%, transparent 60%),
43
+ radial-gradient(ellipse at 80% 20%, rgba(0,196,204,.05) 0%, transparent 50%),
44
+ var(--bg);
45
+ }
46
+
47
+ .login-card {
48
+ background: var(--surface);
49
+ border: 1px solid var(--border);
50
+ border-radius: var(--radius);
51
+ padding: 40px 36px;
52
+ width: 100%;
53
+ max-width: 400px;
54
+ box-shadow: 0 24px 64px rgba(0,0,0,.5);
55
+ }
56
+
57
+ .brand {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 12px;
61
+ margin-bottom: 8px;
62
+ }
63
+
64
+ .brand h1 {
65
+ font-size: 24px;
66
+ font-weight: 700;
67
+ letter-spacing: -0.5px;
68
+ }
69
+
70
+ .brand-icon {
71
+ width: 40px;
72
+ height: 40px;
73
+ border-radius: 10px;
74
+ flex-shrink: 0;
75
+ }
76
+ .brand-icon.small { width: 28px; height: 28px; border-radius: 7px; }
77
+
78
+ .tagline {
79
+ color: var(--muted);
80
+ font-size: 13.5px;
81
+ margin-bottom: 28px;
82
+ }
83
+
84
+ /* Form */
85
+ .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
86
+
87
+ .field label {
88
+ font-size: 13px;
89
+ font-weight: 500;
90
+ color: var(--muted);
91
+ letter-spacing: .4px;
92
+ text-transform: uppercase;
93
+ }
94
+
95
+ .input-wrap { position: relative; display: flex; }
96
+
97
+ .input-wrap input {
98
+ width: 100%;
99
+ background: var(--surface2);
100
+ border: 1px solid var(--border);
101
+ border-radius: var(--radius-sm);
102
+ color: var(--text);
103
+ font-size: 15px;
104
+ padding: 11px 44px 11px 14px;
105
+ outline: none;
106
+ transition: border-color .15s, box-shadow .15s;
107
+ }
108
+
109
+ .input-wrap input:focus {
110
+ border-color: var(--accent);
111
+ box-shadow: 0 0 0 3px rgba(0,196,204,.15);
112
+ }
113
+
114
+ .input-wrap input::placeholder { color: var(--muted); opacity: .6; }
115
+
116
+ .eye-btn {
117
+ position: absolute;
118
+ right: 10px;
119
+ top: 50%;
120
+ transform: translateY(-50%);
121
+ background: none;
122
+ border: none;
123
+ cursor: pointer;
124
+ color: var(--muted);
125
+ display: flex;
126
+ align-items: center;
127
+ padding: 4px;
128
+ border-radius: 4px;
129
+ transition: color .15s;
130
+ }
131
+ .eye-btn:hover { color: var(--text); }
132
+ .eye-btn svg { width: 18px; height: 18px; }
133
+
134
+ /* Buttons */
135
+ .btn {
136
+ display: inline-flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ gap: 6px;
140
+ border: none;
141
+ border-radius: var(--radius-sm);
142
+ cursor: pointer;
143
+ font-size: 14px;
144
+ font-weight: 600;
145
+ padding: 10px 18px;
146
+ transition: background .15s, opacity .15s, transform .1s;
147
+ white-space: nowrap;
148
+ text-decoration: none;
149
+ }
150
+ .btn:active { transform: scale(.97); }
151
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
152
+
153
+ .btn-primary, #login-btn {
154
+ background: var(--accent);
155
+ color: #0d0f14;
156
+ width: 100%;
157
+ padding: 12px;
158
+ font-size: 15px;
159
+ margin-top: 8px;
160
+ border: none;
161
+ border-radius: var(--radius-sm);
162
+ cursor: pointer;
163
+ font-weight: 700;
164
+ transition: background .15s, transform .1s;
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ gap: 8px;
169
+ }
170
+ .btn-primary:hover, #login-btn:hover { background: var(--accent-dk); }
171
+ .btn-primary:active, #login-btn:active { transform: scale(.98); }
172
+ .btn-primary:disabled, #login-btn:disabled { opacity: .5; cursor: not-allowed; }
173
+
174
+ .btn-secondary {
175
+ background: var(--surface2);
176
+ color: var(--text);
177
+ border: 1px solid var(--border);
178
+ }
179
+ .btn-secondary:hover { background: var(--border); }
180
+
181
+ .btn-ghost {
182
+ background: transparent;
183
+ color: var(--muted);
184
+ border: none;
185
+ padding: 8px 12px;
186
+ }
187
+ .btn-ghost:hover { color: var(--text); background: var(--surface2); }
188
+ .btn-ghost.small { font-size: 12px; padding: 4px 10px; }
189
+
190
+ .error-msg {
191
+ color: var(--error);
192
+ font-size: 13px;
193
+ margin-top: 10px;
194
+ text-align: center;
195
+ min-height: 20px;
196
+ }
197
+
198
+ /* ── Spinner ──────────────────────────────────────────────────────────────── */
199
+ .spinner {
200
+ width: 16px;
201
+ height: 16px;
202
+ border: 2px solid rgba(0,0,0,.25);
203
+ border-top-color: #0d0f14;
204
+ border-radius: 50%;
205
+ animation: spin .7s linear infinite;
206
+ flex-shrink: 0;
207
+ }
208
+ @keyframes spin { to { transform: rotate(360deg); } }
209
+
210
+ /* ── Top bar ──────────────────────────────────────────────────────────────── */
211
+ .topbar {
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: space-between;
215
+ padding: 14px 24px;
216
+ background: var(--surface);
217
+ border-bottom: 1px solid var(--border);
218
+ position: sticky;
219
+ top: 0;
220
+ z-index: 10;
221
+ }
222
+
223
+ .topbar-brand {
224
+ display: flex;
225
+ align-items: center;
226
+ gap: 10px;
227
+ font-weight: 700;
228
+ font-size: 16px;
229
+ letter-spacing: -0.3px;
230
+ }
231
+
232
+ .topbar-actions { display: flex; align-items: center; gap: 10px; }
233
+
234
+ /* Status badge */
235
+ .badge {
236
+ font-size: 12px;
237
+ font-weight: 600;
238
+ padding: 3px 10px;
239
+ border-radius: 20px;
240
+ letter-spacing: .3px;
241
+ text-transform: uppercase;
242
+ }
243
+ .badge-running { background: rgba(74,222,128,.15); color: var(--success); }
244
+ .badge-starting { background: rgba(250,204,21,.15); color: var(--warning); }
245
+ .badge-retrying { background: rgba(250,204,21,.15); color: var(--warning); }
246
+ .badge-stopped { background: rgba(255,95,109,.12); color: var(--error); }
247
+ .badge-error { background: rgba(255,95,109,.12); color: var(--error); }
248
+
249
+ /* ── Dashboard layout ────────────────────────────────────────────────────── */
250
+ .dashboard {
251
+ display: grid;
252
+ grid-template-columns: 1fr 1fr;
253
+ gap: 20px;
254
+ padding: 24px;
255
+ max-width: 1100px;
256
+ margin: 0 auto;
257
+ }
258
+
259
+ @media (max-width: 720px) {
260
+ .dashboard { grid-template-columns: 1fr; }
261
+ }
262
+
263
+ /* Setup card spans full width */
264
+ .setup-card { grid-column: 1 / -1; }
265
+
266
+ /* ── Card ─────────────────────────────────────────────────────────────────── */
267
+ .card {
268
+ background: var(--surface);
269
+ border: 1px solid var(--border);
270
+ border-radius: var(--radius);
271
+ overflow: hidden;
272
+ display: flex;
273
+ flex-direction: column;
274
+ }
275
+
276
+ .card-header {
277
+ display: flex;
278
+ align-items: baseline;
279
+ justify-content: space-between;
280
+ padding: 18px 20px 12px;
281
+ border-bottom: 1px solid var(--border);
282
+ }
283
+
284
+ .card-header h2 {
285
+ font-size: 16px;
286
+ font-weight: 700;
287
+ }
288
+
289
+ .card-header p {
290
+ font-size: 13px;
291
+ color: var(--muted);
292
+ margin-top: 2px;
293
+ }
294
+
295
+ /* ── QR area ──────────────────────────────────────────────────────────────── */
296
+ .qr-area {
297
+ flex: 1;
298
+ display: flex;
299
+ align-items: center;
300
+ justify-content: center;
301
+ padding: 28px 20px;
302
+ min-height: 300px;
303
+ }
304
+
305
+ .qr-state {
306
+ display: flex;
307
+ flex-direction: column;
308
+ align-items: center;
309
+ gap: 14px;
310
+ text-align: center;
311
+ }
312
+
313
+ .qr-state p { color: var(--muted); font-size: 14px; }
314
+
315
+ /* Animated loader ring */
316
+ .loader {
317
+ width: 44px;
318
+ height: 44px;
319
+ border: 3px solid var(--border);
320
+ border-top-color: var(--accent);
321
+ border-radius: 50%;
322
+ animation: spin .9s linear infinite;
323
+ }
324
+
325
+ /* QR frame β€” white bg so block chars are scannable */
326
+ .qr-frame {
327
+ background: #ffffff;
328
+ border-radius: 10px;
329
+ padding: 16px;
330
+ box-shadow: 0 0 0 4px rgba(255,255,255,.06);
331
+ display: inline-block;
332
+ }
333
+
334
+ /* The critical styles: tiny monospace, tight lines, dark-on-white */
335
+ #qr-pre {
336
+ font-family: var(--font-mono);
337
+ font-size: 7px;
338
+ line-height: 1;
339
+ color: #000000;
340
+ background: transparent;
341
+ white-space: pre;
342
+ display: block;
343
+ letter-spacing: 0;
344
+ }
345
+
346
+ .qr-hint {
347
+ font-size: 12px !important;
348
+ color: var(--muted) !important;
349
+ }
350
+
351
+ .error-icon {
352
+ font-size: 36px;
353
+ color: var(--error) !important;
354
+ }
355
+
356
+ /* ── Manual registration card ─────────────────────────────────────────────── */
357
+ .manual-card { grid-column: 1 / -1; border-color: rgba(250,204,21,.3); }
358
+
359
+ .manual-body { padding: 16px 20px 22px; display: flex; flex-direction: column; gap: 18px; }
360
+
361
+ .manual-intro { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
362
+
363
+ .manual-step {
364
+ display: flex;
365
+ gap: 14px;
366
+ align-items: flex-start;
367
+ }
368
+ .manual-step > div { display: flex; flex-direction: column; gap: 8px; flex: 1; }
369
+ .manual-step p { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
370
+
371
+ .step-num {
372
+ width: 26px; height: 26px;
373
+ border-radius: 50%;
374
+ background: var(--accent);
375
+ color: #0d0f14;
376
+ font-size: 13px;
377
+ font-weight: 700;
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ flex-shrink: 0;
382
+ margin-top: 2px;
383
+ }
384
+
385
+ .code-block {
386
+ display: flex;
387
+ align-items: center;
388
+ gap: 10px;
389
+ background: var(--bg);
390
+ border: 1px solid var(--border);
391
+ border-radius: var(--radius-sm);
392
+ padding: 10px 14px;
393
+ flex-wrap: wrap;
394
+ }
395
+ .code-block code {
396
+ font-family: var(--font-mono);
397
+ font-size: 12px;
398
+ color: var(--text);
399
+ flex: 1;
400
+ word-break: break-all;
401
+ }
402
+
403
+ .inline-code {
404
+ font-family: var(--font-mono);
405
+ font-size: 12px;
406
+ background: var(--surface2);
407
+ border: 1px solid var(--border);
408
+ border-radius: 4px;
409
+ padding: 1px 5px;
410
+ }
411
+
412
+ .manual-input-row {
413
+ display: flex;
414
+ gap: 10px;
415
+ align-items: center;
416
+ flex-wrap: wrap;
417
+ }
418
+ .manual-input-row input {
419
+ flex: 1;
420
+ min-width: 180px;
421
+ background: var(--surface2);
422
+ border: 1px solid var(--border);
423
+ border-radius: var(--radius-sm);
424
+ color: var(--text);
425
+ font-family: var(--font-mono);
426
+ font-size: 14px;
427
+ padding: 9px 12px;
428
+ outline: none;
429
+ transition: border-color .15s;
430
+ }
431
+ .manual-input-row input:focus { border-color: var(--accent); }
432
+ .manual-btn { padding: 9px 20px; }
433
+
434
+ /* ── Setup card ───────────────────────────────────────────────────────────── */
435
+ .setup-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 14px; }
436
+
437
+ .setup-intro { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
438
+
439
+ .setup-link { color: var(--accent); text-decoration: none; }
440
+ .setup-link:hover { text-decoration: underline; }
441
+
442
+ .secret-row {
443
+ display: flex;
444
+ align-items: center;
445
+ gap: 10px;
446
+ background: var(--bg);
447
+ border: 1px solid var(--border);
448
+ border-radius: var(--radius-sm);
449
+ padding: 10px 14px;
450
+ flex-wrap: wrap;
451
+ }
452
+
453
+ .secret-name {
454
+ font-family: var(--font-mono);
455
+ font-size: 12px;
456
+ color: var(--accent);
457
+ min-width: 200px;
458
+ font-weight: 600;
459
+ }
460
+
461
+ .secret-val {
462
+ font-family: var(--font-mono);
463
+ font-size: 12px;
464
+ color: var(--text);
465
+ flex: 1;
466
+ word-break: break-all;
467
+ min-width: 0;
468
+ }
469
+
470
+ .btn-copy {
471
+ background: var(--surface2);
472
+ border: 1px solid var(--border);
473
+ border-radius: 6px;
474
+ color: var(--muted);
475
+ cursor: pointer;
476
+ font-size: 12px;
477
+ font-weight: 600;
478
+ padding: 4px 12px;
479
+ white-space: nowrap;
480
+ transition: background .15s, color .15s;
481
+ flex-shrink: 0;
482
+ }
483
+ .btn-copy:hover { background: var(--border); color: var(--text); }
484
+ .btn-copy.copied { color: var(--success); border-color: var(--success); }
485
+
486
+ .setup-note { font-size: 12px; color: var(--muted); font-style: italic; }
487
+
488
+ /* ── Log ──────────────────────────────────────────────────────────────────── */
489
+ .log-card { min-height: 300px; }
490
+
491
+ .log-pre {
492
+ flex: 1;
493
+ font-family: var(--font-mono);
494
+ font-size: 12px;
495
+ color: var(--muted);
496
+ background: var(--bg);
497
+ padding: 16px;
498
+ overflow-y: auto;
499
+ max-height: 480px;
500
+ white-space: pre-wrap;
501
+ word-break: break-word;
502
+ line-height: 1.5;
503
+ }
504
+
505
+ /* Scrollbar */
506
+ .log-pre::-webkit-scrollbar { width: 6px; }
507
+ .log-pre::-webkit-scrollbar-track { background: transparent; }
508
+ .log-pre::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }