From 6e304a4df97b78b256f9a60c0737d608d75110de Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 17 Apr 2026 22:33:21 -0700 Subject: [PATCH] wip: shop redirects, chat + blank piece edits, kidlisp/clojure report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- REPORT-kidlisp-clojure-hosting.md | 142 ++++++++ ac-shop/livereload.mjs | 129 ++++++++ system/netlify.toml | 14 + .../public/aesthetic.computer/disks/blank.mjs | 121 +------ .../public/aesthetic.computer/disks/chat.mjs | 312 ++++++++++-------- .../aesthetic.computer/disks/laer-klokken.mjs | 1 + .../aesthetic.computer/disks/prompt.mjs | 2 +- system/public/aesthetic.computer/lib/shop.mjs | 3 + 8 files changed, 477 insertions(+), 247 deletions(-) create mode 100644 REPORT-kidlisp-clojure-hosting.md create mode 100755 ac-shop/livereload.mjs diff --git a/REPORT-kidlisp-clojure-hosting.md b/REPORT-kidlisp-clojure-hosting.md new file mode 100644 index 000000000..d4eec8979 --- /dev/null +++ b/REPORT-kidlisp-clojure-hosting.md @@ -0,0 +1,142 @@ +# Hosting KidLisp on Clojure / ClojureScript + +Date: 2026-04-17 +Author: Claude (for @jeffrey) +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. + +## TL;DR + +**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. + +--- + +## 1. What KidLisp actually is today + +- `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. +- 118 built-in functions across 12 categories (drawing, audio, math, control, animation, data, etc.). +- Extra ports already exist outside the browser runtime: + - `kidlisp-gameboy/` — KidLisp → GBDK/C compiler for Game Boy. + - `kidlisp-n64/` — bare-metal / libdragon experiments. +- 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. +- Non-standard Lisp features that matter for any host decision: + - Timing literals: `1s`, `2s...`, `0.5s!`. + - Cached-code references: `$abc123`, `(embed $code ...)`. + - Handle/timestamp references: `@user/123456`. + - Unquoted URLs in arg position: `(paste https://example.com/a.png x y)`. + - Dynamic color atoms: `rainbow`, `zebra`, `c0..c150`. + - Reader-visible dashes are **subtraction**, not identifier chars. + +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. + +## 2. Why ClojureScript looks tempting + +- **Homoiconicity.** KidLisp ASTs map cleanly onto `clojure.core` sequences and symbols. +- **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. +- **Persistent data structures** for free, plus proper recur/trampolines for the repeat/bunch loops. +- **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. +- **Compiler infrastructure.** Google Closure's advanced optimizations, dead-code elimination, and source maps are mature. +- **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. + +## 3. Why it's the wrong move for KidLisp specifically + +### 3.1 Bundle and boot cost + +- 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. +- Scittle / SCI is smaller (~300–500 KB gzipped) but is an *interpreter* with different perf characteristics than the present kidlisp evaluator. +- 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. +- Aesthetic Computer is mobile-first; a cold-start regression of even 500 ms on low-end devices would be felt immediately. + +### 3.2 Reader is not reusable + +- Clojure's reader cannot parse `2s...`, `0.5s!`, `$abc123`, `@user/123456`, or bare URLs. +- You'd still write a custom reader. At that point, "ClojureScript is a Lisp" buys you **data structures and macros** but not parsing. +- 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. + +### 3.3 Effect boundary friction + +- 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. +- The Disk API changes often (see `disk.mjs` churn). Every change becomes a double-edit: JS definition + CLJS wrapper. + +### 3.4 Ecosystem/ops mismatch + +- 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. +- 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. + +### 3.5 Existing external ports regress + +- `kidlisp-gameboy` compiles KidLisp → C for GBDK. A ClojureScript-hosted evaluator doesn't help this path (still need a bespoke compiler). +- `kidlisp-n64` is assembly-adjacent. Same story. +- 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. + +## 4. State of Clojure → WASM (early 2026) + +Nothing in this space is production-ready for replacing a shipping web runtime. Summary of the tracks: + +### 4.1 jank (LLVM-native Clojure) + +- Native Clojure dialect by Jeaye Wilkerson, targets LLVM IR → native binaries. +- 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. +- **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. + +### 4.2 GraalVM native-image → WASM + +- 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. +- 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. +- **Use case fit:** poor for a browser runtime. Size would dwarf the current evaluator. + +### 4.3 SCI / Scittle (pure JS, not WASM) + +- Small Clojure Interpreter by @borkdude. Runs in the browser today as plain JS (~300–500 KB gz). +- **Not a WASM port** — it's JS. Often miscategorized in WASM discussions. +- Could theoretically be embedded inside a QuickJS-WASM sandbox, but that's stacking interpreters and helps nobody. + +### 4.4 ClojureScript via JS → WASM glue (e.g. Javy, Spin, Kotlin/JS-WASM paths) + +- 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. +- Useful for serverless edge; irrelevant for AC's browser runtime. + +### 4.5 Ferret (Clojure → C++) + +- Ferret AOT-compiles a Clojure subset to C++. C++ → WASM via Emscripten is routine, so Ferret → WASM is feasible for small, statically-knowable programs. +- Doesn't support `eval`, which is the KidLisp core requirement. + +### 4.6 Summary table + +| Track | Supports runtime `eval` | Production-ready for browser | Rough browser size | +|---|---|---|---| +| SCI / Scittle (JS) | Yes | Yes (not WASM) | 300–500 KB gz | +| Self-hosted ClojureScript (JS) | Yes | Yes (not WASM) | 1–2 MB gz | +| jank → WASM | No (AOT) | No, pre-alpha | Unknown, likely multi-MB | +| GraalVM native-image → WASM | Partial | No, experimental | Large | +| Ferret → C++ → WASM | No (AOT) | Niche only | Small but feature-limited | +| Javy/QuickJS-WASM hosting CLJS | Yes (via nested JS) | Shipping for edge, not browser | 2–5 MB | + +**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. + +## 5. Concrete paths forward (ordered by cost) + +1. **Do nothing to the host, improve the current evaluator.** + 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`. + +2. **Adopt Clojure *data* without adopting the Clojure *runtime*.** + Keep the JS evaluator; borrow ideas (persistent vectors via a tiny Immer-like lib, `recur`-style trampolines). Cheap, keeps the bundle lean. + +3. **Move the KidLisp compiler (not the interpreter) to Clojure.** + 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. + +4. **Rewrite the browser evaluator in ClojureScript.** + 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. + +5. **Bet on Clojure WASM.** + 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). + +## 6. Recommendation + +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. + +## Appendix: what would change my mind + +- A production-grade `sci` or `scittle` release that is clearly under 150 KB gzipped with a usable macro system. +- 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. +- jank reaching a 1.0 with a shipping WASM target and in-browser `eval`. +- 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. diff --git a/ac-shop/livereload.mjs b/ac-shop/livereload.mjs new file mode 100755 index 000000000..db01a8658 --- /dev/null +++ b/ac-shop/livereload.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * ac-shop livereload — installs + controls the Shopify live-reload snippet. + * + * Commands: + * node livereload.mjs bump Bump the version marker (triggers a reload in all open tabs). + * node livereload.mjs on Flip the snippet on (AC_LIVERELOAD_ENABLED = true). + * node livereload.mjs off Flip the snippet off (AC_LIVERELOAD_ENABLED = false). + * node livereload.mjs status Show current state (snippet present? enabled? last version?). + */ + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '../aesthetic-computer-vault/shop/.env'); +const envContent = readFileSync(envPath, 'utf-8'); +for (const line of envContent.split('\n')) { + if (line && !line.startsWith('#') && line.includes('=')) { + const [key, ...valueParts] = line.split('='); + process.env[key.trim()] = valueParts.join('=').trim(); + } +} + +const STORE = process.env.SHOPIFY_STORE_DOMAIN; +const TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN; +if (!STORE || !TOKEN) { + console.error('❌ Missing SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_ACCESS_TOKEN'); + process.exit(1); +} + +const base = `https://${STORE}/admin/api/2024-10`; + +async function gq(path, options = {}) { + const r = await fetch(`${base}${path}`, { + headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(options.headers || {}) }, + ...options, + }); + if (!r.ok) { + const txt = await r.text(); + throw new Error(`${r.status}: ${txt}`); + } + return r.json(); +} + +async function getMainTheme() { + const { themes } = await gq('/themes.json'); + const main = themes.find((t) => t.role === 'main'); + if (!main) throw new Error('No main theme found'); + return main; +} + +async function readAsset(themeId, key) { + try { + const { asset } = await gq(`/themes/${themeId}/assets.json?asset[key]=${encodeURIComponent(key)}`); + return asset?.value ?? null; + } catch (e) { + if (String(e).includes('404')) return null; + throw e; + } +} + +async function writeAsset(themeId, key, value) { + await gq(`/themes/${themeId}/assets.json`, { + method: 'PUT', + body: JSON.stringify({ asset: { key, value } }), + }); +} + +async function bump() { + const theme = await getMainTheme(); + const v = String(Date.now()); + await writeAsset(theme.id, 'assets/ac-livereload-version.txt', v); + console.log(`✅ bumped ac-livereload-version.txt → ${v}`); +} + +async function setEnabled(nextEnabled) { + const theme = await getMainTheme(); + const snippet = await readAsset(theme.id, 'snippets/ac-livereload.liquid'); + if (!snippet) { + console.error('❌ snippets/ac-livereload.liquid not found — re-run the install script first.'); + process.exit(1); + } + const newValue = nextEnabled + ? snippet.replace(/AC_LIVERELOAD_ENABLED = false/, 'AC_LIVERELOAD_ENABLED = true') + : snippet.replace(/AC_LIVERELOAD_ENABLED = true/, 'AC_LIVERELOAD_ENABLED = false'); + if (newValue === snippet) { + console.log(`ℹ️ already ${nextEnabled ? 'enabled' : 'disabled'}`); + return; + } + await writeAsset(theme.id, 'snippets/ac-livereload.liquid', newValue); + console.log(`✅ livereload is now ${nextEnabled ? 'ENABLED' : 'DISABLED'}`); + // Trigger a reload so open browsers pick up the new snippet state. + await bump(); +} + +async function status() { + const theme = await getMainTheme(); + const snippet = await readAsset(theme.id, 'snippets/ac-livereload.liquid'); + const version = await readAsset(theme.id, 'assets/ac-livereload-version.txt'); + const themeLiquid = await readAsset(theme.id, 'layout/theme.liquid'); + const rendered = themeLiquid?.includes(`render 'ac-livereload'`) ?? false; + const enabled = snippet?.match(/AC_LIVERELOAD_ENABLED = (true|false)/)?.[1] ?? 'unknown'; + console.log('theme: ', theme.name, `(id=${theme.id})`); + console.log('snippet present: ', !!snippet); + console.log('rendered in layout:', rendered); + console.log('AC_LIVERELOAD: ', enabled); + console.log('last version: ', version); +} + +const cmd = process.argv[2]; +switch (cmd) { + case 'bump': + await bump(); + break; + case 'on': + await setEnabled(true); + break; + case 'off': + await setEnabled(false); + break; + case 'status': + await status(); + break; + default: + console.log('Usage: node livereload.mjs {bump|on|off|status}'); + process.exit(1); +} diff --git a/system/netlify.toml b/system/netlify.toml index bd50b516c..4183d138a 100644 --- a/system/netlify.toml +++ b/system/netlify.toml @@ -2298,6 +2298,20 @@ status = 301 from = "/shop~26.1.3.0.00" to = "https://shop.aesthetic.computer/products/shirts_coral-abex-tee-l_26-1-3-0-00" status = 301 +# 💻 Laptops +[[redirects]] +from = "/26.4.17.12.51" +to = "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51" +status = 301 +[[redirects]] +from = "/shop~26.4.17.12.51" +to = "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51" +status = 301 +# Convenience alias: "shop blank" → AC Native Laptop. (Bare /blank stays the piece route.) +[[redirects]] +from = "/shop~blank" +to = "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51" +status = 301 # END SHOP [[redirects]] from = "/api/bdf-glyph" diff --git a/system/public/aesthetic.computer/disks/blank.mjs b/system/public/aesthetic.computer/disks/blank.mjs index a8f03dfe9..762bce45a 100644 --- a/system/public/aesthetic.computer/disks/blank.mjs +++ b/system/public/aesthetic.computer/disks/blank.mjs @@ -1,15 +1,14 @@ // blank, 26.03.20 // AC Blank — AC Native Laptop product page & checkout +// Checkout is handled by Shopify (shop.aesthetic.computer). Earlier this page +// ran Stripe directly because we hadn't re-enabled Shopify; that path is gone. const { floor, sin, cos, abs, min, max, PI, sqrt } = Math; // Module state -let amount = 12800; -let checkoutUrl = null; -let checkoutReady = false; -let checkoutError = null; -let checkoutLoading = false; -let buyPending = false; +let amount = 12800; // Kept in sync with the Shopify variant price (USD cents). +const SHOP_URL = + "https://shop.aesthetic.computer/products/laptops_ac-native-laptop_26-4-17-12-51"; let thanks = false; // UI elements @@ -57,20 +56,6 @@ const DESCRIPTION_PLAIN = "Receive a @jeffrey approved, refurbished Thinkpad 11e Yoga Gen 6 pre-flashed with AC Native OS and Live USB recovery stick."; const DESCRIPTION = "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."; -const AUTH_TIMEOUT_MS = 1200; - -async function getOptionalToken(api) { - if (!api?.authorize) return null; - - try { - return await Promise.race([ - api.authorize().catch(() => null), - new Promise((resolve) => setTimeout(() => resolve(null), AUTH_TIMEOUT_MS)), - ]); - } catch { - return null; - } -} // Animation let frame = 0; @@ -82,7 +67,6 @@ function displayAmount(amt) { } function getBuyText() { - if (buyPending) return "CHECKING OUT..."; return `BUY LAPTOP ${displayAmount(amount)}`; } @@ -97,7 +81,6 @@ async function boot({ params, ui, screen, cursor, hud, api, handle }) { userHandle = handle(); setupButtons(ui, screen); - fetchCheckout(api); if (!userHandle) fetchHandles(screen); // Prefetch colors for logged-in user if (userHandle) fetchHandleColor(userHandle); @@ -142,43 +125,7 @@ function setupButtons(ui, screen) { manualBtn = new ui.TextButton("ThinkPad 11e Yoga Manual", { x: 6, bottom: 20 + (paperBtn.height || 14) + 4, screen }); } -async function fetchCheckout(api) { - if (checkoutLoading) return; - - checkoutLoading = true; - checkoutReady = false; - checkoutError = null; - checkoutUrl = null; - - try { - const headers = { "Content-Type": "application/json" }; - const token = await getOptionalToken(api); - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch("/api/blank?new=true", { - method: "POST", - headers, - body: JSON.stringify({ amount, currency: "usd" }), - }); - - if (!res.ok) { - checkoutError = `Checkout failed: ${res.status}`; - return; - } - - const data = await res.json(); - if (data?.location) { - checkoutUrl = data.location; - checkoutReady = true; - } else { - checkoutError = data?.error || "Checkout failed"; - } - } catch (e) { - checkoutError = e?.message || "Checkout error"; - } finally { - checkoutLoading = false; - } -} +// Checkout lives on Shopify now — no pre-flight request needed. function paint($) { const { wipe, ink, line, screen, dark: isDark, tri, text } = $; @@ -760,19 +707,7 @@ function paint($) { const isOver = buyBtn.btn.over; const isDown = buyBtn.btn.down; - if (buyPending) { - const pulse = sin(t * 6) * 0.5 + 0.5; - const bgR = isDark ? floor(20 + pulse * 40) : floor(200 + pulse * 30); - const bgG = isDark ? floor(30 + pulse * 30) : floor(220 + pulse * 20); - const bgB = isDark ? 20 : 200; - ink(bgR, bgG, bgB).box(bx, "fill"); - const oA = floor(120 + pulse * 135); - ink(isDark ? [100, 255, 100, oA] : [40, 140, 40, oA]).box(bx, "outline"); - // Shadow text - ink(sr, sg, sb, 120).write(buyText, { x: bx.x + padX + 1, y: bx.y + padY + 1 }, undefined, undefined, false, "unifont"); - ink(isDark ? [160 + floor(pulse * 95), 230, 160] : [30, floor(80 + pulse * 40), 30]) - .write(buyText, { x: bx.x + padX, y: bx.y + padY }, undefined, undefined, false, "unifont"); - } else { + { // Breathing glow animation const breath = sin(t * 2) * 0.5 + 0.5; const wave = sin(t * 3.5) * 0.3 + 0.7; @@ -917,49 +852,13 @@ function act({ event: e, screen, jump, sound, ui, api }) { sound?.synth({ type: "sine", tone: 440, duration: 0.05, volume: 0.3 }); }, push: () => { - if (buyPending) return; - - if (checkoutReady && checkoutUrl) { - sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); - jump(checkoutUrl); - } else if (checkoutError) { - checkoutError = null; - fetchCheckout(api); - sound?.synth({ type: "sine", tone: 550, duration: 0.06, volume: 0.3 }); - buyPending = true; - waitForCheckout(jump, sound, api); - } else { - buyPending = true; - if (!checkoutLoading) fetchCheckout(api); - sound?.synth({ type: "sine", tone: 660, duration: 0.08, volume: 0.3 }); - waitForCheckout(jump, sound, api); - } + sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); + // Jump to the Shopify product (orderable there). + jump(`out:${SHOP_URL}`); }, }); } -async function waitForCheckout(jump, sound, api) { - const maxWait = 10000; - const startTime = Date.now(); - - if (!checkoutReady && !checkoutError && !checkoutLoading) { - fetchCheckout(api); - } - - while (!checkoutReady && !checkoutError && Date.now() - startTime < maxWait) { - await new Promise((r) => setTimeout(r, 100)); - } - - buyPending = false; - - if (checkoutReady && checkoutUrl) { - sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); - jump(checkoutUrl); - } else if (checkoutError) { - sound?.synth({ type: "square", tone: 200, duration: 0.15, volume: 0.3 }); - } -} - function meta() { return { title: "AC Blank Laptop", diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index 64b00469c..eb3453041 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -257,11 +257,76 @@ let newsTickerBounds = null; // { x, y, w, h } for click detection let newsTickerHovered = false; // Hover state for visual feedback let newsFetchPromise = null; // Track fetch to avoid duplicate requests -// � R8dio mini-player system (for laer-klokken) -const R8DIO_STREAM_URL = "https://s3.radio.co/s7cd1ffe2f/listen"; -const R8DIO_STREAM_ID = "chat-r8dio-stream"; -const R8DIO_METADATA_URL = "https://public.radio.co/stations/s7cd1ffe2f/status"; -let r8dioEnabled = false; // Whether r8dio player is shown +// 📻 Mini-player system — station presets (selected per chat via options.radio) +const RADIO_STATIONS = { + r8dio: { + label: "r8Dio", + streamUrl: "https://s3.radio.co/s7cd1ffe2f/listen", + streamId: "chat-r8dio-stream", + metadataUrl: "https://public.radio.co/stations/s7cd1ffe2f/status", + parseTrack: (data) => data?.current_track?.title || "", + labelBg: [35, 25, 18], + labelBgHover: [50, 35, 25], + labelFg: [255, 150, 50], + labelFgHover: [255, 180, 80], + contentBg: [28, 22, 18], + contentBgHover: [40, 30, 25], + separator: [80, 60, 50, 150], + underline: [255, 150, 50, 180], + buttonBg: [55, 40, 25], + buttonBgHover: [80, 55, 35], + buttonOutline: [100, 70, 45], + buttonOutlineHover: [140, 100, 60], + iconColor: [255, 160, 80], + iconColorHover: [255, 200, 120], + loadingColor: [255, 200, 100], + barGradient: (t) => [ + Math.floor(200 + t * 55), + Math.floor(100 + t * 80), + Math.floor(30 + t * 40), + ], + barIdle: [80, 50, 30], + statusColor: [255, 180, 80], + statusDim: [180, 140, 100], + statusIdleOn: [255, 180, 80], + statusIdleOff: [120, 90, 60], + }, + bj: { + label: "KPBJ", + streamUrl: "https://kpbj.hasnoskills.com/listen/kpbj_test_station/radio.mp3", + streamId: "chat-kpbj-stream", + metadataUrl: "https://kpbj.hasnoskills.com/api/nowplaying/kpbj_test_station", + parseTrack: (data) => data?.now_playing?.song?.text || "", + labelBg: [20, 30, 45], + labelBgHover: [35, 50, 70], + labelFg: [255, 200, 140], + labelFgHover: [255, 230, 180], + contentBg: [18, 26, 38], + contentBgHover: [30, 40, 55], + separator: [60, 80, 110, 150], + underline: [255, 200, 140, 180], + buttonBg: [40, 55, 75], + buttonBgHover: [60, 80, 105], + buttonOutline: [90, 120, 150], + buttonOutlineHover: [130, 165, 200], + iconColor: [255, 210, 150], + iconColorHover: [255, 230, 190], + loadingColor: [255, 220, 150], + barGradient: (t) => [ + Math.floor(200 + t * 55), + Math.floor(150 + t * 80), + Math.floor(100 + t * 100), + ], + barIdle: [60, 80, 100], + statusColor: [255, 210, 150], + statusDim: [180, 180, 180], + statusIdleOn: [255, 210, 150], + statusIdleOff: [100, 120, 145], + }, +}; +let activeRadioStation = "bj"; // default; overridden by options.radio +const radioConfig = () => RADIO_STATIONS[activeRadioStation] || RADIO_STATIONS.bj; +let r8dioEnabled = false; // Whether radio mini-player is shown let r8dioPlaying = false; let r8dioLoading = false; let r8dioError = null; @@ -619,7 +684,12 @@ function paint( options, ) { const client = options?.otherChat || chat; - + + // Pick radio station per chat (default "bj"/KPBJ; laer-klokken sets "r8dio") + if (options?.radio && RADIO_STATIONS[options.radio]) { + activeRadioStation = options.radio; + } + // Calculate dynamic bottom margin based on selected font const selectedFontConfig = CHAT_FONTS[userSelectedFont] || CHAT_FONTS["font_1"]; const bottomMargin = getBottomMargin(selectedFontConfig, typeface.blockHeight); @@ -3548,9 +3618,10 @@ function sim({ api, num, send, net, store }) { // Request frequency/waveform data when playing if (r8dioPlaying && send) { - send({ type: "stream:frequencies", content: { id: R8DIO_STREAM_ID } }); + const streamId = radioConfig().streamId; + send({ type: "stream:frequencies", content: { id: streamId } }); if (r8dioNoAnalyserCount >= 10) { - send({ type: "stream:waveform", content: { id: R8DIO_STREAM_ID } }); + send({ type: "stream:waveform", content: { id: streamId } }); } } @@ -3587,32 +3658,34 @@ function sim({ api, num, send, net, store }) { } } -// 📻 Handle BIOS messages for r8dio streaming +// 📻 Handle BIOS messages for radio streaming function receive({ type, content }) { if (!r8dioEnabled) return; - - if (type === "stream:playing" && content.id === R8DIO_STREAM_ID) { + const streamId = radioConfig().streamId; + if (content?.id !== streamId) return; + + if (type === "stream:playing") { r8dioPlaying = true; r8dioLoading = false; r8dioError = null; } - - if (type === "stream:paused" && content.id === R8DIO_STREAM_ID) { + + if (type === "stream:paused") { r8dioPlaying = false; } - - if (type === "stream:stopped" && content.id === R8DIO_STREAM_ID) { + + if (type === "stream:stopped") { r8dioPlaying = false; r8dioLoading = false; } - - if (type === "stream:error" && content.id === R8DIO_STREAM_ID) { + + if (type === "stream:error") { r8dioPlaying = false; r8dioLoading = false; r8dioError = content.error; } - - if (type === "stream:frequencies-data" && content.id === R8DIO_STREAM_ID) { + + if (type === "stream:frequencies-data") { const data = content.data || []; if (data.length > 0 && data.some(v => v > 0)) { r8dioFrequencyData = data; @@ -3622,8 +3695,8 @@ function receive({ type, content }) { r8dioFrequencyData = []; } } - - if (type === "stream:waveform-data" && content.id === R8DIO_STREAM_ID) { + + if (type === "stream:waveform-data") { r8dioWaveformData = content.data || []; } } @@ -3637,50 +3710,49 @@ export { boot, paint, act, sim, receive }; // 📚 Library // (Useful functions used throughout the piece) -// 📻 Fetch r8dio track metadata +// 📻 Fetch current track metadata for the active station async function fetchR8dioMetadata(net) { r8dioLastMetadataFetch = Date.now(); + const cfg = radioConfig(); try { - const response = await fetch(R8DIO_METADATA_URL); + const response = await fetch(cfg.metadataUrl); if (response.ok) { const data = await response.json(); - if (data.current_track && data.current_track.title) { - r8dioTrack = data.current_track.title; - } + const track = cfg.parseTrack?.(data); + if (track) r8dioTrack = track; } } catch (err) { // Silently fail - metadata is optional - console.log("📻 Could not fetch r8dio metadata:", err.message); + console.log("📻 Could not fetch radio metadata:", err.message); } } -// 📻 R8dio playback control +// 📻 Radio playback control function toggleR8dioPlayback(send) { if (r8dioLoading) return; - + const cfg = radioConfig(); + if (r8dioPlaying) { - // Pause - send({ type: "stream:pause", content: { id: R8DIO_STREAM_ID } }); + send({ type: "stream:pause", content: { id: cfg.streamId } }); } else { - // Play r8dioLoading = true; r8dioError = null; - send({ - type: "stream:play", - content: { - id: R8DIO_STREAM_ID, - url: R8DIO_STREAM_URL, - volume: r8dioVolume - } + send({ + type: "stream:play", + content: { + id: cfg.streamId, + url: cfg.streamUrl, + volume: r8dioVolume, + }, }); } } -// 📻 R8dio volume control +// 📻 Radio volume control function setR8dioVolume(vol, send) { r8dioVolume = Math.max(0, Math.min(1, vol)); if (r8dioPlaying && send) { - send({ type: "stream:volume", content: { id: R8DIO_STREAM_ID, volume: r8dioVolume } }); + send({ type: "stream:volume", content: { id: radioConfig().streamId, volume: r8dioVolume } }); } } @@ -4577,8 +4649,8 @@ function paintNewsTicker($, theme) { const newsPrefix = "News"; const uniformLabelWidth = 28; // Fixed width to match both labels - // Ticker dimensions - TWO ROWS - const tickerMaxWidth = 180; + // Ticker dimensions - TWO ROWS. Width auto-expands to fill space + // between the HUD label and the right edge. const tickerRight = screen.width - rightMargin; const tickerY = 2; // Top row Y position const row2Y = tickerY + tickerHeight + rowSpacing; // Second row Y @@ -4613,18 +4685,9 @@ function paintNewsTicker($, theme) { const hudLabelRight = hudLabelOffset + hudLabelWidth; const minGapAfterHud = 10; // Minimum spacing between HUD label and News ticker - // Position calculations - ensure News ticker starts after HUD label + // Position calculations - flush against HUD label on the left, screen edge on the right const scrollAreaRight = tickerRight; - const idealScrollAreaLeft = scrollAreaRight - tickerMaxWidth; - const idealNewsBgX = idealScrollAreaLeft - uniformLabelWidth; - - // Push News ticker to the right if it would overlap the HUD label - const newsBgX = Math.max( - hudLabelRight + minGapAfterHud, // Don't overlap HUD label - idealNewsBgX // Original position - ); - - // Recalculate scroll area left edge based on actual News ticker position + const newsBgX = hudLabelRight + minGapAfterHud; const scrollAreaLeft = newsBgX + uniformLabelWidth; // Colors from theme @@ -4713,158 +4776,137 @@ function paintNewsTicker($, theme) { } } -// 📻 R8dio mini-player bar for laer-klokken (styled like News ticker) +// 📻 Radio mini-player bar (styled like News ticker). Station picked by options.radio. function paintR8dioPlayer($, theme) { const { ink, screen, help, hud } = $; - + const cfg = radioConfig(); + // Initialize bars if needed if (r8dioBars.length === 0) { for (let i = 0; i < R8DIO_BAR_COUNT; i++) { r8dioBars.push({ height: 0, targetHeight: 0 }); } } - + const tickerCharWidth = 4; // MatrixChunky8 char width const tickerHeight = 8; - const tickerPadding = 3; const rightMargin = 0; // Flush right, no margin - - // "r8Dio" prefix styling - uniform width with News label - const r8dioPrefix = "r8Dio"; - const uniformLabelWidth = 28; // Fixed width to match both labels - - // Bar dimensions - match news ticker width - const tickerMaxWidth = 180; + + // Uniform label width matches News label + const uniformLabelWidth = 28; + + // Match news ticker height so we can sit directly beneath it without overlap. + // News ticker total height = (tickerHeight * 2) + rowSpacing(2) + 4 = 22, drawn from y=0. + const newsTotalHeight = (tickerHeight * 2) + 2 + 4; const tickerRight = screen.width - rightMargin; - const tickerY = 14; // Right below news ticker (at y=2, ~12px tall) - + const tickerY = newsTotalHeight + 4; // 4px gap below news ticker + // Calculate HUD label right edge to avoid overlap (same as news ticker) const hudLabelOffset = 6; const hudLabelWidth = hud?.currentLabel?.()?.btn?.box?.w || 0; const hudLabelRight = hudLabelOffset + hudLabelWidth; const minGapAfterHud = 10; - - // Position calculations + + // Position calculations - auto-widen: flush against HUD label on left, screen edge on right const scrollAreaRight = tickerRight; - const idealScrollAreaLeft = scrollAreaRight - tickerMaxWidth; - const idealR8dioBgX = idealScrollAreaLeft - uniformLabelWidth; - - const r8dioBgX = Math.max(hudLabelRight + minGapAfterHud, idealR8dioBgX); + const r8dioBgX = hudLabelRight + minGapAfterHud; const contentAreaLeft = r8dioBgX + uniformLabelWidth; const actualContentWidth = scrollAreaRight - contentAreaLeft; const totalWidth = uniformLabelWidth + actualContentWidth + 1; - + // Store bounds for click detection (entire bar) r8dioPlayerBounds = { x: r8dioBgX, y: tickerY - 2, w: totalWidth, h: tickerHeight + 4 }; - - // "r8Dio" label background - dark with orange text (radio style) - const labelBgColor = r8dioHovered ? [50, 35, 25] : [35, 25, 18]; // Dark warm brown - const labelFgColor = r8dioHovered ? [255, 180, 80] : [255, 150, 50]; // Orange - + + // Label background + foreground from station config + const labelBgColor = r8dioHovered ? cfg.labelBgHover : cfg.labelBg; + const labelFgColor = r8dioHovered ? cfg.labelFgHover : cfg.labelFg; + ink(...labelBgColor, 230).box(r8dioBgX, tickerY - 2, uniformLabelWidth + 1, tickerHeight + 4); - - // Draw "r8Dio" in orange, centered in label area - const labelTextWidth = r8dioPrefix.length * tickerCharWidth; + + // Centered station label + const labelTextWidth = cfg.label.length * tickerCharWidth; const labelX = r8dioBgX + Math.floor((uniformLabelWidth - labelTextWidth) / 2); - ink(...labelFgColor).write("r", { x: labelX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); - ink(...labelFgColor).write("8D", { x: labelX + 4, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); - ink(...labelFgColor).write("io", { x: labelX + 12, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); - - // Content area background - dark (brighter on hover) - const contentBgColor = r8dioHovered ? [40, 30, 25] : [28, 22, 18]; // Dark warm + ink(...labelFgColor).write(cfg.label, { x: labelX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); + + // Content area background + const contentBgColor = r8dioHovered ? cfg.contentBgHover : cfg.contentBg; ink(...contentBgColor, 200).box(contentAreaLeft, tickerY - 2, actualContentWidth + 1, tickerHeight + 4); - - // Separator line between News and r8Dio (subtle) - ink(80, 60, 50, 150).box(r8dioBgX, tickerY - 3, totalWidth, 1); - - // Hover underline indicator (orange) + + // Subtle separator line at the top of the bar + ink(...cfg.separator).box(r8dioBgX, tickerY - 3, totalWidth, 1); + + // Hover underline indicator if (r8dioHovered) { - ink(255, 150, 50, 180).box(r8dioBgX, tickerY + tickerHeight + 1, totalWidth, 1); + ink(...cfg.underline).box(r8dioBgX, tickerY + tickerHeight + 1, totalWidth, 1); } - - // Play/Pause button right after label (not far right) + + // Play/Pause button right after label const btnSize = 10; const btnX = contentAreaLeft + 2; const btnY = tickerY - 1; - + r8dioPlayBtnBounds = { x: btnX - 2, y: btnY - 2, w: btnSize + 4, h: btnSize + 4 }; - - // Button background (orange tint) - const btnBg = r8dioPlayHovered ? [80, 55, 35] : [55, 40, 25]; + + const btnBg = r8dioPlayHovered ? cfg.buttonBgHover : cfg.buttonBg; ink(...btnBg).box(btnX, btnY, btnSize, btnSize); - ink(r8dioPlayHovered ? [140, 100, 60] : [100, 70, 45]).box(btnX, btnY, btnSize, btnSize, "outline"); - - // Play/Pause/Loading icon (orange) - const iconColor = r8dioPlayHovered ? [255, 200, 120] : [255, 160, 80]; + ink(...(r8dioPlayHovered ? cfg.buttonOutlineHover : cfg.buttonOutline)).box(btnX, btnY, btnSize, btnSize, "outline"); + + const iconColor = r8dioPlayHovered ? cfg.iconColorHover : cfg.iconColor; const iconCenterX = btnX + btnSize / 2; const iconCenterY = btnY + btnSize / 2; - + if (r8dioLoading) { - // Simple loading indicator (blinking dot) const phase = Math.floor((help?.repeat || 0) / 15) % 2; - ink(255, 200, 100, phase ? 255 : 100).box(iconCenterX - 1, iconCenterY - 1, 3, 3); + ink(...cfg.loadingColor, phase ? 255 : 100).box(iconCenterX - 1, iconCenterY - 1, 3, 3); } else if (r8dioPlaying) { - // Pause icon (two small bars) ink(...iconColor).box(iconCenterX - 3, iconCenterY - 3, 2, 6); ink(...iconColor).box(iconCenterX + 1, iconCenterY - 3, 2, 6); } else { - // Play icon (small triangle) ink(...iconColor).box(iconCenterX - 2, iconCenterY - 3, 2, 6); ink(...iconColor).box(iconCenterX, iconCenterY - 2, 2, 4); ink(...iconColor).box(iconCenterX + 2, iconCenterY - 1, 1, 2); } - - // Content: mini visualizer bars + status text (after play button) + + // Mini visualizer bars const barAreaX = btnX + btnSize + 4; const barAreaWidth = Math.min(60, actualContentWidth - btnSize - 12); const barWidth = Math.max(1, Math.floor(barAreaWidth / R8DIO_BAR_COUNT) - 1); const maxBarHeight = tickerHeight; - - // Draw mini visualizer bars + for (let i = 0; i < R8DIO_BAR_COUNT; i++) { const bar = r8dioBars[i]; const x = barAreaX + i * (barWidth + 1); const height = Math.max(1, Math.floor(bar.height * maxBarHeight)); - + if (r8dioPlaying || bar.height > 0.05) { - // Orange gradient for visualizer - const t = bar.height; - const r = Math.floor(200 + t * 55); - const g = Math.floor(100 + t * 80); - const b = Math.floor(30 + t * 40); - ink(r, g, b).box(x, tickerY + maxBarHeight - height, barWidth, height); + ink(...cfg.barGradient(bar.height)).box(x, tickerY + maxBarHeight - height, barWidth, height); } else { - // Idle state: dim orange line - ink(80, 50, 30).box(x, tickerY + maxBarHeight - 1, barWidth, 1); + ink(...cfg.barIdle).box(x, tickerY + maxBarHeight - 1, barWidth, 1); } } - + // Status text after visualizer const statusX = barAreaX + barAreaWidth + 4; - const statusTextColor = theme?.messageText || [200, 200, 200]; const statusEndX = scrollAreaRight - 2; - + if (r8dioError) { ink(255, 100, 100).write("err", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); } else if (r8dioLoading) { - ink(255, 180, 80).write("...", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); + ink(...cfg.statusColor).write("...", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); } else if (r8dioPlaying) { - // Show truncated track or "live" (orange text) const maxLen = Math.floor((statusEndX - statusX) / tickerCharWidth); - const text = r8dioTrack + const text = r8dioTrack ? (r8dioTrack.length > maxLen ? r8dioTrack.substring(0, maxLen - 1) + "…" : r8dioTrack) : "live"; - ink(255, 180, 80).write(text, { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); + ink(...cfg.statusColor).write(text, { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); } else { - // Blinking "Listen Now" with > < arrows const blink = Math.floor((help?.repeat || 0) / 20) % 2; - const arrowColor = blink ? [255, 180, 80] : [120, 90, 60]; // Orange blink - const textColor = [180, 140, 100]; + const arrowColor = blink ? cfg.statusIdleOn : cfg.statusIdleOff; ink(...arrowColor).write(">", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); - ink(...textColor).write("Listen Now", { x: statusX + 6, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); + ink(...cfg.statusDim).write("Listen Now", { x: statusX + 6, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); ink(...arrowColor).write("<", { x: statusX + 46, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); } - - // Volume slider removed for slim design - could add keyboard shortcuts later + + // Volume slider removed for slim design r8dioVolSliderBounds = null; } diff --git a/system/public/aesthetic.computer/disks/laer-klokken.mjs b/system/public/aesthetic.computer/disks/laer-klokken.mjs index 9f1af94d3..f53450b91 100644 --- a/system/public/aesthetic.computer/disks/laer-klokken.mjs +++ b/system/public/aesthetic.computer/disks/laer-klokken.mjs @@ -26,6 +26,7 @@ function paint($) { // Custom warm color theme for laer-klokken chat chat.paint($, { otherChat: client.system, + radio: "r8dio", theme: { background: [180, 100, 60], // Warm terracotta/rust background lines: [220, 150, 100, 64], // Soft peach lines diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index 4bd6fbe1b..1e6ca546b 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -199,7 +199,7 @@ let giveBtnParticles = []; // 🎰 Top-right slot: A/B test — pick one randomly on page load const TOP_RIGHT_BTN_CHOICES = ["give", "ad", "os", "products", "blank"]; // const topRightBtnChoice = TOP_RIGHT_BTN_CHOICES[Math.floor(Math.random() * TOP_RIGHT_BTN_CHOICES.length)]; -const topRightBtnChoice = "blank"; // Only show laptop bumper for now +const topRightBtnChoice = "products"; // Curtain product carousel (cycles live Shopify items) let clearBtn; // 🧹 "Blank" button (fixed top-right, appears at 32+ chars) let clearBtnConfirming = false; // Two-tap confirmation state diff --git a/system/public/aesthetic.computer/lib/shop.mjs b/system/public/aesthetic.computer/lib/shop.mjs index b1d0e3d3d..54127703f 100644 --- a/system/public/aesthetic.computer/lib/shop.mjs +++ b/system/public/aesthetic.computer/lib/shop.mjs @@ -26,6 +26,9 @@ const signed = [ "25.12.4.11.23", "25.12.4.11.24", "25.12.4.11.25", + // 💻 Laptops + "26.4.17.12.51", + "blank", // Alias for the AC Native Laptop — "shop blank" in the prompt. ]; export { signed } -- 2.51.2