Monorepo for Aesthetic.Computer
aesthetic.computer
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>help</title>
7<link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png" />
8<link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css" />
9<style>
10:root {
11 --bg: #111; --bg2: #1a1a1a; --fg: #ccc; --fg2: #888;
12 --pink: #ff6b9d; --green: #6bcb77; --cyan: #4ecdc4;
13 --border: #333;
14}
15[data-theme="light"] {
16 --bg: #f4f4f4; --bg2: #fff; --fg: #222; --fg2: #777;
17 --pink: rgb(205, 92, 155); --green: #059669; --cyan: #0891b2;
18 --border: #ccc;
19}
20* { margin: 0; padding: 0; box-sizing: border-box; }
21::-webkit-scrollbar { width: 4px; }
22::-webkit-scrollbar-track { background: transparent; }
23::-webkit-scrollbar-thumb { background: var(--border); }
24html, body { height: 100%; }
25body {
26 font-family: 'Berkeley Mono Variable', monospace;
27 font-size: 14px; line-height: 1.6;
28 background: var(--bg); color: var(--fg);
29 cursor: url('https://aesthetic.computer/aesthetic.computer/cursors/precise.svg') 12 12, auto;
30 -webkit-text-size-adjust: none;
31}
32
33/* ---- LANDING ---- */
34#landing {
35 display: flex; flex-direction: column; align-items: center;
36 justify-content: center; height: 100%;
37 padding: 2em; gap: 1.5em; text-align: center;
38}
39.logo { font-size: 24px; font-weight: normal; letter-spacing: 3px; }
40.logo span { color: var(--pink); }
41.subtitle { font-size: 12px; color: var(--fg2); line-height: 1.7; max-width: 380px; }
42.subtitle a { color: var(--cyan); text-decoration: none; }
43.subtitle a:hover { text-decoration: underline; }
44.divider { width: 40px; height: 1px; background: var(--border); }
45.steps { text-align: left; max-width: 380px; width: 100%; display: flex; flex-direction: column; gap: 0.8em; }
46.step { font-size: 12px; color: var(--fg2); padding-left: 1.5em; position: relative; }
47.step::before { content: attr(data-n); position: absolute; left: 0; color: var(--pink); font-size: 11px; }
48.btn {
49 font-family: inherit; font-size: 13px; letter-spacing: 1px;
50 padding: 8px 24px; background: var(--bg2); color: var(--fg);
51 border: 1px solid var(--border); cursor: pointer;
52 transition: border-color 0.2s, color 0.2s;
53}
54.btn:hover { border-color: var(--pink); color: var(--pink); }
55#authStatus { font-size: 11px; color: var(--fg2); }
56
57/* ---- CHAT APP ---- */
58#app {
59 display: none; flex-direction: column; height: 100%;
60}
61#app.visible { display: flex; }
62
63/* header */
64#header {
65 display: flex; align-items: center; justify-content: space-between;
66 padding: 10px 16px; border-bottom: 1px solid var(--border);
67 flex-shrink: 0;
68}
69#header .logo { font-size: 16px; letter-spacing: 2px; }
70.header-right { display: flex; align-items: center; gap: 12px; }
71#usageBar { font-size: 11px; color: var(--fg2); }
72#handleBadge { font-size: 11px; color: var(--pink); }
73.sign-out-btn {
74 font-family: inherit; font-size: 11px; background: none;
75 border: none; color: var(--fg2); cursor: pointer; padding: 0;
76}
77.sign-out-btn:hover { color: var(--fg); }
78
79/* messages */
80#messages {
81 flex: 1; overflow-y: auto; padding: 16px;
82 display: flex; flex-direction: column; gap: 16px;
83}
84.msg { display: flex; flex-direction: column; gap: 4px; max-width: 680px; }
85.msg.user { align-self: flex-end; align-items: flex-end; }
86.msg.assistant { align-self: flex-start; align-items: flex-start; }
87.msg-role { font-size: 10px; color: var(--fg2); letter-spacing: 1px; text-transform: uppercase; }
88.msg.user .msg-role { color: var(--pink); }
89.msg-body {
90 font-size: 13px; line-height: 1.65; color: var(--fg);
91 background: var(--bg2); border: 1px solid var(--border);
92 padding: 10px 14px; white-space: pre-wrap; word-break: break-word;
93}
94
95/* typing indicator */
96#typing { display: none; align-self: flex-start; }
97#typing.visible { display: flex; }
98#typing .msg-body { color: var(--fg2); font-style: italic; }
99
100/* input area */
101#inputArea {
102 border-top: 1px solid var(--border); padding: 12px 16px;
103 display: flex; gap: 8px; align-items: flex-end; flex-shrink: 0;
104}
105#msgInput {
106 font-family: inherit; font-size: 13px;
107 flex: 1; padding: 8px 12px; resize: none;
108 background: var(--bg2); color: var(--fg);
109 border: 1px solid var(--border);
110 min-height: 38px; max-height: 160px; overflow-y: auto; line-height: 1.5;
111}
112#msgInput:focus { outline: none; border-color: var(--cyan); }
113#msgInput::placeholder { color: var(--fg2); }
114#sendBtn {
115 font-family: inherit; font-size: 12px; letter-spacing: 1px;
116 padding: 8px 16px; height: 38px;
117 background: var(--bg2); color: var(--cyan);
118 border: 1px solid var(--cyan); cursor: pointer; flex-shrink: 0;
119}
120#sendBtn:hover { background: var(--cyan); color: var(--bg); }
121#sendBtn:disabled { opacity: 0.4; cursor: default; background: var(--bg2); color: var(--fg2); border-color: var(--border); }
122
123/* empty state */
124#emptyState {
125 flex: 1; display: flex; flex-direction: column;
126 align-items: center; justify-content: center; gap: 8px;
127 color: var(--fg2); font-size: 12px; text-align: center;
128 padding: 2em;
129}
130#emptyState .hint { font-size: 11px; color: var(--border); }
131
132@media (max-width: 500px) {
133 #header { padding: 8px 12px; }
134 #messages { padding: 12px; }
135 #inputArea { padding: 8px 12px; }
136}
137</style>
138</head>
139<body>
140
141<!-- Landing -->
142<div id="landing">
143 <div class="logo"><span>help</span>.aesthetic.computer</div>
144 <div class="divider"></div>
145 <div class="subtitle">
146 AI assistant for <a href="https://aesthetic.computer" target="_blank">Aesthetic Computer</a>.
147 Ask anything about pieces, KidLisp, or creative computing.
148 </div>
149 <div class="steps">
150 <div class="step" data-n="1.">Sign in with your <a href="https://aesthetic.computer/handle" target="_blank" style="color:var(--cyan)">@handle</a></div>
151 <div class="step" data-n="2.">Ask anything about AC</div>
152 </div>
153 <button class="btn" id="signInBtn">sign in</button>
154 <div id="authStatus"></div>
155</div>
156
157<!-- Chat App -->
158<div id="app">
159 <div id="header">
160 <div class="logo"><span>help</span>.aesthetic.computer</div>
161 <div class="header-right">
162 <span id="usageBar"></span>
163 <span id="handleBadge"></span>
164 <button class="sign-out-btn" id="signOutBtn">sign out</button>
165 </div>
166 </div>
167
168 <div id="messages">
169 <div id="emptyState">
170 <div>ask me anything about aesthetic.computer</div>
171 <div class="hint">pieces · kidlisp · creative computing</div>
172 </div>
173 </div>
174
175 <div id="typing" class="msg assistant">
176 <div class="msg-body">...</div>
177 </div>
178
179 <div id="inputArea">
180 <textarea id="msgInput" rows="1" placeholder="ask something..."></textarea>
181 <button id="sendBtn">send</button>
182 </div>
183</div>
184
185<script>
186let auth0Client = null;
187let accessToken = null;
188const chatHistory = [];
189
190async function authFetch(url, opts = {}) {
191 const headers = { ...opts.headers };
192 if (accessToken) headers.Authorization = 'Bearer ' + accessToken;
193 const resp = await fetch(url, { ...opts, headers });
194 if (resp.status === 401 && auth0Client) {
195 try {
196 accessToken = await auth0Client.getTokenSilently({ cacheMode: 'off' });
197 headers.Authorization = 'Bearer ' + accessToken;
198 return fetch(url, { ...opts, headers });
199 } catch { auth0Client.loginWithRedirect(); }
200 }
201 return resp;
202}
203
204async function init() {
205 document.getElementById('authStatus').textContent = 'loading...';
206 try {
207 await new Promise((resolve, reject) => {
208 const s = document.createElement('script');
209 s.src = 'https://cdn.auth0.com/js/auth0-spa-js/2.1/auth0-spa-js.production.js';
210 s.onload = resolve; s.onerror = reject;
211 document.head.appendChild(s);
212 });
213
214 const config = await fetch('/auth/config').then(r => r.json());
215 auth0Client = await window.auth0.createAuth0Client({
216 domain: config.domain,
217 clientId: config.clientId,
218 cacheLocation: 'localstorage',
219 useRefreshTokens: true,
220 authorizationParams: { redirect_uri: location.origin + location.pathname },
221 });
222
223 if (location.search.includes('state=') && location.search.includes('code=')) {
224 await auth0Client.handleRedirectCallback();
225 window.history.replaceState({}, '', location.pathname);
226 }
227
228 if (await auth0Client.isAuthenticated()) {
229 accessToken = await auth0Client.getTokenSilently();
230 const me = await authFetch('/auth/me').then(r => r.json());
231 showApp(me);
232 } else {
233 document.getElementById('authStatus').textContent = '';
234 document.getElementById('signInBtn').onclick = () => auth0Client.loginWithRedirect();
235 }
236 } catch (err) {
237 document.getElementById('authStatus').textContent = 'error: ' + err.message;
238 }
239}
240
241async function refreshUsage() {
242 try {
243 const { used, limit } = await authFetch('/api/usage').then(r => r.json());
244 document.getElementById('usageBar').textContent = `${used}/${limit} today`;
245 } catch {}
246}
247
248function showApp(me) {
249 document.getElementById('landing').style.display = 'none';
250 document.getElementById('app').classList.add('visible');
251 document.getElementById('handleBadge').textContent = '@' + (me.handle || '?');
252 document.getElementById('signOutBtn').onclick = () =>
253 auth0Client.logout({ logoutParams: { returnTo: location.origin } });
254 refreshUsage();
255 setupChat();
256}
257
258function addMessage(role, content) {
259 const empty = document.getElementById('emptyState');
260 if (empty) empty.remove();
261 const msgs = document.getElementById('messages');
262 const div = document.createElement('div');
263 div.className = `msg ${role}`;
264 div.innerHTML = `<div class="msg-role">${role === 'user' ? 'you' : 'help'}</div><div class="msg-body"></div>`;
265 div.querySelector('.msg-body').textContent = content;
266 msgs.appendChild(div);
267 msgs.scrollTop = msgs.scrollHeight;
268 return div.querySelector('.msg-body');
269}
270
271function setupChat() {
272 const input = document.getElementById('msgInput');
273 const sendBtn = document.getElementById('sendBtn');
274 const typing = document.getElementById('typing');
275 const msgs = document.getElementById('messages');
276
277 input.addEventListener('input', () => {
278 input.style.height = 'auto';
279 input.style.height = Math.min(input.scrollHeight, 160) + 'px';
280 });
281 input.addEventListener('keydown', (e) => {
282 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
283 });
284 sendBtn.onclick = send;
285
286 async function send() {
287 const text = input.value.trim();
288 if (!text || sendBtn.disabled) return;
289
290 input.value = '';
291 input.style.height = 'auto';
292 sendBtn.disabled = true;
293
294 addMessage('user', text);
295 chatHistory.push({ role: 'user', content: text });
296
297 typing.classList.add('visible');
298 msgs.appendChild(typing);
299 msgs.scrollTop = msgs.scrollHeight;
300
301 let fullText = '';
302 let assistantBody = null;
303
304 try {
305 const resp = await authFetch('/api/chat', {
306 method: 'POST',
307 headers: { 'Content-Type': 'application/json' },
308 body: JSON.stringify({ messages: chatHistory }),
309 });
310
311 typing.classList.remove('visible');
312
313 if (!resp.ok) {
314 const err = await resp.json();
315 addMessage('assistant', err.error || 'something went wrong');
316 sendBtn.disabled = false;
317 return;
318 }
319
320 const used = resp.headers.get('X-Rate-Limit-Used');
321 const limit = resp.headers.get('X-Rate-Limit-Total');
322 if (used && limit) document.getElementById('usageBar').textContent = `${used}/${limit} today`;
323
324 assistantBody = addMessage('assistant', '');
325
326 const reader = resp.body.getReader();
327 const decoder = new TextDecoder();
328 while (true) {
329 const { done, value } = await reader.read();
330 if (done) break;
331 fullText += decoder.decode(value, { stream: true });
332 assistantBody.textContent = fullText;
333 msgs.scrollTop = msgs.scrollHeight;
334 }
335
336 chatHistory.push({ role: 'assistant', content: fullText });
337 } catch (err) {
338 typing.classList.remove('visible');
339 addMessage('assistant', 'error: ' + err.message);
340 }
341
342 sendBtn.disabled = false;
343 input.focus();
344 }
345}
346
347// Theme
348if (window.matchMedia('(prefers-color-scheme: light)').matches)
349 document.documentElement.setAttribute('data-theme', 'light');
350window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
351 document.documentElement.setAttribute('data-theme', e.matches ? 'light' : '');
352});
353
354init();
355</script>
356</body>
357</html>