ninja-terminals 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -17
- package/cli.js +23 -0
- package/lib/auth.js +195 -0
- package/lib/hypothesis-validator.js +346 -0
- package/lib/post-session.js +426 -0
- package/lib/pre-dispatch.js +265 -0
- package/lib/prompt-delivery.js +127 -0
- package/lib/settings-gen.js +82 -23
- package/package.json +8 -6
- package/public/app.js +282 -13
- package/public/index.html +45 -0
- package/public/style.css +300 -0
- package/server.js +358 -33
- package/ORCHESTRATOR-PROMPT.md +0 -295
- package/orchestrator/evolution-log.md +0 -33
- package/orchestrator/identity.md +0 -60
- package/orchestrator/metrics/.gitkeep +0 -0
- package/orchestrator/metrics/raw/.gitkeep +0 -0
- package/orchestrator/metrics/session-2026-03-23-setup.md +0 -54
- package/orchestrator/metrics/session-2026-03-24-appcast-build.md +0 -55
- package/orchestrator/playbooks.md +0 -71
- package/orchestrator/security-protocol.md +0 -69
- package/orchestrator/tool-registry.md +0 -96
package/public/app.js
CHANGED
|
@@ -2,6 +2,190 @@
|
|
|
2
2
|
|
|
3
3
|
const WS_BASE = `ws://${location.host}`;
|
|
4
4
|
const API_BASE = '';
|
|
5
|
+
const AUTH_API = '/api';
|
|
6
|
+
const TOKEN_KEY = 'ninja_token';
|
|
7
|
+
|
|
8
|
+
// ── Auth Module ──────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const auth = {
|
|
11
|
+
token: null,
|
|
12
|
+
user: null,
|
|
13
|
+
tier: null,
|
|
14
|
+
terminalsMax: 2,
|
|
15
|
+
|
|
16
|
+
init() {
|
|
17
|
+
const stored = localStorage.getItem(TOKEN_KEY);
|
|
18
|
+
if (!stored) return false;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// Decode JWT payload (base64url)
|
|
22
|
+
const parts = stored.split('.');
|
|
23
|
+
if (parts.length !== 3) {
|
|
24
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
29
|
+
|
|
30
|
+
// Check expiration
|
|
31
|
+
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
|
32
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.token = stored;
|
|
37
|
+
this.user = payload.sub || payload.email || payload.username || null;
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async login(usernameOrEmail, password) {
|
|
46
|
+
const res = await fetch(`${AUTH_API}/auth/login`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ username: usernameOrEmail, password }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const err = await res.json().catch(() => ({}));
|
|
54
|
+
throw new Error(err.message || 'Login failed');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
this.token = data.token || data.accessToken;
|
|
59
|
+
localStorage.setItem(TOKEN_KEY, this.token);
|
|
60
|
+
|
|
61
|
+
await this.validateTier();
|
|
62
|
+
return data;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async activateLicense(key) {
|
|
66
|
+
const res = await fetch(`${AUTH_API}/ninja/activate-license`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ licenseKey: key }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const err = await res.json().catch(() => ({}));
|
|
74
|
+
throw new Error(err.message || 'Invalid license key');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
this.token = data.token || data.accessToken;
|
|
79
|
+
localStorage.setItem(TOKEN_KEY, this.token);
|
|
80
|
+
|
|
81
|
+
await this.validateTier();
|
|
82
|
+
return data;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async validateTier() {
|
|
86
|
+
const res = await fetch(`${API_BASE}/api/session`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'Content-Type': 'application/json',
|
|
90
|
+
...this.getAuthHeader(),
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ token: this.token }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
// Session validation failed, but we still have local token
|
|
97
|
+
// Proceed with defaults
|
|
98
|
+
console.warn('Session validation failed, using defaults');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
this.tier = data.tier || 'free';
|
|
104
|
+
this.terminalsMax = data.terminalsMax || 2;
|
|
105
|
+
if (data.user) this.user = data.user;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async logout() {
|
|
109
|
+
try {
|
|
110
|
+
await fetch(`${API_BASE}/api/session`, {
|
|
111
|
+
method: 'DELETE',
|
|
112
|
+
headers: this.getAuthHeader(),
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore errors on logout
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.token = null;
|
|
119
|
+
this.user = null;
|
|
120
|
+
this.tier = null;
|
|
121
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
122
|
+
|
|
123
|
+
showAuthOverlay();
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
getAuthHeader() {
|
|
127
|
+
return this.token ? { 'Authorization': `Bearer ${this.token}` } : {};
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ── Auth UI ──────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function showAuthOverlay() {
|
|
134
|
+
const overlay = document.getElementById('auth-overlay');
|
|
135
|
+
overlay.classList.remove('hidden');
|
|
136
|
+
document.getElementById('login-email').focus();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hideAuthOverlay() {
|
|
140
|
+
const overlay = document.getElementById('auth-overlay');
|
|
141
|
+
overlay.classList.add('hidden');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setupAuthForms() {
|
|
145
|
+
const loginForm = document.getElementById('login-form');
|
|
146
|
+
const licenseForm = document.getElementById('license-form');
|
|
147
|
+
const loginError = document.getElementById('login-error');
|
|
148
|
+
const logoutBtn = document.getElementById('logout-btn');
|
|
149
|
+
|
|
150
|
+
loginForm.addEventListener('submit', async (e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
loginError.textContent = '';
|
|
153
|
+
|
|
154
|
+
const email = document.getElementById('login-email').value.trim();
|
|
155
|
+
const password = document.getElementById('login-password').value;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await auth.login(email, password);
|
|
159
|
+
hideAuthOverlay();
|
|
160
|
+
startApp();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
loginError.textContent = err.message;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
licenseForm.addEventListener('submit', async (e) => {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
loginError.textContent = '';
|
|
169
|
+
|
|
170
|
+
const key = document.getElementById('license-key').value.trim();
|
|
171
|
+
if (!key) {
|
|
172
|
+
loginError.textContent = 'Please enter a license key';
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
await auth.activateLicense(key);
|
|
178
|
+
hideAuthOverlay();
|
|
179
|
+
startApp();
|
|
180
|
+
} catch (err) {
|
|
181
|
+
loginError.textContent = err.message;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
logoutBtn.addEventListener('click', () => {
|
|
186
|
+
auth.logout();
|
|
187
|
+
});
|
|
188
|
+
}
|
|
5
189
|
|
|
6
190
|
// ── State ────────────────────────────────────────────────────
|
|
7
191
|
|
|
@@ -208,8 +392,9 @@ function createTerminalUI(termData) {
|
|
|
208
392
|
try { fitAddon.fit(); } catch {}
|
|
209
393
|
});
|
|
210
394
|
|
|
211
|
-
// WebSocket
|
|
212
|
-
const
|
|
395
|
+
// WebSocket — include auth token in query string
|
|
396
|
+
const wsUrl = auth.token ? `${WS_BASE}/ws/${id}?token=${encodeURIComponent(auth.token)}` : `${WS_BASE}/ws/${id}`;
|
|
397
|
+
const ws = new WebSocket(wsUrl);
|
|
213
398
|
ws.binaryType = 'arraybuffer';
|
|
214
399
|
|
|
215
400
|
ws.onopen = () => {
|
|
@@ -302,7 +487,7 @@ function startLabelEdit(id, labelEl) {
|
|
|
302
487
|
// Persist to server
|
|
303
488
|
fetch(`${API_BASE}/api/terminals/${id}/label`, {
|
|
304
489
|
method: 'POST',
|
|
305
|
-
headers: { 'Content-Type': 'application/json' },
|
|
490
|
+
headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
|
|
306
491
|
body: JSON.stringify({ label: newLabel }),
|
|
307
492
|
}).catch(() => {});
|
|
308
493
|
};
|
|
@@ -410,7 +595,7 @@ async function closeTerminal(id) {
|
|
|
410
595
|
if (!t) return;
|
|
411
596
|
|
|
412
597
|
try {
|
|
413
|
-
await fetch(`${API_BASE}/api/terminals/${id}`, { method: 'DELETE' });
|
|
598
|
+
await fetch(`${API_BASE}/api/terminals/${id}`, { method: 'DELETE', headers: auth.getAuthHeader() });
|
|
414
599
|
} catch {}
|
|
415
600
|
|
|
416
601
|
t.ws.close();
|
|
@@ -432,7 +617,7 @@ async function closeTerminal(id) {
|
|
|
432
617
|
|
|
433
618
|
async function killTerminal(id) {
|
|
434
619
|
try {
|
|
435
|
-
await fetch(`${API_BASE}/api/terminals/${id}/kill`, { method: 'POST' });
|
|
620
|
+
await fetch(`${API_BASE}/api/terminals/${id}/kill`, { method: 'POST', headers: auth.getAuthHeader() });
|
|
436
621
|
addFeedEntry(`Kill sent to terminal`, id);
|
|
437
622
|
} catch (err) {
|
|
438
623
|
console.error('Kill failed:', err);
|
|
@@ -444,7 +629,7 @@ async function pauseTerminal(id) {
|
|
|
444
629
|
try {
|
|
445
630
|
await fetch(`${API_BASE}/api/terminals/${id}/input`, {
|
|
446
631
|
method: 'POST',
|
|
447
|
-
headers: { 'Content-Type': 'application/json' },
|
|
632
|
+
headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
|
|
448
633
|
body: JSON.stringify({ text: '\x1b' }),
|
|
449
634
|
});
|
|
450
635
|
addFeedEntry(`Escape sent to terminal`, id);
|
|
@@ -455,7 +640,7 @@ async function pauseTerminal(id) {
|
|
|
455
640
|
|
|
456
641
|
async function restartTerminal(id) {
|
|
457
642
|
try {
|
|
458
|
-
await fetch(`${API_BASE}/api/terminals/${id}/restart`, { method: 'POST' });
|
|
643
|
+
await fetch(`${API_BASE}/api/terminals/${id}/restart`, { method: 'POST', headers: auth.getAuthHeader() });
|
|
459
644
|
addFeedEntry(`Restart requested`, id);
|
|
460
645
|
} catch (err) {
|
|
461
646
|
console.error('Restart failed:', err);
|
|
@@ -474,7 +659,7 @@ async function addNewTerminal() {
|
|
|
474
659
|
try {
|
|
475
660
|
const res = await fetch(`${API_BASE}/api/terminals`, {
|
|
476
661
|
method: 'POST',
|
|
477
|
-
headers: { 'Content-Type': 'application/json' },
|
|
662
|
+
headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
|
|
478
663
|
});
|
|
479
664
|
const data = await res.json();
|
|
480
665
|
createTerminalUI(data);
|
|
@@ -699,7 +884,7 @@ function connectSSE() {
|
|
|
699
884
|
|
|
700
885
|
async function fetchTasks() {
|
|
701
886
|
try {
|
|
702
|
-
const res = await fetch(`${API_BASE}/api/tasks
|
|
887
|
+
const res = await fetch(`${API_BASE}/api/tasks`, { headers: auth.getAuthHeader() });
|
|
703
888
|
if (!res.ok) {
|
|
704
889
|
taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
|
|
705
890
|
return;
|
|
@@ -735,7 +920,7 @@ function renderTasks(tasks) {
|
|
|
735
920
|
|
|
736
921
|
async function pollStatus() {
|
|
737
922
|
try {
|
|
738
|
-
const res = await fetch(`${API_BASE}/api/terminals
|
|
923
|
+
const res = await fetch(`${API_BASE}/api/terminals`, { headers: auth.getAuthHeader() });
|
|
739
924
|
const list = await res.json();
|
|
740
925
|
|
|
741
926
|
for (const item of list) {
|
|
@@ -815,19 +1000,22 @@ window.addEventListener('resize', () => {
|
|
|
815
1000
|
resizeTimeout = setTimeout(() => fitAll(), 100);
|
|
816
1001
|
});
|
|
817
1002
|
|
|
818
|
-
// ──
|
|
1003
|
+
// ── Start App (after auth) ───────────────────────────────────
|
|
819
1004
|
|
|
820
|
-
async function
|
|
1005
|
+
async function startApp() {
|
|
821
1006
|
// Request desktop notification permission
|
|
822
1007
|
requestNotificationPermission();
|
|
823
1008
|
|
|
824
1009
|
// Setup sidebar
|
|
825
1010
|
setupSidebar();
|
|
826
1011
|
setupAddTask();
|
|
1012
|
+
setupLearnings();
|
|
827
1013
|
|
|
828
1014
|
// Load existing terminals
|
|
829
1015
|
try {
|
|
830
|
-
const res = await fetch(`${API_BASE}/api/terminals
|
|
1016
|
+
const res = await fetch(`${API_BASE}/api/terminals`, {
|
|
1017
|
+
headers: auth.getAuthHeader(),
|
|
1018
|
+
});
|
|
831
1019
|
const list = await res.json();
|
|
832
1020
|
for (const item of list) {
|
|
833
1021
|
createTerminalUI(item);
|
|
@@ -857,4 +1045,85 @@ async function init() {
|
|
|
857
1045
|
addFeedEntry('Ninja Terminals v2 started');
|
|
858
1046
|
}
|
|
859
1047
|
|
|
1048
|
+
// ── Learnings Module ───────────────────────────────────────────
|
|
1049
|
+
|
|
1050
|
+
function setupLearnings() {
|
|
1051
|
+
const btn = document.getElementById('learnings-btn');
|
|
1052
|
+
const overlay = document.getElementById('learnings-overlay');
|
|
1053
|
+
const closeBtn = document.getElementById('learnings-close');
|
|
1054
|
+
const refreshBtn = document.getElementById('learnings-refresh');
|
|
1055
|
+
const endSessionBtn = document.getElementById('learnings-end-session');
|
|
1056
|
+
const content = document.getElementById('learnings-content');
|
|
1057
|
+
|
|
1058
|
+
if (!btn || !overlay) return;
|
|
1059
|
+
|
|
1060
|
+
async function fetchLearnings() {
|
|
1061
|
+
content.textContent = 'Loading...';
|
|
1062
|
+
try {
|
|
1063
|
+
const res = await fetch(`${API_BASE}/api/learnings/latest`, {
|
|
1064
|
+
headers: auth.getAuthHeader(),
|
|
1065
|
+
});
|
|
1066
|
+
if (!res.ok) throw new Error('Failed to fetch');
|
|
1067
|
+
const data = await res.json();
|
|
1068
|
+
content.textContent = data.plainText || 'No learnings available yet.';
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
content.textContent = `Error: ${err.message}\n\nRun some sessions first to generate learnings.`;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function endSession() {
|
|
1075
|
+
content.textContent = 'Analyzing session...';
|
|
1076
|
+
try {
|
|
1077
|
+
const res = await fetch(`${API_BASE}/api/session/end`, {
|
|
1078
|
+
method: 'POST',
|
|
1079
|
+
headers: auth.getAuthHeader(),
|
|
1080
|
+
});
|
|
1081
|
+
if (!res.ok) throw new Error('Failed to end session');
|
|
1082
|
+
const data = await res.json();
|
|
1083
|
+
content.textContent = data.learningSummary?.plainText || 'Session ended. No learnings generated.';
|
|
1084
|
+
addFeedEntry('Session analyzed - check learnings');
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
content.textContent = `Error: ${err.message}`;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
btn.addEventListener('click', () => {
|
|
1091
|
+
overlay.classList.remove('hidden');
|
|
1092
|
+
fetchLearnings();
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
closeBtn.addEventListener('click', () => {
|
|
1096
|
+
overlay.classList.add('hidden');
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
overlay.addEventListener('click', (e) => {
|
|
1100
|
+
if (e.target === overlay) {
|
|
1101
|
+
overlay.classList.add('hidden');
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
refreshBtn.addEventListener('click', fetchLearnings);
|
|
1106
|
+
endSessionBtn.addEventListener('click', endSession);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ── Initialize ───────────────────────────────────────────────
|
|
1110
|
+
|
|
1111
|
+
async function init() {
|
|
1112
|
+
// Setup auth form handlers
|
|
1113
|
+
setupAuthForms();
|
|
1114
|
+
|
|
1115
|
+
// Check for existing valid session
|
|
1116
|
+
if (auth.init()) {
|
|
1117
|
+
try {
|
|
1118
|
+
await auth.validateTier();
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
console.warn('Tier validation failed:', err);
|
|
1121
|
+
}
|
|
1122
|
+
hideAuthOverlay();
|
|
1123
|
+
startApp();
|
|
1124
|
+
} else {
|
|
1125
|
+
showAuthOverlay();
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
860
1129
|
init();
|
package/public/index.html
CHANGED
|
@@ -11,12 +11,57 @@
|
|
|
11
11
|
<link rel="stylesheet" href="style.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
|
+
<!-- Learnings Modal -->
|
|
15
|
+
<div id="learnings-overlay" class="hidden">
|
|
16
|
+
<div class="learnings-modal">
|
|
17
|
+
<div class="learnings-header">
|
|
18
|
+
<h2>🧠 Session Learnings</h2>
|
|
19
|
+
<button id="learnings-close" class="learnings-close">×</button>
|
|
20
|
+
</div>
|
|
21
|
+
<pre id="learnings-content" class="learnings-content">Loading...</pre>
|
|
22
|
+
<div class="learnings-footer">
|
|
23
|
+
<button id="learnings-refresh" class="auth-btn">Refresh</button>
|
|
24
|
+
<button id="learnings-end-session" class="auth-btn auth-btn-secondary">End Session & Analyze</button>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Auth Overlay -->
|
|
30
|
+
<div id="auth-overlay">
|
|
31
|
+
<div class="auth-card">
|
|
32
|
+
<div class="auth-stripes">
|
|
33
|
+
<div class="stripe s1"></div>
|
|
34
|
+
<div class="stripe s2"></div>
|
|
35
|
+
<div class="stripe s3"></div>
|
|
36
|
+
<div class="stripe s4"></div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="auth-card-inner">
|
|
39
|
+
<h1 class="logo-text">NINJA TERMINALS</h1>
|
|
40
|
+
<p class="auth-subtitle">Multi-Agent Claude Code Orchestrator</p>
|
|
41
|
+
<form id="login-form">
|
|
42
|
+
<input type="text" id="login-email" placeholder="Email or username" required autocomplete="username">
|
|
43
|
+
<input type="password" id="login-password" placeholder="Password" required autocomplete="current-password">
|
|
44
|
+
<button type="submit" class="auth-btn">Sign In</button>
|
|
45
|
+
<p class="auth-error" id="login-error"></p>
|
|
46
|
+
</form>
|
|
47
|
+
<div class="auth-divider"><span>or</span></div>
|
|
48
|
+
<form id="license-form">
|
|
49
|
+
<input type="text" id="license-key" placeholder="Enter license key" autocomplete="off">
|
|
50
|
+
<button type="submit" class="auth-btn auth-btn-secondary">Activate License</button>
|
|
51
|
+
</form>
|
|
52
|
+
<p class="auth-footer">Don't have an account? <a href="https://ninjaterminals.com" target="_blank">Sign up</a></p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
14
57
|
<div id="app">
|
|
15
58
|
<aside id="sidebar">
|
|
16
59
|
<div class="sidebar-header">
|
|
17
60
|
<div class="logo-card">
|
|
18
61
|
<span class="logo">NINJA TERMINALS</span>
|
|
19
62
|
<span class="logo-sub">Multi-Agent Orchestrator</span>
|
|
63
|
+
<button id="learnings-btn" class="learnings-btn" title="View Session Learnings">🧠</button>
|
|
64
|
+
<button id="logout-btn" class="logout-btn" title="Sign out">Logout</button>
|
|
20
65
|
</div>
|
|
21
66
|
<div class="logo-stripes">
|
|
22
67
|
<div class="stripe s1"></div>
|