Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

feat: improve spreadnob octave mapping and ableton downloads

+1164 -147
+38 -48
.devcontainer/config.fish
··· 1922 1922 end 1923 1923 1924 1924 function ac-site 1925 - cd ~/aesthetic-computer/system 1926 - echo "🐱 Starting site..." 1927 - echo "🔍 Cleaning up any stuck processes..." 1928 - pkill -f "netlify dev" 2>/dev/null 1929 - pkill -f "esbuild" 2>/dev/null 1930 - sleep 1 1931 - echo "🔌 Killing ports..." 1932 - timeout 5 npx kill-port 8880 8888 8889 8080 8000 8111 3333 3000 3001 2>/dev/null; or true 1933 - echo "🔗 Linking netlify..." 1934 - timeout 10 netlify link --id $NETLIFY_SITE_ID 2>/dev/null; or true 1935 - echo "🚀 Starting server..." 1936 - npm run local-dev 1925 + # ac-site now runs lith (monolith) instead of netlify dev 1926 + ac-lith 1937 1927 end 1938 1928 1939 1929 function ac-lith ··· 3208 3198 echo " nc -lu 7777 | while read line; do echo \"\$(date '+%H:%M:%S') \$line\"; done" 3209 3199 end 3210 3200 3211 - # 🖥️ Machine Info / SSH Helpers 3212 - # Read machine configs from vault/machines.json 3213 - 3214 - function __ac_machines_file --description "Resolve machines.json with local cache fallback" 3215 - set -l machines_file "/workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json" 3216 - set -l machines_cache "$HOME/.cache/ac/machines.json" 3217 - 3218 - if test -f $machines_file 3219 - mkdir -p (dirname $machines_cache) 3220 - cp $machines_file $machines_cache 2>/dev/null 3221 - echo $machines_file 3222 - return 0 3223 - end 3224 - 3225 - if test -f $machines_cache 3226 - echo $machines_cache 3227 - return 0 3228 - end 3229 - 3230 - return 1 3231 - end 3232 - 3233 - function ac-host --description "Show current host SSH config from machines.json" 3234 - set -l machines_file (__ac_machines_file) 3235 - 3236 - if test $status -ne 0 -o -z "$machines_file" 3237 - echo "❌ machines.json not found in vault or local cache" 3238 - return 1 3239 - end 3201 + # 🖥️ Machine Info / SSH Helpers 3202 + # Read machine configs from vault/machines.json 3203 + 3204 + function __ac_machines_file --description "Resolve machines.json with local cache fallback" 3205 + set -l machines_file "/workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json" 3206 + set -l machines_cache "$HOME/.cache/ac/machines.json" 3207 + 3208 + if test -f $machines_file 3209 + mkdir -p (dirname $machines_cache) 3210 + cp $machines_file $machines_cache 2>/dev/null 3211 + echo $machines_file 3212 + return 0 3213 + end 3214 + 3215 + if test -f $machines_cache 3216 + echo $machines_cache 3217 + return 0 3218 + end 3219 + 3220 + return 1 3221 + end 3222 + 3223 + function ac-host --description "Show current host SSH config from machines.json" 3224 + set -l machines_file (__ac_machines_file) 3225 + 3226 + if test $status -ne 0 -o -z "$machines_file" 3227 + echo "❌ machines.json not found in vault or local cache" 3228 + return 1 3229 + end 3240 3230 3241 3231 set -l machine_key $argv[1] 3242 3232 ··· 3339 3329 ac-host 3340 3330 end 3341 3331 3342 - function ac-host-nmap --description "Run nmap scan on local network via current host" 3343 - set -l machines_file (__ac_machines_file) 3344 - if test $status -ne 0 -o -z "$machines_file" 3345 - echo "❌ machines.json not found in vault or local cache" 3346 - return 1 3347 - end 3348 - set -l search_term $argv[1] 3332 + function ac-host-nmap --description "Run nmap scan on local network via current host" 3333 + set -l machines_file (__ac_machines_file) 3334 + if test $status -ne 0 -o -z "$machines_file" 3335 + echo "❌ machines.json not found in vault or local cache" 3336 + return 1 3337 + end 3338 + set -l search_term $argv[1] 3349 3339 3350 3340 set -l hosts_to_try (jq -r ' 3351 3341 .machines
+1 -1
ac-m4l/AC-SpreadnobClean.amxd.json
··· 483 483 "maxclass": "newobj", 484 484 "numinlets": 1, "numoutlets": 1, "outlettype": [""], 485 485 "patching_rect": [420, 350, 85, 22], 486 - "text": "prepend set" 486 + "text": "prepend text" 487 487 } 488 488 }, 489 489 {
+139
ac-m4l/spreadnob-ui.js
··· 1 + // spreadnob-ui.js — jsui for the spreadnob dial 2 + autowatch = 1; 3 + mgraphics.init(); 4 + mgraphics.relative_coords = 0; 5 + mgraphics.autofill = 0; 6 + 7 + var KEYS = ["A","S","D","F","G","H","J","K","L"]; 8 + var N = 9; 9 + var SWEEP = 270; 10 + var START = 225; // degrees, lower-left 11 + 12 + var val = 0; 13 + var noteIdx = -1; 14 + var name = "click a knob"; 15 + var pmin = 0; 16 + var pmax = 1; 17 + 18 + function note(n) { 19 + // Convert MIDI note to key index (0-8) 20 + var WHITE = [0,2,4,5,7,9,11,12,14]; 21 + var off = n - 48; 22 + noteIdx = -1; 23 + for (var i = 0; i < WHITE.length; i++) { 24 + if (WHITE[i] === off) { noteIdx = i; break; } 25 + } 26 + mgraphics.redraw(); 27 + } 28 + 29 + function value(v) { 30 + val = v; 31 + mgraphics.redraw(); 32 + } 33 + 34 + function target() { 35 + var a = arrayfromargs(arguments); 36 + name = a.join(" "); 37 + mgraphics.redraw(); 38 + } 39 + 40 + function setmin(v) { pmin = v; mgraphics.redraw(); } 41 + function setmax(v) { pmax = v; mgraphics.redraw(); } 42 + 43 + function d2r(d) { return d * Math.PI / 180; } 44 + 45 + function paint() { 46 + with (mgraphics) { 47 + var w = mgraphics.size[0]; 48 + var h = mgraphics.size[1]; 49 + var cx = w / 2; 50 + var cy = h / 2 + 2; 51 + var R = Math.min(w, h) * 0.32; 52 + var tickR = R + 4; 53 + var labelR = R + 14; 54 + 55 + // Background 56 + set_source_rgba(0.12, 0.12, 0.14, 1); 57 + rectangle(0, 0, w, h); 58 + fill(); 59 + 60 + // Track arc (background) 61 + set_source_rgba(0.25, 0.23, 0.28, 1); 62 + set_line_width(5); 63 + // mgraphics angles: clockwise from right. Negate math angles. 64 + arc(cx, cy, R, d2r(-START), d2r(-(START - SWEEP))); 65 + stroke(); 66 + 67 + // Value arc 68 + var t = 0; 69 + if (pmax !== pmin) t = Math.max(0, Math.min(1, (val - pmin) / (pmax - pmin))); 70 + var valDeg = START - t * SWEEP; 71 + if (t > 0.001) { 72 + set_source_rgba(0.42, 1.0, 0.58, 0.9); 73 + set_line_width(5); 74 + arc(cx, cy, R, d2r(-START), d2r(-valDeg)); 75 + stroke(); 76 + } 77 + 78 + // Value dot 79 + var dx = cx + R * Math.cos(d2r(-valDeg)); 80 + var dy = cy + R * Math.sin(d2r(-valDeg)); 81 + set_source_rgba(1, 1, 1, 1); 82 + arc(dx, dy, 4, 0, Math.PI * 2); 83 + fill(); 84 + 85 + // Ticks + key labels 86 + for (var i = 0; i < N; i++) { 87 + var deg = START - (i / (N - 1)) * SWEEP; 88 + var rad = d2r(-deg); 89 + var ox = cx + tickR * Math.cos(rad); 90 + var oy = cy + tickR * Math.sin(rad); 91 + var ix = cx + (R - 2) * Math.cos(rad); 92 + var iy = cy + (R - 2) * Math.sin(rad); 93 + var lx = cx + labelR * Math.cos(rad); 94 + var ly = cy + labelR * Math.sin(rad); 95 + 96 + var hit = (noteIdx === i); 97 + 98 + // Tick line 99 + set_line_width(hit ? 2.5 : 1); 100 + set_source_rgba(hit ? 1 : 0.5, hit ? 1 : 0.48, hit ? 1 : 0.55, 1); 101 + move_to(ox, oy); 102 + line_to(ix, iy); 103 + stroke(); 104 + 105 + // Label 106 + set_source_rgba(hit ? 1 : 0.5, hit ? 1 : 0.48, hit ? 1 : 0.55, 1); 107 + select_font_face("Arial Bold"); 108 + set_font_size(hit ? 11 : 9); 109 + var te = text_measure(KEYS[i]); 110 + move_to(lx - te[0] / 2, ly + te[1] / 3); 111 + show_text(KEYS[i]); 112 + } 113 + 114 + // Center — target name 115 + set_source_rgba(0.85, 0.7, 0.8, 1); 116 + select_font_face("Arial"); 117 + set_font_size(9); 118 + var tn = name.length > 18 ? name.substring(0, 18) + "…" : name; 119 + var te = text_measure(tn); 120 + move_to(cx - te[0] / 2, cy - 4); 121 + show_text(tn); 122 + 123 + // Center — value 124 + var vt = val.toFixed(3); 125 + set_source_rgba(0.42, 1.0, 0.58, 1); 126 + select_font_face("Arial Bold"); 127 + set_font_size(12); 128 + te = text_measure(vt); 129 + move_to(cx - te[0] / 2, cy + 12); 130 + show_text(vt); 131 + 132 + // Title 133 + set_source_rgba(1.0, 0.47, 0.72, 0.6); 134 + select_font_face("Arial Bold"); 135 + set_font_size(8); 136 + move_to(4, 11); 137 + show_text("spreadnob"); 138 + } 139 + }
+263
lith/server.mjs
··· 589 589 </html>`); 590 590 }); 591 591 592 + // --- /api/os-release-upload (ports Netlify edge function os-release-upload.js) --- 593 + app.post("/api/os-release-upload", async (req, res) => { 594 + const { createHmac } = await import("crypto"); 595 + 596 + // Auth: verify AC token 597 + const authHeader = req.headers["authorization"] || ""; 598 + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : ""; 599 + if (!token) return res.status(401).json({ error: "Missing Authorization: Bearer <ac_token>" }); 600 + 601 + let user; 602 + try { 603 + const uiRes = await fetch("https://hi.aesthetic.computer/userinfo", { 604 + headers: { Authorization: `Bearer ${token}` }, 605 + }); 606 + if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`); 607 + user = await uiRes.json(); 608 + } catch (err) { 609 + return res.status(401).json({ error: `Auth failed: ${err.message}` }); 610 + } 611 + 612 + const userSub = user.sub || "unknown"; 613 + const userName = user.name || user.nickname || userSub; 614 + 615 + const accessKey = process.env.DO_SPACES_KEY || process.env.ART_KEY; 616 + const secretKey = process.env.DO_SPACES_SECRET || process.env.ART_SECRET; 617 + if (!accessKey || !secretKey) return res.status(503).json({ error: "Spaces creds not configured" }); 618 + 619 + const bucket = "releases-aesthetic-computer"; 620 + const host = `${bucket}.sfo3.digitaloceanspaces.com`; 621 + 622 + const buildName = req.headers["x-build-name"] || `upload-${Date.now()}`; 623 + const gitHash = req.headers["x-git-hash"] || "unknown"; 624 + const buildTs = req.headers["x-build-ts"] || new Date().toISOString().slice(0, 16); 625 + const commitMsg = req.headers["x-commit-msg"] || ""; 626 + const version = `${buildName} ${gitHash}-${buildTs}`; 627 + 628 + function presignUrl(key, contentType, expiresSec = 900) { 629 + const expires = Math.floor(Date.now() / 1000) + expiresSec; 630 + const stringToSign = `PUT\n\n${contentType}\n${expires}\nx-amz-acl:public-read\n/${bucket}/${key}`; 631 + const sig = createHmac("sha1", secretKey).update(stringToSign).digest("base64"); 632 + return `https://${host}/${key}?AWSAccessKeyId=${encodeURIComponent(accessKey)}&Expires=${expires}&Signature=${encodeURIComponent(sig)}&x-amz-acl=public-read`; 633 + } 634 + 635 + async function s3Put(key, body, contentType) { 636 + const dateStr = new Date().toUTCString(); 637 + const stringToSign = `PUT\n\n${contentType}\n${dateStr}\nx-amz-acl:public-read\n/${bucket}/${key}`; 638 + const sig = createHmac("sha1", secretKey).update(stringToSign).digest("base64"); 639 + const putRes = await fetch(`https://${host}/${key}`, { 640 + method: "PUT", 641 + headers: { Date: dateStr, "Content-Type": contentType, "x-amz-acl": "public-read", Authorization: `AWS ${accessKey}:${sig}` }, 642 + body: typeof body === "string" ? body : body, 643 + }); 644 + if (!putRes.ok) { 645 + const text = await putRes.text(); 646 + throw new Error(`S3 PUT ${key}: ${putRes.status} ${text.slice(0, 200)}`); 647 + } 648 + } 649 + 650 + async function loadMachineTokenSecret() { 651 + try { 652 + const connStr = process.env.MONGODB_CONNECTION_STRING; 653 + if (!connStr) return null; 654 + const { MongoClient } = await import("mongodb"); 655 + const client = new MongoClient(connStr); 656 + await client.connect(); 657 + const dbName = process.env.MONGODB_NAME || "aesthetic"; 658 + const doc = await client.db(dbName).collection("secrets").findOne({ _id: "machine-token" }); 659 + await client.close(); 660 + return doc?.secret || null; 661 + } catch (e) { 662 + console.error("[os-release-upload] Failed to load machine-token secret:", e.message); 663 + return null; 664 + } 665 + } 666 + 667 + async function generateDeviceToken(sub, handle) { 668 + const secret = await loadMachineTokenSecret(); 669 + if (!secret) return null; 670 + const payload = { sub, handle, iat: Math.floor(Date.now() / 1000) }; 671 + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url"); 672 + const sigB64 = createHmac("sha256", secret).update(payloadB64).digest("base64url"); 673 + return `${payloadB64}.${sigB64}`; 674 + } 675 + 676 + const isFinalize = req.headers["x-finalize"] === "true"; 677 + 678 + if (isFinalize) { 679 + const sha256 = req.headers["x-sha256"] || "unknown"; 680 + const size = parseInt(req.headers["x-size"] || "0", 10); 681 + try { 682 + const versionWithSize = `${version}\n${size}`; 683 + await Promise.all([ 684 + s3Put("os/native-notepat-latest.version", versionWithSize, "text/plain"), 685 + s3Put("os/native-notepat-latest.sha256", sha256, "text/plain"), 686 + ]); 687 + let releases = { releases: [] }; 688 + try { 689 + const existing = await fetch(`https://${host}/os/releases.json`); 690 + if (existing.ok) releases = await existing.json(); 691 + } catch { /* first release */ } 692 + const userHandle = req.headers["x-handle"] || user.nickname || user.name || userName; 693 + releases.releases = releases.releases || []; 694 + for (const r of releases.releases) r.deprecated = true; 695 + releases.releases.unshift({ 696 + version, name: buildName, sha256, size, git_hash: gitHash, build_ts: buildTs, 697 + commit_msg: commitMsg, user: userSub, handle: userHandle, 698 + url: `https://${host}/os/native-notepat-latest.vmlinuz`, 699 + archive_url: `https://${host}/os/builds/${buildName}.vmlinuz`, 700 + }); 701 + releases.releases = releases.releases.slice(0, 50); 702 + releases.latest = version; 703 + releases.latest_name = buildName; 704 + const deviceToken = await generateDeviceToken(userSub, userHandle); 705 + if (deviceToken) releases.device_token = deviceToken; 706 + await s3Put("os/releases.json", JSON.stringify(releases, null, 2), "application/json"); 707 + return res.json({ ok: true, name: buildName, version, sha256, size, url: `https://${host}/os/native-notepat-latest.vmlinuz`, user: userSub, userName, deviceToken: !!deviceToken }); 708 + } catch (err) { 709 + return res.status(500).json({ error: `Finalize failed: ${err.message}` }); 710 + } 711 + } 712 + 713 + if (req.headers["x-versioned-upload"] === "true") { 714 + try { 715 + const versionedKey = req.headers["x-versioned-key"] || `os/builds/${buildName}.vmlinuz`; 716 + return res.json({ step: "versioned-upload", versioned_put_url: presignUrl(versionedKey, "application/octet-stream", 1800), key: versionedKey, user: userSub }); 717 + } catch (err) { 718 + return res.status(500).json({ error: `Versioned presign failed: ${err.message}` }); 719 + } 720 + } 721 + 722 + if (req.headers["x-manifest-upload"] === "true") { 723 + try { 724 + return res.json({ step: "manifest-upload", manifest_put_url: presignUrl("os/latest-manifest.json", "application/json"), user: userSub }); 725 + } catch (err) { 726 + return res.status(500).json({ error: `Manifest presign failed: ${err.message}` }); 727 + } 728 + } 729 + 730 + if (req.headers["x-template-upload"] === "true") { 731 + try { 732 + return res.json({ step: "template-upload", iso_put_url: presignUrl("os/native-notepat-latest.iso", "application/x-iso9660-image"), user: userSub }); 733 + } catch (err) { 734 + return res.status(500).json({ error: `Template presign failed: ${err.message}` }); 735 + } 736 + } 737 + 738 + // Step 1: Return presigned URL for vmlinuz upload 739 + try { 740 + return res.json({ step: "upload", vmlinuz_put_url: presignUrl("os/native-notepat-latest.vmlinuz", "application/octet-stream"), version, user: userSub, userName }); 741 + } catch (err) { 742 + return res.status(500).json({ error: `Presign failed: ${err.message}` }); 743 + } 744 + }); 745 + 746 + // --- /api/os-image (ports Netlify edge function os-image.js) --- 747 + app.get("/api/os-image", async (req, res) => { 748 + const authHeader = req.headers["authorization"] || ""; 749 + if (!authHeader) return res.status(401).json({ error: "Authorization required. Log in at aesthetic.computer first." }); 750 + 751 + try { 752 + const ovenRes = await fetch("https://oven.aesthetic.computer/os-image", { 753 + headers: { Authorization: authHeader }, 754 + }); 755 + res.status(ovenRes.status); 756 + res.set("Content-Type", ovenRes.headers.get("content-type") || "application/octet-stream"); 757 + if (ovenRes.headers.get("content-disposition")) res.set("Content-Disposition", ovenRes.headers.get("content-disposition")); 758 + if (ovenRes.headers.get("content-length")) res.set("Content-Length", ovenRes.headers.get("content-length")); 759 + res.set("Access-Control-Allow-Origin", "*"); 760 + const { Readable } = await import("stream"); 761 + Readable.fromWeb(ovenRes.body).pipe(res); 762 + } catch (err) { 763 + return res.status(502).json({ error: `Oven unavailable: ${err.message}` }); 764 + } 765 + }); 766 + 592 767 // --- /media/* handler (ports Netlify edge function media.js) --- 593 768 app.all("/media/*rest", async (req, res) => { 594 769 const parts = req.path.split("/").filter(Boolean); // ["media", ...] ··· 726 901 727 902 // Static files 728 903 app.use(express.static(PUBLIC, { extensions: ["html"], dotfiles: "allow" })); 904 + 905 + // --- keeps-social: SSR meta tags for social crawlers on keep/buy.kidlisp.com --- 906 + const CRAWLER_RE = /twitterbot|facebookexternalhit|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|applebot/i; 907 + const OBJKT_GRAPHQL = "https://data.objkt.com/v3/graphql"; 908 + 909 + async function keepsSocialMiddleware(req, res, next) { 910 + const host = (req.headers.host || "").split(":")[0].toLowerCase(); 911 + const isBuy = host.includes("buy.kidlisp.com"); 912 + const isKeep = host.includes("keep.kidlisp.com"); 913 + if (!isBuy && !isKeep) return next(); 914 + 915 + const seg = req.path.replace(/^\/+/, "").split("/")[0]; 916 + if (!seg.startsWith("$") || seg.length < 2) return next(); 917 + 918 + const ua = req.headers["user-agent"] || ""; 919 + if (!CRAWLER_RE.test(ua)) return next(); 920 + 921 + const code = seg.slice(1); 922 + try { 923 + const [tokenData, ogImage] = await Promise.all([ 924 + fetchKeepsTokenData(code), 925 + resolveKeepsImageUrl(`https://oven.aesthetic.computer/preview/1200x630/$${code}.png`), 926 + ]); 927 + 928 + // Get the HTML from the index function 929 + if (!functions["index"]) return next(); 930 + const event = toEvent(req); 931 + const result = await functions["index"](event, { clientContext: {} }); 932 + let html = result.body || ""; 933 + 934 + const title = `$${code}`; 935 + const subdomain = isBuy ? "buy" : "keep"; 936 + const description = buildKeepsDescription(tokenData, isBuy); 937 + const permalink = `https://${subdomain}.kidlisp.com/$${code}`; 938 + 939 + html = html.replace(/<meta property="og:url"[^>]*\/>/, `<meta property="og:url" content="${permalink}" />`); 940 + html = html.replace(/<meta property="og:title"[^>]*\/>/, `<meta property="og:title" content="${escapeAttr(title)}" />`); 941 + html = html.replace(/<meta property="og:description"[^>]*\/>/, `<meta property="og:description" content="${escapeAttr(description)}" />`); 942 + html = html.replace(/<meta property="og:image" content="[^"]*"[^>]*\/>/, `<meta property="og:image" content="${ogImage}" />`); 943 + html = html.replace(/<meta name="twitter:title"[^>]*\/>/, `<meta name="twitter:title" content="${escapeAttr(title)}" />`); 944 + html = html.replace(/<meta name="twitter:description"[^>]*\/>/, `<meta name="twitter:description" content="${escapeAttr(description)}" />`); 945 + html = html.replace(/<meta name="twitter:image" content="[^"]*"[^>]*\/>/, `<meta name="twitter:image" content="${ogImage}" />`); 946 + 947 + res.set("Content-Type", "text/html; charset=utf-8"); 948 + res.set("Cache-Control", "public, max-age=3600"); 949 + return res.status(200).send(html); 950 + } catch (err) { 951 + console.error("[keeps-social] error:", err); 952 + return next(); 953 + } 954 + } 955 + 956 + async function fetchKeepsTokenData(code) { 957 + const contract = "KT1Q1irsjSZ7EfUN4qHzAB2t7xLBPsAWYwBB"; 958 + const query = `query { token(where: { fa_contract: { _eq: "${contract}" } name: { _eq: "$${code}" } }) { token_id name thumbnail_uri } listing_active(where: { fa_contract: { _eq: "${contract}" } token: { name: { _eq: "$${code}" } } } order_by: { price_xtz: asc } limit: 1) { price_xtz seller_address } }`; 959 + const r = await fetch(OBJKT_GRAPHQL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) }); 960 + if (!r.ok) return null; 961 + const json = await r.json(); 962 + const tokens = json?.data?.token || []; 963 + if (tokens.length === 0) return null; 964 + return { token: tokens[0], listing: (json?.data?.listing_active || [])[0] || null }; 965 + } 966 + 967 + function buildKeepsDescription(tokenData, isBuy) { 968 + if (!tokenData) return isBuy ? "Buy KidLisp generative art on Tezos." : "KidLisp generative art preserved on Tezos."; 969 + const { listing } = tokenData; 970 + if (listing) { 971 + const xtz = (Number(listing.price_xtz) / 1_000_000).toFixed(2); 972 + return isBuy ? `Buy now — ${xtz} XTZ | KidLisp generative art on Tezos` : `For Sale — ${xtz} XTZ | KidLisp generative art on Tezos`; 973 + } 974 + return isBuy ? "Buy KidLisp generative art on Tezos." : "KidLisp generative art preserved on Tezos."; 975 + } 976 + 977 + function escapeAttr(str) { 978 + return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 979 + } 980 + 981 + async function resolveKeepsImageUrl(url) { 982 + try { 983 + const r = await fetch(url, { method: "HEAD", redirect: "follow" }); 984 + if (r.ok && r.url) return r.url; 985 + } catch (e) { 986 + console.error("[keeps-social] image resolve error:", e); 987 + } 988 + return url; 989 + } 990 + 991 + app.use(keepsSocialMiddleware); 729 992 730 993 // SPA fallback → index function 731 994 app.use(async (req, res) => {
+1 -1
system/netlify/edge-functions/os-release-upload.js
··· 138 138 try { 139 139 const connStr = Deno.env.get("MONGODB_CONNECTION_STRING"); 140 140 if (!connStr) return null; 141 - const { MongoClient } = await import("npm:mongodb@6"); 141 + const { MongoClient } = await import("npm:mongodb@7"); 142 142 const client = new MongoClient(connStr); 143 143 await client.connect(); 144 144 const dbName = Deno.env.get("MONGODB_NAME") || "aesthetic";
+5 -5
texput.log
··· 1 - This is XeTeX, Version 3.141592653-2.6-0.999995 (TeX Live 2023) (preloaded format=xelatex 2026.3.17) 26 MAR 2026 12:00 1 + This is XeTeX, Version 3.141592653-2.6-0.999995 (TeX Live 2023) (preloaded format=xelatex 2026.3.29) 1 APR 2026 21:14 2 2 entering extended mode 3 3 restricted \write18 enabled. 4 4 %&-line parsing enabled. 5 - **plork-cards.tex 5 + **card.tex 6 6 7 7 ! Emergency stop. 8 - <*> plork-cards.tex 9 - 8 + <*> card.tex 9 + 10 10 *** (job aborted, file error in nonstop mode) 11 11 12 12 13 13 Here is how much of TeX's memory you used: 14 14 2 strings out of 478682 15 - 21 string characters out of 5849006 15 + 14 string characters out of 5849006 16 16 1842018 words of memory out of 6000000 17 17 20219 multiletter control sequences out of 15000+600000 18 18 512287 words of font info for 32 fonts, out of 8000000 for 9000
+136
vscode-extension/PLANS.md
··· 1 + # VSCode Extension: Dashboard & AT Firehose 2 + 3 + ## Vision 4 + 5 + Replace the current "Source Changes" welcome panel with a **live dashboard** that acts as a firehose for all Aesthetic Computer platform activity — git changes, AT Protocol events, and Tangled knot repo activity — all in one view. 6 + 7 + ## Current State 8 + 9 + The welcome panel (`extension.ts:966-1100`) is a simple git status viewer: 10 + - Runs `git status --porcelain` + `git rev-parse --abbrev-ref HEAD` for the main repo + vault 11 + - Displays file list with status badges (M/A/D/?) 12 + - Click-to-open files in editor 13 + - Debounced refresh on file changes (300ms) 14 + 15 + **Known bug:** `git rev-parse --abbrev-ref HEAD` fails on repos with no commits (e.g. freshly cloned submodules), showing a raw error in the UI instead of a graceful fallback. 16 + 17 + --- 18 + 19 + ## Phase 1: Fix the Git Error + Improve Layout 20 + 21 + **Goal:** Make the existing git status panel work correctly and look better. 22 + 23 + - [ ] Handle `rev-parse` failure gracefully — show `(no commits)` or `(init)` instead of the raw error 24 + - [ ] Improve the visual layout: tighter spacing, better typography, cleaner status badges 25 + - [ ] Add a collapsible section header for "Source Changes" (so it can coexist with new sections) 26 + - [ ] Add timestamp showing last refresh time 27 + 28 + ## Phase 2: Dashboard Shell 29 + 30 + **Goal:** Transform the panel from a single-purpose git viewer into a multi-section dashboard. 31 + 32 + - [ ] Redesign the HTML structure with distinct dashboard sections: 33 + 1. **Source Changes** — the existing git status (cleaned up from Phase 1) 34 + 2. **AT Firehose** — live AT Protocol events (Phase 3) 35 + 3. **Tangled** — knot/repo activity (Phase 4) 36 + - [ ] Add a sticky nav/header with section toggles (show/hide each section) 37 + - [ ] Support auto-scroll vs. paused mode (like a terminal — new events stream in, but scrolling up pauses the feed) 38 + - [ ] Add theme-aware styling for all new sections (dark/light support already exists) 39 + - [ ] Consider making this a sidebar webview instead of (or in addition to) an editor panel 40 + 41 + ## Phase 3: AT Protocol Firehose 42 + 43 + **Goal:** Show live AT Protocol activity happening on the Aesthetic Computer PDS. 44 + 45 + The AC platform already has deep atproto integration: 46 + - PDS at `at.aesthetic.computer` 47 + - Lexicons: `computer.aesthetic.tape`, `computer.aesthetic.mood`, `computer.aesthetic.painting`, etc. 48 + - Existing scripts in `at/scripts/atproto/` 49 + - News posted to atproto via `system/backend/news-atproto.mjs` 50 + - Tapes synced to atproto via `system/backend/tape-atproto.mjs` 51 + 52 + ### What to show in the firehose: 53 + - [ ] **New tapes** — when users record/publish tapes (video content) 54 + - [ ] **Moods** — mood updates from users (`computer.aesthetic.mood`) 55 + - [ ] **Paintings** — shared paintings (`computer.aesthetic.painting`) 56 + - [ ] **News posts** — new news articles published 57 + - [ ] **Handle registrations/updates** — new users or handle changes 58 + - [ ] **Standard site updates** — page edits, new pages 59 + 60 + ### Implementation options: 61 + - **Option A: WebSocket relay** — Add a firehose WebSocket endpoint to the session server or a new lightweight service that subscribes to the PDS `com.atproto.sync.subscribeRepos` firehose and relays events to the extension. 62 + - **Option B: Polling** — Periodically query `com.atproto.repo.listRecords` for each collection on the PDS. Simpler but less real-time. 63 + - **Option C: Direct firehose** — Connect the extension directly to `wss://at.aesthetic.computer/xrpc/com.atproto.sync.subscribeRepos` and decode the CBOR/CAR stream in the extension itself. Most direct but complex. 64 + 65 + ### Recommended: Option A (WebSocket relay) 66 + The extension already has a WebSocket connection to the session server (`extension.ts:2132`). We can add a `firehose:event` message type that the session server relays from the PDS subscription. This keeps the extension lightweight and reuses existing infrastructure. 67 + 68 + ### Event display format: 69 + Each event in the feed should show: 70 + - Timestamp 71 + - Event type icon/badge (tape, mood, painting, news, etc.) 72 + - Actor handle (clickable — opens their AC profile) 73 + - Brief content preview (tape thumbnail, mood text, painting preview, etc.) 74 + - Link to view the full content on aesthetic.computer 75 + 76 + ## Phase 4: Tangled Knot Activity 77 + 78 + **Goal:** Show activity from the Tangled knot (AT Protocol-native git hosting). 79 + 80 + The AC repo is mirrored on Tangled at `tangled.org/aesthetic.computer/core`. The knot server runs on the same PDS droplet (`knot.aesthetic.computer`). 81 + 82 + ### What to show: 83 + - [ ] **Commits** — recent commits pushed to the Tangled knot 84 + - [ ] **Issues/PRs** — if Tangled supports these (check API) 85 + - [ ] **Stars/forks** — social activity on the repo 86 + - [ ] **Cross-reference with GitHub** — show if a commit exists on both GitHub and Tangled 87 + 88 + ### Implementation: 89 + - [ ] Research Tangled API (likely atproto-based — check `tangled.org` for API docs) 90 + - [ ] The knot server is at `knot.aesthetic.computer` — check what XRPC endpoints it exposes 91 + - [ ] Display as a feed of events similar to the AT firehose section 92 + - [ ] Include both the main `aesthetic-computer` repo and any other knot-hosted repos 93 + 94 + ## Phase 5: Polish & Interaction 95 + 96 + - [ ] Add filtering — filter firehose by event type, user, time range 97 + - [ ] Add notification badges on the sidebar icon when new events arrive 98 + - [ ] Keyboard shortcuts for navigating the dashboard 99 + - [ ] Click-through actions: clicking a tape opens it, clicking a commit shows the diff, etc. 100 + - [ ] Sound/visual ping option for specific event types (e.g., new user signups) 101 + - [ ] Consider a compact "ticker" mode that shows a single scrolling line of recent events in the status bar 102 + 103 + --- 104 + 105 + ## Architecture Notes 106 + 107 + ### Data flow 108 + ``` 109 + PDS (at.aesthetic.computer) 110 + └─ com.atproto.sync.subscribeRepos (firehose) 111 + └─ Session Server (relay) 112 + └─ WebSocket → VSCode Extension 113 + └─ Dashboard Webview 114 + 115 + Knot Server (knot.aesthetic.computer) 116 + └─ XRPC / atproto API 117 + └─ Session Server or direct polling 118 + └─ WebSocket → VSCode Extension 119 + └─ Dashboard Webview 120 + 121 + Local Git Repos 122 + └─ git status --porcelain (child_process) 123 + └─ Extension host 124 + └─ postMessage → Dashboard Webview 125 + ``` 126 + 127 + ### Key files to modify 128 + - `vscode-extension/extension.ts` — welcome panel HTML + git status logic 129 + - `session-server/session.mjs` — add firehose relay endpoint 130 + - `vscode-extension/embedded.js` — may need message bridging updates 131 + - `vscode-extension/package.json` — new commands, settings 132 + 133 + ### Dependencies to consider 134 + - `@atproto/api` — already in `system/package.json` 135 + - CBOR decoding for firehose (if doing direct connection) 136 + - The extension currently bundles with esbuild — any new deps need to be bundleable
+577 -88
vscode-extension/extension.ts
··· 861 861 ), 862 862 ); 863 863 864 - // ✦ Git Status Welcome Panel 865 - // Runs `git status --porcelain` directly via child_process for maximum speed. 866 - // Shows changes for both aesthetic-computer and aesthetic-computer-vault. 864 + // ✦ Dashboard — Git Status + AT Protocol Firehose + Tangled Knot Activity 865 + // Multi-section live dashboard for all Aesthetic Computer platform activity. 867 866 868 867 interface GitRepoStatus { 869 868 name: string; ··· 873 872 error?: string; 874 873 } 875 874 875 + interface FirehoseEvent { 876 + id: string; 877 + type: string; // tape, mood, painting, news, handle, piece, kidlisp 878 + action: string; // create, update, delete 879 + handle?: string; 880 + did?: string; 881 + summary: string; 882 + timestamp: string; 883 + url?: string; 884 + } 885 + 886 + interface TangledEvent { 887 + id: string; 888 + type: string; // push, issue, star, fork 889 + repo: string; 890 + author?: string; 891 + summary: string; 892 + timestamp: string; 893 + commitHash?: string; 894 + url?: string; 895 + } 896 + 876 897 // Detect current VS Code theme kind 877 898 function getVSCodeThemeKind(): 'dark' | 'light' { 878 899 const themeName = vscode.window.activeColorTheme.name || ''; ··· 896 917 }); 897 918 } 898 919 899 - // Get git status for a repo directory 920 + // Get git status for a repo directory (with graceful rev-parse fallback) 900 921 async function getGitStatus(repoPath: string, name: string): Promise<GitRepoStatus> { 901 922 try { 902 - const [statusOut, branchOut] = await Promise.all([ 903 - gitExec('status --porcelain', repoPath), 904 - gitExec('rev-parse --abbrev-ref HEAD', repoPath), 905 - ]); 906 - const branch = branchOut.trim(); 923 + const statusOut = await gitExec('status --porcelain', repoPath); 924 + let branch = '(init)'; 925 + try { 926 + branch = (await gitExec('rev-parse --abbrev-ref HEAD', repoPath)).trim(); 927 + } catch { 928 + // No commits yet — rev-parse fails on fresh repos, use symbolic-ref 929 + try { 930 + const ref = (await gitExec('symbolic-ref --short HEAD', repoPath)).trim(); 931 + branch = ref || '(init)'; 932 + } catch { 933 + branch = '(no commits)'; 934 + } 935 + } 907 936 const files = statusOut.split('\n').filter(Boolean).map(line => ({ 908 937 status: line.substring(0, 2).trim(), 909 938 file: line.substring(3), ··· 914 943 } 915 944 } 916 945 946 + // Get recent git log for a repo 947 + async function getGitLog(repoPath: string, count: number = 8): Promise<{ hash: string; subject: string; author: string; date: string }[]> { 948 + try { 949 + const logOut = await gitExec(`log --oneline --format="%h|%s|%an|%cr" -${count}`, repoPath); 950 + return logOut.split('\n').filter(Boolean).map(line => { 951 + const [hash, subject, author, date] = line.split('|'); 952 + return { hash, subject, author, date }; 953 + }); 954 + } catch { 955 + return []; 956 + } 957 + } 958 + 917 959 // Gather status for all repos 918 - async function getAllGitStatus(): Promise<GitRepoStatus[]> { 960 + async function getAllGitStatus(): Promise<{ repos: GitRepoStatus[]; logs: { name: string; commits: { hash: string; subject: string; author: string; date: string }[] }[] }> { 919 961 await _modulesReady; 920 962 const repos: { name: string; root: string }[] = []; 921 963 922 - // Find aesthetic-computer workspace root 923 964 const wsFolder = vscode.workspace.workspaceFolders?.[0]; 924 965 if (wsFolder && fs && path) { 925 966 const acRoot = wsFolder.uri.fsPath; 967 + try { 968 + if (fs.existsSync(path.join(acRoot, '.git'))) { 969 + repos.push({ name: 'aesthetic-computer', root: acRoot }); 970 + } 971 + } catch {} 972 + try { 973 + const vaultPath = path.join(acRoot, 'aesthetic-computer-vault'); 974 + if (fs.statSync(vaultPath).isDirectory() && fs.existsSync(path.join(vaultPath, '.git'))) { 975 + repos.push({ name: 'vault', root: vaultPath }); 976 + } 977 + } catch {} 978 + } 926 979 927 - // Verify main repo is a git repo 980 + const [statuses, logs] = await Promise.all([ 981 + Promise.all(repos.map(r => getGitStatus(r.root, r.name))), 982 + Promise.all(repos.map(async r => ({ name: r.name, commits: await getGitLog(r.root) }))), 983 + ]); 984 + return { repos: statuses, logs }; 985 + } 986 + 987 + // AT Protocol firehose — poll PDS for recent records across all collections 988 + const AT_COLLECTIONS = [ 989 + { collection: 'computer.aesthetic.tape', label: 'Tape', icon: '\u{1F3AC}' }, 990 + { collection: 'computer.aesthetic.mood', label: 'Mood', icon: '\u{1F30A}' }, 991 + { collection: 'computer.aesthetic.painting', label: 'Painting', icon: '\u{1F3A8}' }, 992 + { collection: 'computer.aesthetic.news', label: 'News', icon: '\u{1F4F0}' }, 993 + { collection: 'computer.aesthetic.piece', label: 'Piece', icon: '\u{1F9E9}' }, 994 + { collection: 'computer.aesthetic.kidlisp', label: 'KidLisp', icon: '\u{1F308}' }, 995 + ]; 996 + const PDS_URL = 'https://at.aesthetic.computer'; 997 + const TANGLED_KNOT_URL = 'https://knot.aesthetic.computer'; 998 + 999 + let firehoseEvents: FirehoseEvent[] = []; 1000 + let tangledEvents: TangledEvent[] = []; 1001 + let firehoseSeenIds = new Set<string>(); 1002 + let tangledSeenIds = new Set<string>(); 1003 + 1004 + async function fetchFirehoseEvents(): Promise<FirehoseEvent[]> { 1005 + await _modulesReady; 1006 + if (!http) return []; 1007 + const events: FirehoseEvent[] = []; 1008 + 1009 + // Fetch recent records from each collection on the PDS 1010 + // We list records from the guest/system DID to show platform-wide activity 1011 + for (const col of AT_COLLECTIONS) { 928 1012 try { 929 - const mainGitDir = path.join(acRoot, '.git'); 930 - if (fs.existsSync(mainGitDir)) { 931 - repos.push({ name: 'aesthetic-computer', root: acRoot }); 1013 + const url = `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=5`; 1014 + // Instead, list records for known DIDs — use repo.listRecords with a broader approach 1015 + // For now, use listRepos to discover DIDs, then sample records 1016 + const reposJson = await httpGetJson(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=20`); 1017 + if (!reposJson?.repos) continue; 1018 + 1019 + for (const repo of reposJson.repos.slice(0, 5)) { 1020 + try { 1021 + const recordsJson = await httpGetJson( 1022 + `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo.did)}&collection=${encodeURIComponent(col.collection)}&limit=3&reverse=true` 1023 + ); 1024 + if (!recordsJson?.records) continue; 1025 + for (const rec of recordsJson.records) { 1026 + const id = rec.uri || `${repo.did}:${col.collection}:${rec.cid}`; 1027 + if (firehoseSeenIds.has(id)) continue; 1028 + firehoseSeenIds.add(id); 1029 + 1030 + const val = rec.value || {}; 1031 + let summary = val.title || val.text || val.name || val.code || val.headline || col.label; 1032 + if (summary.length > 80) summary = summary.substring(0, 77) + '...'; 1033 + 1034 + events.push({ 1035 + id, 1036 + type: col.label.toLowerCase(), 1037 + action: 'create', 1038 + did: repo.did, 1039 + handle: val.handle || repo.did.substring(0, 20), 1040 + summary: `${col.icon} ${summary}`, 1041 + timestamp: val.createdAt || val.when || new Date().toISOString(), 1042 + url: val.uri || undefined, 1043 + }); 1044 + } 1045 + } catch {} 932 1046 } 933 1047 } catch {} 1048 + } 934 1049 935 - // Check for vault as a subdirectory 1050 + // Sort by timestamp descending 1051 + events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); 1052 + return events.slice(0, 50); 1053 + } 1054 + 1055 + // Simple HTTPS GET → JSON helper using Node http/https 1056 + function httpGetJson(url: string): Promise<any> { 1057 + return new Promise((resolve) => { 936 1058 try { 937 - const vaultPath = path.join(acRoot, 'aesthetic-computer-vault'); 938 - const stat = fs.statSync(vaultPath); 939 - if (stat.isDirectory()) { 940 - const gitDir = path.join(vaultPath, '.git'); 941 - if (fs.existsSync(gitDir)) { 942 - repos.push({ name: 'vault', root: vaultPath }); 1059 + const https = require('https'); 1060 + const req = https.get(url, { timeout: 8000 }, (res: any) => { 1061 + let data = ''; 1062 + res.on('data', (chunk: string) => { data += chunk; }); 1063 + res.on('end', () => { 1064 + try { resolve(JSON.parse(data)); } catch { resolve(null); } 1065 + }); 1066 + }); 1067 + req.on('error', () => resolve(null)); 1068 + req.on('timeout', () => { req.destroy(); resolve(null); }); 1069 + } catch { resolve(null); } 1070 + }); 1071 + } 1072 + 1073 + async function fetchTangledEvents(): Promise<TangledEvent[]> { 1074 + await _modulesReady; 1075 + const events: TangledEvent[] = []; 1076 + 1077 + // Query the Tangled knot server for recent repo activity 1078 + try { 1079 + // Try the knot's repo endpoint for recent activity 1080 + const repoInfo = await httpGetJson(`${TANGLED_KNOT_URL}/aesthetic-computer.git/info/refs?service=git-upload-pack`); 1081 + // The knot exposes a REST-ish API — try listing recent commits via the appview 1082 + const tangledCommits = await httpGetJson(`https://tangled.org/api/repos/aesthetic.computer/core/commits?limit=10`); 1083 + if (tangledCommits && Array.isArray(tangledCommits)) { 1084 + for (const commit of tangledCommits) { 1085 + const id = commit.sha || commit.hash || commit.id; 1086 + if (!id || tangledSeenIds.has(id)) continue; 1087 + tangledSeenIds.add(id); 1088 + events.push({ 1089 + id, 1090 + type: 'push', 1091 + repo: 'aesthetic.computer/core', 1092 + author: commit.author?.name || commit.author || 'unknown', 1093 + summary: commit.message || commit.subject || '(no message)', 1094 + timestamp: commit.date || commit.timestamp || new Date().toISOString(), 1095 + commitHash: id.substring(0, 7), 1096 + url: `https://tangled.org/aesthetic.computer/core/commit/${id}`, 1097 + }); 1098 + } 1099 + } 1100 + } catch {} 1101 + 1102 + // Also try the git log locally if the tangled remote is configured 1103 + try { 1104 + const wsFolder = vscode.workspace.workspaceFolders?.[0]; 1105 + if (wsFolder && path) { 1106 + const acRoot = wsFolder.uri.fsPath; 1107 + // Check if tangled remote exists 1108 + const remotes = await gitExec('remote -v', acRoot); 1109 + if (remotes.includes('tangled') || remotes.includes('knot')) { 1110 + const remoteName = remotes.includes('tangled') ? 'tangled' : 'knot'; 1111 + try { 1112 + await gitExec(`fetch ${remoteName} --no-tags`, acRoot); 1113 + } catch {} 1114 + const logOut = await gitExec(`log ${remoteName}/main --oneline --format="%h|%s|%an|%cr" -10`, acRoot); 1115 + for (const line of logOut.split('\n').filter(Boolean)) { 1116 + const [hash, subject, author, date] = line.split('|'); 1117 + const id = `tangled:${hash}`; 1118 + if (tangledSeenIds.has(id)) continue; 1119 + tangledSeenIds.add(id); 1120 + events.push({ 1121 + id, 1122 + type: 'push', 1123 + repo: 'aesthetic.computer/core', 1124 + author, 1125 + summary: subject, 1126 + timestamp: date, 1127 + commitHash: hash, 1128 + url: `https://tangled.org/aesthetic.computer/core`, 1129 + }); 943 1130 } 944 1131 } 945 - } catch {} 1132 + } 1133 + } catch {} 1134 + 1135 + return events; 1136 + } 1137 + 1138 + // Send all dashboard data to the welcome panel 1139 + let dashboardPending = false; 1140 + async function sendDashboardData() { 1141 + if (!welcomePanel || dashboardPending) return; 1142 + dashboardPending = true; 1143 + try { 1144 + const gitData = await getAllGitStatus(); 1145 + if (welcomePanel) { 1146 + welcomePanel.webview.postMessage({ command: 'gitStatus', repos: gitData.repos, logs: gitData.logs }); 1147 + } 1148 + } finally { 1149 + dashboardPending = false; 946 1150 } 1151 + } 947 1152 948 - return Promise.all(repos.map(r => getGitStatus(r.root, r.name))); 1153 + // Fetch and send firehose data (separate from git for independent refresh) 1154 + let firehosePending = false; 1155 + async function sendFirehoseData() { 1156 + if (!welcomePanel || firehosePending) return; 1157 + firehosePending = true; 1158 + try { 1159 + const events = await fetchFirehoseEvents(); 1160 + if (events.length > 0) { 1161 + firehoseEvents = events; 1162 + } 1163 + if (welcomePanel) { 1164 + welcomePanel.webview.postMessage({ command: 'firehose', events: firehoseEvents }); 1165 + } 1166 + } finally { 1167 + firehosePending = false; 1168 + } 949 1169 } 950 1170 951 - // Send git status update to the welcome panel 952 - let gitStatusPending = false; 953 - async function sendGitStatusToPanel() { 954 - if (!welcomePanel || gitStatusPending) return; 955 - gitStatusPending = true; 1171 + // Fetch and send Tangled data 1172 + let tangledPending = false; 1173 + async function sendTangledData() { 1174 + if (!welcomePanel || tangledPending) return; 1175 + tangledPending = true; 956 1176 try { 957 - const statuses = await getAllGitStatus(); 1177 + const events = await fetchTangledEvents(); 1178 + if (events.length > 0) { 1179 + tangledEvents = events; 1180 + } 958 1181 if (welcomePanel) { 959 - welcomePanel.webview.postMessage({ command: 'gitStatus', repos: statuses }); 1182 + welcomePanel.webview.postMessage({ command: 'tangled', events: tangledEvents }); 960 1183 } 961 1184 } finally { 962 - gitStatusPending = false; 1185 + tangledPending = false; 963 1186 } 964 1187 } 965 1188 966 - function getWelcomePanelHtml(): string { 1189 + function getDashboardHtml(): string { 967 1190 const theme = getVSCodeThemeKind(); 968 1191 const c = theme === 'light' 969 1192 ? { bg: '#fcf7c5', fg: '#281e5a', fgMuted: '#907070', fgDim: '#b0a080', accent: '#387adf', 970 1193 added: '#1a7f37', modified: '#9a6700', deleted: '#cf222e', untracked: '#6e7781', 971 - fileBg: '#f6f0d0', fileBorder: '#e8e0b0', hoverBg: '#efe8c0', repoBg: '#f0e8b8', headerBg: '#e8dfa0' } 1194 + fileBg: '#f6f0d0', fileBorder: '#e8e0b0', hoverBg: '#efe8c0', repoBg: '#f0e8b8', headerBg: '#e8dfa0', 1195 + sectionBg: '#f2ecc0', badgeBg: '#e0d8a0', tapeBg: '#e8f0d8', moodBg: '#d8e8f0', paintBg: '#f0d8e8', 1196 + newsBg: '#e8e0d8', commitBg: '#e0e8d8', liveDot: '#1a7f37', onlineBg: '#d8f0d8' } 972 1197 : { bg: '#181318', fg: '#e0d0e0', fgMuted: '#807080', fgDim: '#504050', accent: '#a87090', 973 1198 added: '#3fb950', modified: '#d29922', deleted: '#f85149', untracked: '#606870', 974 - fileBg: '#1e1820', fileBorder: '#2a2030', hoverBg: '#28202e', repoBg: '#201828', headerBg: '#1a1220' }; 1199 + fileBg: '#1e1820', fileBorder: '#2a2030', hoverBg: '#28202e', repoBg: '#201828', headerBg: '#1a1220', 1200 + sectionBg: '#1a1520', badgeBg: '#2a2030', tapeBg: '#1a2818', moodBg: '#182028', paintBg: '#281828', 1201 + newsBg: '#282018', commitBg: '#182818', liveDot: '#3fb950', onlineBg: '#1a2818' }; 975 1202 976 1203 return `<!DOCTYPE html> 977 1204 <html lang="en"> 978 1205 <head> 979 1206 <meta charset="UTF-8"> 980 1207 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 981 - <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';"> 1208 + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src https://at.aesthetic.computer https://knot.aesthetic.computer https://tangled.org;"> 982 1209 <style> 983 1210 * { margin: 0; padding: 0; box-sizing: border-box; } 984 - body { background: ${c.bg}; color: ${c.fg}; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 12px; height: 100vh; overflow: auto; padding: 0; } 985 - .header { padding: 16px 20px 12px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid ${c.fileBorder}; background: ${c.headerBg}; position: sticky; top: 0; z-index: 1; } 986 - .header-star { font-size: 18px; color: ${c.accent}; } 987 - .header-title { font-size: 13px; font-weight: 600; letter-spacing: 0.5px; } 988 - .header-branch { color: ${c.fgMuted}; font-size: 11px; margin-left: auto; } 1211 + body { background: ${c.bg}; color: ${c.fg}; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 11px; height: 100vh; overflow: auto; padding: 0; } 1212 + 1213 + /* Header */ 1214 + .dash-header { padding: 12px 16px 10px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid ${c.fileBorder}; background: ${c.headerBg}; position: sticky; top: 0; z-index: 10; } 1215 + .dash-header .star { font-size: 16px; color: ${c.accent}; } 1216 + .dash-header .title { font-size: 12px; font-weight: 600; letter-spacing: 0.5px; } 1217 + .dash-header .live { margin-left: auto; display: flex; align-items: center; gap: 5px; font-size: 10px; color: ${c.fgMuted}; } 1218 + .dash-header .live-dot { width: 6px; height: 6px; border-radius: 50%; background: ${c.liveDot}; animation: livePulse 2s ease-in-out infinite; } 1219 + @keyframes livePulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } 1220 + 1221 + /* Section nav tabs */ 1222 + .section-tabs { display: flex; gap: 0; border-bottom: 1px solid ${c.fileBorder}; background: ${c.sectionBg}; position: sticky; top: 39px; z-index: 9; overflow-x: auto; } 1223 + .section-tab { padding: 6px 12px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: ${c.fgMuted}; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; white-space: nowrap; display: flex; align-items: center; gap: 5px; } 1224 + .section-tab:hover { color: ${c.fg}; background: ${c.hoverBg}; } 1225 + .section-tab.active { color: ${c.accent}; border-bottom-color: ${c.accent}; } 1226 + .section-tab .badge { background: ${c.badgeBg}; color: ${c.fgMuted}; padding: 1px 5px; border-radius: 8px; font-size: 9px; font-weight: 400; min-width: 16px; text-align: center; } 1227 + 1228 + /* Sections */ 1229 + .section { display: none; } 1230 + .section.visible { display: block; } 1231 + 1232 + /* Source Changes section */ 989 1233 .repo { margin: 0; } 990 - .repo-header { padding: 10px 20px 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; color: ${c.fgMuted}; background: ${c.repoBg}; border-bottom: 1px solid ${c.fileBorder}; display: flex; align-items: center; gap: 8px; } 1234 + .repo-header { padding: 8px 16px 5px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; color: ${c.fgMuted}; background: ${c.repoBg}; border-bottom: 1px solid ${c.fileBorder}; display: flex; align-items: center; gap: 6px; } 991 1235 .repo-header .branch { font-weight: 400; text-transform: none; letter-spacing: 0; color: ${c.accent}; } 992 1236 .repo-header .count { font-weight: 400; text-transform: none; letter-spacing: 0; color: ${c.fgDim}; margin-left: auto; } 993 1237 .file-list { list-style: none; } 994 - .file-item { display: flex; align-items: center; gap: 8px; padding: 4px 20px; cursor: pointer; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; } 1238 + .file-item { display: flex; align-items: center; gap: 6px; padding: 3px 16px; cursor: pointer; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; } 995 1239 .file-item:hover { background: ${c.hoverBg}; } 996 - .status { font-weight: 700; width: 18px; text-align: center; flex-shrink: 0; font-size: 11px; } 1240 + .status { font-weight: 700; width: 16px; text-align: center; flex-shrink: 0; font-size: 10px; } 997 1241 .status.M { color: ${c.modified}; } 998 1242 .status.A { color: ${c.added}; } 999 1243 .status.D { color: ${c.deleted}; } ··· 1003 1247 .file-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 1004 1248 .file-dir { color: ${c.fgMuted}; } 1005 1249 .file-name { color: ${c.fg}; } 1006 - .empty { padding: 20px; text-align: center; color: ${c.fgMuted}; font-style: italic; } 1007 - .error { padding: 12px 20px; color: ${c.deleted}; font-size: 11px; } 1008 - .loading { padding: 20px; text-align: center; color: ${c.fgMuted}; } 1009 - .loading .dot { display: inline-block; animation: pulse 1.5s ease-in-out infinite; color: ${c.accent}; } 1010 - @keyframes pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } } 1250 + .empty { padding: 16px; text-align: center; color: ${c.fgMuted}; font-style: italic; font-size: 11px; } 1251 + .error { padding: 10px 16px; color: ${c.deleted}; font-size: 10px; } 1252 + 1253 + /* Git log */ 1254 + .git-log { border-top: 1px solid ${c.fileBorder}; } 1255 + .log-item { display: flex; align-items: center; gap: 6px; padding: 3px 16px; border-bottom: 1px solid ${c.fileBorder}; font-size: 10px; } 1256 + .log-hash { color: ${c.accent}; font-weight: 600; flex-shrink: 0; } 1257 + .log-subject { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: ${c.fg}; } 1258 + .log-date { color: ${c.fgDim}; flex-shrink: 0; font-size: 9px; } 1259 + 1260 + /* Firehose section */ 1261 + .firehose-feed { max-height: none; } 1262 + .feed-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; animation: fadeIn 0.3s ease; } 1263 + .feed-item:hover { background: ${c.hoverBg}; } 1264 + .feed-item.new { background: ${c.onlineBg}; } 1265 + @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } 1266 + .feed-icon { font-size: 14px; flex-shrink: 0; line-height: 1.4; } 1267 + .feed-body { flex: 1; min-width: 0; } 1268 + .feed-handle { color: ${c.accent}; font-weight: 600; font-size: 10px; } 1269 + .feed-summary { color: ${c.fg}; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 1270 + .feed-time { color: ${c.fgDim}; font-size: 9px; flex-shrink: 0; align-self: center; } 1271 + .feed-type-badge { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-right: 4px; } 1272 + .feed-type-badge.tape { background: ${c.tapeBg}; color: ${c.added}; } 1273 + .feed-type-badge.mood { background: ${c.moodBg}; color: ${c.accent}; } 1274 + .feed-type-badge.painting { background: ${c.paintBg}; color: ${c.modified}; } 1275 + .feed-type-badge.news { background: ${c.newsBg}; color: ${c.fg}; } 1276 + .feed-type-badge.piece { background: ${c.sectionBg}; color: ${c.fgMuted}; } 1277 + .feed-type-badge.kidlisp { background: ${c.tapeBg}; color: ${c.modified}; } 1278 + 1279 + /* Tangled section */ 1280 + .tangled-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; } 1281 + .tangled-item:hover { background: ${c.hoverBg}; } 1282 + .tangled-hash { color: ${c.accent}; font-weight: 600; font-size: 10px; flex-shrink: 0; font-family: inherit; } 1283 + .tangled-body { flex: 1; min-width: 0; } 1284 + .tangled-msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: ${c.fg}; } 1285 + .tangled-meta { color: ${c.fgDim}; font-size: 9px; margin-top: 1px; } 1286 + 1287 + /* Filter bar */ 1288 + .filter-bar { display: flex; gap: 4px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; background: ${c.sectionBg}; flex-wrap: wrap; } 1289 + .filter-pill { padding: 2px 8px; border-radius: 10px; font-size: 9px; cursor: pointer; border: 1px solid ${c.fileBorder}; color: ${c.fgMuted}; transition: all 0.15s; } 1290 + .filter-pill:hover { border-color: ${c.accent}; color: ${c.fg}; } 1291 + .filter-pill.active { background: ${c.accent}; color: ${c.bg}; border-color: ${c.accent}; } 1292 + 1293 + /* Loading & status */ 1294 + .loading { padding: 16px; text-align: center; color: ${c.fgMuted}; } 1295 + .loading .dot { display: inline-block; animation: livePulse 1.5s ease-in-out infinite; color: ${c.accent}; } 1296 + .status-line { padding: 4px 16px; font-size: 9px; color: ${c.fgDim}; text-align: right; border-bottom: 1px solid ${c.fileBorder}; } 1011 1297 </style> 1012 1298 </head> 1013 1299 <body> 1014 - <div class="header"> 1015 - <span class="header-star">✦</span> 1016 - <span class="header-title">Source Changes</span> 1300 + <div class="dash-header"> 1301 + <span class="star">✦</span> 1302 + <span class="title">Dashboard</span> 1303 + <span class="live"><span class="live-dot"></span>live</span> 1304 + </div> 1305 + 1306 + <div class="section-tabs"> 1307 + <div class="section-tab active" data-section="source">Source <span class="badge" id="source-badge">0</span></div> 1308 + <div class="section-tab" data-section="firehose">AT Firehose <span class="badge" id="firehose-badge">0</span></div> 1309 + <div class="section-tab" data-section="tangled">Tangled <span class="badge" id="tangled-badge">0</span></div> 1017 1310 </div> 1018 - <div id="content"> 1019 - <div class="loading"><span class="dot">✦</span> scanning...</div> 1311 + 1312 + <div id="section-source" class="section visible"> 1313 + <div id="source-content"> 1314 + <div class="loading"><span class="dot">✦</span> scanning repos...</div> 1315 + </div> 1020 1316 </div> 1317 + 1318 + <div id="section-firehose" class="section"> 1319 + <div class="filter-bar" id="firehose-filters"> 1320 + <span class="filter-pill active" data-filter="all">All</span> 1321 + <span class="filter-pill" data-filter="tape">Tape</span> 1322 + <span class="filter-pill" data-filter="mood">Mood</span> 1323 + <span class="filter-pill" data-filter="painting">Painting</span> 1324 + <span class="filter-pill" data-filter="news">News</span> 1325 + <span class="filter-pill" data-filter="piece">Piece</span> 1326 + <span class="filter-pill" data-filter="kidlisp">KidLisp</span> 1327 + </div> 1328 + <div id="firehose-content" class="firehose-feed"> 1329 + <div class="loading"><span class="dot">✦</span> connecting to PDS...</div> 1330 + </div> 1331 + </div> 1332 + 1333 + <div id="section-tangled" class="section"> 1334 + <div id="tangled-content"> 1335 + <div class="loading"><span class="dot">✦</span> fetching knot activity...</div> 1336 + </div> 1337 + </div> 1338 + 1021 1339 <script> 1022 1340 (function() { 1023 1341 const vscode = acquireVsCodeApi(); 1024 - const content = document.getElementById('content'); 1342 + 1343 + // State 1344 + let activeSection = 'source'; 1345 + let activeFilter = 'all'; 1346 + let firehoseEvents = []; 1347 + let tangledEvents = []; 1348 + let autoscroll = true; 1349 + 1350 + // Tab switching 1351 + document.querySelectorAll('.section-tab').forEach(tab => { 1352 + tab.addEventListener('click', () => { 1353 + document.querySelectorAll('.section-tab').forEach(t => t.classList.remove('active')); 1354 + document.querySelectorAll('.section').forEach(s => s.classList.remove('visible')); 1355 + tab.classList.add('active'); 1356 + const section = tab.getAttribute('data-section'); 1357 + activeSection = section; 1358 + document.getElementById('section-' + section).classList.add('visible'); 1359 + }); 1360 + }); 1361 + 1362 + // Filter pills 1363 + document.getElementById('firehose-filters').addEventListener('click', (e) => { 1364 + const pill = e.target.closest('.filter-pill'); 1365 + if (!pill) return; 1366 + document.querySelectorAll('#firehose-filters .filter-pill').forEach(p => p.classList.remove('active')); 1367 + pill.classList.add('active'); 1368 + activeFilter = pill.getAttribute('data-filter'); 1369 + renderFirehose(); 1370 + }); 1371 + 1372 + // Auto-scroll detection 1373 + document.addEventListener('scroll', () => { 1374 + const el = document.scrollingElement || document.documentElement; 1375 + autoscroll = (el.scrollHeight - el.scrollTop - el.clientHeight) < 50; 1376 + }); 1377 + 1378 + function esc(s) { 1379 + return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 1380 + } 1381 + 1382 + function timeAgo(ts) { 1383 + if (!ts) return ''; 1384 + // If it's already relative (like "2 hours ago"), return as-is 1385 + if (typeof ts === 'string' && ts.includes('ago')) return ts; 1386 + const diff = Date.now() - new Date(ts).getTime(); 1387 + if (isNaN(diff)) return ts; 1388 + const s = Math.floor(diff / 1000); 1389 + if (s < 60) return s + 's ago'; 1390 + const m = Math.floor(s / 60); 1391 + if (m < 60) return m + 'm ago'; 1392 + const h = Math.floor(m / 60); 1393 + if (h < 24) return h + 'h ago'; 1394 + const d = Math.floor(h / 24); 1395 + return d + 'd ago'; 1396 + } 1025 1397 1026 1398 function statusLabel(s) { 1027 1399 if (s === '??' || s === '?') return { letter: '?', cls: 'q', title: 'Untracked' }; 1028 - const c = s.charAt(0) === ' ' ? s.charAt(1) : s.charAt(0); 1400 + const ch = s.charAt(0) === ' ' ? s.charAt(1) : s.charAt(0); 1029 1401 const map = { M: 'Modified', A: 'Added', D: 'Deleted', R: 'Renamed', C: 'Copied', U: 'Unmerged' }; 1030 - return { letter: c, cls: c, title: map[c] || c }; 1031 - } 1032 - 1033 - function esc(s) { 1034 - return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 1402 + return { letter: ch, cls: ch, title: map[ch] || ch }; 1035 1403 } 1036 1404 1037 - function renderRepos(repos) { 1405 + // Render source changes + git log 1406 + function renderSource(repos, logs) { 1407 + const el = document.getElementById('source-content'); 1038 1408 if (!repos || repos.length === 0) { 1039 - content.innerHTML = '<div class="empty">No git repositories detected in workspace.</div>'; 1409 + el.innerHTML = '<div class="empty">No git repositories detected.</div>'; 1410 + document.getElementById('source-badge').textContent = '0'; 1040 1411 return; 1041 1412 } 1042 1413 1414 + let totalChanges = 0; 1043 1415 let html = ''; 1044 1416 for (const repo of repos) { 1417 + totalChanges += repo.files.length; 1045 1418 html += '<div class="repo">'; 1046 1419 html += '<div class="repo-header">'; 1047 1420 html += '<span>' + esc(repo.name) + '</span>'; 1048 1421 html += '<span class="branch">' + esc(repo.branch) + '</span>'; 1049 - html += '<span class="count">' + repo.files.length + ' change' + (repo.files.length !== 1 ? 's' : '') + '</span>'; 1422 + html += '<span class="count">' + repo.files.length + '</span>'; 1050 1423 html += '</div>'; 1051 1424 1052 1425 if (repo.error) { 1053 - html += '<div class="error">git error: ' + esc(repo.error) + '</div>'; 1426 + html += '<div class="error">' + esc(repo.error) + '</div>'; 1054 1427 } else if (repo.files.length === 0) { 1055 - html += '<div class="empty">Working tree clean</div>'; 1428 + html += '<div class="empty">Clean</div>'; 1056 1429 } else { 1057 1430 html += '<ul class="file-list">'; 1058 1431 for (const f of repo.files) { ··· 1067 1440 } 1068 1441 html += '</ul>'; 1069 1442 } 1443 + 1444 + // Recent commits 1445 + const repoLog = logs && logs.find(l => l.name === repo.name); 1446 + if (repoLog && repoLog.commits.length > 0) { 1447 + html += '<div class="git-log">'; 1448 + for (const c of repoLog.commits) { 1449 + html += '<div class="log-item">'; 1450 + html += '<span class="log-hash">' + esc(c.hash) + '</span>'; 1451 + html += '<span class="log-subject">' + esc(c.subject) + '</span>'; 1452 + html += '<span class="log-date">' + esc(c.date) + '</span>'; 1453 + html += '</div>'; 1454 + } 1455 + html += '</div>'; 1456 + } 1457 + 1070 1458 html += '</div>'; 1071 1459 } 1072 - content.innerHTML = html; 1460 + el.innerHTML = html; 1461 + document.getElementById('source-badge').textContent = String(totalChanges); 1462 + } 1463 + 1464 + // Render firehose events 1465 + function renderFirehose() { 1466 + const el = document.getElementById('firehose-content'); 1467 + const filtered = activeFilter === 'all' ? firehoseEvents : firehoseEvents.filter(e => e.type === activeFilter); 1468 + 1469 + if (filtered.length === 0) { 1470 + el.innerHTML = '<div class="empty">No events yet. Waiting for AT Protocol activity...</div>'; 1471 + return; 1472 + } 1473 + 1474 + let html = ''; 1475 + for (const evt of filtered) { 1476 + html += '<div class="feed-item" data-url="' + esc(evt.url || '') + '">'; 1477 + html += '<div class="feed-body">'; 1478 + html += '<span class="feed-type-badge ' + esc(evt.type) + '">' + esc(evt.type) + '</span>'; 1479 + if (evt.handle) html += '<span class="feed-handle">@' + esc(evt.handle) + '</span>'; 1480 + html += '<div class="feed-summary">' + esc(evt.summary) + '</div>'; 1481 + html += '</div>'; 1482 + html += '<span class="feed-time">' + timeAgo(evt.timestamp) + '</span>'; 1483 + html += '</div>'; 1484 + } 1485 + el.innerHTML = html; 1486 + document.getElementById('firehose-badge').textContent = String(firehoseEvents.length); 1487 + } 1488 + 1489 + // Render Tangled events 1490 + function renderTangled() { 1491 + const el = document.getElementById('tangled-content'); 1492 + if (tangledEvents.length === 0) { 1493 + el.innerHTML = '<div class="empty">No Tangled knot activity detected.</div>'; 1494 + document.getElementById('tangled-badge').textContent = '0'; 1495 + return; 1496 + } 1497 + 1498 + let html = ''; 1499 + for (const evt of tangledEvents) { 1500 + html += '<div class="tangled-item" data-url="' + esc(evt.url || '') + '">'; 1501 + if (evt.commitHash) html += '<span class="tangled-hash">' + esc(evt.commitHash) + '</span>'; 1502 + html += '<div class="tangled-body">'; 1503 + html += '<div class="tangled-msg">' + esc(evt.summary) + '</div>'; 1504 + html += '<div class="tangled-meta">'; 1505 + if (evt.author) html += esc(evt.author) + ' '; 1506 + html += timeAgo(evt.timestamp); 1507 + html += '</div>'; 1508 + html += '</div>'; 1509 + html += '</div>'; 1510 + } 1511 + el.innerHTML = html; 1512 + document.getElementById('tangled-badge').textContent = String(tangledEvents.length); 1073 1513 } 1074 1514 1075 - // Handle clicks on file items — open in editor 1076 - content.addEventListener('click', (e) => { 1515 + // Click handlers 1516 + document.getElementById('source-content').addEventListener('click', (e) => { 1077 1517 const item = e.target.closest('.file-item'); 1078 1518 if (!item) return; 1079 1519 const root = item.getAttribute('data-root'); 1080 1520 const file = item.getAttribute('data-file'); 1081 - if (root && file) { 1082 - vscode.postMessage({ command: 'openFile', root, file }); 1083 - } 1521 + if (root && file) vscode.postMessage({ command: 'openFile', root, file }); 1084 1522 }); 1085 1523 1086 - // Receive git status updates from extension 1524 + document.getElementById('firehose-content').addEventListener('click', (e) => { 1525 + const item = e.target.closest('.feed-item'); 1526 + if (!item) return; 1527 + const url = item.getAttribute('data-url'); 1528 + if (url) vscode.postMessage({ command: 'openExternal', url }); 1529 + }); 1530 + 1531 + document.getElementById('tangled-content').addEventListener('click', (e) => { 1532 + const item = e.target.closest('.tangled-item'); 1533 + if (!item) return; 1534 + const url = item.getAttribute('data-url'); 1535 + if (url) vscode.postMessage({ command: 'openExternal', url }); 1536 + }); 1537 + 1538 + // Keyboard shortcuts 1539 + document.addEventListener('keydown', (e) => { 1540 + if (e.key === '1') { document.querySelector('[data-section="source"]').click(); } 1541 + if (e.key === '2') { document.querySelector('[data-section="firehose"]').click(); } 1542 + if (e.key === '3') { document.querySelector('[data-section="tangled"]').click(); } 1543 + }); 1544 + 1545 + // Message handling from extension 1087 1546 window.addEventListener('message', (event) => { 1088 1547 const msg = event.data; 1089 1548 if (msg.command === 'gitStatus') { 1090 - renderRepos(msg.repos); 1549 + renderSource(msg.repos, msg.logs); 1550 + } else if (msg.command === 'firehose') { 1551 + firehoseEvents = msg.events || []; 1552 + renderFirehose(); 1553 + } else if (msg.command === 'tangled') { 1554 + tangledEvents = msg.events || []; 1555 + renderTangled(); 1091 1556 } 1092 1557 }); 1093 1558 1094 1559 // Request initial data 1095 - vscode.postMessage({ command: 'requestGitStatus' }); 1560 + vscode.postMessage({ command: 'requestDashboard' }); 1096 1561 })(); 1097 1562 </script> 1098 1563 </body> 1099 1564 </html>`; 1100 1565 } 1101 1566 1102 - // ✨ Welcome Panel — shows git status for aesthetic-computer + vault 1567 + // ✦ Dashboard Panel — git status + AT firehose + Tangled knot 1103 1568 function showWelcomePanel() { 1104 1569 if (welcomePanel) { 1105 1570 welcomePanel.reveal(vscode.ViewColumn.One); ··· 1108 1573 1109 1574 welcomePanel = vscode.window.createWebviewPanel( 1110 1575 "aestheticWelcome", 1111 - "✦ Source Changes", 1576 + "✦ Dashboard", 1112 1577 vscode.ViewColumn.One, 1113 1578 { enableScripts: true } 1114 1579 ); ··· 1116 1581 welcomePanel.webview.onDidReceiveMessage( 1117 1582 message => { 1118 1583 switch (message.command) { 1584 + case 'requestDashboard': 1585 + sendDashboardData(); 1586 + sendFirehoseData(); 1587 + sendTangledData(); 1588 + return; 1119 1589 case 'requestGitStatus': 1120 - sendGitStatusToPanel(); 1590 + sendDashboardData(); 1121 1591 return; 1122 1592 case 'openFile': 1123 1593 if (message.root && message.file) { ··· 1126 1596 const openPath = vscode.Uri.file(filePath); 1127 1597 vscode.workspace.openTextDocument(openPath).then( 1128 1598 doc => vscode.window.showTextDocument(doc), 1129 - () => {} // file might be deleted 1599 + () => {} 1130 1600 ); 1131 1601 } 1132 1602 } 1133 1603 return; 1604 + case 'openExternal': 1605 + if (message.url) { 1606 + vscode.env.openExternal(vscode.Uri.parse(message.url)); 1607 + } 1608 + return; 1134 1609 } 1135 1610 }, 1136 1611 undefined, ··· 1139 1614 1140 1615 welcomePanel.onDidDispose(() => { 1141 1616 welcomePanel = null; 1617 + if (firehoseInterval) { clearInterval(firehoseInterval); firehoseInterval = undefined; } 1618 + if (tangledInterval) { clearInterval(tangledInterval); tangledInterval = undefined; } 1142 1619 }); 1143 1620 1144 - welcomePanel.webview.html = getWelcomePanelHtml(); 1621 + welcomePanel.webview.html = getDashboardHtml(); 1145 1622 1146 - // Send initial git status after a brief delay for webview to initialize 1147 - setTimeout(() => sendGitStatusToPanel(), 100); 1623 + // Send initial data 1624 + setTimeout(() => { 1625 + sendDashboardData(); 1626 + sendFirehoseData(); 1627 + sendTangledData(); 1628 + }, 100); 1629 + 1630 + // Poll firehose every 30 seconds, Tangled every 60 seconds 1631 + firehoseInterval = setInterval(() => sendFirehoseData(), 30000); 1632 + tangledInterval = setInterval(() => sendTangledData(), 60000); 1148 1633 } 1149 1634 1635 + let firehoseInterval: NodeJS.Timeout | undefined; 1636 + let tangledInterval: NodeJS.Timeout | undefined; 1637 + 1150 1638 // Refresh welcome panel on theme change 1151 1639 function refreshWelcomePanel() { 1152 1640 if (welcomePanel) { 1153 - welcomePanel.webview.html = getWelcomePanelHtml(); 1154 - setTimeout(() => sendGitStatusToPanel(), 100); 1641 + welcomePanel.webview.html = getDashboardHtml(); 1642 + setTimeout(() => { 1643 + sendDashboardData(); 1644 + sendFirehoseData(); 1645 + sendTangledData(); 1646 + }, 100); 1155 1647 } 1156 1648 } 1157 1649 ··· 1160 1652 const wsFolder = vscode.workspace.workspaceFolders?.[0]; 1161 1653 if (!wsFolder) return; 1162 1654 1163 - // Debounce: coalesce rapid file changes into one git status refresh 1164 1655 let refreshTimer: NodeJS.Timeout | undefined; 1165 1656 function debouncedRefresh() { 1166 1657 if (refreshTimer) clearTimeout(refreshTimer); 1167 - refreshTimer = setTimeout(() => sendGitStatusToPanel(), 300); 1658 + refreshTimer = setTimeout(() => sendDashboardData(), 300); 1168 1659 } 1169 1660 1170 - // Watch for all file changes in the workspace (covers both repos) 1171 1661 const watcher = vscode.workspace.createFileSystemWatcher( 1172 1662 new vscode.RelativePattern(wsFolder, '**/*') 1173 1663 ); ··· 1176 1666 watcher.onDidDelete(debouncedRefresh); 1177 1667 context.subscriptions.push(watcher); 1178 1668 1179 - // Also refresh when documents are saved (catches staging operations) 1180 1669 context.subscriptions.push( 1181 1670 vscode.workspace.onDidSaveTextDocument(() => debouncedRefresh()) 1182 1671 );
+2 -2
vscode-extension/package-lock.json
··· 1 1 { 2 2 "name": "aesthetic-computer-code", 3 - "version": "1.271.2", 3 + "version": "1.272.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "aesthetic-computer-code", 9 - "version": "1.271.2", 9 + "version": "1.272.0", 10 10 "license": "None", 11 11 "dependencies": { 12 12 "acorn": "^8.15.0",
+2 -2
vscode-extension/package.json
··· 4 4 "displayName": "Aesthetic Computer", 5 5 "icon": "resources/icon.png", 6 6 "author": "Jeffrey Alan Scudder", 7 - "version": "1.271.2", 7 + "version": "1.272.0", 8 8 "description": "Code, run, and publish your pieces. Includes Aesthetic Computer themes and KidLisp syntax highlighting.", 9 9 "engines": { 10 10 "vscode": "^1.105.0" ··· 183 183 }, 184 184 { 185 185 "command": "aestheticComputer.welcome", 186 - "title": "Aesthetic Computer: Welcome ✨" 186 + "title": "Aesthetic Computer: Dashboard ✦" 187 187 }, 188 188 { 189 189 "command": "aestheticComputer.showOTADetails",