Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

aa: chat.mjs-style scrolling, inertial fling, tool-result surfacing

Mobile UX was unusable. Adopt chat.mjs scroll model: 0=newest at bottom,
positive=into older. Inertial fling with bounce, 5px tap/drag threshold,
mask-clipped scrollback, layout caching, scrollbar, and keyboard:close
after submit. Also surface tool_result blocks so long Bash output is
visible instead of silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+306 -131
+306 -131
system/public/aesthetic.computer/disks/aa.mjs
··· 2 2 // Phone-side chat with AA — your remote Claude on the macbook, 3 3 // reached via help.aesthetic.computer. @jeffrey only. 4 4 5 - const { floor, min, max } = Math; 5 + const { floor, max, min, abs } = Math; 6 6 7 - const ENDPOINT_PROD = "https://help.aesthetic.computer"; 8 - const ENDPOINT_DEV = "https://help.aesthetic.computer"; // same; bridge enforces auth 9 - const PROMPT_PLACEHOLDER = "ask aa…"; 7 + const ENDPOINT = "https://help.aesthetic.computer"; 10 8 11 - let endpoint = ENDPOINT_PROD; 9 + // ───────── state ───────── 12 10 let token = null; 13 11 let userHandle = null; 14 12 let userSub = null; ··· 17 15 let authChecked = false; 18 16 let authError = null; 19 17 20 - let log = []; // [{ role: "user"|"assistant"|"tool"|"system"|"error", text }] 21 - let pending = false; // true while a response is streaming 22 - let pendingText = ""; // partial assistant text being streamed 18 + // log entries: { role: "user"|"assistant"|"tool"|"toolResult"|"system"|"error", text } 19 + let log = []; 20 + let pending = false; 21 + let pendingText = ""; 23 22 let abortCtrl = null; 24 - let scrollY = 0; // pixels scrolled (positive = scrolled up to see older) 25 23 26 24 let input = null; 27 - let sendBtn = null; 28 - let resetBtn = null; 25 + 26 + // ───────── scroll (chat.mjs convention: 0 = newest at bottom, +y = into older) ───────── 27 + let scroll = 0; 28 + let totalScrollHeight = 0; 29 + let chatHeight = 0; 30 + let layoutDirty = true; 31 + let cachedLines = []; // [{ text, color }] 32 + let lastScreenW = 0; 33 + 34 + let scrollVelocity = 0; 35 + let isFlinging = false; 36 + let isDragging = false; 37 + let dragStartPos = null; 38 + const SCROLL_FRICTION = 0.92; 39 + const SCROLL_MIN_VELOCITY = 0.5; 40 + const BOUNCE_STIFFNESS = 0.15; 41 + const BOUNCE_DAMPING = 0.7; 42 + const MAX_OVERSCROLL = 60; 43 + const DRAG_THRESHOLD = 5; 44 + 45 + // ───────── layout constants ───────── 46 + const PAD = 6; 47 + const LINE_H = 12; 48 + const HEADER_H = 18; 49 + const INPUT_H = 64; // TextInput preview frame 29 50 30 51 const PALETTE = { 31 52 bg: [10, 10, 14], 32 53 bgLight: [248, 246, 240], 33 54 fg: [232, 232, 240], 34 55 fgLight: [22, 22, 28], 56 + divider: [60, 60, 80, 120], 35 57 user: [110, 200, 255], 36 58 assistant: [220, 220, 230], 37 59 tool: [240, 200, 90], 60 + toolResult:[150, 180, 110], 38 61 system: [140, 140, 160], 39 62 error: [255, 100, 100], 40 63 pending: [180, 180, 90], 41 64 }; 42 65 43 - function colors(dark, kind) { 44 - if (kind === "bg") return dark ? PALETTE.bg : PALETTE.bgLight; 45 - if (kind === "fg") return dark ? PALETTE.fg : PALETTE.fgLight; 46 - return PALETTE[kind]; 66 + function colorFor(role) { 67 + if (role === "user") return PALETTE.user; 68 + if (role === "assistant") return PALETTE.assistant; 69 + if (role === "tool") return PALETTE.tool; 70 + if (role === "toolResult") return PALETTE.toolResult; 71 + if (role === "error") return PALETTE.error; 72 + return PALETTE.system; 47 73 } 48 74 49 - async function boot({ api, ui, screen, cursor, hud, handle, user, params }) { 75 + function prefixFor(role) { 76 + if (role === "user") return "› "; 77 + if (role === "tool") return "→ "; 78 + if (role === "toolResult") return " "; 79 + if (role === "error") return "! "; 80 + if (role === "assistant") return " "; 81 + return "· "; 82 + } 83 + 84 + // ───────── boot ───────── 85 + async function boot({ api, ui, screen, cursor, hud, handle, user, params, send }) { 50 86 cursor("native"); 51 87 hud.labelBack(); 52 88 53 89 userHandle = handle(); 54 90 userSub = user?.sub || null; 55 91 56 - // Allow override: aa:dev → use a local bridge if you ever run one with public CORS 57 - if (params[0] === "dev") endpoint = ENDPOINT_DEV; 58 - 59 92 log.push({ role: "system", text: "AA — your remote claude on the macbook." }); 93 + layoutDirty = true; 60 94 61 95 if (!userSub) { 62 - authChecked = true; 63 96 authError = "log in first"; 97 + authChecked = true; 64 98 return; 65 99 } 66 100 ··· 71 105 authChecked = true; 72 106 return; 73 107 } 74 - // The bridge will reject if sub doesn't match ADMIN_SUB. 75 - // We probe /api/session to confirm before showing the input. 76 - const probe = await fetch(`${endpoint}/api/session`, { 108 + const probe = await fetch(`${ENDPOINT}/api/session`, { 77 109 headers: { Authorization: `Bearer ${token}` }, 78 110 }); 79 111 if (probe.status === 200) { 80 112 const data = await probe.json(); 81 113 isAdmin = true; 82 114 authChecked = true; 83 - if (data.sessionId) { 84 - log.push({ role: "system", text: `resuming session ${data.sessionId.slice(0, 8)}…` }); 85 - } else { 86 - log.push({ role: "system", text: "fresh session." }); 87 - } 115 + log.push({ 116 + role: "system", 117 + text: data.sessionId 118 + ? `resuming session ${data.sessionId.slice(0, 8)}…` 119 + : "fresh session.", 120 + }); 121 + layoutDirty = true; 88 122 } else if (probe.status === 403) { 89 123 authError = "not admin (this piece is @jeffrey-only)"; 90 124 authChecked = true; ··· 99 133 100 134 if (!isAdmin) return; 101 135 102 - input = new ui.TextInput(api, "", async (text) => { 103 - text = (text || "").trim(); 104 - if (!text) return; 105 - if (text === "/reset") { 106 - await reset(); 136 + input = new ui.TextInput( 137 + api, 138 + "ask aa…", 139 + async (text) => { 140 + text = (text || "").replace(/\s+$/, ""); 141 + if (!text) return; 142 + if (text === "/reset") { 143 + await reset(); 144 + } else if (text === "/clear") { 145 + log = [{ role: "system", text: "cleared." }]; 146 + layoutDirty = true; 147 + } else if (text === "/cancel" || text === "/stop") { 148 + if (abortCtrl) abortCtrl.abort(); 149 + } else { 150 + await send(text); 151 + } 107 152 input.text = ""; 108 - return; 109 - } 110 - if (text === "/clear") { 111 - log = [{ role: "system", text: "cleared." }]; 112 - input.text = ""; 113 - return; 114 - } 115 - if (text === "/cancel" || text === "/stop") { 116 - if (abortCtrl) abortCtrl.abort(); 117 - input.text = ""; 118 - return; 119 - } 120 - await send(text); 121 - input.text = ""; 122 - }); 153 + input.showBlink = false; 154 + input.mute = true; 155 + send({ type: "keyboard:close" }); 156 + }, 157 + { 158 + scheme: { 159 + text: 255, 160 + background: [0, 180], 161 + block: 255, 162 + highlight: 0, 163 + guideline: [255, 128], 164 + }, 165 + hideGutter: false, 166 + closeOnEmptyEnter: true, 167 + }, 168 + ); 123 169 } 124 170 171 + // ───────── network ───────── 125 172 async function reset() { 126 173 try { 127 - await fetch(`${endpoint}/api/reset`, { 174 + await fetch(`${ENDPOINT}/api/reset`, { 128 175 method: "POST", 129 176 headers: { Authorization: `Bearer ${token}` }, 130 177 }); 131 - log.push({ role: "system", text: "session reset." }); 178 + pushLog({ role: "system", text: "session reset." }); 132 179 } catch (err) { 133 - log.push({ role: "error", text: `reset failed: ${err.message}` }); 180 + pushLog({ role: "error", text: `reset failed: ${err.message}` }); 134 181 } 135 182 } 136 183 137 184 async function send(text) { 138 185 if (pending) { 139 - log.push({ role: "system", text: "still thinking — type /cancel to stop." }); 186 + pushLog({ role: "system", text: "still thinking — type /cancel to stop." }); 140 187 return; 141 188 } 142 - log.push({ role: "user", text }); 189 + pushLog({ role: "user", text }); 143 190 pending = true; 144 191 pendingText = ""; 145 - scrollY = 0; // jump to bottom on new turn 192 + scroll = 0; // jump to bottom on user turn 193 + scrollVelocity = 0; 194 + isFlinging = false; 146 195 abortCtrl = new AbortController(); 147 196 148 197 try { 149 - const res = await fetch(`${endpoint}/api/chat`, { 198 + const res = await fetch(`${ENDPOINT}/api/chat`, { 150 199 method: "POST", 151 200 headers: { 152 201 "Content-Type": "application/json", ··· 157 206 }); 158 207 if (!res.ok) { 159 208 const errText = await res.text().catch(() => `${res.status}`); 160 - log.push({ role: "error", text: `${res.status}: ${errText.slice(0, 200)}` }); 209 + pushLog({ role: "error", text: `${res.status}: ${errText.slice(0, 200)}` }); 161 210 return; 162 211 } 163 212 await consumeSSE(res); 164 213 } catch (err) { 165 214 if (err.name === "AbortError") { 166 - log.push({ role: "system", text: "cancelled." }); 215 + pushLog({ role: "system", text: "cancelled." }); 167 216 } else { 168 - log.push({ role: "error", text: err.message }); 217 + pushLog({ role: "error", text: err.message }); 169 218 } 170 219 } finally { 171 220 pending = false; 172 221 pendingText = ""; 173 222 abortCtrl = null; 223 + layoutDirty = true; 174 224 } 175 225 } 176 226 ··· 198 248 for (const line of block.split("\n")) { 199 249 if (line.startsWith("event:")) event = line.slice(6).trim(); 200 250 else if (line.startsWith("data:")) data += line.slice(5).trim(); 201 - else if (line.startsWith(":")) continue; // comment / heartbeat 202 251 } 203 252 if (!data) return; 204 253 let payload; ··· 206 255 207 256 if (event === "claude") handleClaudeEvent(payload); 208 257 else if (event === "stderr") { 209 - // Surface only non-empty, non-warning stderr (claude prints noisy warnings) 210 258 const t = (payload.text || "").trim(); 211 - if (t && !t.startsWith("Warning:")) log.push({ role: "system", text: t }); 259 + if (t && !t.startsWith("Warning:")) pushLog({ role: "system", text: t }); 212 260 } else if (event === "error") { 213 - log.push({ role: "error", text: payload.message || "error" }); 261 + pushLog({ role: "error", text: payload.message || "error" }); 214 262 } else if (event === "done") { 215 263 if (pendingText) { 216 - log.push({ role: "assistant", text: pendingText }); 264 + pushLog({ role: "assistant", text: pendingText }); 217 265 pendingText = ""; 218 266 } 219 267 } 268 + layoutDirty = true; 220 269 } 221 270 222 271 function handleClaudeEvent(ev) { 223 - // stream-json from `claude --print --output-format stream-json` 224 272 if (ev.type === "assistant" && ev.message?.content) { 225 273 for (const block of ev.message.content) { 226 274 if (block.type === "text" && block.text) { 227 - // Accumulate; commit as one message on `done` to avoid duplicates 228 275 pendingText += block.text; 229 276 } else if (block.type === "tool_use") { 230 277 const summary = `${block.name}${block.input ? " " + summarizeToolInput(block.input) : ""}`; 231 - log.push({ role: "tool", text: `→ ${summary}` }); 278 + pushLog({ role: "tool", text: summary }); 232 279 } 233 280 } 234 - } else if (ev.type === "result") { 235 - // Final result event has the canonical answer; replace any partial. 236 - if (ev.result) { 237 - pendingText = ev.result; 281 + } else if (ev.type === "user" && ev.message?.content) { 282 + // Surface tool results from the user-role echo events 283 + for (const block of ev.message.content) { 284 + if (block.type === "tool_result") { 285 + const out = stringifyToolResult(block.content); 286 + if (out) pushLog({ role: "toolResult", text: truncate(out, 600) }); 287 + } 238 288 } 289 + } else if (ev.type === "result" && ev.result) { 290 + pendingText = ev.result; 239 291 } 240 292 } 241 293 242 294 function summarizeToolInput(input) { 243 - if (typeof input === "string") return input.slice(0, 80); 244 - // Pull the most likely useful field 295 + if (typeof input === "string") return truncate(input, 80); 245 296 const k = input.command || input.file_path || input.path || input.pattern || input.query || input.url; 246 - if (k) return String(k).slice(0, 80); 247 - return JSON.stringify(input).slice(0, 80); 297 + if (k) return truncate(String(k), 80); 298 + return truncate(JSON.stringify(input), 80); 299 + } 300 + 301 + function stringifyToolResult(content) { 302 + if (!content) return ""; 303 + if (typeof content === "string") return content; 304 + if (Array.isArray(content)) { 305 + return content 306 + .map((c) => (typeof c === "string" ? c : c?.text || "")) 307 + .filter(Boolean) 308 + .join("\n"); 309 + } 310 + return String(content); 311 + } 312 + 313 + function truncate(s, n) { 314 + s = String(s); 315 + return s.length > n ? s.slice(0, n - 1) + "…" : s; 248 316 } 249 317 250 - // ───────── paint ───────── 318 + function pushLog(entry) { 319 + log.push(entry); 320 + layoutDirty = true; 321 + } 251 322 252 - const PAD = 6; 253 - const LINE_H = 12; 254 - const HEADER_H = 18; 255 - const INPUT_H = 64; // TextInput needs room for its prompt + gutter buttons 323 + // ───────── layout ───────── 324 + function rewrap(charW, innerW) { 325 + const maxChars = max(8, floor(innerW / charW)); 326 + cachedLines = []; 327 + for (const entry of log) { 328 + const color = colorFor(entry.role); 329 + const prefix = prefixFor(entry.role); 330 + const lines = wrapText(prefix + entry.text, maxChars); 331 + for (const ln of lines) cachedLines.push({ text: ln, color }); 332 + cachedLines.push({ text: "", color }); // gutter 333 + } 334 + } 256 335 257 - function wrap(text, charW, maxW) { 258 - const maxChars = max(8, floor(maxW / charW)); 259 - const lines = []; 336 + function wrapText(text, maxChars) { 337 + const out = []; 260 338 for (const para of String(text).split("\n")) { 261 - if (!para) { lines.push(""); continue; } 339 + if (!para) { out.push(""); continue; } 262 340 let i = 0; 263 341 while (i < para.length) { 264 - lines.push(para.slice(i, i + maxChars)); 342 + out.push(para.slice(i, i + maxChars)); 265 343 i += maxChars; 266 344 } 267 345 } 268 - return lines; 346 + return out; 269 347 } 270 348 349 + // ───────── paint ───────── 271 350 function paint($) { 272 - const { wipe, ink, write, screen, dark } = $; 351 + const { wipe, ink, write, screen, dark, mask, unmask, needsPaint } = $; 273 352 const w = screen.width; 274 353 const h = screen.height; 275 354 276 - wipe(...colors(dark, "bg")); 355 + wipe(...(dark ? PALETTE.bg : PALETTE.bgLight)); 277 356 278 357 // Header 279 - ink(...colors(dark, "fg")).write("AA", { x: PAD, y: PAD }); 358 + const fg = dark ? PALETTE.fg : PALETTE.fgLight; 359 + ink(...fg).write("AA", { x: PAD, y: PAD }); 280 360 const status = pending ? "thinking…" : (isAdmin ? "ready" : (authError || "checking…")); 281 361 const statusColor = pending ? PALETTE.pending : (isAdmin ? PALETTE.user : PALETTE.error); 282 362 ink(...statusColor).write(status, { x: PAD + 32, y: PAD }); ··· 285 365 const tag = `@${userHandle}`; 286 366 ink(...PALETTE.system).write(tag, { x: w - tag.length * 6 - PAD, y: PAD }); 287 367 } 368 + ink(...PALETTE.divider).box(0, HEADER_H - 2, w, 1, "fill"); 288 369 289 - // Divider 290 - ink(...PALETTE.system, 100).box(0, HEADER_H - 2, w, 1, "fill"); 291 - 292 - // Scrollback area bounds 370 + // Scroll region bounds 293 371 const scrollTop = HEADER_H; 294 372 const scrollBottom = h - INPUT_H - PAD; 295 - const scrollH = scrollBottom - scrollTop; 373 + chatHeight = scrollBottom - scrollTop; 296 374 const charW = 6; 297 375 const innerW = w - PAD * 2; 298 376 299 - // Build flat list of {color, text} lines, in display order (top→bottom) 300 - const lines = []; 301 - for (const entry of log) { 302 - const color = colors(dark, "fg"); 303 - let prefix = ""; 304 - let textColor; 305 - if (entry.role === "user") { prefix = "› "; textColor = PALETTE.user; } 306 - else if (entry.role === "assistant") { prefix = " "; textColor = PALETTE.assistant; } 307 - else if (entry.role === "tool") { prefix = " "; textColor = PALETTE.tool; } 308 - else if (entry.role === "error") { prefix = "! "; textColor = PALETTE.error; } 309 - else { prefix = "· "; textColor = PALETTE.system; } 310 - const wrapped = wrap(prefix + entry.text, charW, innerW); 311 - for (const ln of wrapped) lines.push({ text: ln, color: textColor }); 312 - lines.push({ text: "", color: textColor }); // blank between entries 377 + // Re-layout when log/screen changes 378 + if (layoutDirty || w !== lastScreenW) { 379 + rewrap(charW, innerW); 380 + lastScreenW = w; 381 + layoutDirty = false; 313 382 } 314 383 315 - // Live partial assistant text 316 - if (pending && pendingText) { 317 - const wrapped = wrap(" " + pendingText + "▌", charW, innerW); 318 - for (const ln of wrapped) lines.push({ text: ln, color: PALETTE.assistant }); 319 - } else if (pending && !pendingText) { 320 - lines.push({ text: " …", color: PALETTE.pending }); 384 + // Append live partial assistant text (not cached — changes each frame) 385 + let liveLines = []; 386 + if (pending) { 387 + const tail = pendingText 388 + ? wrapText(" " + pendingText + "▌", floor(innerW / charW)) 389 + : [" …"]; 390 + liveLines = tail.map((t) => ({ 391 + text: t, 392 + color: pendingText ? PALETTE.assistant : PALETTE.pending, 393 + })); 321 394 } 322 395 323 - // Render bottom-aligned with scroll offset 324 - const totalH = lines.length * LINE_H; 325 - const maxScroll = max(0, totalH - scrollH); 326 - scrollY = min(scrollY, maxScroll); 327 - const startY = scrollBottom - totalH + scrollY; 396 + const allLines = liveLines.length ? cachedLines.concat(liveLines) : cachedLines; 397 + totalScrollHeight = allLines.length * LINE_H; 328 398 329 - for (let i = 0; i < lines.length; i++) { 399 + // Inertial scroll physics 400 + if (isFlinging) { 401 + const maxS = max(0, totalScrollHeight - chatHeight + 5); 402 + const outOfBounds = scroll < 0 || scroll > maxS; 403 + if (outOfBounds) { 404 + const target = scroll < 0 ? 0 : maxS; 405 + const displacement = scroll - target; 406 + scrollVelocity -= displacement * BOUNCE_STIFFNESS; 407 + scrollVelocity *= BOUNCE_DAMPING; 408 + scroll += scrollVelocity; 409 + if (abs(displacement) < 0.5 && abs(scrollVelocity) < SCROLL_MIN_VELOCITY) { 410 + scroll = target; 411 + scrollVelocity = 0; 412 + isFlinging = false; 413 + } 414 + } else { 415 + scrollVelocity *= SCROLL_FRICTION; 416 + scroll += scrollVelocity; 417 + if (scroll < 0 || scroll > maxS) scrollVelocity *= 0.5; 418 + if (abs(scrollVelocity) < SCROLL_MIN_VELOCITY) { 419 + scrollVelocity = 0; 420 + isFlinging = false; 421 + } 422 + } 423 + needsPaint(); 424 + } 425 + 426 + // Mask scroll region so messages don't bleed into header or input 427 + mask({ x: 0, y: scrollTop, width: w, height: chatHeight }); 428 + 429 + // Bottom-anchored render: bottom of last line sits at scrollBottom + scroll 430 + // (positive scroll → content slides down, revealing older above) 431 + const startY = scrollBottom - totalScrollHeight + scroll; 432 + for (let i = 0; i < allLines.length; i++) { 330 433 const y = startY + i * LINE_H; 331 434 if (y < scrollTop - LINE_H || y > scrollBottom) continue; 332 - const ln = lines[i]; 435 + const ln = allLines[i]; 333 436 if (!ln.text) continue; 334 437 ink(...ln.color).write(ln.text, { x: PAD, y }); 335 438 } 336 439 337 - // Mask area below input zone 338 - ink(...colors(dark, "bg")).box(0, scrollBottom + 1, w, h - scrollBottom); 440 + // Scrollbar (only if content overflows) 441 + const maxS = max(0, totalScrollHeight - chatHeight); 442 + if (maxS > 0) { 443 + const segH = max(8, (chatHeight / totalScrollHeight) * chatHeight); 444 + // scroll=0 → bar at bottom, scroll=maxS → bar at top 445 + const barY = scrollTop + (1 - scroll / maxS) * (chatHeight - segH); 446 + ink(...PALETTE.system, 140).box(w - 3, barY, 2, segH); 447 + } 339 448 340 - // Input 449 + unmask(); 450 + 451 + // Input frame 341 452 if (input) { 342 453 const frameY = h - INPUT_H; 343 - ink(...PALETTE.system, 80).box(0, frameY, w, 1, "fill"); 454 + ink(...PALETTE.divider).box(0, frameY, w, 1, "fill"); 344 455 input.paint($, false, { x: 0, y: frameY + 1, width: w, height: INPUT_H - 1 }); 345 456 } else if (authChecked && !isAdmin) { 346 457 const msg = authError || "not authorized"; 347 458 ink(...PALETTE.error).write(msg, { center: "x", y: h - 16, screen }); 348 459 } 349 460 350 - if (pending) $.needsPaint(); 461 + if (pending) needsPaint(); 351 462 } 352 463 464 + // ───────── act ───────── 353 465 function act({ api, event: e, screen }) { 354 - // Scroll the log when dragging in the scrollback area (not the input frame) 355 - if (e.is("draw") && e.dy) { 356 - const inInputFrame = e.y && e.y >= screen.height - INPUT_H; 357 - if (!inInputFrame) scrollY = max(0, scrollY + e.dy); 466 + // Touch begin: capture for drag-vs-tap discrimination 467 + if (e.is("touch")) { 468 + dragStartPos = { x: e.x, y: e.y }; 469 + isDragging = false; 470 + isFlinging = false; 471 + scrollVelocity = 0; 472 + } 473 + 474 + // Drag in scrollback area → scroll 475 + if (e.is("draw")) { 476 + const inInputFrame = e.y >= screen.height - INPUT_H; 477 + if (!inInputFrame) { 478 + if (dragStartPos && !isDragging) { 479 + const dx = abs(e.x - dragStartPos.x); 480 + const dy = abs(e.y - dragStartPos.y); 481 + if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) isDragging = true; 482 + } 483 + if (isDragging) { 484 + // Drag down → see older (scroll +); drag up → see newer 485 + const dy = e.delta?.y ?? 0; 486 + scroll += dy; 487 + scrollVelocity = dy; 488 + boundScrollSoft(); 489 + } 490 + } 491 + } 492 + 493 + // Lift: maybe start fling 494 + if (e.is("lift")) { 495 + if (isDragging && abs(scrollVelocity) > SCROLL_MIN_VELOCITY) { 496 + isFlinging = true; 497 + } else if (scroll < 0 || scroll > maxScroll()) { 498 + // Released into overscroll → bounce back 499 + isFlinging = true; 500 + } 501 + isDragging = false; 502 + dragStartPos = null; 358 503 } 359 - if (e.is("keyboard:down:pageup")) scrollY += LINE_H * 5; 360 - if (e.is("keyboard:down:pagedown")) scrollY = max(0, scrollY - LINE_H * 5); 504 + 505 + // Wheel 506 + if (e.is("scroll")) { 507 + isFlinging = false; 508 + scroll += e.y; // positive wheel down → reveal older 509 + boundScroll(); 510 + } 511 + 512 + // Keyboard scroll 513 + if (e.is("keyboard:down:pageup")) { scroll += LINE_H * 5; boundScroll(); } 514 + if (e.is("keyboard:down:pagedown")) { scroll -= LINE_H * 5; boundScroll(); } 515 + if (e.is("keyboard:down:home")) { scroll = maxScroll(); boundScroll(); } 516 + if (e.is("keyboard:down:end")) { scroll = 0; } 517 + 518 + // Reframe → re-layout 519 + if (e.is("reframed")) layoutDirty = true; 361 520 362 521 if (input) input.act(api); 522 + } 523 + 524 + function boundScroll() { 525 + if (scroll < 0) scroll = 0; 526 + const m = maxScroll(); 527 + if (scroll > m) scroll = m; 528 + } 529 + 530 + function boundScrollSoft() { 531 + const m = maxScroll(); 532 + if (scroll < -MAX_OVERSCROLL) scroll = -MAX_OVERSCROLL; 533 + if (scroll > m + MAX_OVERSCROLL) scroll = m + MAX_OVERSCROLL; 534 + } 535 + 536 + function maxScroll() { 537 + return max(0, totalScrollHeight - chatHeight + 5); 363 538 } 364 539 365 540 function meta() {