Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

commits: text UI buttons + Tangled feed via /api/commits

Replace all piece-specific keyboard shortcuts with ui.TextButtons
(tangled link, compact/detail, play/pause, refresh) matching the rest
of AC. Fetch from a new /api/commits endpoint that runs git log on the
Tangled remote instead of hitting GitHub's API directly, and fix the
author truncation so "prompt.ac/@jeffrey" renders as "@jeffrey".

Also label netlify.toml as deprecated — lith is the host now.

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

+427 -466
+6
system/netlify.toml
··· 1 + # DEPRECATED: Aesthetic Computer no longer deploys on Netlify. The production 2 + # host is the lith Express monolith at lith.aesthetic.computer (see CLAUDE.md). 3 + # This file is retained for historical reference only — do not add new routes 4 + # here. Backend handlers still live in system/netlify/functions/ (the path is 5 + # historical), and lith adapts them via app.all("/api/:fn", …) at runtime. 6 + 1 7 [build] 2 8 base = "system" 3 9 publish = "public"
+151
system/netlify/functions/commits.mjs
··· 1 + // Returns paginated commits from the Tangled remote (git log via local mirror). 2 + // Source of truth: tangled.org/aesthetic.computer/core (mirrored on knot.aesthetic.computer). 3 + 4 + import fs from "fs"; 5 + import path from "path"; 6 + import { execFile } from "child_process"; 7 + import { promisify } from "util"; 8 + 9 + const execFileAsync = promisify(execFile); 10 + const GIT_BRANCH = process.env.VERSION_GIT_BRANCH || "main"; 11 + const MAX_PER_PAGE = 100; 12 + const FETCH_TTL_MS = 60 * 1000; 13 + 14 + let lastFetchAt = 0; 15 + 16 + function getRepoRoot() { 17 + const candidates = [ 18 + path.resolve(process.cwd(), ".."), 19 + process.cwd(), 20 + ]; 21 + return candidates.find((c) => fs.existsSync(path.join(c, ".git"))) || null; 22 + } 23 + 24 + async function git(args, repoRoot) { 25 + const { stdout } = await execFileAsync("git", ["-C", repoRoot, ...args], { 26 + timeout: 15000, 27 + maxBuffer: 8 * 1024 * 1024, 28 + }); 29 + return stdout; 30 + } 31 + 32 + async function getPreferredRemote(repoRoot) { 33 + if (process.env.VERSION_GIT_REMOTE) return process.env.VERSION_GIT_REMOTE; 34 + const remotes = (await git(["remote"], repoRoot)) 35 + .split("\n") 36 + .map((r) => r.trim()) 37 + .filter(Boolean); 38 + if (remotes.includes("tangled")) return "tangled"; 39 + if (remotes.includes("origin")) return "origin"; 40 + return remotes[0] || "origin"; 41 + } 42 + 43 + async function maybeFetch(remote, repoRoot) { 44 + if (Date.now() - lastFetchAt < FETCH_TTL_MS) return; 45 + try { 46 + await git(["fetch", "--quiet", remote, GIT_BRANCH], repoRoot); 47 + lastFetchAt = Date.now(); 48 + } catch { 49 + // Ignore; stale data is better than an error. 50 + } 51 + } 52 + 53 + function parseCommits(raw) { 54 + const blocks = raw.split("<<COMMIT>>").map((b) => b.trim()).filter(Boolean); 55 + const commits = []; 56 + for (const block of blocks) { 57 + const lines = block.split("\n"); 58 + const header = lines[0]; 59 + const parts = header.split("|"); 60 + if (parts.length < 6) continue; 61 + const [sha, parentField, author, email, date, ...messageParts] = parts; 62 + const message = messageParts.join("|"); 63 + 64 + let additions = 0; 65 + let deletions = 0; 66 + let files = 0; 67 + for (let i = 1; i < lines.length; i++) { 68 + const line = lines[i].trim(); 69 + if (!line) continue; 70 + const [a, d, f] = line.split("\t"); 71 + if (!f) continue; 72 + files += 1; 73 + if (a === "-" || d === "-") continue; 74 + additions += Number(a) || 0; 75 + deletions += Number(d) || 0; 76 + } 77 + 78 + commits.push({ 79 + sha, 80 + shortSha: sha.slice(0, 7), 81 + parents: parentField ? parentField.split(" ").filter(Boolean).length : 0, 82 + author, 83 + email, 84 + date, 85 + message, 86 + additions, 87 + deletions, 88 + files, 89 + }); 90 + } 91 + return commits; 92 + } 93 + 94 + export default async (request) => { 95 + const url = new URL(request.url); 96 + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10)); 97 + const perPage = Math.min( 98 + MAX_PER_PAGE, 99 + Math.max(1, parseInt(url.searchParams.get("per_page") || "30", 10)), 100 + ); 101 + const skip = (page - 1) * perPage; 102 + 103 + const repoRoot = getRepoRoot(); 104 + if (!repoRoot) { 105 + return new Response( 106 + JSON.stringify({ error: "repo not found", commits: [] }), 107 + { status: 500, headers: { "Content-Type": "application/json" } }, 108 + ); 109 + } 110 + 111 + try { 112 + const remote = await getPreferredRemote(repoRoot); 113 + await maybeFetch(remote, repoRoot); 114 + 115 + const raw = await git( 116 + [ 117 + "log", 118 + `${remote}/${GIT_BRANCH}`, 119 + `--skip=${skip}`, 120 + `--max-count=${perPage}`, 121 + "--pretty=format:<<COMMIT>>%H|%P|%an|%ae|%cI|%s", 122 + "--numstat", 123 + ], 124 + repoRoot, 125 + ); 126 + 127 + const commits = parseCommits(raw); 128 + 129 + return new Response( 130 + JSON.stringify({ 131 + page, 132 + perPage, 133 + hasMore: commits.length === perPage, 134 + commits, 135 + }), 136 + { 137 + headers: { 138 + "Content-Type": "application/json", 139 + "Cache-Control": "public, max-age=60", 140 + }, 141 + }, 142 + ); 143 + } catch (e) { 144 + return new Response( 145 + JSON.stringify({ error: e.message, commits: [] }), 146 + { status: 500, headers: { "Content-Type": "application/json" } }, 147 + ); 148 + } 149 + }; 150 + 151 + export const config = { path: "/api/commits" };
+270 -466
system/public/aesthetic.computer/disks/commits.mjs
··· 1 1 // commits, 2025.1.14 2 - // Live GitHub commit feed for aesthetic-computer 3 - // ╔═══════════════════════════════════════════════════════════╗ 4 - // ║ A typographically ornate commit history visualization ║ 5 - // ╚═══════════════════════════════════════════════════════════╝ 2 + // Live Tangled commit feed for aesthetic.computer/core. 3 + // Source: https://tangled.org/aesthetic.computer/core (via /api/commits) 6 4 7 - const { max, min, floor, ceil, abs, sin, cos, PI } = Math; 5 + const { max, min, floor, sin } = Math; 8 6 9 - const REPO = "whistlegraph/aesthetic-computer"; 7 + const TANGLED_REPO_URL = "https://tangled.org/aesthetic.computer/core"; 10 8 const POLL_INTERVAL = 30000; // 30 seconds 11 - const COMMITS_PER_PAGE = 30; // Fewer per page since we fetch details 9 + const COMMITS_PER_PAGE = 30; 12 10 13 11 let commits = []; 14 12 let scroll = 0; ··· 19 17 let error = null; 20 18 let lastFetch = 0; 21 19 let pollTimer = null; 22 - let rowHeight = 10; // MatrixChunky8 is 8px + 2px spacing for elegance 23 - let topMargin = 18; // Below HUD label 24 - let bottomMargin = 20; // Footer area 20 + let rowHeight = 10; 21 + let topMargin = 18; 22 + let bottomMargin = 22; 25 23 let hue = 0; 26 24 let pulsePhase = 0; 27 25 let needsLayout = true; 28 - let autoScroll = false; // Start paused 29 - let autoScrollDelay = 2000; // 2 second delay before auto-scroll 30 - let loadTime = 0; // When commits first loaded 26 + let autoScroll = false; 27 + let autoScrollDelay = 2000; 28 + let loadTime = 0; 31 29 let autoScrollSpeed = 0.25; 32 30 let currentPage = 1; 33 31 let hasMoreCommits = true; 34 - let showDetailedView = true; // Toggle detailed stats 32 + let showDetailedView = true; 35 33 let frameCount = 0; 36 - let hoveredCommit = null; 37 - let selectedCommit = null; 38 34 39 - // Stats cache for detailed commit info 40 - const statsCache = new Map(); 41 - const statsFetching = new Set(); 35 + // UI buttons (created in boot, painted in paint, handled in act). 36 + let tangledBtn = null; 37 + let detailBtn = null; 38 + let playBtn = null; 39 + let refreshBtn = null; 42 40 43 - // GitHub link box for click detection 44 - let githubLinkBox = null; 45 - 46 - // Visual theming 47 41 const FONT = "MatrixChunky8"; 48 42 const COLORS = { 49 43 bg: [10, 12, 18], 50 - bgAccent: [16, 18, 26], 51 44 line: [35, 40, 55], 52 - lineAccent: [50, 55, 75], 53 45 sha: [255, 180, 100], 54 46 shaNew: [150, 255, 150], 55 47 author: [180, 150, 255], ··· 64 56 day: [120, 140, 160], 65 57 }; 66 58 67 - // Parse relative time 59 + // Parse relative time. 68 60 function timeAgo(dateStr) { 69 61 const now = new Date(); 70 62 const past = new Date(dateStr); 71 63 const seconds = Math.floor((now - past) / 1000); 72 - 73 64 const units = [ 74 65 { name: "y", seconds: 31536000 }, 75 66 { name: "mo", seconds: 2592000 }, ··· 79 70 { name: "m", seconds: 60 }, 80 71 { name: "s", seconds: 1 }, 81 72 ]; 82 - 83 73 for (const unit of units) { 84 74 const count = Math.floor(seconds / unit.seconds); 85 75 if (count >= 1) return `${count}${unit.name}`; ··· 87 77 return "now"; 88 78 } 89 79 90 - // Format numbers with commas 91 80 function formatNum(n) { 92 81 if (n >= 1000) return (n / 1000).toFixed(1) + "k"; 93 82 return String(n); 94 83 } 95 84 96 - // Generate sparkline bar for additions/deletions 85 + // Extract a handle-like label from git author names. Tangled commits are 86 + // authored as e.g. "prompt.ac/@jeffrey" — we want the `@jeffrey` part. 87 + function formatAuthor(name) { 88 + if (!name) return "@?"; 89 + const atMatch = name.match(/@([A-Za-z0-9._-]+)/); 90 + if (atMatch) return "@" + atMatch[1]; 91 + const first = name.split(/\s+/)[0].toLowerCase(); 92 + return "@" + first; 93 + } 94 + 97 95 function statsBar(add, del, maxWidth = 30) { 98 96 const total = add + del; 99 97 if (total === 0) return { addW: 0, delW: 0 }; 100 - const scale = min(1, total / 200); // Scale to max 200 changes 98 + const scale = min(1, total / 200); 101 99 const w = floor(maxWidth * scale); 102 100 const addW = total > 0 ? max(1, floor((add / total) * w)) : 0; 103 101 const delW = total > 0 ? max(1, floor((del / total) * w)) : 0; 104 102 return { addW, delW }; 105 103 } 106 104 107 - // Bound scroll like chat.mjs does 108 105 function boundScroll() { 109 106 if (scroll < 0) scroll = 0; 110 107 if (scroll > totalScrollHeight - chatHeight + 5) { ··· 112 109 } 113 110 } 114 111 115 - // Fetch detailed stats for a single commit 116 - async function fetchCommitStats(sha) { 117 - if (statsCache.has(sha) || statsFetching.has(sha)) return; 118 - statsFetching.add(sha); 119 - 120 - try { 121 - const response = await fetch( 122 - `https://api.github.com/repos/${REPO}/commits/${sha}` 123 - ); 124 - if (response.ok) { 125 - const data = await response.json(); 126 - statsCache.set(sha, { 127 - additions: data.stats?.additions || 0, 128 - deletions: data.stats?.deletions || 0, 129 - files: data.files?.length || 0, 130 - }); 131 - } 132 - } catch (e) { 133 - console.warn("Failed to fetch commit stats:", sha, e); 134 - } finally { 135 - statsFetching.delete(sha); 136 - } 137 - } 138 - 139 - // Get timeline marker info for a date 140 112 function getTimelineMarker(dateStr, prevDateStr) { 141 113 const d = new Date(dateStr); 142 114 const prev = prevDateStr ? new Date(prevDateStr) : null; 143 - 144 115 const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 145 116 const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 146 - 147 - // Check for year change (biggest) 117 + 148 118 if (!prev || d.getFullYear() !== prev.getFullYear()) { 149 119 return { type: "year", label: `◆ ${d.getFullYear()} ◆`, color: COLORS.year }; 150 120 } 151 - 152 - // Check for month change 153 121 if (d.getMonth() !== prev.getMonth()) { 154 122 return { type: "month", label: `── ${months[d.getMonth()]} ──`, color: COLORS.month }; 155 123 } 156 - 157 - // Check for week change (Sunday boundary) 158 124 const weekOfYear = (date) => { 159 125 const start = new Date(date.getFullYear(), 0, 1); 160 126 return Math.ceil(((date - start) / 86400000 + start.getDay() + 1) / 7); ··· 162 128 if (weekOfYear(d) !== weekOfYear(prev)) { 163 129 return { type: "week", label: `· Week ${weekOfYear(d)} ·`, color: COLORS.week }; 164 130 } 165 - 166 - // Check for day change 167 131 if (d.getDate() !== prev.getDate()) { 168 132 return { type: "day", label: `${days[d.getDay()]} ${d.getDate()}`, color: COLORS.day }; 169 133 } 170 - 171 134 return null; 172 135 } 173 136 174 - // Fetch commits from GitHub API 175 137 async function fetchCommits(page = 1, append = false) { 176 138 try { 177 139 if (page === 1) loading = commits.length === 0; 178 140 else loadingMore = true; 179 - 180 - const response = await fetch( 181 - `https://api.github.com/repos/${REPO}/commits?per_page=${COMMITS_PER_PAGE}&page=${page}` 182 - ); 183 - 184 - if (!response.ok) { 185 - throw new Error(`GitHub API error: ${response.status}`); 186 - } 187 - 141 + 142 + const response = await fetch(`/api/commits?page=${page}&per_page=${COMMITS_PER_PAGE}`); 143 + if (!response.ok) throw new Error(`commits API error: ${response.status}`); 188 144 const data = await response.json(); 189 - 190 - // Check if we got fewer commits than requested (end of history) 191 - if (data.length < COMMITS_PER_PAGE) { 192 - hasMoreCommits = false; 193 - } 194 - 195 - const newCommits = data.map(c => ({ 196 - sha: c.sha.slice(0, 7), 145 + if (data.error) throw new Error(data.error); 146 + 147 + hasMoreCommits = !!data.hasMore; 148 + 149 + const newCommits = (data.commits || []).map((c) => ({ 150 + sha: c.shortSha, 197 151 fullSha: c.sha, 198 - message: c.commit.message.split("\n")[0], // First line only 199 - fullMessage: c.commit.message, // Keep full message for expanded view 200 - author: c.commit.author.name, 201 - email: c.commit.author.email, 202 - date: c.commit.author.date, 203 - avatar: c.author?.avatar_url, 204 - parents: c.parents?.length || 0, // For merge detection 152 + message: (c.message || "").split("\n")[0], 153 + fullMessage: c.message || "", 154 + author: c.author, 155 + email: c.email, 156 + date: c.date, 157 + parents: c.parents || 0, 158 + additions: c.additions || 0, 159 + deletions: c.deletions || 0, 160 + files: c.files || 0, 205 161 })); 206 - 162 + 207 163 if (append) { 208 - // Filter out duplicates 209 - const existingShas = new Set(commits.map(c => c.fullSha)); 210 - const unique = newCommits.filter(c => !existingShas.has(c.fullSha)); 211 - commits = [...commits, ...unique]; 164 + const existing = new Set(commits.map((c) => c.fullSha)); 165 + commits = [...commits, ...newCommits.filter((c) => !existing.has(c.fullSha))]; 212 166 } else { 213 - // Check for new commits at top 214 167 const hadCommits = commits.length > 0; 215 168 const oldFirstSha = commits[0]?.fullSha; 216 169 commits = newCommits; 217 - 218 - // Flash if new commits arrived 219 170 if (hadCommits && commits[0]?.fullSha !== oldFirstSha) { 220 - hue = 120; // Flash green for new commit 171 + hue = 120; 221 172 } 222 173 } 223 - 224 - // Fetch stats for visible commits 225 - newCommits.slice(0, 10).forEach(c => fetchCommitStats(c.fullSha)); 226 - 174 + 227 175 lastFetch = Date.now(); 228 176 loading = false; 229 177 loadingMore = false; 230 178 error = null; 231 179 needsLayout = true; 232 180 currentPage = page; 233 - 234 - // Track when commits first loaded for auto-scroll delay 235 - if (page === 1 && !append && loadTime === 0) { 236 - loadTime = Date.now(); 237 - } 181 + 182 + if (page === 1 && !append && loadTime === 0) loadTime = Date.now(); 238 183 } catch (err) { 239 184 error = err.message; 240 185 loading = false; ··· 243 188 } 244 189 } 245 190 246 - // Load more commits when scrolling near bottom 247 191 async function loadMoreIfNeeded() { 248 192 if (loadingMore || !hasMoreCommits) return; 249 - 250 - const scrollNearBottom = scroll > totalScrollHeight - chatHeight - 200; 251 - if (scrollNearBottom) { 193 + if (scroll > totalScrollHeight - chatHeight - 200) { 252 194 await fetchCommits(currentPage + 1, true); 253 195 } 254 196 } 255 197 256 - function boot({ screen, store }) { 257 - // Initial fetch 198 + function buildButtons({ ui, screen }) { 199 + tangledBtn = new ui.TextButton("tangled", { right: 4, top: 4, screen }); 200 + detailBtn = new ui.TextButton(showDetailedView ? "compact" : "detail", { 201 + left: 4, 202 + bottom: 4, 203 + screen, 204 + }); 205 + playBtn = new ui.TextButton(autoScroll ? "pause" : "play", { 206 + left: 4 + detailBtn.width + 4, 207 + bottom: 4, 208 + screen, 209 + }); 210 + refreshBtn = new ui.TextButton("refresh", { right: 4, bottom: 4, screen }); 211 + } 212 + 213 + function syncButtons({ screen }) { 214 + if (!tangledBtn) return; 215 + // Labels track state. 216 + detailBtn.replaceLabel(showDetailedView ? "compact" : "detail"); 217 + playBtn.replaceLabel(autoScroll ? "pause" : "play"); 218 + // Reposition (widths change with label). 219 + tangledBtn.reposition({ right: 4, top: 4, screen }); 220 + refreshBtn.reposition({ right: 4, bottom: 4, screen }); 221 + detailBtn.reposition({ left: 4, bottom: 4, screen }); 222 + playBtn.reposition({ left: 4 + detailBtn.width + 4, bottom: 4, screen }); 223 + } 224 + 225 + // Color schemes: [fill, outline, text] / [hover fill, hover outline, hover text] 226 + const BTN_SCHEME = [[20, 24, 34], [80, 90, 120], [180, 190, 220]]; 227 + const BTN_HOVER = [[30, 35, 50], [140, 160, 210], [220, 230, 255]]; 228 + const LINK_SCHEME = [[0, 0, 0, 0], [40, 60, 110], [130, 170, 255]]; 229 + const LINK_HOVER = [[20, 30, 60], [140, 180, 255], [220, 235, 255]]; 230 + const PLAY_SCHEME = [[14, 28, 20], [60, 160, 100], [140, 230, 170]]; 231 + const PLAY_HOVER = [[20, 40, 30], [110, 220, 150], [200, 255, 220]]; 232 + 233 + function boot({ ui, screen }) { 258 234 fetchCommits(); 259 - 260 - // Start polling for new commits 261 235 pollTimer = setInterval(() => fetchCommits(1, false), POLL_INTERVAL); 262 - 263 - // Always start at top with fresh state 264 236 scroll = 0; 265 237 autoScroll = false; 266 238 loadTime = 0; 267 239 frameCount = 0; 268 - } 269 - 270 - // Draw decorative elements 271 - function drawDecor(ink, line, box, w, h, phase) { 272 - // Subtle corner decorations 273 - const cornerSize = 6; 274 - const c = [40, 45, 60, 150 + sin(phase) * 30]; 275 - 276 - // Top-left corner 277 - ink(...c).line(0, cornerSize, 0, 0); 278 - ink(...c).line(0, 0, cornerSize, 0); 279 - 280 - // Top-right corner 281 - ink(...c).line(w - cornerSize, 0, w - 1, 0); 282 - ink(...c).line(w - 1, 0, w - 1, cornerSize); 283 - 284 - // Bottom-left corner 285 - ink(...c).line(0, h - cornerSize, 0, h - 1); 286 - ink(...c).line(0, h - 1, cornerSize, h - 1); 287 - 288 - // Bottom-right corner 289 - ink(...c).line(w - cornerSize, h - 1, w - 1, h - 1); 290 - ink(...c).line(w - 1, h - cornerSize, w - 1, h - 1); 291 - } 292 - 293 - // Render stats bars with smooth animation 294 - function drawStats(ink, box, x, y, stats, w) { 295 - if (!stats) return y; 296 - 297 - const { addW, delW } = statsBar(stats.additions, stats.deletions, min(w - 60, 40)); 298 - const barY = y; 299 - const barH = 4; 300 - 301 - // Stats bar background 302 - ink(25, 28, 35).box(x, barY, addW + delW + 2, barH); 303 - 304 - // Additions bar (green) 305 - if (addW > 0) { 306 - ink(...COLORS.additions).box(x, barY, addW, barH); 307 - } 308 - 309 - // Deletions bar (red) 310 - if (delW > 0) { 311 - ink(...COLORS.deletions).box(x + addW, barY, delW, barH); 312 - } 313 - 314 - return barY + barH + 2; 240 + buildButtons({ ui, screen }); 241 + topMargin = 4 + (tangledBtn?.height || 18) + 4; 242 + bottomMargin = 4 + (detailBtn?.height || 18) + 4; 315 243 } 316 244 317 - function paint({ wipe, ink, screen, line, text, box, typeface, num, needsPaint, mask, unmask }) { 245 + function paint($) { 246 + const { wipe, ink, screen, line, text, box, needsPaint, mask, unmask, ui } = $; 318 247 const { width: w, height: h } = screen; 319 248 frameCount++; 320 249 pulsePhase += 0.05; 321 - 322 - // Rich dark background with subtle gradient simulation 250 + 323 251 const bgPulse = sin(pulsePhase * 0.3) * 2; 324 252 wipe(COLORS.bg[0] + bgPulse, COLORS.bg[1] + bgPulse, COLORS.bg[2] + bgPulse); 325 - 326 - // Draw decorative corner elements 327 - drawDecor(ink, line, box, w, h, pulsePhase); 328 253 329 - // Header: GitHub link right only (HUD handles piece name) 330 - const headerY = 8; 331 - const ghText = "GitHub →"; 332 - const ghTextW = text.width(ghText, FONT); 333 - const ghX = w - ghTextW - 4; 334 - const ghGlow = 150 + sin(pulsePhase * 1.5) * 40; 335 - ink(100, 140, 255, ghGlow).write(ghText, { x: ghX, y: headerY }, false, undefined, false, FONT); 336 - githubLinkBox = { x: ghX - 2, y: headerY - 2, w: ghTextW + 4, h: 12 }; 254 + // Ensure buttons exist (in case of hot-reload) and keep them in sync. 255 + if (!tangledBtn) buildButtons({ ui, screen }); 256 + syncButtons({ screen }); 257 + topMargin = 4 + tangledBtn.height + 4; 258 + bottomMargin = 4 + detailBtn.height + 4; 337 259 338 260 // Top divider line with gradient effect 339 261 const topLineY = topMargin - 1; ··· 341 263 const alpha = 40 + sin(i * 0.02 + pulsePhase) * 15; 342 264 ink(50, 55, 75, alpha).box(i, topLineY, 1, 1); 343 265 } 344 - 266 + 345 267 // Bottom divider line 346 268 const botLineY = h - bottomMargin; 347 269 for (let i = 0; i < w; i++) { 348 270 const alpha = 40 + sin(i * 0.02 - pulsePhase) * 15; 349 271 ink(50, 55, 75, alpha).box(i, botLineY, 1, 1); 350 272 } 351 - 273 + 352 274 if (loading && commits.length === 0) { 353 - // Animated loading indicator 354 275 const dots = ".".repeat((floor(frameCount / 10) % 4)); 355 - const loadText = `Loading commits${dots}`; 356 - ink(150, 150, 180).write(loadText, { center: "xy", x: w / 2, y: h / 2 }, false, undefined, false, FONT); 276 + ink(150, 150, 180).write( 277 + `Loading commits${dots}`, 278 + { center: "xy", x: w / 2, y: h / 2 }, 279 + false, 280 + undefined, 281 + false, 282 + FONT, 283 + ); 284 + paintButtons($); 357 285 needsPaint(); 358 286 return; 359 287 } 360 - 288 + 361 289 if (error && commits.length === 0) { 362 - ink(255, 100, 100).write("Error: " + error.slice(0, 40), { center: "xy", x: w / 2, y: h / 2 }, false, undefined, false, FONT); 290 + ink(255, 100, 100).write( 291 + "Error: " + error.slice(0, 40), 292 + { center: "xy", x: w / 2, y: h / 2 }, 293 + false, 294 + undefined, 295 + false, 296 + FONT, 297 + ); 298 + paintButtons($); 363 299 return; 364 300 } 365 - 366 - // Calculate heights - expanded view with stats 301 + 367 302 chatHeight = h - topMargin - bottomMargin; 368 - const baseCommitHeight = rowHeight + 2; // Single row per commit 369 - const expandedCommitHeight = rowHeight * 4 + 4; // Space for stats (no file list) 303 + const baseCommitHeight = rowHeight + 2; 304 + const expandedCommitHeight = rowHeight * 4 + 4; 370 305 const commitHeight = showDetailedView ? expandedCommitHeight : baseCommitHeight; 371 - const yearMarkerHeight = 18; // More prominent year markers 306 + const yearMarkerHeight = 18; 372 307 const monthMarkerHeight = 14; 373 308 const smallMarkerHeight = rowHeight + 4; 374 - 375 - // Calculate total height including timeline markers 309 + 376 310 if (needsLayout) { 377 311 let height = 0; 378 312 for (let i = 0; i < commits.length; i++) { ··· 388 322 totalScrollHeight = height; 389 323 needsLayout = false; 390 324 } 391 - 392 - // Mask off the scrollable area 393 - mask({ 394 - x: 0, 395 - y: topMargin, 396 - width: w, 397 - height: chatHeight, 398 - }); 399 - 400 - // Fetch stats for visible commits 401 - let visibleStart = floor(scroll / commitHeight); 402 - let visibleEnd = ceil((scroll + chatHeight) / commitHeight); 403 - for (let i = max(0, visibleStart - 2); i < min(commits.length, visibleEnd + 2); i++) { 404 - const c = commits[i]; 405 - if (c && !statsCache.has(c.fullSha) && !statsFetching.has(c.fullSha)) { 406 - fetchCommitStats(c.fullSha); 407 - } 408 - } 409 - 410 - // Draw commits with timeline markers 325 + 326 + mask({ x: 0, y: topMargin, width: w, height: chatHeight }); 327 + 411 328 let y = topMargin - scroll; 412 - 329 + 413 330 for (let i = 0; i < commits.length; i++) { 414 331 const commit = commits[i]; 415 332 const prevDate = i > 0 ? commits[i - 1].date : null; 416 333 const marker = getTimelineMarker(commit.date, prevDate); 417 - const stats = statsCache.get(commit.fullSha); 418 - 419 - // Timeline marker 334 + 420 335 if (marker) { 421 336 let markerH = smallMarkerHeight; 422 - 423 - if (marker.type === "year") { 424 - markerH = yearMarkerHeight; 425 - } else if (marker.type === "month") { 426 - markerH = monthMarkerHeight; 427 - } 428 - 337 + if (marker.type === "year") markerH = yearMarkerHeight; 338 + else if (marker.type === "month") markerH = monthMarkerHeight; 339 + 429 340 if (y + markerH >= topMargin - markerH && y < h - bottomMargin + markerH) { 430 - // Background for marker with gradient 431 - const bgAlpha = marker.type === "year" ? 220 : (marker.type === "month" ? 180 : 100); 341 + const bgAlpha = marker.type === "year" ? 220 : marker.type === "month" ? 180 : 100; 432 342 ink(30, 28, 45, bgAlpha).box(0, y, w, markerH); 433 - 434 - // Marker text 435 343 const textY = y + Math.floor((markerH - 8) / 2); 436 - 344 + 437 345 if (marker.type === "year") { 438 - // Year: centered, ornate 439 346 const yearGlow = 180 + sin(pulsePhase * 2) * 40; 440 347 ink(marker.color[0], marker.color[1], marker.color[2], yearGlow).write( 441 - marker.label, { center: "x", x: w / 2, y: textY }, false, undefined, false, FONT 348 + marker.label, 349 + { center: "x", x: w / 2, y: textY }, 350 + false, 351 + undefined, 352 + false, 353 + FONT, 442 354 ); 443 355 } else if (marker.type === "month") { 444 - // Month: left aligned with accent line 445 356 ink(...marker.color).write(marker.label, { x: 4, y: textY }, false, undefined, false, FONT); 446 - // Accent line 447 357 const labelW = text.width(marker.label, FONT) + 8; 448 - ink(marker.color[0], marker.color[1], marker.color[2], 60).line(labelW, y + markerH / 2, w - 4, y + markerH / 2); 358 + ink(marker.color[0], marker.color[1], marker.color[2], 60).line( 359 + labelW, 360 + y + markerH / 2, 361 + w - 4, 362 + y + markerH / 2, 363 + ); 449 364 } else { 450 - // Week/Day: subtle with dot 451 365 ink(marker.color[0], marker.color[1], marker.color[2], 180).write( 452 - marker.label, { x: 4, y: textY }, false, undefined, false, FONT 366 + marker.label, 367 + { x: 4, y: textY }, 368 + false, 369 + undefined, 370 + false, 371 + FONT, 453 372 ); 454 373 } 455 374 } 456 375 y += markerH; 457 376 } 458 - 459 - // Skip if outside visible area (with buffer) 377 + 460 378 if (y + commitHeight < topMargin - 20) { 461 379 y += commitHeight; 462 380 continue; ··· 465 383 y += commitHeight; 466 384 continue; 467 385 } 468 - 469 - // Commit row background (alternating subtle stripes with inner padding) 386 + 470 387 if (showDetailedView) { 471 388 const rowPadding = 3; 472 389 if (i % 2 === 0) { 473 390 ink(18, 20, 28, 100).box(0, y + rowPadding, w, commitHeight - rowPadding * 2); 474 391 } 475 - // Subtle separator line between commits 476 392 ink(40, 45, 60, 40).line(4, y + commitHeight - 1, w - 4, y + commitHeight - 1); 477 - } else { 478 - if (i % 2 === 0) { 479 - ink(18, 20, 28, 60).box(0, y, w, commitHeight); 480 - } 393 + } else if (i % 2 === 0) { 394 + ink(18, 20, 28, 60).box(0, y, w, commitHeight); 481 395 } 482 - 483 - // Row 1: SHA, time, author (with vertical offset for padding) 484 - const contentY = y + (showDetailedView ? 4 : 1); // Top padding within commit block 396 + 397 + const contentY = y + (showDetailedView ? 4 : 1); 485 398 const isNew = i === 0 && hue > 60; 486 399 const shaPulse = isNew ? 200 + sin(pulsePhase * 4) * 55 : 0; 487 400 const shaColor = isNew ? [150 + shaPulse * 0.4, 255, 150 + shaPulse * 0.2] : COLORS.sha; 488 - 489 - // SHA with subtle box 401 + 490 402 ink(30, 32, 42).box(2, contentY, 34, rowHeight); 491 403 ink(...shaColor).write(commit.sha, { x: 4, y: contentY + 1 }, false, undefined, false, FONT); 492 - 493 - // Merge indicator 404 + 494 405 let xOffset = 38; 495 406 if (commit.parents > 1) { 496 407 ink(180, 140, 200).write("⊕", { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); 497 408 xOffset += 10; 498 409 } 499 - 500 - // Time ago 410 + 501 411 const ago = timeAgo(commit.date); 502 412 ink(...COLORS.time).write(ago, { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); 503 - xOffset += text.width(ago + " ", FONT); // Extra space 504 - 505 - // Author (truncate to fit) 506 - const author = "@" + commit.author.split(" ")[0].toLowerCase().slice(0, 12); 413 + xOffset += text.width(ago + " ", FONT); 414 + 415 + const author = formatAuthor(commit.author); 507 416 ink(...COLORS.author).write(author, { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); 508 417 const authorEndX = xOffset + text.width(author, FONT); 509 - 510 418 const charWidth = 4; 511 419 512 420 if (!showDetailedView) { 513 - // Single-row mode: ticker message after author 514 421 const msgX = authorEndX + 6; 515 422 const availableChars = Math.floor((w - msgX - 8) / charWidth); 516 423 const msg = commit.message; 517 - 518 424 if (msg.length <= availableChars) { 519 425 ink(...COLORS.message).write(msg, { x: msgX, y: contentY + 1 }, false, undefined, false, FONT); 520 426 } else { 521 - // Ticker: scroll the message with seamless wrap 522 427 const separator = " · "; 523 428 const fullTicker = msg + separator; 524 429 const tickerLen = fullTicker.length; ··· 531 436 } 532 437 533 438 if (showDetailedView) { 534 - // Row 2: Message (with extra line spacing) 535 - const msgY = contentY + rowHeight + 2; 536 - const maxChars = Math.floor((w - 8) / charWidth); 537 - const msg = commit.message.slice(0, maxChars); 538 - ink(...COLORS.message).write(msg, { x: 4, y: msgY }, false, undefined, false, FONT); 439 + const msgY = contentY + rowHeight + 2; 440 + const maxChars = Math.floor((w - 8) / charWidth); 441 + const msg = commit.message.slice(0, maxChars); 442 + ink(...COLORS.message).write(msg, { x: 4, y: msgY }, false, undefined, false, FONT); 539 443 540 - // Row 3-4: Stats (if detailed view and stats loaded) 541 - { 542 - const statsY = contentY + rowHeight * 2 + 4; // Extra spacing before stats 543 - 544 - if (stats) { 545 - // Stats line: +additions -deletions files 546 - let sx = 4; 547 - 548 - // Additions 549 - const addText = `+${formatNum(stats.additions)}`; 550 - ink(...COLORS.additions).write(addText, { x: sx, y: statsY }, false, undefined, false, FONT); 551 - sx += text.width(addText + " ", FONT); 552 - 553 - // Deletions 554 - const delText = `-${formatNum(stats.deletions)}`; 555 - ink(...COLORS.deletions).write(delText, { x: sx, y: statsY }, false, undefined, false, FONT); 556 - sx += text.width(delText + " ", FONT); 557 - 558 - // Files changed 559 - const filesText = `${stats.files}f`; 560 - ink(...COLORS.files).write(filesText, { x: sx, y: statsY }, false, undefined, false, FONT); 561 - sx += text.width(filesText + " ", FONT); 562 - 563 - // Mini bar chart 564 - const { addW, delW } = statsBar(stats.additions, stats.deletions, 35); 565 - if (addW > 0) { 566 - ink(...COLORS.additions, 180).box(sx, statsY + 2, addW, 4); 567 - } 568 - if (delW > 0) { 569 - ink(...COLORS.deletions, 180).box(sx + addW, statsY + 2, delW, 4); 570 - } 571 - 572 - } else if (statsFetching.has(commit.fullSha)) { 573 - // Loading indicator for stats 574 - const loadDots = ".".repeat((floor(frameCount / 8) % 4)); 575 - ink(80, 80, 100).write(`loading${loadDots}`, { x: 4, y: statsY }, false, undefined, false, FONT); 576 - } else { 577 - // Placeholder 578 - ink(50, 50, 60).write("···", { x: 4, y: statsY }, false, undefined, false, FONT); 579 - } 580 - } 581 - } // end showDetailedView 444 + const statsY = contentY + rowHeight * 2 + 4; 445 + let sx = 4; 446 + 447 + const addText = `+${formatNum(commit.additions)}`; 448 + ink(...COLORS.additions).write(addText, { x: sx, y: statsY }, false, undefined, false, FONT); 449 + sx += text.width(addText + " ", FONT); 450 + 451 + const delText = `-${formatNum(commit.deletions)}`; 452 + ink(...COLORS.deletions).write(delText, { x: sx, y: statsY }, false, undefined, false, FONT); 453 + sx += text.width(delText + " ", FONT); 454 + 455 + const filesText = `${commit.files}f`; 456 + ink(...COLORS.files).write(filesText, { x: sx, y: statsY }, false, undefined, false, FONT); 457 + sx += text.width(filesText + " ", FONT); 458 + 459 + const { addW, delW } = statsBar(commit.additions, commit.deletions, 35); 460 + if (addW > 0) ink(...COLORS.additions, 180).box(sx, statsY + 2, addW, 4); 461 + if (delW > 0) ink(...COLORS.deletions, 180).box(sx + addW, statsY + 2, delW, 4); 582 462 583 - // Subtle separator with gradient (detail view only) 584 - if (showDetailedView) { 585 463 const sepY = y + commitHeight - 1; 586 - for (let sx = 4; sx < w - 4; sx++) { 587 - const alpha = 20 + sin(sx * 0.1) * 10; 588 - ink(35, 38, 50, alpha).box(sx, sepY, 1, 1); 464 + for (let sxp = 4; sxp < w - 4; sxp++) { 465 + const alpha = 20 + sin(sxp * 0.1) * 10; 466 + ink(35, 38, 50, alpha).box(sxp, sepY, 1, 1); 589 467 } 590 468 } 591 - 469 + 592 470 y += commitHeight; 593 471 } 594 - 595 - // Loading more indicator at bottom 472 + 596 473 if (loadingMore) { 597 474 const loadDots = ".".repeat((floor(frameCount / 10) % 4)); 598 - ink(150, 150, 180).write(`Loading more${loadDots}`, { x: 4, y: h - bottomMargin - rowHeight - 4 }, false, undefined, false, FONT); 475 + ink(150, 150, 180).write( 476 + `Loading more${loadDots}`, 477 + { x: 4, y: h - bottomMargin - rowHeight - 4 }, 478 + false, 479 + undefined, 480 + false, 481 + FONT, 482 + ); 599 483 } 600 - 601 - unmask(); // End masking 602 - 603 - // 📜 Scroll bar (ornate version) 484 + 485 + unmask(); 486 + 487 + // 📜 Scroll bar 604 488 if (totalScrollHeight > chatHeight) { 605 - // Track 606 489 ink(25, 28, 35).box(w - 4, topMargin, 3, chatHeight); 607 - 608 - // Decorative track edges 609 490 ink(40, 45, 55).line(w - 5, topMargin, w - 5, topMargin + chatHeight); 610 491 ink(40, 45, 55).line(w - 1, topMargin, w - 1, topMargin + chatHeight); 611 - 612 492 const segHeight = max(8, floor((chatHeight / totalScrollHeight) * chatHeight)); 613 493 const scrollRatio = scroll / max(1, totalScrollHeight - chatHeight); 614 494 const boxY = topMargin + floor(scrollRatio * (chatHeight - segHeight)); 615 - 616 - // Thumb with glow 617 495 const thumbColor = autoScroll ? COLORS.additions : [200, 150, 255]; 618 496 const thumbGlow = 50 + sin(pulsePhase * 2) * 30; 619 497 ink(thumbColor[0], thumbColor[1], thumbColor[2], thumbGlow).box(w - 6, boxY - 1, 7, segHeight + 2); 620 498 ink(...thumbColor).box(w - 4, boxY, 3, segHeight); 621 499 } 622 - 623 - // ═══════════════════════════════════════════════════════════ 624 - // Footer area (ornate status bar) 625 - // ═══════════════════════════════════════════════════════════ 626 - const footerY = h - bottomMargin + 3; 500 + 501 + paintButtons($); 502 + 503 + // Status between buttons (commit count + next-poll countdown). 627 504 const sinceLastFetch = Date.now() - lastFetch; 628 505 const nextPoll = Math.max(0, Math.ceil((POLL_INTERVAL - sinceLastFetch) / 1000)); 629 - 630 - // Auto-scroll delay progress bar 506 + const countText = hasMoreCommits ? `${commits.length}+ · ${nextPoll}s` : `${commits.length} · ${nextPoll}s`; 507 + const countX = Math.floor(w / 2 - text.width(countText, FONT) / 2); 508 + ink(100, 105, 130).write( 509 + countText, 510 + { x: countX, y: h - bottomMargin + 7 }, 511 + false, 512 + undefined, 513 + false, 514 + FONT, 515 + ); 516 + 631 517 const sinceLoad = Date.now() - loadTime; 632 - const delayProgress = loadTime > 0 ? Math.min(1, sinceLoad / autoScrollDelay) : 0; 633 - const waitingToScroll = loadTime > 0 && !autoScroll && delayProgress < 1; 634 - 635 - // Left section: playback state 636 - if (waitingToScroll) { 637 - // Progress bar during delay 638 - const barWidth = 24; 639 - const barX = 4; 640 - ink(30, 32, 40).box(barX, footerY, barWidth, 7); 641 - ink(100, 200, 150).box(barX, footerY, Math.floor(barWidth * delayProgress), 7); 642 - ink(60, 65, 80).box(barX, footerY, barWidth, 7, "outline"); 643 - } else { 644 - // Auto-scroll indicator with icon 645 - const playIcon = autoScroll ? "►" : "║║"; 646 - const playColor = autoScroll ? COLORS.additions : [100, 100, 120]; 647 - ink(...playColor).write(playIcon, { x: 4, y: footerY }, false, undefined, false, FONT); 648 - } 649 - 650 - // Poll countdown with subtle animation 651 - const pollAlpha = 150 + sin(pulsePhase + nextPoll * 0.2) * 50; 652 - ink(80, 85, 105, pollAlpha).write(`${nextPoll}s`, { x: waitingToScroll ? 32 : 20, y: footerY }, false, undefined, false, FONT); 653 - 654 - // Center: view toggle hint 655 - const toggleHint = showDetailedView ? "[d]etail" : "[d]etail"; 656 - const hintX = floor(w / 2) - floor(text.width(toggleHint, FONT) / 2); 657 - ink(60, 65, 80).write(toggleHint, { x: hintX, y: footerY }, false, undefined, false, FONT); 658 - 659 - // Right section: commit count 660 - const countText = hasMoreCommits ? `${commits.length}+` : `${commits.length}`; 661 - const countX = w - text.width(countText, FONT) - 4; 662 - ink(100, 105, 130).write(countText, { x: countX, y: footerY }, false, undefined, false, FONT); 663 - 664 - // Stats cache indicator 665 - const cacheText = `${statsCache.size}★`; 666 - ink(60, 80, 60).write(cacheText, { x: countX - text.width(cacheText + " ", FONT), y: footerY }, false, undefined, false, FONT); 667 - 668 - // Keep painting for animations 669 - if (autoScroll || waitingToScroll || statsFetching.size > 0 || !showDetailedView) needsPaint(); 518 + const waitingToScroll = 519 + loadTime > 0 && !autoScroll && sinceLoad < autoScrollDelay; 520 + 521 + if (autoScroll || waitingToScroll) needsPaint(); 522 + } 523 + 524 + function paintButtons($) { 525 + if (!tangledBtn) return; 526 + tangledBtn.paint($, LINK_SCHEME, LINK_HOVER); 527 + detailBtn.paint($, BTN_SCHEME, BTN_HOVER); 528 + playBtn.paint($, PLAY_SCHEME, PLAY_HOVER); 529 + refreshBtn.paint($, BTN_SCHEME, BTN_HOVER); 670 530 } 671 531 672 - function act({ event: e, screen, store, jump }) { 673 - const { height: h } = screen; 674 - 675 - // 📜 Scrolling - any manual scroll disables auto-scroll 532 + function act({ event: e, screen, jump, ui, net }) { 533 + if (!tangledBtn) buildButtons({ ui, screen }); 534 + 535 + // Buttons. 536 + tangledBtn.act(e, () => { 537 + jump(`out:${TANGLED_REPO_URL}`); 538 + }); 539 + 540 + detailBtn.act(e, () => { 541 + showDetailedView = !showDetailedView; 542 + needsLayout = true; 543 + }); 544 + 545 + playBtn.act(e, () => { 546 + autoScroll = !autoScroll; 547 + if (autoScroll) scroll = 0; 548 + }); 549 + 550 + refreshBtn.act(e, () => { 551 + fetchCommits(1, false); 552 + }); 553 + 554 + // Scroll via drag — disables auto-scroll. 676 555 if (e.is("draw")) { 677 556 autoScroll = false; 678 - scroll -= e.delta.y; // Invert for natural scroll direction 679 - boundScroll(); 680 - loadMoreIfNeeded(); 681 - } 682 - 683 - // Keyboard controls 684 - if (e.is("keyboard:down:arrowup") || e.is("keyboard:down:k")) { 685 - autoScroll = false; 686 - scroll -= rowHeight * 4; 687 - boundScroll(); 688 - } 689 - 690 - if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:j")) { 691 - autoScroll = false; 692 - scroll += rowHeight * 4; 693 - boundScroll(); 694 - loadMoreIfNeeded(); 695 - } 696 - 697 - if (e.is("keyboard:down:home")) { 698 - autoScroll = false; 699 - scroll = 0; 700 - } 701 - 702 - if (e.is("keyboard:down:end")) { 703 - autoScroll = false; 704 - scroll = max(0, totalScrollHeight - chatHeight + 5); 705 - loadMoreIfNeeded(); 706 - } 707 - 708 - // Page up/down 709 - if (e.is("keyboard:down:pageup")) { 710 - autoScroll = false; 711 - scroll -= chatHeight * 0.8; 712 - boundScroll(); 713 - } 714 - 715 - if (e.is("keyboard:down:pagedown")) { 716 - autoScroll = false; 717 - scroll += chatHeight * 0.8; 557 + scroll -= e.delta.y; 718 558 boundScroll(); 719 559 loadMoreIfNeeded(); 720 560 } 721 - 722 - // Toggle auto-scroll with Space 723 - if (e.is("keyboard:down: ")) { 724 - autoScroll = !autoScroll; 725 - if (autoScroll) scroll = 0; // Reset to top when enabling 726 - } 727 - 728 - // Toggle detailed view with D 729 - if (e.is("keyboard:down:d")) { 730 - showDetailedView = !showDetailedView; 731 - needsLayout = true; 732 - } 733 - 734 - // Refresh on R 735 - if (e.is("keyboard:down:r")) { 736 - statsCache.clear(); 737 - fetchCommits(1, false); 738 - } 739 - 740 - // GitHub link click 741 - if (e.is("touch") && githubLinkBox) { 742 - const { x, y } = e; 743 - if (x >= githubLinkBox.x && x <= githubLinkBox.x + githubLinkBox.w && 744 - y >= githubLinkBox.y && y <= githubLinkBox.y + githubLinkBox.h) { 745 - jump(`out:https://github.com/${REPO}`); 746 - } 561 + 562 + if (e.is("reframed")) { 563 + syncButtons({ screen }); 747 564 } 748 565 749 - // Back to prompt 750 - if (e.is("keyboard:down:escape")) { 751 - jump("prompt"); 752 - } 566 + // Back to prompt (standard AC convention). 567 + if (e.is("keyboard:down:escape") || e.is("keyboard:down:`")) jump("prompt"); 568 + if (e.is("keyboard:down:backspace")) jump("prompt"); 753 569 } 754 570 755 - function sim({ store }) { 756 - // Decay the new-commit flash 571 + function sim() { 757 572 if (hue > 0) hue = Math.max(0, hue - 0.5); 758 - 759 - // Auto-start scrolling after delay 573 + 760 574 const sinceLoad = Date.now() - loadTime; 761 575 if (loadTime > 0 && !autoScroll && sinceLoad >= autoScrollDelay && scroll === 0) { 762 576 autoScroll = true; 763 577 } 764 - 765 - // Auto-scroll 578 + 766 579 if (autoScroll && totalScrollHeight > chatHeight) { 767 580 scroll += autoScrollSpeed; 768 581 boundScroll(); 769 - 770 - // Loop back to top when reaching end 771 - if (scroll >= totalScrollHeight - chatHeight) { 772 - scroll = 0; 773 - } 774 - 582 + if (scroll >= totalScrollHeight - chatHeight) scroll = 0; 775 583 loadMoreIfNeeded(); 776 584 } 777 585 } 778 586 779 587 function leave() { 780 - // Clean up polling 781 588 if (pollTimer) { 782 589 clearInterval(pollTimer); 783 590 pollTimer = null; 784 591 } 785 - // Clear caches 786 - statsCache.clear(); 787 - statsFetching.clear(); 788 592 } 789 593 790 594 function meta() { 791 595 return { 792 596 title: "Commits", 793 - desc: "╔═══════════════════════════════════════╗\n║ Live GitHub commit feed with stats ║\n║ +additions -deletions • files changed ║\n╚═══════════════════════════════════════╝", 597 + desc: "Live Tangled commit feed for aesthetic.computer/core.", 794 598 }; 795 599 } 796 600