Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

wip: shop redirects, chat + blank piece edits, kidlisp/clojure report

Bundled outstanding local changes — netlify shop-redirect entries for
the AC Native Laptop SKU + shop~blank alias, ongoing edits to chat and
blank pieces, a small tweak to laer-klokken and prompt, and the in-progress
kidlisp-clojure-hosting report. Adds ac-shop/livereload.mjs helper.

+477 -247
+142
REPORT-kidlisp-clojure-hosting.md
··· 1 + # Hosting KidLisp on Clojure / ClojureScript 2 + 3 + Date: 2026-04-17 4 + Author: Claude (for @jeffrey) 5 + Scope: Feasibility assessment of moving the KidLisp runtime from hand-rolled JS to a Clojure/ClojureScript host, plus the state of Clojure → WASM as of early 2026. 6 + 7 + ## TL;DR 8 + 9 + **Not recommended as a wholesale migration.** ClojureScript is a natural Lisp host in principle, but KidLisp's specific surface area — bespoke reader syntax, tight coupling to the Aesthetic Computer Disk API, and an expectation of ~instant boot inside an already-large web runtime — makes a rewrite costly while delivering modest linguistic upside. The most defensible path, if Clojure appeals for other reasons, is a compile-time target (KidLisp AST → ClojureScript forms) rather than a runtime port. **Clojure on WASM** in 2026 is still pre-production across the board; nothing on that track is ready to replace a shipping JS runtime. 10 + 11 + --- 12 + 13 + ## 1. What KidLisp actually is today 14 + 15 + - `system/public/aesthetic.computer/lib/kidlisp.mjs` — **~15,400 lines** of hand-written ES modules: reader, evaluator, effect dispatch, and timing DSL all in one. 16 + - 118 built-in functions across 12 categories (drawing, audio, math, control, animation, data, etc.). 17 + - Extra ports already exist outside the browser runtime: 18 + - `kidlisp-gameboy/` — KidLisp → GBDK/C compiler for Game Boy. 19 + - `kidlisp-n64/` — bare-metal / libdragon experiments. 20 + - Integrated with the JS-native **Disk API** (`lib/disk.mjs`) via destructured function bags (`{ wipe, ink, line, ... }`) — effects are immediate-mode calls into canvas/WebGL/audio graph. 21 + - Non-standard Lisp features that matter for any host decision: 22 + - Timing literals: `1s`, `2s...`, `0.5s!`. 23 + - Cached-code references: `$abc123`, `(embed $code ...)`. 24 + - Handle/timestamp references: `@user/123456`. 25 + - Unquoted URLs in arg position: `(paste https://example.com/a.png x y)`. 26 + - Dynamic color atoms: `rainbow`, `zebra`, `c0..c150`. 27 + - Reader-visible dashes are **subtraction**, not identifier chars. 28 + 29 + These extensions mean KidLisp is only *approximately* a Lisp at the reader level. Any host — Clojure included — would need a custom reader, so "it's already a Lisp" is a weaker argument than it first appears. 30 + 31 + ## 2. Why ClojureScript looks tempting 32 + 33 + - **Homoiconicity.** KidLisp ASTs map cleanly onto `clojure.core` sequences and symbols. 34 + - **Macros.** Timing (`1s`, `2s...`), `(once ...)`, `(later ...)` all read like macro fodder — ClojureScript macros would give them a first-class home instead of special-cased evaluator branches. 35 + - **Persistent data structures** for free, plus proper recur/trampolines for the repeat/bunch loops. 36 + - **REPL-driven iteration.** shadow-cljs + reagent-style hot reload is culturally aligned with "tweak a number, keep the canvas" — which is exactly what `REPORT-kidlisp-realtime-state.md` says KidLisp still doesn't do well. 37 + - **Compiler infrastructure.** Google Closure's advanced optimizations, dead-code elimination, and source maps are mature. 38 + - **Shared language across hosts.** In principle one ClojureScript codebase could target browser (cljs), native (jank/GraalVM), and JVM simultaneously — appealing for the Game Boy / N64 / OS targets already in the repo. 39 + 40 + ## 3. Why it's the wrong move for KidLisp specifically 41 + 42 + ### 3.1 Bundle and boot cost 43 + 44 + - A minimal self-hosted ClojureScript runtime (for runtime eval of user code — which KidLisp *must* do) brings in `cljs.js` + the analyzer + the reader: **~1–2 MB gzipped** in practice, even after advanced optimizations, because you cannot DCE a runtime evaluator. 45 + - Scittle / SCI is smaller (~300–500 KB gzipped) but is an *interpreter* with different perf characteristics than the present kidlisp evaluator. 46 + - The current kidlisp.mjs ships as part of Disk; its footprint is already accounted for and tree-shakes against the rest of the runtime. A CLJS host would add weight *on top of* disk.mjs (572 KB) rather than replacing anything JS-shaped. 47 + - Aesthetic Computer is mobile-first; a cold-start regression of even 500 ms on low-end devices would be felt immediately. 48 + 49 + ### 3.2 Reader is not reusable 50 + 51 + - Clojure's reader cannot parse `2s...`, `0.5s!`, `$abc123`, `@user/123456`, or bare URLs. 52 + - You'd still write a custom reader. At that point, "ClojureScript is a Lisp" buys you **data structures and macros** but not parsing. 53 + - Worse: you must teach editor/LSP/formatter tooling that these literals exist, or give them up. The existing `kidlisp-reference.mjs` is already a docs-first contract; splitting it across Clojure reader + custom reader risks drift. 54 + 55 + ### 3.3 Effect boundary friction 56 + 57 + - KidLisp calls are side effects on a JS graphics/audio API designed around destructuring (`{ wipe, ink, paste }`). ClojureScript interop with that shape is verbose (`(.wipe api)` / `(js/api.wipe)`) unless you wrap everything, and then you're maintaining two APIs. 58 + - The Disk API changes often (see `disk.mjs` churn). Every change becomes a double-edit: JS definition + CLJS wrapper. 59 + 60 + ### 3.4 Ecosystem/ops mismatch 61 + 62 + - The rest of AC is `.mjs`: boot, bios, disk, session server, netlify functions, lith deploy. Introducing a ClojureScript build chain (shadow-cljs, deps.edn, JVM on the build box) fights the current fish-based single-toolchain ethos. 63 + - lith (DO VPS) deploys pull from the tangled knot and run Node. Adding a JVM-class dep to the deploy pipeline is a real cost. 64 + 65 + ### 3.5 Existing external ports regress 66 + 67 + - `kidlisp-gameboy` compiles KidLisp → C for GBDK. A ClojureScript-hosted evaluator doesn't help this path (still need a bespoke compiler). 68 + - `kidlisp-n64` is assembly-adjacent. Same story. 69 + - If anything, the Game Boy and N64 work suggests KidLisp's *semantic model* is the stable asset and the **evaluator language is incidental** — which argues for keeping the evaluator where its neighbors live (JS in the browser, C on GB, asm on N64), not centralizing on Clojure. 70 + 71 + ## 4. State of Clojure → WASM (early 2026) 72 + 73 + Nothing in this space is production-ready for replacing a shipping web runtime. Summary of the tracks: 74 + 75 + ### 4.1 jank (LLVM-native Clojure) 76 + 77 + - Native Clojure dialect by Jeaye Wilkerson, targets LLVM IR → native binaries. 78 + - LLVM's `wasm32` backend is mature, so in principle jank can emit WASM. In practice, the Clojure runtime (persistent collections, keywords, vars, multimethods) ships as a C++ runtime library that has to be built for the target; the WASM build path has been "experimental / pre-alpha" through 2025 and into early 2026. 79 + - **Not a credible host for a browser interpreter today.** Would also need AOT — jank doesn't give you in-browser `eval` out of the box. 80 + 81 + ### 4.2 GraalVM native-image → WASM 82 + 83 + - Oracle's GraalVM has an experimental WebAssembly backend (`native-image --tool:wasm` / Truffle-on-WASM variants). Works for tiny programs; Clojure pulls in a large JVM surface and hits `UnsupportedFeatureError` on real-world code regularly. 84 + - Babashka (GraalVM + SCI) demonstrates Clojure can AOT to a native binary, but Babashka's own maintainers have not committed to WASM as a shipping target. 85 + - **Use case fit:** poor for a browser runtime. Size would dwarf the current evaluator. 86 + 87 + ### 4.3 SCI / Scittle (pure JS, not WASM) 88 + 89 + - Small Clojure Interpreter by @borkdude. Runs in the browser today as plain JS (~300–500 KB gz). 90 + - **Not a WASM port** — it's JS. Often miscategorized in WASM discussions. 91 + - Could theoretically be embedded inside a QuickJS-WASM sandbox, but that's stacking interpreters and helps nobody. 92 + 93 + ### 4.4 ClojureScript via JS → WASM glue (e.g. Javy, Spin, Kotlin/JS-WASM paths) 94 + 95 + - Tools like Javy (Shopify) and Spin/Wasmtime embed JS engines inside WASM. You *can* run ClojureScript-compiled JS inside a WASM-embedded JS engine. That doubles the interpreter layers. 96 + - Useful for serverless edge; irrelevant for AC's browser runtime. 97 + 98 + ### 4.5 Ferret (Clojure → C++) 99 + 100 + - Ferret AOT-compiles a Clojure subset to C++. C++ → WASM via Emscripten is routine, so Ferret → WASM is feasible for small, statically-knowable programs. 101 + - Doesn't support `eval`, which is the KidLisp core requirement. 102 + 103 + ### 4.6 Summary table 104 + 105 + | Track | Supports runtime `eval` | Production-ready for browser | Rough browser size | 106 + |---|---|---|---| 107 + | SCI / Scittle (JS) | Yes | Yes (not WASM) | 300–500 KB gz | 108 + | Self-hosted ClojureScript (JS) | Yes | Yes (not WASM) | 1–2 MB gz | 109 + | jank → WASM | No (AOT) | No, pre-alpha | Unknown, likely multi-MB | 110 + | GraalVM native-image → WASM | Partial | No, experimental | Large | 111 + | Ferret → C++ → WASM | No (AOT) | Niche only | Small but feature-limited | 112 + | Javy/QuickJS-WASM hosting CLJS | Yes (via nested JS) | Shipping for edge, not browser | 2–5 MB | 113 + 114 + **Bottom line:** if the reason to move to Clojure is to eventually run KidLisp *as WASM*, wait. In 2026 the realistic browser deployment of Clojure is still JavaScript (via CLJS or SCI), not WebAssembly. 115 + 116 + ## 5. Concrete paths forward (ordered by cost) 117 + 118 + 1. **Do nothing to the host, improve the current evaluator.** 119 + The single biggest pain point identified in `REPORT-kidlisp-realtime-state.md` is the *full-reset-on-edit* behavior. That is a state-management issue, not a language-host issue. Fix it in `kidlisp.mjs`. 120 + 121 + 2. **Adopt Clojure *data* without adopting the Clojure *runtime*.** 122 + Keep the JS evaluator; borrow ideas (persistent vectors via a tiny Immer-like lib, `recur`-style trampolines). Cheap, keeps the bundle lean. 123 + 124 + 3. **Move the KidLisp compiler (not the interpreter) to Clojure.** 125 + The Game Boy and N64 ports are essentially compilers. If you want a unified compilation story, a JVM-hosted Clojure compiler that emits C / asm / JS makes sense there, because it runs at dev time, not in the user's browser. This is the **highest-value** place Clojure would land in AC. 126 + 127 + 4. **Rewrite the browser evaluator in ClojureScript.** 128 + Only if KidLisp stops being primarily a browser artifact *and* you commit to shadow-cljs-in-the-deploy-pipeline. Rough order: 6–12 eng-weeks to reach parity, assuming you keep a custom reader and a CLJS-shaped Disk API wrapper. Meaningful perf work on top. 129 + 130 + 5. **Bet on Clojure WASM.** 131 + Premature in early 2026. Revisit when jank's WASM target ships stable builds and has `eval` (or when an ahead-of-time model becomes acceptable for KidLisp). 132 + 133 + ## 6. Recommendation 134 + 135 + Keep the runtime in JavaScript. Invest Clojure/ClojureScript effort, if any, in the **compiler tier** (the place where KidLisp becomes Game Boy C, N64 asm, or native binaries) — not the browser interpreter. Do not block on Clojure WASM; the 2026 state of that ecosystem is not where a shipping creative-computing runtime should live. 136 + 137 + ## Appendix: what would change my mind 138 + 139 + - A production-grade `sci` or `scittle` release that is clearly under 150 KB gzipped with a usable macro system. 140 + - A decision to rebuild Aesthetic Computer's client around a JVM/GraalVM deploy story for other reasons (e.g. collapsing session-server + site onto a single JVM runtime), at which point CLJS becomes a natural front-end complement. 141 + - jank reaching a 1.0 with a shipping WASM target and in-browser `eval`. 142 + - KidLisp growing a module system / macro layer that genuinely outstrips what the hand-rolled evaluator can express — at that point a host language with real macros starts paying for itself.
+129
ac-shop/livereload.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * ac-shop livereload — installs + controls the Shopify live-reload snippet. 4 + * 5 + * Commands: 6 + * node livereload.mjs bump Bump the version marker (triggers a reload in all open tabs). 7 + * node livereload.mjs on Flip the snippet on (AC_LIVERELOAD_ENABLED = true). 8 + * node livereload.mjs off Flip the snippet off (AC_LIVERELOAD_ENABLED = false). 9 + * node livereload.mjs status Show current state (snippet present? enabled? last version?). 10 + */ 11 + 12 + import { readFileSync } from 'fs'; 13 + import { fileURLToPath } from 'url'; 14 + import { dirname, join } from 'path'; 15 + 16 + const __dirname = dirname(fileURLToPath(import.meta.url)); 17 + const envPath = join(__dirname, '../aesthetic-computer-vault/shop/.env'); 18 + const envContent = readFileSync(envPath, 'utf-8'); 19 + for (const line of envContent.split('\n')) { 20 + if (line && !line.startsWith('#') && line.includes('=')) { 21 + const [key, ...valueParts] = line.split('='); 22 + process.env[key.trim()] = valueParts.join('=').trim(); 23 + } 24 + } 25 + 26 + const STORE = process.env.SHOPIFY_STORE_DOMAIN; 27 + const TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN; 28 + if (!STORE || !TOKEN) { 29 + console.error('❌ Missing SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_ACCESS_TOKEN'); 30 + process.exit(1); 31 + } 32 + 33 + const base = `https://${STORE}/admin/api/2024-10`; 34 + 35 + async function gq(path, options = {}) { 36 + const r = await fetch(`${base}${path}`, { 37 + headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(options.headers || {}) }, 38 + ...options, 39 + }); 40 + if (!r.ok) { 41 + const txt = await r.text(); 42 + throw new Error(`${r.status}: ${txt}`); 43 + } 44 + return r.json(); 45 + } 46 + 47 + async function getMainTheme() { 48 + const { themes } = await gq('/themes.json'); 49 + const main = themes.find((t) => t.role === 'main'); 50 + if (!main) throw new Error('No main theme found'); 51 + return main; 52 + } 53 + 54 + async function readAsset(themeId, key) { 55 + try { 56 + const { asset } = await gq(`/themes/${themeId}/assets.json?asset[key]=${encodeURIComponent(key)}`); 57 + return asset?.value ?? null; 58 + } catch (e) { 59 + if (String(e).includes('404')) return null; 60 + throw e; 61 + } 62 + } 63 + 64 + async function writeAsset(themeId, key, value) { 65 + await gq(`/themes/${themeId}/assets.json`, { 66 + method: 'PUT', 67 + body: JSON.stringify({ asset: { key, value } }), 68 + }); 69 + } 70 + 71 + async function bump() { 72 + const theme = await getMainTheme(); 73 + const v = String(Date.now()); 74 + await writeAsset(theme.id, 'assets/ac-livereload-version.txt', v); 75 + console.log(`✅ bumped ac-livereload-version.txt → ${v}`); 76 + } 77 + 78 + async function setEnabled(nextEnabled) { 79 + const theme = await getMainTheme(); 80 + const snippet = await readAsset(theme.id, 'snippets/ac-livereload.liquid'); 81 + if (!snippet) { 82 + console.error('❌ snippets/ac-livereload.liquid not found — re-run the install script first.'); 83 + process.exit(1); 84 + } 85 + const newValue = nextEnabled 86 + ? snippet.replace(/AC_LIVERELOAD_ENABLED = false/, 'AC_LIVERELOAD_ENABLED = true') 87 + : snippet.replace(/AC_LIVERELOAD_ENABLED = true/, 'AC_LIVERELOAD_ENABLED = false'); 88 + if (newValue === snippet) { 89 + console.log(`ℹ️ already ${nextEnabled ? 'enabled' : 'disabled'}`); 90 + return; 91 + } 92 + await writeAsset(theme.id, 'snippets/ac-livereload.liquid', newValue); 93 + console.log(`✅ livereload is now ${nextEnabled ? 'ENABLED' : 'DISABLED'}`); 94 + // Trigger a reload so open browsers pick up the new snippet state. 95 + await bump(); 96 + } 97 + 98 + async function status() { 99 + const theme = await getMainTheme(); 100 + const snippet = await readAsset(theme.id, 'snippets/ac-livereload.liquid'); 101 + const version = await readAsset(theme.id, 'assets/ac-livereload-version.txt'); 102 + const themeLiquid = await readAsset(theme.id, 'layout/theme.liquid'); 103 + const rendered = themeLiquid?.includes(`render 'ac-livereload'`) ?? false; 104 + const enabled = snippet?.match(/AC_LIVERELOAD_ENABLED = (true|false)/)?.[1] ?? 'unknown'; 105 + console.log('theme: ', theme.name, `(id=${theme.id})`); 106 + console.log('snippet present: ', !!snippet); 107 + console.log('rendered in layout:', rendered); 108 + console.log('AC_LIVERELOAD: ', enabled); 109 + console.log('last version: ', version); 110 + } 111 + 112 + const cmd = process.argv[2]; 113 + switch (cmd) { 114 + case 'bump': 115 + await bump(); 116 + break; 117 + case 'on': 118 + await setEnabled(true); 119 + break; 120 + case 'off': 121 + await setEnabled(false); 122 + break; 123 + case 'status': 124 + await status(); 125 + break; 126 + default: 127 + console.log('Usage: node livereload.mjs {bump|on|off|status}'); 128 + process.exit(1); 129 + }
+14
system/netlify.toml
··· 2298 2298 from = "/shop~26.1.3.0.00" 2299 2299 to = "https://shop.aesthetic.computer/products/shirts_coral-abex-tee-l_26-1-3-0-00" 2300 2300 status = 301 2301 + # 💻 Laptops 2302 + [[redirects]] 2303 + from = "/26.4.17.12.51" 2304 + to = "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51" 2305 + status = 301 2306 + [[redirects]] 2307 + from = "/shop~26.4.17.12.51" 2308 + to = "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51" 2309 + status = 301 2310 + # Convenience alias: "shop blank" → AC Native Laptop. (Bare /blank stays the piece route.) 2311 + [[redirects]] 2312 + from = "/shop~blank" 2313 + to = "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51" 2314 + status = 301 2301 2315 # END SHOP 2302 2316 [[redirects]] 2303 2317 from = "/api/bdf-glyph"
+10 -111
system/public/aesthetic.computer/disks/blank.mjs
··· 1 1 // blank, 26.03.20 2 2 // AC Blank — AC Native Laptop product page & checkout 3 + // Checkout is handled by Shopify (shop.aesthetic.computer). Earlier this page 4 + // ran Stripe directly because we hadn't re-enabled Shopify; that path is gone. 3 5 4 6 const { floor, sin, cos, abs, min, max, PI, sqrt } = Math; 5 7 6 8 // Module state 7 - let amount = 12800; 8 - let checkoutUrl = null; 9 - let checkoutReady = false; 10 - let checkoutError = null; 11 - let checkoutLoading = false; 12 - let buyPending = false; 9 + let amount = 12800; // Kept in sync with the Shopify variant price (USD cents). 10 + const SHOP_URL = 11 + "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51"; 13 12 let thanks = false; 14 13 15 14 // UI elements ··· 57 56 "Receive a @jeffrey approved, refurbished Thinkpad 11e Yoga Gen 6 pre-flashed with AC Native OS and Live USB recovery stick."; 58 57 const DESCRIPTION = 59 58 "Receive a \\255,100,255\\@jeffrey\\reset\\ approved, refurbished Thinkpad 11e Yoga Gen 6 pre-flashed with AC Native OS and Live USB recovery stick."; 60 - const AUTH_TIMEOUT_MS = 1200; 61 - 62 - async function getOptionalToken(api) { 63 - if (!api?.authorize) return null; 64 - 65 - try { 66 - return await Promise.race([ 67 - api.authorize().catch(() => null), 68 - new Promise((resolve) => setTimeout(() => resolve(null), AUTH_TIMEOUT_MS)), 69 - ]); 70 - } catch { 71 - return null; 72 - } 73 - } 74 59 75 60 // Animation 76 61 let frame = 0; ··· 82 67 } 83 68 84 69 function getBuyText() { 85 - if (buyPending) return "CHECKING OUT..."; 86 70 return `BUY LAPTOP ${displayAmount(amount)}`; 87 71 } 88 72 ··· 97 81 98 82 userHandle = handle(); 99 83 setupButtons(ui, screen); 100 - fetchCheckout(api); 101 84 if (!userHandle) fetchHandles(screen); 102 85 // Prefetch colors for logged-in user 103 86 if (userHandle) fetchHandleColor(userHandle); ··· 142 125 manualBtn = new ui.TextButton("ThinkPad 11e Yoga Manual", { x: 6, bottom: 20 + (paperBtn.height || 14) + 4, screen }); 143 126 } 144 127 145 - async function fetchCheckout(api) { 146 - if (checkoutLoading) return; 147 - 148 - checkoutLoading = true; 149 - checkoutReady = false; 150 - checkoutError = null; 151 - checkoutUrl = null; 152 - 153 - try { 154 - const headers = { "Content-Type": "application/json" }; 155 - const token = await getOptionalToken(api); 156 - if (token) headers.Authorization = `Bearer ${token}`; 157 - 158 - const res = await fetch("/api/blank?new=true", { 159 - method: "POST", 160 - headers, 161 - body: JSON.stringify({ amount, currency: "usd" }), 162 - }); 163 - 164 - if (!res.ok) { 165 - checkoutError = `Checkout failed: ${res.status}`; 166 - return; 167 - } 168 - 169 - const data = await res.json(); 170 - if (data?.location) { 171 - checkoutUrl = data.location; 172 - checkoutReady = true; 173 - } else { 174 - checkoutError = data?.error || "Checkout failed"; 175 - } 176 - } catch (e) { 177 - checkoutError = e?.message || "Checkout error"; 178 - } finally { 179 - checkoutLoading = false; 180 - } 181 - } 128 + // Checkout lives on Shopify now — no pre-flight request needed. 182 129 183 130 function paint($) { 184 131 const { wipe, ink, line, screen, dark: isDark, tri, text } = $; ··· 760 707 const isOver = buyBtn.btn.over; 761 708 const isDown = buyBtn.btn.down; 762 709 763 - if (buyPending) { 764 - const pulse = sin(t * 6) * 0.5 + 0.5; 765 - const bgR = isDark ? floor(20 + pulse * 40) : floor(200 + pulse * 30); 766 - const bgG = isDark ? floor(30 + pulse * 30) : floor(220 + pulse * 20); 767 - const bgB = isDark ? 20 : 200; 768 - ink(bgR, bgG, bgB).box(bx, "fill"); 769 - const oA = floor(120 + pulse * 135); 770 - ink(isDark ? [100, 255, 100, oA] : [40, 140, 40, oA]).box(bx, "outline"); 771 - // Shadow text 772 - ink(sr, sg, sb, 120).write(buyText, { x: bx.x + padX + 1, y: bx.y + padY + 1 }, undefined, undefined, false, "unifont"); 773 - ink(isDark ? [160 + floor(pulse * 95), 230, 160] : [30, floor(80 + pulse * 40), 30]) 774 - .write(buyText, { x: bx.x + padX, y: bx.y + padY }, undefined, undefined, false, "unifont"); 775 - } else { 710 + { 776 711 // Breathing glow animation 777 712 const breath = sin(t * 2) * 0.5 + 0.5; 778 713 const wave = sin(t * 3.5) * 0.3 + 0.7; ··· 917 852 sound?.synth({ type: "sine", tone: 440, duration: 0.05, volume: 0.3 }); 918 853 }, 919 854 push: () => { 920 - if (buyPending) return; 921 - 922 - if (checkoutReady && checkoutUrl) { 923 - sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); 924 - jump(checkoutUrl); 925 - } else if (checkoutError) { 926 - checkoutError = null; 927 - fetchCheckout(api); 928 - sound?.synth({ type: "sine", tone: 550, duration: 0.06, volume: 0.3 }); 929 - buyPending = true; 930 - waitForCheckout(jump, sound, api); 931 - } else { 932 - buyPending = true; 933 - if (!checkoutLoading) fetchCheckout(api); 934 - sound?.synth({ type: "sine", tone: 660, duration: 0.08, volume: 0.3 }); 935 - waitForCheckout(jump, sound, api); 936 - } 855 + sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); 856 + // Jump to the Shopify product (orderable there). 857 + jump(`out:${SHOP_URL}`); 937 858 }, 938 859 }); 939 - } 940 - 941 - async function waitForCheckout(jump, sound, api) { 942 - const maxWait = 10000; 943 - const startTime = Date.now(); 944 - 945 - if (!checkoutReady && !checkoutError && !checkoutLoading) { 946 - fetchCheckout(api); 947 - } 948 - 949 - while (!checkoutReady && !checkoutError && Date.now() - startTime < maxWait) { 950 - await new Promise((r) => setTimeout(r, 100)); 951 - } 952 - 953 - buyPending = false; 954 - 955 - if (checkoutReady && checkoutUrl) { 956 - sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); 957 - jump(checkoutUrl); 958 - } else if (checkoutError) { 959 - sound?.synth({ type: "square", tone: 200, duration: 0.15, volume: 0.3 }); 960 - } 961 860 } 962 861 963 862 function meta() {
+177 -135
system/public/aesthetic.computer/disks/chat.mjs
··· 257 257 let newsTickerHovered = false; // Hover state for visual feedback 258 258 let newsFetchPromise = null; // Track fetch to avoid duplicate requests 259 259 260 - // � R8dio mini-player system (for laer-klokken) 261 - const R8DIO_STREAM_URL = "https://s3.radio.co/s7cd1ffe2f/listen"; 262 - const R8DIO_STREAM_ID = "chat-r8dio-stream"; 263 - const R8DIO_METADATA_URL = "https://public.radio.co/stations/s7cd1ffe2f/status"; 264 - let r8dioEnabled = false; // Whether r8dio player is shown 260 + // 📻 Mini-player system — station presets (selected per chat via options.radio) 261 + const RADIO_STATIONS = { 262 + r8dio: { 263 + label: "r8Dio", 264 + streamUrl: "https://s3.radio.co/s7cd1ffe2f/listen", 265 + streamId: "chat-r8dio-stream", 266 + metadataUrl: "https://public.radio.co/stations/s7cd1ffe2f/status", 267 + parseTrack: (data) => data?.current_track?.title || "", 268 + labelBg: [35, 25, 18], 269 + labelBgHover: [50, 35, 25], 270 + labelFg: [255, 150, 50], 271 + labelFgHover: [255, 180, 80], 272 + contentBg: [28, 22, 18], 273 + contentBgHover: [40, 30, 25], 274 + separator: [80, 60, 50, 150], 275 + underline: [255, 150, 50, 180], 276 + buttonBg: [55, 40, 25], 277 + buttonBgHover: [80, 55, 35], 278 + buttonOutline: [100, 70, 45], 279 + buttonOutlineHover: [140, 100, 60], 280 + iconColor: [255, 160, 80], 281 + iconColorHover: [255, 200, 120], 282 + loadingColor: [255, 200, 100], 283 + barGradient: (t) => [ 284 + Math.floor(200 + t * 55), 285 + Math.floor(100 + t * 80), 286 + Math.floor(30 + t * 40), 287 + ], 288 + barIdle: [80, 50, 30], 289 + statusColor: [255, 180, 80], 290 + statusDim: [180, 140, 100], 291 + statusIdleOn: [255, 180, 80], 292 + statusIdleOff: [120, 90, 60], 293 + }, 294 + bj: { 295 + label: "KPBJ", 296 + streamUrl: "https://kpbj.hasnoskills.com/listen/kpbj_test_station/radio.mp3", 297 + streamId: "chat-kpbj-stream", 298 + metadataUrl: "https://kpbj.hasnoskills.com/api/nowplaying/kpbj_test_station", 299 + parseTrack: (data) => data?.now_playing?.song?.text || "", 300 + labelBg: [20, 30, 45], 301 + labelBgHover: [35, 50, 70], 302 + labelFg: [255, 200, 140], 303 + labelFgHover: [255, 230, 180], 304 + contentBg: [18, 26, 38], 305 + contentBgHover: [30, 40, 55], 306 + separator: [60, 80, 110, 150], 307 + underline: [255, 200, 140, 180], 308 + buttonBg: [40, 55, 75], 309 + buttonBgHover: [60, 80, 105], 310 + buttonOutline: [90, 120, 150], 311 + buttonOutlineHover: [130, 165, 200], 312 + iconColor: [255, 210, 150], 313 + iconColorHover: [255, 230, 190], 314 + loadingColor: [255, 220, 150], 315 + barGradient: (t) => [ 316 + Math.floor(200 + t * 55), 317 + Math.floor(150 + t * 80), 318 + Math.floor(100 + t * 100), 319 + ], 320 + barIdle: [60, 80, 100], 321 + statusColor: [255, 210, 150], 322 + statusDim: [180, 180, 180], 323 + statusIdleOn: [255, 210, 150], 324 + statusIdleOff: [100, 120, 145], 325 + }, 326 + }; 327 + let activeRadioStation = "bj"; // default; overridden by options.radio 328 + const radioConfig = () => RADIO_STATIONS[activeRadioStation] || RADIO_STATIONS.bj; 329 + let r8dioEnabled = false; // Whether radio mini-player is shown 265 330 let r8dioPlaying = false; 266 331 let r8dioLoading = false; 267 332 let r8dioError = null; ··· 619 684 options, 620 685 ) { 621 686 const client = options?.otherChat || chat; 622 - 687 + 688 + // Pick radio station per chat (default "bj"/KPBJ; laer-klokken sets "r8dio") 689 + if (options?.radio && RADIO_STATIONS[options.radio]) { 690 + activeRadioStation = options.radio; 691 + } 692 + 623 693 // Calculate dynamic bottom margin based on selected font 624 694 const selectedFontConfig = CHAT_FONTS[userSelectedFont] || CHAT_FONTS["font_1"]; 625 695 const bottomMargin = getBottomMargin(selectedFontConfig, typeface.blockHeight); ··· 3548 3618 3549 3619 // Request frequency/waveform data when playing 3550 3620 if (r8dioPlaying && send) { 3551 - send({ type: "stream:frequencies", content: { id: R8DIO_STREAM_ID } }); 3621 + const streamId = radioConfig().streamId; 3622 + send({ type: "stream:frequencies", content: { id: streamId } }); 3552 3623 if (r8dioNoAnalyserCount >= 10) { 3553 - send({ type: "stream:waveform", content: { id: R8DIO_STREAM_ID } }); 3624 + send({ type: "stream:waveform", content: { id: streamId } }); 3554 3625 } 3555 3626 } 3556 3627 ··· 3587 3658 } 3588 3659 } 3589 3660 3590 - // 📻 Handle BIOS messages for r8dio streaming 3661 + // 📻 Handle BIOS messages for radio streaming 3591 3662 function receive({ type, content }) { 3592 3663 if (!r8dioEnabled) return; 3593 - 3594 - if (type === "stream:playing" && content.id === R8DIO_STREAM_ID) { 3664 + const streamId = radioConfig().streamId; 3665 + if (content?.id !== streamId) return; 3666 + 3667 + if (type === "stream:playing") { 3595 3668 r8dioPlaying = true; 3596 3669 r8dioLoading = false; 3597 3670 r8dioError = null; 3598 3671 } 3599 - 3600 - if (type === "stream:paused" && content.id === R8DIO_STREAM_ID) { 3672 + 3673 + if (type === "stream:paused") { 3601 3674 r8dioPlaying = false; 3602 3675 } 3603 - 3604 - if (type === "stream:stopped" && content.id === R8DIO_STREAM_ID) { 3676 + 3677 + if (type === "stream:stopped") { 3605 3678 r8dioPlaying = false; 3606 3679 r8dioLoading = false; 3607 3680 } 3608 - 3609 - if (type === "stream:error" && content.id === R8DIO_STREAM_ID) { 3681 + 3682 + if (type === "stream:error") { 3610 3683 r8dioPlaying = false; 3611 3684 r8dioLoading = false; 3612 3685 r8dioError = content.error; 3613 3686 } 3614 - 3615 - if (type === "stream:frequencies-data" && content.id === R8DIO_STREAM_ID) { 3687 + 3688 + if (type === "stream:frequencies-data") { 3616 3689 const data = content.data || []; 3617 3690 if (data.length > 0 && data.some(v => v > 0)) { 3618 3691 r8dioFrequencyData = data; ··· 3622 3695 r8dioFrequencyData = []; 3623 3696 } 3624 3697 } 3625 - 3626 - if (type === "stream:waveform-data" && content.id === R8DIO_STREAM_ID) { 3698 + 3699 + if (type === "stream:waveform-data") { 3627 3700 r8dioWaveformData = content.data || []; 3628 3701 } 3629 3702 } ··· 3637 3710 // 📚 Library 3638 3711 // (Useful functions used throughout the piece) 3639 3712 3640 - // 📻 Fetch r8dio track metadata 3713 + // 📻 Fetch current track metadata for the active station 3641 3714 async function fetchR8dioMetadata(net) { 3642 3715 r8dioLastMetadataFetch = Date.now(); 3716 + const cfg = radioConfig(); 3643 3717 try { 3644 - const response = await fetch(R8DIO_METADATA_URL); 3718 + const response = await fetch(cfg.metadataUrl); 3645 3719 if (response.ok) { 3646 3720 const data = await response.json(); 3647 - if (data.current_track && data.current_track.title) { 3648 - r8dioTrack = data.current_track.title; 3649 - } 3721 + const track = cfg.parseTrack?.(data); 3722 + if (track) r8dioTrack = track; 3650 3723 } 3651 3724 } catch (err) { 3652 3725 // Silently fail - metadata is optional 3653 - console.log("📻 Could not fetch r8dio metadata:", err.message); 3726 + console.log("📻 Could not fetch radio metadata:", err.message); 3654 3727 } 3655 3728 } 3656 3729 3657 - // 📻 R8dio playback control 3730 + // 📻 Radio playback control 3658 3731 function toggleR8dioPlayback(send) { 3659 3732 if (r8dioLoading) return; 3660 - 3733 + const cfg = radioConfig(); 3734 + 3661 3735 if (r8dioPlaying) { 3662 - // Pause 3663 - send({ type: "stream:pause", content: { id: R8DIO_STREAM_ID } }); 3736 + send({ type: "stream:pause", content: { id: cfg.streamId } }); 3664 3737 } else { 3665 - // Play 3666 3738 r8dioLoading = true; 3667 3739 r8dioError = null; 3668 - send({ 3669 - type: "stream:play", 3670 - content: { 3671 - id: R8DIO_STREAM_ID, 3672 - url: R8DIO_STREAM_URL, 3673 - volume: r8dioVolume 3674 - } 3740 + send({ 3741 + type: "stream:play", 3742 + content: { 3743 + id: cfg.streamId, 3744 + url: cfg.streamUrl, 3745 + volume: r8dioVolume, 3746 + }, 3675 3747 }); 3676 3748 } 3677 3749 } 3678 3750 3679 - // 📻 R8dio volume control 3751 + // 📻 Radio volume control 3680 3752 function setR8dioVolume(vol, send) { 3681 3753 r8dioVolume = Math.max(0, Math.min(1, vol)); 3682 3754 if (r8dioPlaying && send) { 3683 - send({ type: "stream:volume", content: { id: R8DIO_STREAM_ID, volume: r8dioVolume } }); 3755 + send({ type: "stream:volume", content: { id: radioConfig().streamId, volume: r8dioVolume } }); 3684 3756 } 3685 3757 } 3686 3758 ··· 4577 4649 const newsPrefix = "News"; 4578 4650 const uniformLabelWidth = 28; // Fixed width to match both labels 4579 4651 4580 - // Ticker dimensions - TWO ROWS 4581 - const tickerMaxWidth = 180; 4652 + // Ticker dimensions - TWO ROWS. Width auto-expands to fill space 4653 + // between the HUD label and the right edge. 4582 4654 const tickerRight = screen.width - rightMargin; 4583 4655 const tickerY = 2; // Top row Y position 4584 4656 const row2Y = tickerY + tickerHeight + rowSpacing; // Second row Y ··· 4613 4685 const hudLabelRight = hudLabelOffset + hudLabelWidth; 4614 4686 const minGapAfterHud = 10; // Minimum spacing between HUD label and News ticker 4615 4687 4616 - // Position calculations - ensure News ticker starts after HUD label 4688 + // Position calculations - flush against HUD label on the left, screen edge on the right 4617 4689 const scrollAreaRight = tickerRight; 4618 - const idealScrollAreaLeft = scrollAreaRight - tickerMaxWidth; 4619 - const idealNewsBgX = idealScrollAreaLeft - uniformLabelWidth; 4620 - 4621 - // Push News ticker to the right if it would overlap the HUD label 4622 - const newsBgX = Math.max( 4623 - hudLabelRight + minGapAfterHud, // Don't overlap HUD label 4624 - idealNewsBgX // Original position 4625 - ); 4626 - 4627 - // Recalculate scroll area left edge based on actual News ticker position 4690 + const newsBgX = hudLabelRight + minGapAfterHud; 4628 4691 const scrollAreaLeft = newsBgX + uniformLabelWidth; 4629 4692 4630 4693 // Colors from theme ··· 4713 4776 } 4714 4777 } 4715 4778 4716 - // 📻 R8dio mini-player bar for laer-klokken (styled like News ticker) 4779 + // 📻 Radio mini-player bar (styled like News ticker). Station picked by options.radio. 4717 4780 function paintR8dioPlayer($, theme) { 4718 4781 const { ink, screen, help, hud } = $; 4719 - 4782 + const cfg = radioConfig(); 4783 + 4720 4784 // Initialize bars if needed 4721 4785 if (r8dioBars.length === 0) { 4722 4786 for (let i = 0; i < R8DIO_BAR_COUNT; i++) { 4723 4787 r8dioBars.push({ height: 0, targetHeight: 0 }); 4724 4788 } 4725 4789 } 4726 - 4790 + 4727 4791 const tickerCharWidth = 4; // MatrixChunky8 char width 4728 4792 const tickerHeight = 8; 4729 - const tickerPadding = 3; 4730 4793 const rightMargin = 0; // Flush right, no margin 4731 - 4732 - // "r8Dio" prefix styling - uniform width with News label 4733 - const r8dioPrefix = "r8Dio"; 4734 - const uniformLabelWidth = 28; // Fixed width to match both labels 4735 - 4736 - // Bar dimensions - match news ticker width 4737 - const tickerMaxWidth = 180; 4794 + 4795 + // Uniform label width matches News label 4796 + const uniformLabelWidth = 28; 4797 + 4798 + // Match news ticker height so we can sit directly beneath it without overlap. 4799 + // News ticker total height = (tickerHeight * 2) + rowSpacing(2) + 4 = 22, drawn from y=0. 4800 + const newsTotalHeight = (tickerHeight * 2) + 2 + 4; 4738 4801 const tickerRight = screen.width - rightMargin; 4739 - const tickerY = 14; // Right below news ticker (at y=2, ~12px tall) 4740 - 4802 + const tickerY = newsTotalHeight + 4; // 4px gap below news ticker 4803 + 4741 4804 // Calculate HUD label right edge to avoid overlap (same as news ticker) 4742 4805 const hudLabelOffset = 6; 4743 4806 const hudLabelWidth = hud?.currentLabel?.()?.btn?.box?.w || 0; 4744 4807 const hudLabelRight = hudLabelOffset + hudLabelWidth; 4745 4808 const minGapAfterHud = 10; 4746 - 4747 - // Position calculations 4809 + 4810 + // Position calculations - auto-widen: flush against HUD label on left, screen edge on right 4748 4811 const scrollAreaRight = tickerRight; 4749 - const idealScrollAreaLeft = scrollAreaRight - tickerMaxWidth; 4750 - const idealR8dioBgX = idealScrollAreaLeft - uniformLabelWidth; 4751 - 4752 - const r8dioBgX = Math.max(hudLabelRight + minGapAfterHud, idealR8dioBgX); 4812 + const r8dioBgX = hudLabelRight + minGapAfterHud; 4753 4813 const contentAreaLeft = r8dioBgX + uniformLabelWidth; 4754 4814 const actualContentWidth = scrollAreaRight - contentAreaLeft; 4755 4815 const totalWidth = uniformLabelWidth + actualContentWidth + 1; 4756 - 4816 + 4757 4817 // Store bounds for click detection (entire bar) 4758 4818 r8dioPlayerBounds = { x: r8dioBgX, y: tickerY - 2, w: totalWidth, h: tickerHeight + 4 }; 4759 - 4760 - // "r8Dio" label background - dark with orange text (radio style) 4761 - const labelBgColor = r8dioHovered ? [50, 35, 25] : [35, 25, 18]; // Dark warm brown 4762 - const labelFgColor = r8dioHovered ? [255, 180, 80] : [255, 150, 50]; // Orange 4763 - 4819 + 4820 + // Label background + foreground from station config 4821 + const labelBgColor = r8dioHovered ? cfg.labelBgHover : cfg.labelBg; 4822 + const labelFgColor = r8dioHovered ? cfg.labelFgHover : cfg.labelFg; 4823 + 4764 4824 ink(...labelBgColor, 230).box(r8dioBgX, tickerY - 2, uniformLabelWidth + 1, tickerHeight + 4); 4765 - 4766 - // Draw "r8Dio" in orange, centered in label area 4767 - const labelTextWidth = r8dioPrefix.length * tickerCharWidth; 4825 + 4826 + // Centered station label 4827 + const labelTextWidth = cfg.label.length * tickerCharWidth; 4768 4828 const labelX = r8dioBgX + Math.floor((uniformLabelWidth - labelTextWidth) / 2); 4769 - ink(...labelFgColor).write("r", { x: labelX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4770 - ink(...labelFgColor).write("8D", { x: labelX + 4, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4771 - ink(...labelFgColor).write("io", { x: labelX + 12, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4772 - 4773 - // Content area background - dark (brighter on hover) 4774 - const contentBgColor = r8dioHovered ? [40, 30, 25] : [28, 22, 18]; // Dark warm 4829 + ink(...labelFgColor).write(cfg.label, { x: labelX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4830 + 4831 + // Content area background 4832 + const contentBgColor = r8dioHovered ? cfg.contentBgHover : cfg.contentBg; 4775 4833 ink(...contentBgColor, 200).box(contentAreaLeft, tickerY - 2, actualContentWidth + 1, tickerHeight + 4); 4776 - 4777 - // Separator line between News and r8Dio (subtle) 4778 - ink(80, 60, 50, 150).box(r8dioBgX, tickerY - 3, totalWidth, 1); 4779 - 4780 - // Hover underline indicator (orange) 4834 + 4835 + // Subtle separator line at the top of the bar 4836 + ink(...cfg.separator).box(r8dioBgX, tickerY - 3, totalWidth, 1); 4837 + 4838 + // Hover underline indicator 4781 4839 if (r8dioHovered) { 4782 - ink(255, 150, 50, 180).box(r8dioBgX, tickerY + tickerHeight + 1, totalWidth, 1); 4840 + ink(...cfg.underline).box(r8dioBgX, tickerY + tickerHeight + 1, totalWidth, 1); 4783 4841 } 4784 - 4785 - // Play/Pause button right after label (not far right) 4842 + 4843 + // Play/Pause button right after label 4786 4844 const btnSize = 10; 4787 4845 const btnX = contentAreaLeft + 2; 4788 4846 const btnY = tickerY - 1; 4789 - 4847 + 4790 4848 r8dioPlayBtnBounds = { x: btnX - 2, y: btnY - 2, w: btnSize + 4, h: btnSize + 4 }; 4791 - 4792 - // Button background (orange tint) 4793 - const btnBg = r8dioPlayHovered ? [80, 55, 35] : [55, 40, 25]; 4849 + 4850 + const btnBg = r8dioPlayHovered ? cfg.buttonBgHover : cfg.buttonBg; 4794 4851 ink(...btnBg).box(btnX, btnY, btnSize, btnSize); 4795 - ink(r8dioPlayHovered ? [140, 100, 60] : [100, 70, 45]).box(btnX, btnY, btnSize, btnSize, "outline"); 4796 - 4797 - // Play/Pause/Loading icon (orange) 4798 - const iconColor = r8dioPlayHovered ? [255, 200, 120] : [255, 160, 80]; 4852 + ink(...(r8dioPlayHovered ? cfg.buttonOutlineHover : cfg.buttonOutline)).box(btnX, btnY, btnSize, btnSize, "outline"); 4853 + 4854 + const iconColor = r8dioPlayHovered ? cfg.iconColorHover : cfg.iconColor; 4799 4855 const iconCenterX = btnX + btnSize / 2; 4800 4856 const iconCenterY = btnY + btnSize / 2; 4801 - 4857 + 4802 4858 if (r8dioLoading) { 4803 - // Simple loading indicator (blinking dot) 4804 4859 const phase = Math.floor((help?.repeat || 0) / 15) % 2; 4805 - ink(255, 200, 100, phase ? 255 : 100).box(iconCenterX - 1, iconCenterY - 1, 3, 3); 4860 + ink(...cfg.loadingColor, phase ? 255 : 100).box(iconCenterX - 1, iconCenterY - 1, 3, 3); 4806 4861 } else if (r8dioPlaying) { 4807 - // Pause icon (two small bars) 4808 4862 ink(...iconColor).box(iconCenterX - 3, iconCenterY - 3, 2, 6); 4809 4863 ink(...iconColor).box(iconCenterX + 1, iconCenterY - 3, 2, 6); 4810 4864 } else { 4811 - // Play icon (small triangle) 4812 4865 ink(...iconColor).box(iconCenterX - 2, iconCenterY - 3, 2, 6); 4813 4866 ink(...iconColor).box(iconCenterX, iconCenterY - 2, 2, 4); 4814 4867 ink(...iconColor).box(iconCenterX + 2, iconCenterY - 1, 1, 2); 4815 4868 } 4816 - 4817 - // Content: mini visualizer bars + status text (after play button) 4869 + 4870 + // Mini visualizer bars 4818 4871 const barAreaX = btnX + btnSize + 4; 4819 4872 const barAreaWidth = Math.min(60, actualContentWidth - btnSize - 12); 4820 4873 const barWidth = Math.max(1, Math.floor(barAreaWidth / R8DIO_BAR_COUNT) - 1); 4821 4874 const maxBarHeight = tickerHeight; 4822 - 4823 - // Draw mini visualizer bars 4875 + 4824 4876 for (let i = 0; i < R8DIO_BAR_COUNT; i++) { 4825 4877 const bar = r8dioBars[i]; 4826 4878 const x = barAreaX + i * (barWidth + 1); 4827 4879 const height = Math.max(1, Math.floor(bar.height * maxBarHeight)); 4828 - 4880 + 4829 4881 if (r8dioPlaying || bar.height > 0.05) { 4830 - // Orange gradient for visualizer 4831 - const t = bar.height; 4832 - const r = Math.floor(200 + t * 55); 4833 - const g = Math.floor(100 + t * 80); 4834 - const b = Math.floor(30 + t * 40); 4835 - ink(r, g, b).box(x, tickerY + maxBarHeight - height, barWidth, height); 4882 + ink(...cfg.barGradient(bar.height)).box(x, tickerY + maxBarHeight - height, barWidth, height); 4836 4883 } else { 4837 - // Idle state: dim orange line 4838 - ink(80, 50, 30).box(x, tickerY + maxBarHeight - 1, barWidth, 1); 4884 + ink(...cfg.barIdle).box(x, tickerY + maxBarHeight - 1, barWidth, 1); 4839 4885 } 4840 4886 } 4841 - 4887 + 4842 4888 // Status text after visualizer 4843 4889 const statusX = barAreaX + barAreaWidth + 4; 4844 - const statusTextColor = theme?.messageText || [200, 200, 200]; 4845 4890 const statusEndX = scrollAreaRight - 2; 4846 - 4891 + 4847 4892 if (r8dioError) { 4848 4893 ink(255, 100, 100).write("err", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4849 4894 } else if (r8dioLoading) { 4850 - ink(255, 180, 80).write("...", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4895 + ink(...cfg.statusColor).write("...", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4851 4896 } else if (r8dioPlaying) { 4852 - // Show truncated track or "live" (orange text) 4853 4897 const maxLen = Math.floor((statusEndX - statusX) / tickerCharWidth); 4854 - const text = r8dioTrack 4898 + const text = r8dioTrack 4855 4899 ? (r8dioTrack.length > maxLen ? r8dioTrack.substring(0, maxLen - 1) + "…" : r8dioTrack) 4856 4900 : "live"; 4857 - ink(255, 180, 80).write(text, { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4901 + ink(...cfg.statusColor).write(text, { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4858 4902 } else { 4859 - // Blinking "Listen Now" with > < arrows 4860 4903 const blink = Math.floor((help?.repeat || 0) / 20) % 2; 4861 - const arrowColor = blink ? [255, 180, 80] : [120, 90, 60]; // Orange blink 4862 - const textColor = [180, 140, 100]; 4904 + const arrowColor = blink ? cfg.statusIdleOn : cfg.statusIdleOff; 4863 4905 ink(...arrowColor).write(">", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4864 - ink(...textColor).write("Listen Now", { x: statusX + 6, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4906 + ink(...cfg.statusDim).write("Listen Now", { x: statusX + 6, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4865 4907 ink(...arrowColor).write("<", { x: statusX + 46, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4866 4908 } 4867 - 4868 - // Volume slider removed for slim design - could add keyboard shortcuts later 4909 + 4910 + // Volume slider removed for slim design 4869 4911 r8dioVolSliderBounds = null; 4870 4912 }
+1
system/public/aesthetic.computer/disks/laer-klokken.mjs
··· 26 26 // Custom warm color theme for laer-klokken chat 27 27 chat.paint($, { 28 28 otherChat: client.system, 29 + radio: "r8dio", 29 30 theme: { 30 31 background: [180, 100, 60], // Warm terracotta/rust background 31 32 lines: [220, 150, 100, 64], // Soft peach lines
+1 -1
system/public/aesthetic.computer/disks/prompt.mjs
··· 199 199 // 🎰 Top-right slot: A/B test — pick one randomly on page load 200 200 const TOP_RIGHT_BTN_CHOICES = ["give", "ad", "os", "products", "blank"]; 201 201 // const topRightBtnChoice = TOP_RIGHT_BTN_CHOICES[Math.floor(Math.random() * TOP_RIGHT_BTN_CHOICES.length)]; 202 - const topRightBtnChoice = "blank"; // Only show laptop bumper for now 202 + const topRightBtnChoice = "products"; // Curtain product carousel (cycles live Shopify items) 203 203 204 204 let clearBtn; // 🧹 "Blank" button (fixed top-right, appears at 32+ chars) 205 205 let clearBtnConfirming = false; // Two-tap confirmation state
+3
system/public/aesthetic.computer/lib/shop.mjs
··· 26 26 "25.12.4.11.23", 27 27 "25.12.4.11.24", 28 28 "25.12.4.11.25", 29 + // 💻 Laptops 30 + "26.4.17.12.51", 31 + "blank", // Alias for the AC Native Laptop — "shop blank" in the prompt. 29 32 ]; 30 33 31 34 export { signed }