Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

feat(native): add wasm prompt preview route

+1577
+34
fedac/native/wasm/README.md
··· 1 + # AC Native WASM Prototype 2 + 3 + This is a small browser-hosted prototype for running a slice of `ac-native` 4 + offline in a single HTML file. 5 + 6 + Current shape: 7 + 8 + - Boots the real native [`prompt.mjs`](/workspaces/aesthetic-computer/fedac/native/pieces/prompt.mjs) piece 9 + - Uses a tiny WebAssembly raster core for framebuffer clears and boxes 10 + - Uses browser shims for keyboard input, config storage, theme, wifi, and piece jumps 11 + - Generates a single offline HTML artifact 12 + 13 + Build: 14 + 15 + ```bash 16 + cd /workspaces/aesthetic-computer/fedac/native/wasm 17 + npm install 18 + npm run build 19 + ``` 20 + 21 + Output: 22 + 23 + - `/workspaces/aesthetic-computer/fedac/native/build/ac-native-prompt-offline.html` 24 + - `/workspaces/aesthetic-computer/system/public/ac-native-wasm/index.html` 25 + 26 + Production route: 27 + 28 + - `https://aesthetic.computer/ac-native-wasm/` 29 + 30 + Notes: 31 + 32 + - This is intentionally not full `ac-native` parity yet. 33 + - Text rendering is still done by the browser canvas host. 34 + - Native-only features like raw WiFi control, PTY, power management, and Linux device access are stubbed.
+902
fedac/native/wasm/build-offline-page.mjs
··· 1 + import fs from "node:fs/promises"; 2 + import path from "node:path"; 3 + import { fileURLToPath } from "node:url"; 4 + import wabtFactory from "wabt"; 5 + 6 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 7 + const nativeRoot = path.resolve(__dirname, ".."); 8 + const promptPath = path.join(nativeRoot, "pieces", "prompt.mjs"); 9 + const outputDir = path.join(nativeRoot, "build"); 10 + const outputPath = path.join(outputDir, "ac-native-prompt-offline.html"); 11 + const publicOutputDir = path.resolve(nativeRoot, "..", "..", "system", "public", "ac-native-wasm"); 12 + const publicOutputPath = path.join(publicOutputDir, "index.html"); 13 + 14 + const promptSource = await fs.readFile(promptPath, "utf8"); 15 + const wabt = await wabtFactory(); 16 + 17 + const watSource = String.raw`(module 18 + (memory (export "memory") 80) 19 + (global $width (mut i32) (i32.const 0)) 20 + (global $height (mut i32) (i32.const 0)) 21 + (global $ink_r (mut i32) (i32.const 255)) 22 + (global $ink_g (mut i32) (i32.const 255)) 23 + (global $ink_b (mut i32) (i32.const 255)) 24 + (global $ink_a (mut i32) (i32.const 255)) 25 + 26 + (func (export "init") (param $w i32) (param $h i32) (result i32) 27 + local.get $w 28 + global.set $width 29 + local.get $h 30 + global.set $height 31 + i32.const 0 32 + ) 33 + 34 + (func (export "set_ink") (param $r i32) (param $g i32) (param $b i32) (param $a i32) 35 + local.get $r 36 + global.set $ink_r 37 + local.get $g 38 + global.set $ink_g 39 + local.get $b 40 + global.set $ink_b 41 + local.get $a 42 + global.set $ink_a 43 + ) 44 + 45 + (func $pixel_offset (param $x i32) (param $y i32) (result i32) 46 + local.get $y 47 + global.get $width 48 + i32.mul 49 + local.get $x 50 + i32.add 51 + i32.const 4 52 + i32.mul 53 + ) 54 + 55 + (func $blend (param $dst i32) (param $src i32) (param $a i32) (result i32) 56 + local.get $src 57 + local.get $a 58 + i32.mul 59 + local.get $dst 60 + i32.const 255 61 + local.get $a 62 + i32.sub 63 + i32.mul 64 + i32.add 65 + i32.const 127 66 + i32.add 67 + i32.const 255 68 + i32.div_u 69 + ) 70 + 71 + (func (export "clear") 72 + (local $i i32) 73 + (local $total i32) 74 + (local $addr i32) 75 + 76 + global.get $width 77 + global.get $height 78 + i32.mul 79 + local.set $total 80 + 81 + block $done 82 + loop $loop 83 + local.get $i 84 + local.get $total 85 + i32.ge_u 86 + br_if $done 87 + 88 + local.get $i 89 + i32.const 4 90 + i32.mul 91 + local.set $addr 92 + 93 + local.get $addr 94 + global.get $ink_r 95 + i32.store8 96 + 97 + local.get $addr 98 + i32.const 1 99 + i32.add 100 + global.get $ink_g 101 + i32.store8 102 + 103 + local.get $addr 104 + i32.const 2 105 + i32.add 106 + global.get $ink_b 107 + i32.store8 108 + 109 + local.get $addr 110 + i32.const 3 111 + i32.add 112 + i32.const 255 113 + i32.store8 114 + 115 + local.get $i 116 + i32.const 1 117 + i32.add 118 + local.set $i 119 + br $loop 120 + end 121 + end 122 + ) 123 + 124 + (func (export "fill_rect") (param $x i32) (param $y i32) (param $w i32) (param $h i32) 125 + (local $yy i32) 126 + (local $xx i32) 127 + (local $px i32) 128 + (local $py i32) 129 + (local $addr i32) 130 + (local $dr i32) 131 + (local $dg i32) 132 + (local $db i32) 133 + 134 + block $outer_done 135 + loop $outer 136 + local.get $yy 137 + local.get $h 138 + i32.ge_u 139 + br_if $outer_done 140 + 141 + i32.const 0 142 + local.set $xx 143 + 144 + block $inner_done 145 + loop $inner 146 + local.get $xx 147 + local.get $w 148 + i32.ge_u 149 + br_if $inner_done 150 + 151 + local.get $x 152 + local.get $xx 153 + i32.add 154 + local.set $px 155 + 156 + local.get $y 157 + local.get $yy 158 + i32.add 159 + local.set $py 160 + 161 + local.get $px 162 + local.get $py 163 + call $pixel_offset 164 + local.set $addr 165 + 166 + global.get $ink_a 167 + i32.const 255 168 + i32.eq 169 + if 170 + local.get $addr 171 + global.get $ink_r 172 + i32.store8 173 + 174 + local.get $addr 175 + i32.const 1 176 + i32.add 177 + global.get $ink_g 178 + i32.store8 179 + 180 + local.get $addr 181 + i32.const 2 182 + i32.add 183 + global.get $ink_b 184 + i32.store8 185 + 186 + local.get $addr 187 + i32.const 3 188 + i32.add 189 + i32.const 255 190 + i32.store8 191 + else 192 + local.get $addr 193 + i32.load8_u 194 + local.set $dr 195 + 196 + local.get $addr 197 + i32.const 1 198 + i32.add 199 + i32.load8_u 200 + local.set $dg 201 + 202 + local.get $addr 203 + i32.const 2 204 + i32.add 205 + i32.load8_u 206 + local.set $db 207 + 208 + local.get $addr 209 + local.get $dr 210 + global.get $ink_r 211 + global.get $ink_a 212 + call $blend 213 + i32.store8 214 + 215 + local.get $addr 216 + i32.const 1 217 + i32.add 218 + local.get $dg 219 + global.get $ink_g 220 + global.get $ink_a 221 + call $blend 222 + i32.store8 223 + 224 + local.get $addr 225 + i32.const 2 226 + i32.add 227 + local.get $db 228 + global.get $ink_b 229 + global.get $ink_a 230 + call $blend 231 + i32.store8 232 + 233 + local.get $addr 234 + i32.const 3 235 + i32.add 236 + i32.const 255 237 + i32.store8 238 + end 239 + 240 + local.get $xx 241 + i32.const 1 242 + i32.add 243 + local.set $xx 244 + br $inner 245 + end 246 + end 247 + 248 + local.get $yy 249 + i32.const 1 250 + i32.add 251 + local.set $yy 252 + br $outer 253 + end 254 + end 255 + ) 256 + )`; 257 + 258 + const wasmModule = wabt.parseWat("raster.wat", watSource); 259 + const { buffer } = wasmModule.toBinary({ log: false, write_debug_names: true }); 260 + const wasmBase64 = Buffer.from(buffer).toString("base64"); 261 + 262 + const html = `<!doctype html> 263 + <html lang="en"> 264 + <head> 265 + <meta charset="utf-8"> 266 + <meta name="viewport" content="width=device-width, initial-scale=1"> 267 + <title>AC Native Prompt WASM Prototype</title> 268 + <style> 269 + :root { 270 + --bg: #111013; 271 + --panel: rgba(255, 255, 255, 0.06); 272 + --border: rgba(255, 255, 255, 0.14); 273 + --text: #f3e9ef; 274 + --muted: #baa9b5; 275 + --accent: #ff6b9d; 276 + } 277 + * { box-sizing: border-box; } 278 + html, body { height: 100%; } 279 + body { 280 + margin: 0; 281 + display: grid; 282 + place-items: center; 283 + background: 284 + radial-gradient(circle at top, rgba(255, 107, 157, 0.18), transparent 34%), 285 + radial-gradient(circle at bottom right, rgba(93, 209, 196, 0.16), transparent 28%), 286 + linear-gradient(180deg, #17131a 0%, #0e0c10 100%); 287 + color: var(--text); 288 + font-family: "Iosevka Aile", "IBM Plex Sans", ui-sans-serif, sans-serif; 289 + } 290 + .shell { 291 + width: min(96vw, 1120px); 292 + padding: 18px; 293 + border: 1px solid var(--border); 294 + border-radius: 20px; 295 + background: rgba(10, 8, 12, 0.82); 296 + box-shadow: 0 32px 90px rgba(0, 0, 0, 0.45); 297 + backdrop-filter: blur(18px); 298 + } 299 + .bar { 300 + display: flex; 301 + align-items: center; 302 + justify-content: space-between; 303 + gap: 16px; 304 + margin-bottom: 14px; 305 + } 306 + .title { 307 + font-size: 14px; 308 + letter-spacing: 0.08em; 309 + text-transform: uppercase; 310 + color: var(--muted); 311 + } 312 + .hint { 313 + font-size: 13px; 314 + color: var(--muted); 315 + } 316 + .hint strong { color: var(--text); font-weight: 600; } 317 + .stage { 318 + position: relative; 319 + width: 100%; 320 + aspect-ratio: 3 / 2; 321 + border-radius: 14px; 322 + overflow: hidden; 323 + border: 1px solid rgba(255, 255, 255, 0.08); 324 + background: #0e0d11; 325 + } 326 + canvas { 327 + width: 100%; 328 + height: 100%; 329 + display: block; 330 + image-rendering: pixelated; 331 + image-rendering: crisp-edges; 332 + } 333 + .status { 334 + display: flex; 335 + justify-content: space-between; 336 + gap: 16px; 337 + margin-top: 12px; 338 + font-size: 12px; 339 + color: var(--muted); 340 + } 341 + .status strong { color: var(--accent); } 342 + </style> 343 + </head> 344 + <body> 345 + <div class="shell"> 346 + <div class="bar"> 347 + <div class="title">AC Native Prompt • WASM Offline Prototype</div> 348 + <div class="hint"><strong>Type</strong> in the canvas. <strong>Esc</strong> clears. <strong>Tab</strong> completes. <strong>Backspace</strong> returns from stub pieces.</div> 349 + </div> 350 + <div class="stage"> 351 + <canvas id="screen" width="960" height="640" tabindex="0" aria-label="AC Native Prompt WASM Prototype"></canvas> 352 + </div> 353 + <div class="status"> 354 + <div id="status-left">booting...</div> 355 + <div id="status-right">single-file offline html</div> 356 + </div> 357 + </div> 358 + <script type="module"> 359 + const WASM_BASE64 = ${JSON.stringify(wasmBase64)}; 360 + const PROMPT_SOURCE = ${JSON.stringify(promptSource)}; 361 + const screen = document.getElementById("screen"); 362 + const ctx = screen.getContext("2d", { alpha: false }); 363 + ctx.imageSmoothingEnabled = false; 364 + 365 + const statusLeft = document.getElementById("status-left"); 366 + const statusRight = document.getElementById("status-right"); 367 + 368 + function bytesFromBase64(base64) { 369 + const text = atob(base64); 370 + const bytes = new Uint8Array(text.length); 371 + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i); 372 + return bytes; 373 + } 374 + 375 + function clone(value) { 376 + return value == null ? value : JSON.parse(JSON.stringify(value)); 377 + } 378 + 379 + const wasmBytes = bytesFromBase64(WASM_BASE64); 380 + const { instance } = await WebAssembly.instantiate(wasmBytes, {}); 381 + const wasm = instance.exports; 382 + 383 + const WIDTH = screen.width; 384 + const HEIGHT = screen.height; 385 + const bufferPtr = wasm.init(WIDTH, HEIGHT); 386 + const pixelView = new Uint8ClampedArray(wasm.memory.buffer, bufferPtr, WIDTH * HEIGHT * 4); 387 + const imageData = new ImageData(pixelView, WIDTH, HEIGHT); 388 + 389 + globalThis.__theme = (function() { 390 + function getLAOffset() { 391 + const d = new Date(); 392 + const m = d.getUTCMonth(); 393 + const y = d.getUTCFullYear(); 394 + if (m > 2 && m < 10) return 7; 395 + if (m < 2 || m > 10) return 8; 396 + if (m === 2) { 397 + const mar1 = new Date(y, 2, 1); 398 + const ss = 8 + (7 - mar1.getDay()) % 7; 399 + return d.getUTCDate() > ss || (d.getUTCDate() === ss && d.getUTCHours() >= 10) ? 7 : 8; 400 + } 401 + const nov1 = new Date(y, 10, 1); 402 + const fs = 1 + (7 - nov1.getDay()) % 7; 403 + return d.getUTCDate() < fs || (d.getUTCDate() === fs && d.getUTCHours() < 9) ? 7 : 8; 404 + } 405 + function getLAHour() { 406 + return (new Date().getUTCHours() - getLAOffset() + 24) % 24; 407 + } 408 + const t = { dark: true, _lastCheck: 0, _overrideId: null, _override: null }; 409 + t.presets = { 410 + serious: { 411 + label: "serious", desc: "black & white", 412 + dark: { bg:[0,0,0], bgAlt:[10,10,10], bgDim:[0,0,0], fg:255, fgDim:160, fgMute:90, bar:[15,15,15], border:[60,60,60], accent:[128,128,128], ok:[200,200,200], err:[255,100,100], warn:[200,200,100], link:[180,180,255], pad:[10,10,10], padSharp:[5,5,5], padLine:[40,40,40], cursor:[255,255,255] }, 413 + light: { bg:[255,255,255], bgAlt:[245,245,245], bgDim:[235,235,235], fg:0, fgDim:80, fgMute:160, bar:[240,240,240], border:[180,180,180], accent:[100,100,100], ok:[40,40,40], err:[180,40,40], warn:[120,100,20], link:[40,40,180], pad:[245,245,245], padSharp:[230,230,230], padLine:[200,200,200], cursor:[0,0,0] } 414 + }, 415 + neo: { 416 + label: "neo", desc: "lime & black", 417 + dark: { bg:[0,0,0], bgAlt:[5,10,5], bgDim:[0,0,0], fg:200, fgDim:120, fgMute:60, bar:[5,15,5], border:[0,80,0], accent:[0,200,80], ok:[0,255,0], err:[255,50,50], warn:[200,255,0], link:[0,180,255], pad:[5,10,5], padSharp:[0,5,0], padLine:[0,50,0], cursor:[0,255,80] }, 418 + light: { bg:[220,255,220], bgAlt:[230,255,230], bgDim:[200,240,200], fg:10, fgDim:60, fgMute:120, bar:[200,240,200], border:[100,180,100], accent:[0,140,60], ok:[0,120,40], err:[180,30,30], warn:[120,140,0], link:[0,80,180], pad:[210,245,210], padSharp:[190,230,190], padLine:[140,200,140], cursor:[0,120,40] } 419 + }, 420 + ember: { 421 + label: "ember", desc: "warm amber", 422 + dark: { bg:[20,12,8], bgAlt:[28,18,12], bgDim:[14,8,5], fg:220, fgDim:150, fgMute:90, bar:[35,20,12], border:[60,35,20], accent:[255,140,40], ok:[120,220,80], err:[255,70,50], warn:[255,200,60], link:[255,180,100], pad:[28,18,12], padSharp:[18,10,6], padLine:[55,35,22], cursor:[255,120,30] }, 423 + light: { bg:[255,245,230], bgAlt:[255,250,240], bgDim:[245,235,218], fg:40, fgDim:90, fgMute:140, bar:[245,232,215], border:[210,190,160], accent:[200,100,20], ok:[40,140,50], err:[190,40,30], warn:[180,120,20], link:[180,90,20], pad:[250,240,225], padSharp:[238,225,208], padLine:[220,200,175], cursor:[200,90,15] } 424 + } 425 + }; 426 + t.apply = function(id) { 427 + if (!id || id === "default") { 428 + t._overrideId = null; 429 + t._override = null; 430 + } else if (t.presets[id]) { 431 + t._overrideId = id; 432 + t._override = t.presets[id]; 433 + } 434 + t._lastCheck = 0; 435 + return t.update(); 436 + }; 437 + t.update = function() { 438 + const now = Date.now(); 439 + if (now - t._lastCheck < 5000) return t; 440 + t._lastCheck = now; 441 + const h = getLAHour(); 442 + t.dark = (t._forceDark !== undefined) ? !!t._forceDark : (h >= 20 || h < 7); 443 + t.hour = h; 444 + t.bg = t.dark ? [20, 20, 25] : [240, 238, 232]; 445 + t.bgAlt = t.dark ? [28, 28, 30] : [250, 248, 244]; 446 + t.bgDim = t.dark ? [15, 15, 18] : [230, 228, 222]; 447 + t.fg = t.dark ? 220 : 40; 448 + t.fgDim = t.dark ? 140 : 100; 449 + t.fgMute = t.dark ? 80 : 150; 450 + t.bar = t.dark ? [35, 20, 30] : [225, 220, 215]; 451 + t.border = t.dark ? [55, 35, 45] : [200, 195, 190]; 452 + t.accent = t.dark ? [200, 100, 140] : [180, 60, 120]; 453 + t.ok = t.dark ? [80, 255, 120] : [30, 160, 60]; 454 + t.err = t.dark ? [255, 85, 85] : [200, 40, 40]; 455 + t.warn = t.dark ? [255, 200, 60] : [180, 120, 20]; 456 + t.link = t.dark ? [120, 200, 255] : [40, 100, 200]; 457 + t.pad = t.dark ? [28, 28, 30] : [250, 248, 244]; 458 + t.padSharp = t.dark ? [18, 18, 20] : [235, 232, 228]; 459 + t.padLine = t.dark ? [50, 50, 55] : [210, 205, 200]; 460 + t.cursor = t.dark ? [220, 80, 140] : [180, 50, 110]; 461 + if (t._override) { 462 + const mode = t.dark ? t._override.dark : t._override.light; 463 + if (mode) { 464 + for (const key of Object.keys(mode)) t[key] = mode[key]; 465 + } 466 + } 467 + return t; 468 + }; 469 + t.update(); 470 + return t; 471 + })(); 472 + 473 + function makeStorage() { 474 + const fallback = new Map(); 475 + return { 476 + get(key) { 477 + try { 478 + return localStorage.getItem(key); 479 + } catch (_) { 480 + return fallback.has(key) ? fallback.get(key) : null; 481 + } 482 + }, 483 + set(key, value) { 484 + try { 485 + localStorage.setItem(key, value); 486 + } catch (_) { 487 + fallback.set(key, value); 488 + } 489 + } 490 + }; 491 + } 492 + 493 + const storage = makeStorage(); 494 + const storageKey = "ac-native-wasm:files"; 495 + 496 + function loadFiles() { 497 + const raw = storage.get(storageKey); 498 + if (!raw) { 499 + return { 500 + "/mnt/config.json": JSON.stringify({ piece: "prompt", darkMode: "auto" }, null, 2), 501 + "/mnt/wifi_creds.json": "[]" 502 + }; 503 + } 504 + try { 505 + return JSON.parse(raw); 506 + } catch (_) { 507 + return { 508 + "/mnt/config.json": JSON.stringify({ piece: "prompt", darkMode: "auto" }, null, 2), 509 + "/mnt/wifi_creds.json": "[]" 510 + }; 511 + } 512 + } 513 + 514 + const files = loadFiles(); 515 + function persistFiles() { 516 + storage.set(storageKey, JSON.stringify(files)); 517 + } 518 + 519 + const state = { 520 + currentPieceName: "prompt", 521 + currentPiece: null, 522 + currentPieceSpec: "prompt", 523 + currentParams: [], 524 + currentColon: [], 525 + paintCount: 0, 526 + jumpMessage: "", 527 + lastPost: "", 528 + statusText: "booting native prompt", 529 + events: [], 530 + focused: false 531 + }; 532 + 533 + const wifi = { 534 + connected: false, 535 + state: 0, 536 + networks: [], 537 + _connectTimer: null, 538 + scan() { 539 + wifi.state = 3; 540 + setTimeout(() => { 541 + wifi.networks = [ 542 + { ssid: "aesthetic.computer", signal: 92, encrypted: true }, 543 + { ssid: "offline.lab", signal: 44, encrypted: false } 544 + ]; 545 + wifi.state = 0; 546 + }, 120); 547 + }, 548 + connect(ssid, pass) { 549 + wifi.state = 4; 550 + clearTimeout(wifi._connectTimer); 551 + wifi._connectTimer = setTimeout(() => { 552 + wifi.connected = true; 553 + wifi.state = 0; 554 + wifi.networks = [{ ssid, signal: 96, encrypted: !!pass }]; 555 + }, 280); 556 + }, 557 + disconnect() { 558 + clearTimeout(wifi._connectTimer); 559 + wifi.connected = false; 560 + wifi.state = 0; 561 + } 562 + }; 563 + 564 + function normalizeKey(event) { 565 + const key = event.key; 566 + if (key === " ") return "space"; 567 + if (key === "Enter") return "enter"; 568 + if (key === "Backspace") return "backspace"; 569 + if (key === "Delete") return "delete"; 570 + if (key === "Escape") return "escape"; 571 + if (key === "Tab") return "tab"; 572 + if (key === "Shift") return "shift"; 573 + if (key === "ArrowLeft") return "arrowleft"; 574 + if (key === "ArrowRight") return "arrowright"; 575 + if (key === "ArrowUp") return "arrowup"; 576 + if (key === "ArrowDown") return "arrowdown"; 577 + if (key === "Home") return "home"; 578 + if (key === "End") return "end"; 579 + return typeof key === "string" ? key.toLowerCase() : ""; 580 + } 581 + 582 + function makeEvent(type, key) { 583 + return { 584 + key, 585 + type, 586 + is(match) { 587 + return type === match; 588 + } 589 + }; 590 + } 591 + 592 + const blockedKeys = new Set([ 593 + "space", "enter", "backspace", "delete", "escape", "tab", 594 + "arrowleft", "arrowright", "arrowup", "arrowdown", "home", "end" 595 + ]); 596 + 597 + screen.addEventListener("click", () => screen.focus()); 598 + screen.addEventListener("focus", () => { state.focused = true; }); 599 + screen.addEventListener("blur", () => { state.focused = false; }); 600 + window.addEventListener("keydown", (event) => { 601 + const key = normalizeKey(event); 602 + if (!key) return; 603 + if (blockedKeys.has(key) || key.length === 1 || key === "shift") event.preventDefault(); 604 + state.events.push(makeEvent(key === "shift" ? "keyboard:down:shift" : "keyboard:down", key)); 605 + }); 606 + window.addEventListener("keyup", (event) => { 607 + const key = normalizeKey(event); 608 + if (key === "shift") { 609 + event.preventDefault(); 610 + state.events.push(makeEvent("keyboard:up:shift", key)); 611 + } 612 + }); 613 + 614 + const rasterApi = (() => { 615 + let ink = [255, 255, 255, 255]; 616 + function setInk(r = 255, g = 255, b = 255, a = 255) { 617 + ink = [r | 0, g | 0, b | 0, a == null ? 255 : a | 0]; 618 + wasm.set_ink(ink[0], ink[1], ink[2], ink[3]); 619 + } 620 + function clampRect(x, y, w, h) { 621 + let rx = Math.trunc(x); 622 + let ry = Math.trunc(y); 623 + let rw = Math.trunc(w); 624 + let rh = Math.trunc(h); 625 + if (rw < 0) { rx += rw; rw = -rw; } 626 + if (rh < 0) { ry += rh; rh = -rh; } 627 + if (rx < 0) { rw += rx; rx = 0; } 628 + if (ry < 0) { rh += ry; ry = 0; } 629 + if (rx + rw > WIDTH) rw = WIDTH - rx; 630 + if (ry + rh > HEIGHT) rh = HEIGHT - ry; 631 + return rw > 0 && rh > 0 ? [rx, ry, rw, rh] : null; 632 + } 633 + return { 634 + wipe(r, g, b, a = 255) { 635 + setInk(r, g, b, a); 636 + wasm.clear(); 637 + }, 638 + ink(r, g, b, a = 255) { 639 + setInk(r, g, b, a); 640 + }, 641 + box(x, y, w, h, filled = true) { 642 + const rect = clampRect(x, y, w, h); 643 + if (!rect) return; 644 + if (filled) { 645 + wasm.fill_rect(rect[0], rect[1], rect[2], rect[3]); 646 + return; 647 + } 648 + wasm.fill_rect(rect[0], rect[1], rect[2], 1); 649 + wasm.fill_rect(rect[0], rect[1] + rect[3] - 1, rect[2], 1); 650 + wasm.fill_rect(rect[0], rect[1], 1, rect[3]); 651 + wasm.fill_rect(rect[0] + rect[2] - 1, rect[1], 1, rect[3]); 652 + } 653 + }; 654 + })(); 655 + 656 + const textQueue = []; 657 + function drawText(text, options = {}) { 658 + textQueue.push({ 659 + text: String(text), 660 + x: Math.trunc(options.x ?? 0), 661 + y: Math.trunc(options.y ?? 0), 662 + size: options.size ?? 1, 663 + font: options.font ?? "6x10", 664 + color: clone(currentInk) 665 + }); 666 + } 667 + 668 + let currentInk = [255, 255, 255, 255]; 669 + const hostApi = { 670 + wipe(r, g, b, a = 255) { 671 + currentInk = [r | 0, g | 0, b | 0, a | 0]; 672 + rasterApi.wipe(r, g, b, a); 673 + }, 674 + ink(r, g, b, a = 255) { 675 + currentInk = [r | 0, g | 0, b | 0, a | 0]; 676 + rasterApi.ink(r, g, b, a); 677 + }, 678 + box: rasterApi.box, 679 + write: drawText, 680 + screen: { width: WIDTH, height: HEIGHT }, 681 + paintCount: 0, 682 + wifi, 683 + sound: { 684 + speakCached() {}, 685 + speak() {} 686 + } 687 + }; 688 + 689 + function readConfig() { 690 + const raw = files["/mnt/config.json"]; 691 + if (!raw) return {}; 692 + try { 693 + return JSON.parse(raw); 694 + } catch (_) { 695 + return {}; 696 + } 697 + } 698 + 699 + const pieces = {}; 700 + function makeStubPiece(title, describe) { 701 + let lines = []; 702 + return { 703 + boot({ system, params, colon }) { 704 + lines = [ 705 + title, 706 + describe({ params, colon, system }), 707 + "This prototype is running the real native prompt piece,", 708 + "but other native pieces are still browser stubs.", 709 + "Press Backspace or Escape to return." 710 + ]; 711 + }, 712 + act({ event, system }) { 713 + if (event.is("keyboard:down") && (event.key === "backspace" || event.key === "escape")) { 714 + system.jump("prompt"); 715 + } 716 + }, 717 + sim() {}, 718 + paint({ wipe, ink, box, write, screen }) { 719 + const T = globalThis.__theme.update(); 720 + wipe(T.bg[0], T.bg[1], T.bg[2]); 721 + ink(T.accent[0], T.accent[1], T.accent[2]); 722 + box(36, 36, screen.width - 72, screen.height - 72, true); 723 + ink(T.bg[0], T.bg[1], T.bg[2]); 724 + box(40, 40, screen.width - 80, screen.height - 80, true); 725 + ink(T.fg, T.fg, T.fg + 10); 726 + lines.forEach((line, index) => { 727 + write(line, { x: 56, y: 64 + index * 16, size: 1, font: "6x10" }); 728 + }); 729 + } 730 + }; 731 + } 732 + 733 + pieces.lisp = makeStubPiece("lisp", () => "KidLisp eval is not wired into the browser host yet."); 734 + pieces.list = makeStubPiece("list", ({ system }) => "Available offline pieces: " + system.listPieces().join(", ")); 735 + pieces.claude = makeStubPiece("claude", () => "Claude integration is native-only for now."); 736 + pieces.login = makeStubPiece("login", ({ params }) => params[0] ? "Login code: " + params[0] : "No login code provided."); 737 + 738 + const promptUrl = URL.createObjectURL(new Blob([PROMPT_SOURCE], { type: "text/javascript" })); 739 + const promptPiece = await import(promptUrl); 740 + pieces.prompt = promptPiece; 741 + 742 + function normalizeJumpTarget(spec) { 743 + const raw = String(spec || "prompt").trim() || "prompt"; 744 + const colonIndex = raw.indexOf(":"); 745 + const baseSpec = colonIndex >= 0 ? raw.slice(0, colonIndex) : raw; 746 + const colon = colonIndex >= 0 ? raw.slice(colonIndex + 1).split(":").filter(Boolean) : []; 747 + const spaceIndex = baseSpec.indexOf(" "); 748 + const pieceName = (spaceIndex >= 0 ? baseSpec.slice(0, spaceIndex) : baseSpec).toLowerCase(); 749 + const params = spaceIndex >= 0 ? baseSpec.slice(spaceIndex + 1).split(" ").filter(Boolean) : []; 750 + return { pieceName, params, colon, raw }; 751 + } 752 + 753 + const systemApi = { 754 + version: "ac-native wasm prompt prototype", 755 + get sshStarted() { 756 + return false; 757 + }, 758 + listPieces() { 759 + return Object.keys(pieces).sort(); 760 + }, 761 + readFile(filePath) { 762 + return Object.prototype.hasOwnProperty.call(files, filePath) ? files[filePath] : ""; 763 + }, 764 + writeFile(filePath, value) { 765 + files[filePath] = String(value); 766 + persistFiles(); 767 + return true; 768 + }, 769 + saveConfig(key, value) { 770 + const config = readConfig(); 771 + config[key] = value; 772 + files["/mnt/config.json"] = JSON.stringify(config, null, 2); 773 + persistFiles(); 774 + }, 775 + reboot() { 776 + state.statusText = "reboot requested (stubbed in browser)"; 777 + }, 778 + poweroff() { 779 + state.statusText = "poweroff requested (stubbed in browser)"; 780 + }, 781 + startSSH() { 782 + state.statusText = "ssh requested (stubbed in browser)"; 783 + }, 784 + fetchPost(url, body, headers) { 785 + state.lastPost = "POST " + url; 786 + console.log("[ac-native-wasm] fetchPost", { url, body, headers }); 787 + }, 788 + usbMidi: { 789 + status() { 790 + return { enabled: false, active: false, reason: "browser-stub" }; 791 + }, 792 + enable() { 793 + return { enabled: false, active: false, reason: "browser-stub" }; 794 + }, 795 + disable() { 796 + return { enabled: false, active: false, reason: "browser-stub" }; 797 + }, 798 + refresh() { 799 + return { enabled: false, active: false, reason: "browser-stub" }; 800 + } 801 + }, 802 + jump(spec) { 803 + loadPiece(spec); 804 + } 805 + }; 806 + 807 + async function loadPiece(spec) { 808 + const target = normalizeJumpTarget(spec); 809 + state.currentPieceSpec = target.raw; 810 + state.currentPieceName = pieces[target.pieceName] ? target.pieceName : "lisp"; 811 + state.currentParams = target.params; 812 + state.currentColon = target.colon; 813 + state.currentPiece = pieces[state.currentPieceName]; 814 + state.paintCount = 0; 815 + state.jumpMessage = target.raw; 816 + state.statusText = "piece -> " + state.currentPieceName; 817 + if (typeof state.currentPiece.boot === "function") { 818 + state.currentPiece.boot({ 819 + ...hostApi, 820 + system: systemApi, 821 + params: clone(state.currentParams), 822 + colon: clone(state.currentColon) 823 + }); 824 + } 825 + } 826 + 827 + function flushFrame() { 828 + ctx.putImageData(imageData, 0, 0); 829 + ctx.textBaseline = "top"; 830 + ctx.font = "10px 'Iosevka Term', 'IBM Plex Mono', monospace"; 831 + textQueue.forEach((entry) => { 832 + const [r, g, b, a] = entry.color; 833 + ctx.fillStyle = "rgba(" + r + ", " + g + ", " + b + ", " + (a / 255) + ")"; 834 + ctx.fillText(entry.text, entry.x, entry.y); 835 + }); 836 + textQueue.length = 0; 837 + } 838 + 839 + function tick() { 840 + hostApi.paintCount = state.paintCount; 841 + if (typeof state.currentPiece?.sim === "function") { 842 + state.currentPiece.sim({ 843 + ...hostApi, 844 + system: systemApi, 845 + params: clone(state.currentParams), 846 + colon: clone(state.currentColon) 847 + }); 848 + } 849 + 850 + while (state.events.length > 0) { 851 + const event = state.events.shift(); 852 + if (typeof state.currentPiece?.act === "function") { 853 + state.currentPiece.act({ 854 + ...hostApi, 855 + event, 856 + system: systemApi, 857 + params: clone(state.currentParams), 858 + colon: clone(state.currentColon) 859 + }); 860 + } 861 + } 862 + 863 + if (typeof state.currentPiece?.paint === "function") { 864 + state.currentPiece.paint({ 865 + ...hostApi, 866 + system: systemApi, 867 + params: clone(state.currentParams), 868 + colon: clone(state.currentColon) 869 + }); 870 + } 871 + 872 + flushFrame(); 873 + state.paintCount += 1; 874 + const config = readConfig(); 875 + statusLeft.textContent = state.statusText; 876 + statusRight.textContent = [ 877 + wifi.connected ? "wifi: connected" : "wifi: offline", 878 + config.darkMode ? "mode: " + config.darkMode : "mode: auto", 879 + state.focused ? "kbd: focused" : "kbd: click to focus" 880 + ].join(" • "); 881 + requestAnimationFrame(tick); 882 + } 883 + 884 + await loadPiece("prompt"); 885 + statusLeft.textContent = "running prompt.mjs"; 886 + statusRight.textContent = "click canvas to focus keyboard"; 887 + screen.focus(); 888 + requestAnimationFrame(tick); 889 + </script> 890 + </body> 891 + </html> 892 + `; 893 + 894 + await fs.mkdir(outputDir, { recursive: true }); 895 + await fs.writeFile(outputPath, html, "utf8"); 896 + await fs.mkdir(publicOutputDir, { recursive: true }); 897 + await fs.writeFile(publicOutputPath, html, "utf8"); 898 + 899 + console.log("Built offline artifact:"); 900 + console.log(outputPath); 901 + console.log("Built production route:"); 902 + console.log(publicOutputPath);
+11
fedac/native/wasm/package.json
··· 1 + { 2 + "name": "ac-native-wasm-prototype", 3 + "private": true, 4 + "type": "module", 5 + "scripts": { 6 + "build": "node build-offline-page.mjs" 7 + }, 8 + "dependencies": { 9 + "wabt": "^1.0.37" 10 + } 11 + }
+630
system/public/ac-native-wasm/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>AC Native Prompt WASM Prototype</title> 7 + <style> 8 + :root { 9 + --bg: #111013; 10 + --panel: rgba(255, 255, 255, 0.06); 11 + --border: rgba(255, 255, 255, 0.14); 12 + --text: #f3e9ef; 13 + --muted: #baa9b5; 14 + --accent: #ff6b9d; 15 + } 16 + * { box-sizing: border-box; } 17 + html, body { height: 100%; } 18 + body { 19 + margin: 0; 20 + display: grid; 21 + place-items: center; 22 + background: 23 + radial-gradient(circle at top, rgba(255, 107, 157, 0.18), transparent 34%), 24 + radial-gradient(circle at bottom right, rgba(93, 209, 196, 0.16), transparent 28%), 25 + linear-gradient(180deg, #17131a 0%, #0e0c10 100%); 26 + color: var(--text); 27 + font-family: "Iosevka Aile", "IBM Plex Sans", ui-sans-serif, sans-serif; 28 + } 29 + .shell { 30 + width: min(96vw, 1120px); 31 + padding: 18px; 32 + border: 1px solid var(--border); 33 + border-radius: 20px; 34 + background: rgba(10, 8, 12, 0.82); 35 + box-shadow: 0 32px 90px rgba(0, 0, 0, 0.45); 36 + backdrop-filter: blur(18px); 37 + } 38 + .bar { 39 + display: flex; 40 + align-items: center; 41 + justify-content: space-between; 42 + gap: 16px; 43 + margin-bottom: 14px; 44 + } 45 + .title { 46 + font-size: 14px; 47 + letter-spacing: 0.08em; 48 + text-transform: uppercase; 49 + color: var(--muted); 50 + } 51 + .hint { 52 + font-size: 13px; 53 + color: var(--muted); 54 + } 55 + .hint strong { color: var(--text); font-weight: 600; } 56 + .stage { 57 + position: relative; 58 + width: 100%; 59 + aspect-ratio: 3 / 2; 60 + border-radius: 14px; 61 + overflow: hidden; 62 + border: 1px solid rgba(255, 255, 255, 0.08); 63 + background: #0e0d11; 64 + } 65 + canvas { 66 + width: 100%; 67 + height: 100%; 68 + display: block; 69 + image-rendering: pixelated; 70 + image-rendering: crisp-edges; 71 + } 72 + .status { 73 + display: flex; 74 + justify-content: space-between; 75 + gap: 16px; 76 + margin-top: 12px; 77 + font-size: 12px; 78 + color: var(--muted); 79 + } 80 + .status strong { color: var(--accent); } 81 + </style> 82 + </head> 83 + <body> 84 + <div class="shell"> 85 + <div class="bar"> 86 + <div class="title">AC Native Prompt • WASM Offline Prototype</div> 87 + <div class="hint"><strong>Type</strong> in the canvas. <strong>Esc</strong> clears. <strong>Tab</strong> completes. <strong>Backspace</strong> returns from stub pieces.</div> 88 + </div> 89 + <div class="stage"> 90 + <canvas id="screen" width="960" height="640" tabindex="0" aria-label="AC Native Prompt WASM Prototype"></canvas> 91 + </div> 92 + <div class="status"> 93 + <div id="status-left">booting...</div> 94 + <div id="status-right">single-file offline html</div> 95 + </div> 96 + </div> 97 + <script type="module"> 98 + const WASM_BASE64 = "AGFzbQEAAAABGARgAn9/AX9gBH9/f38AYAN/f38Bf2AAAAMHBgABAAIDAQUDAQBQBiMGfwFBAAt/AUEAC38BQf8BC38BQf8BC38BQf8BC38BQf8BCwcvBQZtZW1vcnkCAARpbml0AAAHc2V0X2luawABBWNsZWFyAAQJZmlsbF9yZWN0AAUK5wIGDAAgACQAIAEkAUEACxIAIAAkAiABJAMgAiQEIAMkBQsNACABIwBsIABqQQRsCxkAIAEgAmwgAEH/ASACa2xqQf8AakH/AW4LTgEDfyMAIwFsIQECQANAIAAgAU8NASAAQQRsIQIgAiMCOgAAIAJBAWojAzoAACACQQJqIwQ6AAAgAkEDakH/AToAACAAQQFqIQAMAAsLC80BAQh/AkADQCAEIANPDQFBACEFAkADQCAFIAJPDQEgACAFaiEGIAEgBGohByAGIAcQAiEIIwVB/wFGBEAgCCMCOgAAIAhBAWojAzoAACAIQQJqIwQ6AAAgCEEDakH/AToAAAUgCC0AACEJIAhBAWotAAAhCiAIQQJqLQAAIQsgCCAJIwIjBRADOgAAIAhBAWogCiMDIwUQAzoAACAIQQJqIAsjBCMFEAM6AAAgCEEDakH/AToAAAsgBUEBaiEFDAALCyAEQQFqIQQMAAsLCwC9AQRuYW1lARYCAgxwaXhlbF9vZmZzZXQDBWJsZW5kAnAGAAIAAXcBAWgBBAABcgEBZwIBYgMBYQICAAF4AQF5AwMAA2RzdAEDc3JjAgFhBAMAAWkBBXRvdGFsAgRhZGRyBQwAAXgBAXkCAXcDAWgEAnl5BQJ4eAYCcHgHAnB5CARhZGRyCQJkcgoCZGcLAmRiBywGAAV3aWR0aAEGaGVpZ2h0AgVpbmtfcgMFaW5rX2cEBWlua19iBQVpbmtfYQ=="; 99 + const PROMPT_SOURCE = "// prompt.mjs — Native command-line piece for fedac\n// Text input at top-left, pink block cursor, no prompt prefix.\n// Anything typed (except \"notepat\") is evaluated as KidLisp source.\n\nlet input = \"\";\nlet cursor = 0; // cursor position within input\nlet cursorVisible = true;\nlet cursorFrame = 0;\nlet history = [];\nlet historyIndex = -1;\nlet message = \"\";\nlet messageFrame = 0;\nlet shiftHeld = false;\nlet frame = 0;\nlet voiceOff = false; // true when user sets voice to \"off\"\nlet T = __theme.update(); // global theme (auto dark/light)\nlet tabMatches = []; // current tab completion candidates\nlet tabIndex = -1; // cycling index for tab\nlet tabPrefix = \"\"; // what was typed before tab\n\n// Discovered piece names (populated in boot via system.listPieces)\nlet PIECE_NAMES = [];\n// Built-in non-piece commands\nconst BUILTIN_COMMANDS = [\n \"version\", \"reboot\", \"off\", \"clear\", \"help\", \"ssh\", \"hi\", \"bye\", \"ls\", \"papers\", \"login\", \"midi\",\n];\n// All completable commands (built in boot)\nlet COMMANDS = [];\n// $code aliases\nconst CODE_NAMES = [\"$roz\"];\n// Piece descriptions (for tab completion display)\nconst PIECE_DESC = {\n \"notepat\": \"synthesizer instrument\",\n \"os\": \"system update (OTA)\",\n \"wifi\": \"network picker\",\n \"claude\": \"AI assistant\",\n \"terminal\": \"PTY terminal\",\n \"geo\": \"geolocation\",\n \"chat\": \"real-time chat\",\n \"laer-klokken\": \"clock room chat\",\n \"machine\": \"hardware info\",\n \"roz\": \"generative art\",\n \"list\": \"all commands\",\n \"ls\": \"→ list\",\n \"papers\": \"papers site\",\n \"clock\": \"melody clock\",\n \"brick-breaker\": \"paddle + ball game\",\n \"gostop\": \"go/stop rhythm\",\n \"beat\": \"percussion\",\n \"shh\": \"noise drone\",\n \"dync\": \"percussive pad\",\n \"chart\": \"diagram sketch\",\n \"f3ral3xp\": \"feral expression\",\n \"3x3\": \"ortholinear pad\",\n \"print\": \"printer / thermal\",\n \"theme\": \"prompt theme\",\n \"voice\": \"system voice\",\n \"login\": \"switch identity\",\n \"midi\": \"usb midi + udp relay\",\n \"dark\": \"dark mode\",\n \"light\": \"light mode\",\n \"auto\": \"auto dark/light\",\n \"error\": \"error screen\",\n \"404\": \"not found\",\n};\n\n// WiFi auto-connect state\nconst AC_SSID = \"aesthetic.computer\";\nconst AC_PASS = \"aesthetic.computer\";\nconst CREDS_PATH = \"/mnt/wifi_creds.json\";\nlet savedCreds = [];\nlet autoConnectFrame = 0;\nlet connectStartFrame = -9999;\n\nconst SHIFT_MAP = {\n \"1\":\"!\", \"2\":\"@\", \"3\":\"#\", \"4\":\"$\", \"5\":\"%\",\n \"6\":\"^\", \"7\":\"&\", \"8\":\"*\", \"9\":\"(\", \"0\":\")\",\n \"-\":\"_\", \"=\":\"+\", \"[\":\"{\", \"]\":\"}\", \"\\\\\":\"|\",\n \";\":\":\", \"'\":'\"', \",\":\"<\", \".\":\">\", \"/\":\"?\", \"`\":\"~\",\n};\n\n// KidLisp syntax highlighting colors\nconst KL_KEYWORDS = new Set([\n \"ink\",\"line\",\"circle\",\"box\",\"wipe\",\"fade\",\"write\",\"grid\",\"plot\",\n \"spin\",\"zoom\",\"scroll\",\"contrast\",\"noise\",\"blend\",\"mirror\",\n \"w\",\"h\",\"w/2\",\"h/2\",\"t\",\"frame\",\"pi\",\"tau\",\n \"sin\",\"cos\",\"tan\",\"abs\",\"sqrt\",\"floor\",\"ceil\",\"round\",\"min\",\"max\",\"pow\",\"mod\",\n \"?\",\"...\",\n]);\nconst KL_COLORS = new Set([\n \"red\",\"green\",\"blue\",\"cyan\",\"magenta\",\"yellow\",\"white\",\"black\",\n \"orange\",\"pink\",\"purple\",\"gray\",\"grey\",\"rainbow\",\n]);\n\nfunction klTokenColor(token) {\n if (KL_COLORS.has(token)) return [255, 140, 100]; // warm orange for color names\n if (KL_KEYWORDS.has(token)) return [120, 180, 255]; // blue for keywords\n if (/^-?\\d+(\\.\\d+)?$/.test(token)) return [180, 220, 140]; // green for numbers\n if (/^[a-z]/.test(token) && token.includes(\"/\")) return [180, 220, 140]; // w/2 etc\n return null; // default\n}\n\n// Built-in $code aliases\nconst CODES = {\n \"$roz\": `fade:red-blue-black-blue-red\nink (? rainbow white 0) (1s... 24 64)\nline w/2 0 w/2 h\n(spin (2s... -1.125 1.125)) (zoom 1.1)\n(0.5s (contrast 1.05))\n(scroll (? -0.1 0 0.1) (? -0.1 0 0.1))\nink (? cyan yellow magenta) 8\ncircle w/2 h/2 (? 2 4 8)`,\n};\n\nfunction readUsbMidiStatus(system) {\n try {\n return system?.usbMidi?.status?.() || system?.usbMidi || null;\n } catch (_) {\n return system?.usbMidi || null;\n }\n}\n\nfunction formatUsbMidiStatus(status) {\n if (!status) return \"usb midi unavailable\";\n if (status.active) {\n let msg = \"usb midi active\";\n if (status.alsaDevice) msg += \" \" + status.alsaDevice;\n else if (status.udc) msg += \" \" + status.udc;\n if (status.port) msg += \" via \" + status.port;\n return msg;\n }\n if (status.enabled) {\n return status.reason && status.reason !== \"ok\"\n ? \"usb midi enabled (\" + status.reason + \")\"\n : \"usb midi enabled\";\n }\n if (status.reason && status.reason !== \"disabled\" && status.reason !== \"uninitialized\") {\n return \"usb midi off (\" + status.reason + \")\";\n }\n return \"usb midi off\";\n}\n\nfunction readConfig(system) {\n try {\n const raw = system?.readFile?.(\"/mnt/config.json\");\n return raw ? JSON.parse(raw) : {};\n } catch (_) {\n return {};\n }\n}\n\nfunction udpMidiRelayEnabled(system) {\n const cfg = readConfig(system);\n return cfg.udpMidiBroadcast === true || cfg.udpMidiBroadcast === \"true\";\n}\n\nfunction formatUdpMidiRelayStatus(system) {\n const cfg = readConfig(system);\n const enabled = cfg.udpMidiBroadcast === true || cfg.udpMidiBroadcast === \"true\";\n if (!enabled) return \"udp midi relay off\";\n if (cfg.handle) return \"udp midi relay on @\" + cfg.handle;\n return \"udp midi relay on\";\n}\n\nfunction boot({ system }) {\n message = \"\";\n // Load saved theme from config (persists across reboots)\n try {\n const raw = system?.readFile?.(\"/mnt/config.json\");\n if (raw) {\n const cfg = JSON.parse(raw);\n if (cfg.theme) __theme.apply(cfg.theme);\n if (cfg.darkMode === \"dark\") __theme._forceDark = true;\n else if (cfg.darkMode === \"light\") __theme._forceDark = false;\n if (cfg.darkMode) { __theme._lastCheck = 0; __theme.update(); }\n voiceOff = cfg.voice === \"off\";\n }\n } catch (_) {}\n // Discover all available pieces dynamically\n PIECE_NAMES = (system?.listPieces?.() || []).filter(n => n !== \"prompt\" && n !== \"lisp\" && n !== \"cc\");\n PIECE_NAMES.sort();\n COMMANDS = [...PIECE_NAMES, ...BUILTIN_COMMANDS, ...CODE_NAMES];\n // Restore input from KidLisp return (backspace/escape preserves source)\n if (globalThis.__promptRestore) {\n input = globalThis.__promptRestore;\n cursor = input.length;\n globalThis.__promptRestore = undefined;\n }\n // Load saved WiFi credentials\n try {\n const raw = system?.readFile?.(CREDS_PATH);\n if (raw) savedCreds = JSON.parse(raw);\n } catch (_) {}\n}\n\nfunction act({ event: e, system, sound }) {\n if (e.is(\"keyboard:down:shift\")) { shiftHeld = true; return; }\n if (e.is(\"keyboard:up:shift\")) { shiftHeld = false; return; }\n\n if (e.is(\"keyboard:down\")) {\n const key = e.key;\n cursorFrame = 0;\n cursorVisible = true;\n\n // Keystroke voicing (JS fallback — C input loop also fires for zero-latency)\n if (!voiceOff) {\n if (key.length === 1) {\n sound?.speakCached?.(shiftHeld ? (SHIFT_MAP[key] ?? key.toUpperCase()) : key);\n } else if (key === \"space\") {\n sound?.speakCached?.(\"space\");\n } else if (key === \"backspace\") {\n sound?.speakCached?.(\"back\");\n } else if (key === \"enter\" || key === \"return\") {\n sound?.speakCached?.(\"enter\");\n } else if (key === \"escape\") {\n sound?.speakCached?.(\"clear\");\n } else if (key === \"tab\") {\n sound?.speakCached?.(\"tab\");\n }\n }\n\n if (key === \"tab\") {\n // Tab completion\n const prefix = input.slice(0, cursor).toLowerCase();\n if (prefix.length > 0) {\n if (tabPrefix !== prefix) {\n // New prefix — build match list\n tabPrefix = prefix;\n tabMatches = COMMANDS.filter(c => c.startsWith(prefix) && c !== prefix);\n tabIndex = -1;\n }\n if (tabMatches.length > 0) {\n tabIndex = (tabIndex + 1) % tabMatches.length;\n const match = tabMatches[tabIndex];\n input = match + input.slice(cursor);\n cursor = match.length;\n }\n }\n return;\n }\n // Any non-tab key resets tab state\n tabPrefix = \"\";\n tabMatches = [];\n tabIndex = -1;\n\n if (key === \"enter\" || key === \"return\") {\n const cmd = input.trim();\n if (cmd.length > 0) {\n history.unshift(cmd);\n historyIndex = -1;\n execute(cmd, system);\n input = \"\";\n cursor = 0;\n }\n } else if (key === \"backspace\") {\n if (cursor > 0) {\n input = input.slice(0, cursor - 1) + input.slice(cursor);\n cursor--;\n }\n } else if (key === \"delete\") {\n if (cursor < input.length) {\n input = input.slice(0, cursor) + input.slice(cursor + 1);\n }\n } else if (key === \"escape\") {\n input = \"\";\n cursor = 0;\n } else if (key === \"arrowleft\") {\n if (cursor > 0) cursor--;\n } else if (key === \"arrowright\") {\n if (cursor < input.length) cursor++;\n } else if (key === \"home\") {\n cursor = 0;\n } else if (key === \"end\") {\n cursor = input.length;\n } else if (key === \"arrowup\") {\n if (history.length > 0 && historyIndex < history.length - 1) {\n historyIndex++;\n input = history[historyIndex];\n cursor = input.length;\n }\n } else if (key === \"arrowdown\") {\n if (historyIndex > 0) {\n historyIndex--;\n input = history[historyIndex];\n cursor = input.length;\n } else if (historyIndex === 0) {\n historyIndex = -1;\n input = \"\";\n cursor = 0;\n }\n } else if (key === \"space\") {\n input = input.slice(0, cursor) + \" \" + input.slice(cursor);\n cursor++;\n } else if (key.length === 1) {\n const ch = shiftHeld ? (SHIFT_MAP[key] ?? key.toUpperCase()) : key;\n input = input.slice(0, cursor) + ch + input.slice(cursor);\n cursor++;\n }\n }\n}\n\nfunction execute(cmd, system) {\n const lower = cmd.toLowerCase();\n // Handle colon params: \"clock:cdefg\" → piece=\"clock\", rest passed via jump\n const colonIdx = lower.indexOf(\":\");\n const baseName = colonIdx >= 0 ? lower.slice(0, colonIdx) : lower;\n // Also handle space params: \"clock cdefg\" → piece=\"clock\"\n const spaceIdx = lower.indexOf(\" \");\n const baseWord = spaceIdx >= 0 ? lower.slice(0, spaceIdx) : lower;\n\n // Built-in commands (non-piece)\n if (lower === \"version\") {\n message = system?.version || \"unknown\";\n messageFrame = 0;\n return;\n }\n if (lower === \"reboot\") {\n system?.reboot?.();\n return;\n }\n if (lower === \"off\" || lower === \"shutdown\" || lower === \"poweroff\") {\n message = \"shutting down...\";\n messageFrame = 0;\n system?.poweroff?.();\n return;\n }\n if (baseWord === \"login\") {\n const code = cmd.slice(spaceIdx > 0 ? spaceIdx + 1 : cmd.length).trim();\n message = \"~> login\";\n messageFrame = 0;\n system?.jump?.(code ? \"login:\" + code : \"login\");\n return;\n }\n if (lower === \"clear\") {\n history = [];\n message = \"\";\n return;\n }\n if (lower === \"help\") {\n message = \"type a piece name or kidlisp — tab to complete\";\n messageFrame = 0;\n return;\n }\n if (lower === \"ssh\") {\n if (system?.sshStarted) {\n message = \"ssh running on port 22\";\n } else {\n system?.startSSH?.();\n message = \"starting ssh...\";\n }\n messageFrame = 0;\n return;\n }\n if (baseWord === \"midi\") {\n const arg = spaceIdx >= 0 ? lower.slice(spaceIdx + 1).trim() : \"status\";\n if (arg === \"relay\" || arg.startsWith(\"relay \")) {\n const relayArg = arg === \"relay\" ? \"status\" : arg.slice(\"relay \".length).trim();\n if (!relayArg || relayArg === \"status\") {\n message = formatUdpMidiRelayStatus(system);\n messageFrame = 0;\n return;\n }\n if (relayArg === \"on\" || relayArg === \"enable\") {\n system?.saveConfig?.(\"udpMidiBroadcast\", \"true\");\n message = formatUdpMidiRelayStatus(system);\n messageFrame = 0;\n return;\n }\n if (relayArg === \"off\" || relayArg === \"disable\") {\n system?.saveConfig?.(\"udpMidiBroadcast\", \"false\");\n message = formatUdpMidiRelayStatus(system);\n messageFrame = 0;\n return;\n }\n message = \"usage: midi relay [status|on|off]\";\n messageFrame = 0;\n return;\n }\n if (!arg || arg === \"status\") {\n message = formatUsbMidiStatus(readUsbMidiStatus(system)) + \" | \" + formatUdpMidiRelayStatus(system);\n messageFrame = 0;\n return;\n }\n if (arg === \"on\" || arg === \"enable\") {\n system?.saveConfig?.(\"usbMidi\", \"true\");\n const status = system?.usbMidi?.enable?.() || readUsbMidiStatus(system);\n message = formatUsbMidiStatus(status);\n messageFrame = 0;\n return;\n }\n if (arg === \"off\" || arg === \"disable\") {\n system?.saveConfig?.(\"usbMidi\", \"false\");\n const status = system?.usbMidi?.disable?.() || readUsbMidiStatus(system);\n message = formatUsbMidiStatus(status);\n messageFrame = 0;\n return;\n }\n if (arg === \"refresh\" || arg === \"retry\") {\n const status = system?.usbMidi?.refresh?.() || readUsbMidiStatus(system);\n message = formatUsbMidiStatus(status);\n messageFrame = 0;\n return;\n }\n message = \"usage: midi [status|on|off|refresh|relay]\";\n messageFrame = 0;\n return;\n }\n if (lower === \"hi\") {\n const raw = system?.readFile?.(\"/mnt/config.json\");\n if (raw) {\n try {\n const cfg = JSON.parse(raw);\n if (cfg.handle) {\n message = \"logged in as @\" + cfg.handle;\n } else if (cfg.email) {\n message = \"logged in as \" + cfg.email;\n } else {\n message = \"config exists but no user\";\n }\n } catch (_) { message = \"config parse error\"; }\n } else {\n message = \"not logged in — flash from an authenticated CLI\";\n }\n messageFrame = 0;\n return;\n }\n if (lower === \"bye\") {\n const raw = system?.readFile?.(\"/mnt/config.json\");\n let cfg = {};\n if (raw) try { cfg = JSON.parse(raw); } catch (_) {}\n const had = cfg.handle || cfg.email;\n delete cfg.handle;\n delete cfg.email;\n delete cfg.sub;\n system?.writeFile?.(\"/mnt/config.json\", JSON.stringify(cfg, null, 2));\n message = had ? \"logged out (was @\" + had + \")\" : \"already logged out\";\n messageFrame = 0;\n return;\n }\n\n // Set default boot piece\n if (baseWord === \"boot\") {\n if (spaceIdx > 0) {\n const pieceName = cmd.slice(spaceIdx + 1).trim();\n if (pieceName) {\n system?.saveConfig?.(\"piece\", pieceName);\n message = \"boot → \" + pieceName;\n } else {\n message = \"usage: boot <piece-name>\";\n }\n } else {\n // Show current boot piece\n const raw = system?.readFile?.(\"/mnt/config.json\");\n let cur = \"notepat\";\n if (raw) try { cur = JSON.parse(raw).piece || cur; } catch (_) {}\n message = \"boot piece: \" + cur;\n }\n messageFrame = 0;\n return;\n }\n\n // Dark/light mode override\n if (lower === \"dark\") {\n __theme._forceDark = true;\n __theme._lastCheck = 0;\n __theme.update();\n system?.saveConfig?.(\"darkMode\", \"dark\");\n message = \"dark mode\";\n messageFrame = 0;\n return;\n }\n if (lower === \"light\") {\n __theme._forceDark = false;\n __theme._lastCheck = 0;\n __theme.update();\n system?.saveConfig?.(\"darkMode\", \"light\");\n message = \"light mode\";\n messageFrame = 0;\n return;\n }\n if (lower === \"auto\") {\n __theme._forceDark = undefined;\n __theme._lastCheck = 0;\n __theme.update();\n system?.saveConfig?.(\"darkMode\", \"auto\");\n message = \"auto dark/light (LA time)\";\n messageFrame = 0;\n return;\n }\n\n // Check for built-in $code aliases\n if (CODES[lower]) {\n message = \"~> \" + lower;\n messageFrame = 0;\n globalThis.__kidlispSource = CODES[lower];\n globalThis.__kidlispLabel = lower;\n system?.jump?.(\"lisp\");\n return;\n }\n\n // \"claude TOKEN\" — associate Claude Code OAuth token with your handle\n if (baseWord === \"claude\" && spaceIdx > 0) {\n const token = cmd.slice(spaceIdx + 1).trim();\n if (token.startsWith(\"sk-ant-\")) {\n message = \"saving token...\";\n messageFrame = 0;\n // Read handle from config\n let handle = \"\";\n let acToken = \"\";\n try {\n const raw = system?.readFile?.(\"/mnt/config.json\");\n if (raw) {\n const cfg = JSON.parse(raw);\n handle = cfg.handle || \"\";\n acToken = cfg.token || \"\";\n }\n } catch (_) {}\n if (!handle) {\n message = \"not logged in — flash from authenticated CLI first\";\n messageFrame = 0;\n return;\n }\n // POST to API\n const url = \"https://aesthetic.computer/.netlify/functions/claude-token\";\n const body = JSON.stringify({ token });\n const headers = JSON.stringify({ \"Content-Type\": \"application/json\", \"Authorization\": \"Bearer \" + acToken });\n system?.fetchPost?.(url, body, headers);\n // Also save locally for immediate use\n system?.writeFile?.(\"/claude-token\", token);\n message = \"token saved for @\" + handle;\n messageFrame = 0;\n return;\n }\n }\n\n // \"git TOKEN\" — associate GitHub PAT with your handle\n if (baseWord === \"git\" && spaceIdx > 0) {\n const pat = cmd.slice(spaceIdx + 1).trim();\n if (pat.startsWith(\"ghp_\")) {\n message = \"saving github token...\";\n messageFrame = 0;\n let handle = \"\";\n let acToken = \"\";\n try {\n const raw = system?.readFile?.(\"/mnt/config.json\");\n if (raw) {\n const cfg = JSON.parse(raw);\n handle = cfg.handle || \"\";\n acToken = cfg.token || \"\";\n }\n } catch (_) {}\n if (!handle) {\n message = \"not logged in — flash from authenticated CLI first\";\n messageFrame = 0;\n return;\n }\n const url = \"https://aesthetic.computer/.netlify/functions/claude-token\";\n const body = JSON.stringify({ githubPat: pat });\n const headers = JSON.stringify({ \"Content-Type\": \"application/json\", \"Authorization\": \"Bearer \" + acToken });\n system?.fetchPost?.(url, body, headers);\n system?.writeFile?.(\"/github-pat\", pat);\n message = \"github token saved for @\" + handle;\n messageFrame = 0;\n return;\n }\n }\n\n // \"papers\" opens papers.aesthetic.computer\n if (lower === \"papers\") {\n message = \"~> papers.aesthetic.computer\";\n messageFrame = 0;\n // Native can't open a browser — show the URL as a message\n return;\n }\n\n // \"ls\" is an alias for \"list\"\n if (baseName === \"ls\" || baseWord === \"ls\") {\n message = \"~> list\";\n messageFrame = 0;\n system?.jump?.(\"list\");\n return;\n }\n\n // \"code\" is an alias for \"claude\" piece\n if (baseName === \"code\" || baseWord === \"code\") {\n message = \"~> code\";\n messageFrame = 0;\n system?.jump?.(\"claude\");\n return;\n }\n\n // Dynamic piece jump — check if the command matches any discovered piece\n if (PIECE_NAMES.includes(baseName) || PIECE_NAMES.includes(baseWord)) {\n const pieceName = PIECE_NAMES.includes(baseName) ? baseName : baseWord;\n message = \"~> \" + pieceName;\n messageFrame = 0;\n // Native ac-native jump() only parses colon-separated params (see\n // jump_target/jump_params split in ac-native.c). Convert any space-\n // separated form \"clock ceg dfa\" into \"clock:ceg:dfa\" so the piece\n // receives params=[\"ceg\",\"dfa\"] instead of loading \"/pieces/clock ceg dfa.mjs\".\n // Commands that already use colons (e.g. \"mo:1:c:e:g\") pass through unchanged.\n let jumpArg = cmd;\n if (cmd.indexOf(\":\") < 0 && cmd.indexOf(\" \") >= 0) {\n jumpArg = cmd.replace(/\\s+/g, \":\");\n }\n system?.jump?.(jumpArg);\n return;\n }\n\n // Everything else → KidLisp evaluator\n message = \"~> lisp\";\n messageFrame = 0;\n globalThis.__kidlispSource = cmd;\n globalThis.__kidlispLabel = cmd;\n system?.jump?.(\"lisp\");\n}\n\n// Draw syntax-highlighted KidLisp text\nfunction drawHighlighted(text, x0, y0, charW, ink, write, font) {\n // Tokenize by spaces and parens, preserving positions\n let cx = x0;\n let i = 0;\n while (i < text.length) {\n // Skip whitespace\n if (text[i] === \" \") {\n cx += charW;\n i++;\n continue;\n }\n // Parens get special color\n if (text[i] === \"(\" || text[i] === \")\") {\n ink(180, 120, 200); // purple parens\n write(text[i], { x: cx, y: y0, size: 1, font });\n cx += charW;\n i++;\n continue;\n }\n // Colon (fade separator)\n if (text[i] === \":\") {\n ink(140, 140, 160);\n write(\":\", { x: cx, y: y0, size: 1, font });\n cx += charW;\n i++;\n continue;\n }\n // Collect token\n let token = \"\";\n const start = i;\n while (i < text.length && text[i] !== \" \" && text[i] !== \"(\" && text[i] !== \")\") {\n token += text[i];\n i++;\n }\n const color = klTokenColor(token.toLowerCase());\n if (color) {\n ink(color[0], color[1], color[2]);\n } else {\n ink(T.fg, T.fg, T.fg + 10); // default text\n }\n write(token, { x: cx, y: y0, size: 1, font });\n cx += token.length * charW;\n }\n}\n\nfunction paint({ wipe, ink, box, write, screen, paintCount, wifi, system }) {\n frame++;\n const T = __theme.update();\n wipe(T.bg[0], T.bg[1], T.bg[2]);\n\n const W = screen.width;\n const H = screen.height;\n\n // WiFi auto-connect (runs every frame, same logic as notepat)\n if (wifi && !wifi.connected) {\n autoConnectFrame++;\n const knownCreds = [\n { ssid: AC_SSID, pass: AC_PASS },\n ...savedCreds.filter(c => c.ssid !== AC_SSID),\n ];\n const knownSSIDs = new Set(knownCreds.map(c => c.ssid));\n const isConnecting = wifi.state === 3 || wifi.state === 4;\n const isIdle = !isConnecting;\n\n // Timeout connecting after 3s\n if (isConnecting && frame - connectStartFrame > 180) {\n wifi.disconnect?.();\n autoConnectFrame = -30;\n }\n\n if (isIdle) {\n if (autoConnectFrame <= 1 || autoConnectFrame % 120 === 0) wifi.scan?.();\n if (autoConnectFrame % 120 === 60) {\n const nets = wifi.networks || [];\n const matches = nets\n .filter(n => n.ssid && knownSSIDs.has(n.ssid))\n .sort((a, b) => b.signal - a.signal);\n if (matches.length > 0) {\n const best = matches[0];\n const cred = knownCreds.find(c => c.ssid === best.ssid);\n wifi.connect(cred.ssid, cred.pass);\n connectStartFrame = frame;\n }\n }\n }\n }\n\n const charW = 6; // 6x10 font\n const charH = 10;\n const lineH = 12;\n const x0 = 4;\n const y0 = 4;\n const font = \"6x10\";\n\n // Cursor blink\n cursorFrame++;\n if (cursorFrame % 30 === 0) cursorVisible = !cursorVisible;\n\n // Input text with syntax highlighting\n drawHighlighted(input, x0, y0, charW, ink, write, font);\n\n // Pure block cursor\n if (cursorVisible) {\n const cx = x0 + cursor * charW;\n ink(T.cursor[0], T.cursor[1], T.cursor[2]);\n box(cx, y0, charW, charH, true);\n // Draw character under cursor if not at end\n if (cursor < input.length) {\n ink(T.dark ? 255 : 255, T.dark ? 255 : 255, T.dark ? 255 : 255);\n write(input[cursor], { x: cx, y: y0, size: 1, font });\n }\n }\n\n // Ghosted tab completion suggestions below input (with descriptions)\n let completionH = 0;\n {\n const prefix = input.slice(0, cursor).toLowerCase();\n if (prefix.length > 0) {\n const matches = COMMANDS.filter(c => c.startsWith(prefix) && c !== prefix);\n if (matches.length > 0) {\n const sugY = y0 + lineH + 1;\n for (let mi = 0; mi < Math.min(matches.length, 8); mi++) {\n const m = matches[mi];\n const sy = sugY + mi * (charH + 1);\n // Ghosted: dim text, highlight the matching prefix portion\n ink(T.fgDim, T.fgDim - 20, T.fgDim + 20);\n write(m.slice(0, prefix.length), { x: x0, y: sy, size: 1, font });\n ink(T.fgMute, T.fgMute - 10, T.fgMute + 10);\n write(m.slice(prefix.length), { x: x0 + prefix.length * charW, y: sy, size: 1, font });\n // Show description after the name\n const desc = PIECE_DESC[m];\n if (desc) {\n ink(T.fgMute - 20, T.fgMute - 20, T.fgMute - 10);\n write(\" \" + desc, { x: x0 + m.length * charW, y: sy, size: 1, font });\n }\n // Highlight current tab selection\n if (tabMatches.length > 0 && tabIndex >= 0 && matches[mi] === tabMatches[tabIndex]) {\n const fullLen = desc ? m.length + 1 + desc.length : m.length;\n ink(T.accent[0], T.accent[1], T.accent[2], 40);\n box(x0 - 1, sy - 1, fullLen * charW + 2, charH + 2, true);\n }\n }\n completionH = Math.min(matches.length, 8) * (charH + 1) + 4;\n }\n }\n }\n\n // History below input (also syntax highlighted)\n let hy = y0 + lineH + 4 + completionH;\n for (let i = 0; i < history.length && hy < H - 20; i++) {\n // Dim the history entries\n const entry = history[i];\n const lower = entry.toLowerCase();\n // Navigation commands in dim purple, KidLisp source highlighted but dimmed\n if (COMMANDS.includes(lower) || BUILTIN_COMMANDS.includes(lower)) {\n ink(T.fgMute, T.fgMute - 10, T.fgMute + 10);\n write(entry, { x: x0, y: hy, size: 1, font });\n } else {\n // KidLisp history — dim highlight\n drawHighlighted(entry, x0, hy, charW,\n (r, g, b) => ink(Math.floor(r * 0.4), Math.floor(g * 0.4), Math.floor(b * 0.4)),\n write, font);\n }\n hy += lineH;\n }\n\n // Message (bottom)\n if (message.length > 0) {\n ink(T.fgDim, T.fgDim - 10, T.fgDim + 20);\n write(message, { x: x0, y: H - 14, size: 1, font });\n }\n}\n\nfunction sim() {}\n\nconst system = \"prompt\";\nexport { boot, paint, act, sim, system };\n"; 100 + const screen = document.getElementById("screen"); 101 + const ctx = screen.getContext("2d", { alpha: false }); 102 + ctx.imageSmoothingEnabled = false; 103 + 104 + const statusLeft = document.getElementById("status-left"); 105 + const statusRight = document.getElementById("status-right"); 106 + 107 + function bytesFromBase64(base64) { 108 + const text = atob(base64); 109 + const bytes = new Uint8Array(text.length); 110 + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i); 111 + return bytes; 112 + } 113 + 114 + function clone(value) { 115 + return value == null ? value : JSON.parse(JSON.stringify(value)); 116 + } 117 + 118 + const wasmBytes = bytesFromBase64(WASM_BASE64); 119 + const { instance } = await WebAssembly.instantiate(wasmBytes, {}); 120 + const wasm = instance.exports; 121 + 122 + const WIDTH = screen.width; 123 + const HEIGHT = screen.height; 124 + const bufferPtr = wasm.init(WIDTH, HEIGHT); 125 + const pixelView = new Uint8ClampedArray(wasm.memory.buffer, bufferPtr, WIDTH * HEIGHT * 4); 126 + const imageData = new ImageData(pixelView, WIDTH, HEIGHT); 127 + 128 + globalThis.__theme = (function() { 129 + function getLAOffset() { 130 + const d = new Date(); 131 + const m = d.getUTCMonth(); 132 + const y = d.getUTCFullYear(); 133 + if (m > 2 && m < 10) return 7; 134 + if (m < 2 || m > 10) return 8; 135 + if (m === 2) { 136 + const mar1 = new Date(y, 2, 1); 137 + const ss = 8 + (7 - mar1.getDay()) % 7; 138 + return d.getUTCDate() > ss || (d.getUTCDate() === ss && d.getUTCHours() >= 10) ? 7 : 8; 139 + } 140 + const nov1 = new Date(y, 10, 1); 141 + const fs = 1 + (7 - nov1.getDay()) % 7; 142 + return d.getUTCDate() < fs || (d.getUTCDate() === fs && d.getUTCHours() < 9) ? 7 : 8; 143 + } 144 + function getLAHour() { 145 + return (new Date().getUTCHours() - getLAOffset() + 24) % 24; 146 + } 147 + const t = { dark: true, _lastCheck: 0, _overrideId: null, _override: null }; 148 + t.presets = { 149 + serious: { 150 + label: "serious", desc: "black & white", 151 + dark: { bg:[0,0,0], bgAlt:[10,10,10], bgDim:[0,0,0], fg:255, fgDim:160, fgMute:90, bar:[15,15,15], border:[60,60,60], accent:[128,128,128], ok:[200,200,200], err:[255,100,100], warn:[200,200,100], link:[180,180,255], pad:[10,10,10], padSharp:[5,5,5], padLine:[40,40,40], cursor:[255,255,255] }, 152 + light: { bg:[255,255,255], bgAlt:[245,245,245], bgDim:[235,235,235], fg:0, fgDim:80, fgMute:160, bar:[240,240,240], border:[180,180,180], accent:[100,100,100], ok:[40,40,40], err:[180,40,40], warn:[120,100,20], link:[40,40,180], pad:[245,245,245], padSharp:[230,230,230], padLine:[200,200,200], cursor:[0,0,0] } 153 + }, 154 + neo: { 155 + label: "neo", desc: "lime & black", 156 + dark: { bg:[0,0,0], bgAlt:[5,10,5], bgDim:[0,0,0], fg:200, fgDim:120, fgMute:60, bar:[5,15,5], border:[0,80,0], accent:[0,200,80], ok:[0,255,0], err:[255,50,50], warn:[200,255,0], link:[0,180,255], pad:[5,10,5], padSharp:[0,5,0], padLine:[0,50,0], cursor:[0,255,80] }, 157 + light: { bg:[220,255,220], bgAlt:[230,255,230], bgDim:[200,240,200], fg:10, fgDim:60, fgMute:120, bar:[200,240,200], border:[100,180,100], accent:[0,140,60], ok:[0,120,40], err:[180,30,30], warn:[120,140,0], link:[0,80,180], pad:[210,245,210], padSharp:[190,230,190], padLine:[140,200,140], cursor:[0,120,40] } 158 + }, 159 + ember: { 160 + label: "ember", desc: "warm amber", 161 + dark: { bg:[20,12,8], bgAlt:[28,18,12], bgDim:[14,8,5], fg:220, fgDim:150, fgMute:90, bar:[35,20,12], border:[60,35,20], accent:[255,140,40], ok:[120,220,80], err:[255,70,50], warn:[255,200,60], link:[255,180,100], pad:[28,18,12], padSharp:[18,10,6], padLine:[55,35,22], cursor:[255,120,30] }, 162 + light: { bg:[255,245,230], bgAlt:[255,250,240], bgDim:[245,235,218], fg:40, fgDim:90, fgMute:140, bar:[245,232,215], border:[210,190,160], accent:[200,100,20], ok:[40,140,50], err:[190,40,30], warn:[180,120,20], link:[180,90,20], pad:[250,240,225], padSharp:[238,225,208], padLine:[220,200,175], cursor:[200,90,15] } 163 + } 164 + }; 165 + t.apply = function(id) { 166 + if (!id || id === "default") { 167 + t._overrideId = null; 168 + t._override = null; 169 + } else if (t.presets[id]) { 170 + t._overrideId = id; 171 + t._override = t.presets[id]; 172 + } 173 + t._lastCheck = 0; 174 + return t.update(); 175 + }; 176 + t.update = function() { 177 + const now = Date.now(); 178 + if (now - t._lastCheck < 5000) return t; 179 + t._lastCheck = now; 180 + const h = getLAHour(); 181 + t.dark = (t._forceDark !== undefined) ? !!t._forceDark : (h >= 20 || h < 7); 182 + t.hour = h; 183 + t.bg = t.dark ? [20, 20, 25] : [240, 238, 232]; 184 + t.bgAlt = t.dark ? [28, 28, 30] : [250, 248, 244]; 185 + t.bgDim = t.dark ? [15, 15, 18] : [230, 228, 222]; 186 + t.fg = t.dark ? 220 : 40; 187 + t.fgDim = t.dark ? 140 : 100; 188 + t.fgMute = t.dark ? 80 : 150; 189 + t.bar = t.dark ? [35, 20, 30] : [225, 220, 215]; 190 + t.border = t.dark ? [55, 35, 45] : [200, 195, 190]; 191 + t.accent = t.dark ? [200, 100, 140] : [180, 60, 120]; 192 + t.ok = t.dark ? [80, 255, 120] : [30, 160, 60]; 193 + t.err = t.dark ? [255, 85, 85] : [200, 40, 40]; 194 + t.warn = t.dark ? [255, 200, 60] : [180, 120, 20]; 195 + t.link = t.dark ? [120, 200, 255] : [40, 100, 200]; 196 + t.pad = t.dark ? [28, 28, 30] : [250, 248, 244]; 197 + t.padSharp = t.dark ? [18, 18, 20] : [235, 232, 228]; 198 + t.padLine = t.dark ? [50, 50, 55] : [210, 205, 200]; 199 + t.cursor = t.dark ? [220, 80, 140] : [180, 50, 110]; 200 + if (t._override) { 201 + const mode = t.dark ? t._override.dark : t._override.light; 202 + if (mode) { 203 + for (const key of Object.keys(mode)) t[key] = mode[key]; 204 + } 205 + } 206 + return t; 207 + }; 208 + t.update(); 209 + return t; 210 + })(); 211 + 212 + function makeStorage() { 213 + const fallback = new Map(); 214 + return { 215 + get(key) { 216 + try { 217 + return localStorage.getItem(key); 218 + } catch (_) { 219 + return fallback.has(key) ? fallback.get(key) : null; 220 + } 221 + }, 222 + set(key, value) { 223 + try { 224 + localStorage.setItem(key, value); 225 + } catch (_) { 226 + fallback.set(key, value); 227 + } 228 + } 229 + }; 230 + } 231 + 232 + const storage = makeStorage(); 233 + const storageKey = "ac-native-wasm:files"; 234 + 235 + function loadFiles() { 236 + const raw = storage.get(storageKey); 237 + if (!raw) { 238 + return { 239 + "/mnt/config.json": JSON.stringify({ piece: "prompt", darkMode: "auto" }, null, 2), 240 + "/mnt/wifi_creds.json": "[]" 241 + }; 242 + } 243 + try { 244 + return JSON.parse(raw); 245 + } catch (_) { 246 + return { 247 + "/mnt/config.json": JSON.stringify({ piece: "prompt", darkMode: "auto" }, null, 2), 248 + "/mnt/wifi_creds.json": "[]" 249 + }; 250 + } 251 + } 252 + 253 + const files = loadFiles(); 254 + function persistFiles() { 255 + storage.set(storageKey, JSON.stringify(files)); 256 + } 257 + 258 + const state = { 259 + currentPieceName: "prompt", 260 + currentPiece: null, 261 + currentPieceSpec: "prompt", 262 + currentParams: [], 263 + currentColon: [], 264 + paintCount: 0, 265 + jumpMessage: "", 266 + lastPost: "", 267 + statusText: "booting native prompt", 268 + events: [], 269 + focused: false 270 + }; 271 + 272 + const wifi = { 273 + connected: false, 274 + state: 0, 275 + networks: [], 276 + _connectTimer: null, 277 + scan() { 278 + wifi.state = 3; 279 + setTimeout(() => { 280 + wifi.networks = [ 281 + { ssid: "aesthetic.computer", signal: 92, encrypted: true }, 282 + { ssid: "offline.lab", signal: 44, encrypted: false } 283 + ]; 284 + wifi.state = 0; 285 + }, 120); 286 + }, 287 + connect(ssid, pass) { 288 + wifi.state = 4; 289 + clearTimeout(wifi._connectTimer); 290 + wifi._connectTimer = setTimeout(() => { 291 + wifi.connected = true; 292 + wifi.state = 0; 293 + wifi.networks = [{ ssid, signal: 96, encrypted: !!pass }]; 294 + }, 280); 295 + }, 296 + disconnect() { 297 + clearTimeout(wifi._connectTimer); 298 + wifi.connected = false; 299 + wifi.state = 0; 300 + } 301 + }; 302 + 303 + function normalizeKey(event) { 304 + const key = event.key; 305 + if (key === " ") return "space"; 306 + if (key === "Enter") return "enter"; 307 + if (key === "Backspace") return "backspace"; 308 + if (key === "Delete") return "delete"; 309 + if (key === "Escape") return "escape"; 310 + if (key === "Tab") return "tab"; 311 + if (key === "Shift") return "shift"; 312 + if (key === "ArrowLeft") return "arrowleft"; 313 + if (key === "ArrowRight") return "arrowright"; 314 + if (key === "ArrowUp") return "arrowup"; 315 + if (key === "ArrowDown") return "arrowdown"; 316 + if (key === "Home") return "home"; 317 + if (key === "End") return "end"; 318 + return typeof key === "string" ? key.toLowerCase() : ""; 319 + } 320 + 321 + function makeEvent(type, key) { 322 + return { 323 + key, 324 + type, 325 + is(match) { 326 + return type === match; 327 + } 328 + }; 329 + } 330 + 331 + const blockedKeys = new Set([ 332 + "space", "enter", "backspace", "delete", "escape", "tab", 333 + "arrowleft", "arrowright", "arrowup", "arrowdown", "home", "end" 334 + ]); 335 + 336 + screen.addEventListener("click", () => screen.focus()); 337 + screen.addEventListener("focus", () => { state.focused = true; }); 338 + screen.addEventListener("blur", () => { state.focused = false; }); 339 + window.addEventListener("keydown", (event) => { 340 + const key = normalizeKey(event); 341 + if (!key) return; 342 + if (blockedKeys.has(key) || key.length === 1 || key === "shift") event.preventDefault(); 343 + state.events.push(makeEvent(key === "shift" ? "keyboard:down:shift" : "keyboard:down", key)); 344 + }); 345 + window.addEventListener("keyup", (event) => { 346 + const key = normalizeKey(event); 347 + if (key === "shift") { 348 + event.preventDefault(); 349 + state.events.push(makeEvent("keyboard:up:shift", key)); 350 + } 351 + }); 352 + 353 + const rasterApi = (() => { 354 + let ink = [255, 255, 255, 255]; 355 + function setInk(r = 255, g = 255, b = 255, a = 255) { 356 + ink = [r | 0, g | 0, b | 0, a == null ? 255 : a | 0]; 357 + wasm.set_ink(ink[0], ink[1], ink[2], ink[3]); 358 + } 359 + function clampRect(x, y, w, h) { 360 + let rx = Math.trunc(x); 361 + let ry = Math.trunc(y); 362 + let rw = Math.trunc(w); 363 + let rh = Math.trunc(h); 364 + if (rw < 0) { rx += rw; rw = -rw; } 365 + if (rh < 0) { ry += rh; rh = -rh; } 366 + if (rx < 0) { rw += rx; rx = 0; } 367 + if (ry < 0) { rh += ry; ry = 0; } 368 + if (rx + rw > WIDTH) rw = WIDTH - rx; 369 + if (ry + rh > HEIGHT) rh = HEIGHT - ry; 370 + return rw > 0 && rh > 0 ? [rx, ry, rw, rh] : null; 371 + } 372 + return { 373 + wipe(r, g, b, a = 255) { 374 + setInk(r, g, b, a); 375 + wasm.clear(); 376 + }, 377 + ink(r, g, b, a = 255) { 378 + setInk(r, g, b, a); 379 + }, 380 + box(x, y, w, h, filled = true) { 381 + const rect = clampRect(x, y, w, h); 382 + if (!rect) return; 383 + if (filled) { 384 + wasm.fill_rect(rect[0], rect[1], rect[2], rect[3]); 385 + return; 386 + } 387 + wasm.fill_rect(rect[0], rect[1], rect[2], 1); 388 + wasm.fill_rect(rect[0], rect[1] + rect[3] - 1, rect[2], 1); 389 + wasm.fill_rect(rect[0], rect[1], 1, rect[3]); 390 + wasm.fill_rect(rect[0] + rect[2] - 1, rect[1], 1, rect[3]); 391 + } 392 + }; 393 + })(); 394 + 395 + const textQueue = []; 396 + function drawText(text, options = {}) { 397 + textQueue.push({ 398 + text: String(text), 399 + x: Math.trunc(options.x ?? 0), 400 + y: Math.trunc(options.y ?? 0), 401 + size: options.size ?? 1, 402 + font: options.font ?? "6x10", 403 + color: clone(currentInk) 404 + }); 405 + } 406 + 407 + let currentInk = [255, 255, 255, 255]; 408 + const hostApi = { 409 + wipe(r, g, b, a = 255) { 410 + currentInk = [r | 0, g | 0, b | 0, a | 0]; 411 + rasterApi.wipe(r, g, b, a); 412 + }, 413 + ink(r, g, b, a = 255) { 414 + currentInk = [r | 0, g | 0, b | 0, a | 0]; 415 + rasterApi.ink(r, g, b, a); 416 + }, 417 + box: rasterApi.box, 418 + write: drawText, 419 + screen: { width: WIDTH, height: HEIGHT }, 420 + paintCount: 0, 421 + wifi, 422 + sound: { 423 + speakCached() {}, 424 + speak() {} 425 + } 426 + }; 427 + 428 + function readConfig() { 429 + const raw = files["/mnt/config.json"]; 430 + if (!raw) return {}; 431 + try { 432 + return JSON.parse(raw); 433 + } catch (_) { 434 + return {}; 435 + } 436 + } 437 + 438 + const pieces = {}; 439 + function makeStubPiece(title, describe) { 440 + let lines = []; 441 + return { 442 + boot({ system, params, colon }) { 443 + lines = [ 444 + title, 445 + describe({ params, colon, system }), 446 + "This prototype is running the real native prompt piece,", 447 + "but other native pieces are still browser stubs.", 448 + "Press Backspace or Escape to return." 449 + ]; 450 + }, 451 + act({ event, system }) { 452 + if (event.is("keyboard:down") && (event.key === "backspace" || event.key === "escape")) { 453 + system.jump("prompt"); 454 + } 455 + }, 456 + sim() {}, 457 + paint({ wipe, ink, box, write, screen }) { 458 + const T = globalThis.__theme.update(); 459 + wipe(T.bg[0], T.bg[1], T.bg[2]); 460 + ink(T.accent[0], T.accent[1], T.accent[2]); 461 + box(36, 36, screen.width - 72, screen.height - 72, true); 462 + ink(T.bg[0], T.bg[1], T.bg[2]); 463 + box(40, 40, screen.width - 80, screen.height - 80, true); 464 + ink(T.fg, T.fg, T.fg + 10); 465 + lines.forEach((line, index) => { 466 + write(line, { x: 56, y: 64 + index * 16, size: 1, font: "6x10" }); 467 + }); 468 + } 469 + }; 470 + } 471 + 472 + pieces.lisp = makeStubPiece("lisp", () => "KidLisp eval is not wired into the browser host yet."); 473 + pieces.list = makeStubPiece("list", ({ system }) => "Available offline pieces: " + system.listPieces().join(", ")); 474 + pieces.claude = makeStubPiece("claude", () => "Claude integration is native-only for now."); 475 + pieces.login = makeStubPiece("login", ({ params }) => params[0] ? "Login code: " + params[0] : "No login code provided."); 476 + 477 + const promptUrl = URL.createObjectURL(new Blob([PROMPT_SOURCE], { type: "text/javascript" })); 478 + const promptPiece = await import(promptUrl); 479 + pieces.prompt = promptPiece; 480 + 481 + function normalizeJumpTarget(spec) { 482 + const raw = String(spec || "prompt").trim() || "prompt"; 483 + const colonIndex = raw.indexOf(":"); 484 + const baseSpec = colonIndex >= 0 ? raw.slice(0, colonIndex) : raw; 485 + const colon = colonIndex >= 0 ? raw.slice(colonIndex + 1).split(":").filter(Boolean) : []; 486 + const spaceIndex = baseSpec.indexOf(" "); 487 + const pieceName = (spaceIndex >= 0 ? baseSpec.slice(0, spaceIndex) : baseSpec).toLowerCase(); 488 + const params = spaceIndex >= 0 ? baseSpec.slice(spaceIndex + 1).split(" ").filter(Boolean) : []; 489 + return { pieceName, params, colon, raw }; 490 + } 491 + 492 + const systemApi = { 493 + version: "ac-native wasm prompt prototype", 494 + get sshStarted() { 495 + return false; 496 + }, 497 + listPieces() { 498 + return Object.keys(pieces).sort(); 499 + }, 500 + readFile(filePath) { 501 + return Object.prototype.hasOwnProperty.call(files, filePath) ? files[filePath] : ""; 502 + }, 503 + writeFile(filePath, value) { 504 + files[filePath] = String(value); 505 + persistFiles(); 506 + return true; 507 + }, 508 + saveConfig(key, value) { 509 + const config = readConfig(); 510 + config[key] = value; 511 + files["/mnt/config.json"] = JSON.stringify(config, null, 2); 512 + persistFiles(); 513 + }, 514 + reboot() { 515 + state.statusText = "reboot requested (stubbed in browser)"; 516 + }, 517 + poweroff() { 518 + state.statusText = "poweroff requested (stubbed in browser)"; 519 + }, 520 + startSSH() { 521 + state.statusText = "ssh requested (stubbed in browser)"; 522 + }, 523 + fetchPost(url, body, headers) { 524 + state.lastPost = "POST " + url; 525 + console.log("[ac-native-wasm] fetchPost", { url, body, headers }); 526 + }, 527 + usbMidi: { 528 + status() { 529 + return { enabled: false, active: false, reason: "browser-stub" }; 530 + }, 531 + enable() { 532 + return { enabled: false, active: false, reason: "browser-stub" }; 533 + }, 534 + disable() { 535 + return { enabled: false, active: false, reason: "browser-stub" }; 536 + }, 537 + refresh() { 538 + return { enabled: false, active: false, reason: "browser-stub" }; 539 + } 540 + }, 541 + jump(spec) { 542 + loadPiece(spec); 543 + } 544 + }; 545 + 546 + async function loadPiece(spec) { 547 + const target = normalizeJumpTarget(spec); 548 + state.currentPieceSpec = target.raw; 549 + state.currentPieceName = pieces[target.pieceName] ? target.pieceName : "lisp"; 550 + state.currentParams = target.params; 551 + state.currentColon = target.colon; 552 + state.currentPiece = pieces[state.currentPieceName]; 553 + state.paintCount = 0; 554 + state.jumpMessage = target.raw; 555 + state.statusText = "piece -> " + state.currentPieceName; 556 + if (typeof state.currentPiece.boot === "function") { 557 + state.currentPiece.boot({ 558 + ...hostApi, 559 + system: systemApi, 560 + params: clone(state.currentParams), 561 + colon: clone(state.currentColon) 562 + }); 563 + } 564 + } 565 + 566 + function flushFrame() { 567 + ctx.putImageData(imageData, 0, 0); 568 + ctx.textBaseline = "top"; 569 + ctx.font = "10px 'Iosevka Term', 'IBM Plex Mono', monospace"; 570 + textQueue.forEach((entry) => { 571 + const [r, g, b, a] = entry.color; 572 + ctx.fillStyle = "rgba(" + r + ", " + g + ", " + b + ", " + (a / 255) + ")"; 573 + ctx.fillText(entry.text, entry.x, entry.y); 574 + }); 575 + textQueue.length = 0; 576 + } 577 + 578 + function tick() { 579 + hostApi.paintCount = state.paintCount; 580 + if (typeof state.currentPiece?.sim === "function") { 581 + state.currentPiece.sim({ 582 + ...hostApi, 583 + system: systemApi, 584 + params: clone(state.currentParams), 585 + colon: clone(state.currentColon) 586 + }); 587 + } 588 + 589 + while (state.events.length > 0) { 590 + const event = state.events.shift(); 591 + if (typeof state.currentPiece?.act === "function") { 592 + state.currentPiece.act({ 593 + ...hostApi, 594 + event, 595 + system: systemApi, 596 + params: clone(state.currentParams), 597 + colon: clone(state.currentColon) 598 + }); 599 + } 600 + } 601 + 602 + if (typeof state.currentPiece?.paint === "function") { 603 + state.currentPiece.paint({ 604 + ...hostApi, 605 + system: systemApi, 606 + params: clone(state.currentParams), 607 + colon: clone(state.currentColon) 608 + }); 609 + } 610 + 611 + flushFrame(); 612 + state.paintCount += 1; 613 + const config = readConfig(); 614 + statusLeft.textContent = state.statusText; 615 + statusRight.textContent = [ 616 + wifi.connected ? "wifi: connected" : "wifi: offline", 617 + config.darkMode ? "mode: " + config.darkMode : "mode: auto", 618 + state.focused ? "kbd: focused" : "kbd: click to focus" 619 + ].join(" • "); 620 + requestAnimationFrame(tick); 621 + } 622 + 623 + await loadPiece("prompt"); 624 + statusLeft.textContent = "running prompt.mjs"; 625 + statusRight.textContent = "click canvas to focus keyboard"; 626 + screen.focus(); 627 + requestAnimationFrame(tick); 628 + </script> 629 + </body> 630 + </html>