diff --git a/.gitignore b/.gitignore index 3985af0c71..ea832c14da 100644 --- a/.gitignore +++ b/.gitignore @@ -578,3 +578,6 @@ tmp/nopaint-sheets/ # agent/PR worktree scratch (client work checked out beside the repo) /.tmp-pr*/ + +# Built by `node easel/bin/pack.mjs` from easel/ — derived, not source. +system/public/easel.tar.gz diff --git a/easel/bin/pack.mjs b/easel/bin/pack.mjs new file mode 100755 index 0000000000..4bf4143c83 --- /dev/null +++ b/easel/bin/pack.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// pack — build the tarball easel.sh downloads. +// +// What travels: the source, the bin, the shell integration, the context bundle, +// the licence, the README, and package.json. What does not: tests, the git +// directory, and anything generated. An install is a thing you run, not a +// checkout you develop in — and every file here is a file someone has to be +// allowed to have, so the list is explicit rather than an exclusion glob that +// quietly grows. +// +// node easel/bin/pack.mjs → system/public/easel.tar.gz +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EASEL = join(HERE, ".."); +const REPO = join(EASEL, ".."); +const OUT = join(REPO, "system", "public", "easel.tar.gz"); + +const INCLUDE = ["bin", "src", "shell", "context", "package.json", "README.md", "LICENSE"]; + +const version = JSON.parse(readFileSync(join(EASEL, "package.json"), "utf8")).version; + +for (const entry of INCLUDE) { + try { + statSync(join(EASEL, entry)); + } catch { + console.error(`missing: ${entry}`); + process.exit(1); + } +} + +mkdirSync(dirname(OUT), { recursive: true }); +// --no-mac-metadata and a fixed mtime keep the tarball byte-stable across +// machines, so a rebuild that changed nothing does not look like a new release. +execFileSync("tar", [ + "--no-mac-metadata", + "--no-xattrs", + "-czf", OUT, + "-C", EASEL, + ...INCLUDE, +], { stdio: "inherit" }); + +const size = statSync(OUT).size; +console.log(`easel.tar.gz — v${version}, ${(size / 1024).toFixed(0)} KB`); +console.log(` ${OUT}`); diff --git a/easel/bin/sync-context.mjs b/easel/bin/sync-context.mjs new file mode 100755 index 0000000000..5dda56baa5 --- /dev/null +++ b/easel/bin/sync-context.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// sync-context — copy the Aesthetic Computer authoring guides into easel/context/. +// +// Easel tells the model how to write an AC piece by naming the repo's guides and +// asking it to read them. That works inside the monorepo and nowhere else: the +// lookup is existsSync against the working directory, so an installed Easel +// opened on someone's Desktop passes along no AC knowledge at all. It becomes a +// general-purpose editor that happens to publish to a URL. +// +// So the guides travel with the tool. They are 24 KB of markdown against 224 KB +// of source — a tenth of what is already being shipped — and they are the entire +// reason to install this rather than use the vendor CLI directly. +// +// Copies, not symlinks: a symlink does not survive an npm tarball or a curl +// install. Copies drift, so `npm test` fails when they do, and this script is +// how you fix it. +// +// node bin/sync-context.mjs # write the bundle +// node bin/sync-context.mjs --check # exit 1 if it is stale +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EASEL = join(HERE, ".."); +const REPO = join(EASEL, ".."); +const OUT = join(EASEL, "context"); + +// What a model needs to write a piece, and nothing else. Deliberately not the +// whole repository: this is the knowledge that is about Aesthetic Computer +// rather than about this checkout. +export const BUNDLE = [ + ["SCREEN.md", "screen.md", "how a piece draws on the AC canvas"], + ["HAND.md", "hand.md", "how the code reads"], + ["system/public/aesthetic.computer/disks/CLAUDE.md", "pieces.md", "the piece authoring guide"], + ["kidlisp/README.md", "kidlisp.md", "the KidLisp language"], +]; + +const header = (from, subject) => + `\n\n`; + +export function build() { + mkdirSync(OUT, { recursive: true }); + return BUNDLE.map(([from, to, subject]) => { + const body = header(from, subject) + readFileSync(join(REPO, from), "utf8"); + return { path: join(OUT, to), body, from, to, subject }; + }); +} + +const check = process.argv.includes("--check"); +let stale = 0; +for (const file of build()) { + let current = ""; + try { current = readFileSync(file.path, "utf8"); } catch {} + if (current === file.body) continue; + stale += 1; + if (check) { + console.error(`stale: context/${file.to} no longer matches ${file.from}`); + } else { + writeFileSync(file.path, file.body); + console.log(`wrote context/${file.to} (${(file.body.length / 1024).toFixed(1)} KB)`); + } +} + +if (check && stale) { + console.error(`\n${stale} file(s) stale — run \`npm run context\`.`); + process.exit(1); +} +if (check) console.log("context bundle is current."); +else if (!stale) console.log("context bundle already current."); diff --git a/easel/context/hand.md b/easel/context/hand.md new file mode 100644 index 0000000000..2ede44e7e7 --- /dev/null +++ b/easel/context/hand.md @@ -0,0 +1,106 @@ + + +# The Hand — a code style guide for Aesthetic Computer + +companion to `papers/VOICE.md` and `GRAPHICS.md`. VOICE.md governs how papers *sound*, GRAPHICS.md how a PR or doc *draws*; this governs how the code *reads*. + +it exists because of what *The Hand and the Loop* (`papers/arxiv-hand-and-loop`) found: across three eras the codebase gained velocity and breadth and lost the instrument — the terse, single-mind discipline of the hand-written years. this guide is how the hand gets handed back to the loop. an agent that reads this should write code that knows it's part of an instrument, not just code that runs. + +## the one rule everything else serves + +**a piece of this system should fit in one mind.** terseness here is not golf — it's the compression that comes from knowing the idiom so well you don't need the long name. write small because small is knowable. + +## naming + +- a name that teaches the idiom beats a correct generic one. `choose` over `getRandomKey`. `p2` for a 2d point, `pA`/`pB` for two of them, `c` for a color in a tight loop. +- a name earns its length. module-level and api names can be longer — strangers read them. locals in a tight function stay short — they're read in context. +- playful is allowed. `flip`, `nonvalue`, `anyKey`. a name can have a sense of humor. +- don't rename to satisfy a linter's idea of clarity. clarity here is idiom-fluency, not verbosity. + +## comments + +- explain *why*, not *what*. the code already says what. `// a holder for the logged-in user. (defined in boot)` — factual, parenthetical, sparse. +- no JSDoc ceremony on internal functions. if the signature needs a paragraph, the function is wrong. +- no banner walls. a `// === SECTION ===` every twenty lines is the loop labeling its own output. one short comment where the *why* is non-obvious beats a decorative header. +- a comment that restates the next line is noise. delete it. +- emoji in a comment is a tag, not decoration. the lifecycle headers earn theirs because they're a learned fingering. a brain emoji on a variable does not. + +## the piece spine + +- pieces keep the lifecycle shape: `boot`, `paint`, `act`, `sim`, `leave`. same fingering every time, so any piece is legible at a glance. +- destructure the api in the param list — `function paint({ wipe, ink, screen })` — take what you use, nothing more. +- a piece is a leaf. leaves stay small and bare (see *the gradient*). + +## defensiveness + +the subtle one. the loop's reflex is to wrap everything; the hand's reflex is to let it crash and tell you. neither is always right. + +- in dev, let it throw. a guard that swallows an error you needed to see is worse than the crash. +- guard at *boundaries* — network, user input, audio that should degrade not die. `sound?.synth?.()` is good; a missing synth shouldn't kill the frame. +- don't guard between two lines you wrote that can't be null. a `try`/`catch` around your own pure function is ceremony. +- the test: would this guard ever fire from a cause i can't see at the call site? if no, drop it. + +## file size + +- a file should fit in one head. when it stops fitting, split it — not into a `utils` dumping ground, but into parts that each have a spine. +- foundational libs are where bloat hides and hurts most. `disk.mjs` at 16k lines is not more capable than the system that fit in 5k — it's just less knowable. +- growth from real surface (a new subsystem) is fine. growth from boilerplate, redundant guards, and restated comments is the thing to carve. + +## logging + +- sparse and contextual. a log is for a reader at 2am, not for filling the console. +- one emoji as a tag is fine — `📦 [DISK] imports: 40ms`. a different emoji on every line is the loop decorating. +- delete debug logs before they ship. `rm prompt logs` is a real commit and a good habit. + +## commits + +- lowercase, sound like a person. ``mo `sno` tweaks``. backticks around piece names. +- conventional prefixes (`fix:`/`feat:`) are optional, not a ritual. use one when it genuinely helps scanning; don't perform it. +- co-authorship trailers stay — credit is honest — but the subject line is still yours to write like you mean it. + +## the gradient + +not everything should be carved to the bone. the honest split: + +- **leaves (pieces) — keep them small, let the loop generate them.** a 24-line generated `stripes.mjs` already reads like the hand. don't gold-plate leaves. +- **libs (the foundation) — these are the instrument; a human hand owns them.** legibility here outweighs feature velocity. spend the carving budget here. +- **new infra meant for others to read — a little more structure is fine.** legible-to-strangers is a real goal there; don't force piece-terseness onto a public api someone else maintains. + +--- + +## the rehandify pass (active campaign) + +bring the foundational libs back to a size a single mind can hold — without breaking the live system or wasting effort on leaves that are already fine. + +**what this is, and is not.** the goal is *knowability* — better abstraction, cleaner seams, modules that each fit a head. it is NOT compression for its own sake. terse 5k lines that only fit *one specific* head is not a win over legible 12k — it just trades loop-bloat for author-lock-in, a bus factor of one. and much of the growth was real surface (permission systems, metadata, kidlisp, VST), not fluff — you can't carve features, only ceremony. so the honest carveable target is the *redundant* guards, banner walls, and restated comments, not the line count. and it is NOT safe without tests: `disk.mjs`/`bios.mjs` are under-tested (the paper: ~2.8% of commits touch tests), and refactoring under-tested core for aesthetics is a foot-gun. characterization tests come first or the pass doesn't start. + +the paper named the targets, in priority order by pain: + +1. `disk.mjs` — 16,821 lines (was 5,154). worst offender; the whole api flows through it. +2. `bios.mjs` — 21,679 lines (was 5,211). +3. `kidlisp.mjs` — 15,640 lines. +4. `boot.mjs` — 2,039 lines for a bootstrap (was 203). + +principles for the pass: + +- **behavior-preserving only.** rehandify is refactor, never rewrite. no feature changes ride along. +- **one file, one pass at a time.** never carve two core files in the same change. +- **measured.** every pass reports before/after on the hand metrics (LOC, try/catch count, null guards, comment density, emoji logs), so the work is legible and we don't kid ourselves. +- **test-backed.** a carve lands only behind green tests + a manual run of the surface it touches. core libs have no margin for "looks fine." +- **leaves are out of scope.** pieces are already small. don't touch them. +- **8gb discipline.** one carve at a time; no parallel chromium/builds. + +phases: + +- **phase 0 — the meter + a safety net.** two things, both cheap and both worth it regardless of how far the pass goes. (a) a small script (`toolchain/hand/hand-meter.mjs`) that computes the paper's metrics for any file at HEAD, and at a given git rev so we can compare to the 2023 version — turns "feels bloated" into a number, and shows where ceremony ends and real surface begins. (b) characterization tests around the slice of `disk.mjs`/`bios.mjs` behavior a pass will touch — the precondition for any carve. ~an afternoon for the meter; the tests are ongoing and gate everything downstream. +- **phase 1 — split, don't shrink.** the first carve of a god-file isn't deleting lines, it's finding the seams. pull self-contained regions of `disk.mjs` into sibling modules that each have a spine and fit a head. behavior identical; the win is abstraction and knowability, not a smaller number. this is the safest high-value move and may be *most* of the worthwhile work — if the file is legible as a set of modules, line count stops mattering. +- **phase 2 — collapse the ceremony.** within each module: remove banner walls, restated comments, and redundant guards (the ones that can't fire — keep the boundary ones). restore terse local names where they teach the idiom. every removal justified against *the gradient*. +- **phase 3 — hold the line.** the meter runs in CI (warn, don't block) on the core libs, so new bloat is visible the day it lands instead of a year later. commit-voice and comment discipline come back by habit, helped by this guide being in the loop's context. + +success isn't a line-count target for its own sake — it's `disk.mjs` and `bios.mjs` becoming a set of modules where each one fits in a single mind again. the property the paper says we lost. measure it, don't vibe it. + +--- + +*maintained by @jeffrey — companion to `papers/VOICE.md` and `papers/arxiv-hand-and-loop`. update when the hand evolves.* diff --git a/easel/context/kidlisp.md b/easel/context/kidlisp.md new file mode 100644 index 0000000000..755856822f --- /dev/null +++ b/easel/context/kidlisp.md @@ -0,0 +1,206 @@ + + +# KidLisp + +KidLisp is a minimal Lisp dialect designed for creating generative art and interactive experiences within the Aesthetic Computer system. It provides a simple yet powerful language for expressing visual and temporal patterns. + +## 🗂️ Directory Structure + +### Core Implementation +- **`system/public/aesthetic.computer/lib/kidlisp.mjs`** - The main KidLisp evaluator and runtime +- **`system/netlify/functions/store-kidlisp.mjs`** - Backend storage and retrieval API for KidLisp pieces + +### Tools +- **`kidlisp/tools/`** - Development and analysis tools + - `source-tree.mjs` - Analyzes embedded layer structure and performance characteristics + - `get-source.mjs` - Simple source code fetcher + - `README.md` - Tool documentation + +### Documentation +- **`docs/kidlisp-embed-feature.md`** - Embedding system documentation +- **`docs/kidlisp-feed-feature.md`** - Feed system documentation +- **`reports/tape-command-research.md`** - Analysis of tape recording system and framerate issues + +### Examples & Tests +- **`kidlisp/examples/`** - Example KidLisp pieces and patterns +- **`kidlisp/tests/`** - Test suite for the evaluator + +## 🚀 Quick Start + +### Running KidLisp +KidLisp pieces are executed within the Aesthetic Computer environment. The main entry point is through the `kidlisp.mjs` evaluator. + +### Analyzing Pieces +Use the tools in `kidlisp/tools/` to analyze existing pieces: + +```bash +# Analyze structure and performance +./kidlisp/tools/source-tree.mjs $cow + +# Get raw source code +./kidlisp/tools/get-source.mjs $ican +``` + +Both tools require the dev server to be running (`npm run dev`) as they connect to the local KidLisp storage API. + +## 📖 Language Reference + +### Basic Syntax +KidLisp uses S-expressions (parentheses-based syntax): +```lisp +(function arg1 arg2 ...) +``` + +### Core Functions +The evaluator in `kidlisp.mjs` implements a comprehensive set of **118 functions** across 12 categories: + +- **Graphics**: `wipe`, `ink`, `line`, `box`, `circle`, `tri`, `plot`, `flood`, `shape` +- **Transformations**: `zoom`, `scroll`, `spin`, `blur`, `contrast` - 11 pixel-level effects +- **Math**: `+`, `-`, `*`, `/`, `sin`, `cos`, `random`, `wiggle` - 14 mathematical operations +- **3D Graphics**: `cube`, `form`, `trans` - 8 three-dimensional functions +- **Audio**: `mic`, `melody`, `overtone` - 6 sound processing functions +- **System**: `width`, `height`, `frame`, `clock`, `fps` - 9 system properties +- **Colors**: All CSS colors plus `rainbow`, `zebra` - 19 color functions +- **Control**: `def`, `later`, `if`, `once`, `repeat` - 7 language constructs + +#### Graphics & Transformations +KidLisp provides 11 pixel-level transformation functions: + +- **`(scroll x y)`** - Translation with wrapping +- **`(zoom factor)`** - Scale transformation with accumulation +- **`(spin angle)`** - Rotation around screen center +- **`(blur amount)`** - Gaussian blur effect +- **`(contrast level)`** - Contrast adjustment +- **`(blur amount)`** - Gaussian blur effect + +Examples: +```lisp +(scroll 5 0) ; Move right by 5 pixels +(zoom 1.1) ; Gradual zoom in +(spin 1) ; Slow rotation +(blur 2) ; Soft blur effect +``` + +*For a complete API reference with all 118 functions, see [`COMPLETE_API_MAP.md`](COMPLETE_API_MAP.md).* + +*For detailed transformation documentation, see [`docs/suck-complete-technical-guide.md`](docs/suck-complete-technical-guide.md).* + +## 🔧 Architecture + +### Evaluator (`kidlisp.mjs`) +The main evaluator is a tree-walking interpreter that: +- Parses S-expressions into an AST +- Maintains execution context and variable bindings +- Provides built-in functions for graphics and interaction +- Handles embedded piece loading and execution +- Manages performance optimization for real-time rendering + +### Storage Backend (`store-kidlisp.mjs`) +The storage system provides: +- **POST** - Store new KidLisp pieces +- **GET `?code=nanoid`** - Retrieve single piece by ID +- **GET `?codes=id1,id2,id3`** - Batch retrieve multiple pieces +- Persistent storage with nanoid-based addressing + +### Embedding System +KidLisp supports embedding other pieces as layers: +```lisp +($other-piece-id arg1 arg2) +``` +This enables composition and reusability while maintaining performance through caching. + +## 🔄 Transformation Functions + +KidLisp includes 11 transformation functions for pixel manipulation: + +| Function | Purpose | Example | +|----------|---------|---------| +| `scroll` | Move pixels with wrapping | `(scroll 5 0)` | +| `zoom` | Scale from center | `(zoom 1.1)` | +| `spin` | Rotate canvas | `(spin 1)` | +| `blur` | Gaussian blur | `(blur 2)` | +| `contrast` | Adjust contrast | `(contrast 1.5)` | +| `suck` | Radial displacement | `(suck 0.5)` | +| `pan` | Camera movement | `(pan 10 5)` | +| `sort` | Sort pixels by brightness | `(sort)` | +| `resetSpin` | Reset rotation | `(resetSpin)` | +| `smoothspin` | Smooth rotation | `(smoothspin 0.1)` | +| `unpan` | Reset camera | `(unpan)` | + +```lisp +; Basic transformation examples +(scroll 5 0) ; Move right by 5 pixels +(zoom 1.1) ; Gradual zoom in +(spin 1) ; Slow rotation +(blur 2) ; Soft blur effect +(suck 0.5) ; Radial displacement +``` + +## 🛠️ Development + +### Tools +The `kidlisp/tools/` directory contains utilities for: +- **Source Analysis** - Understanding piece structure and dependencies +- **Performance Profiling** - Identifying expensive operations +- **Debugging** - Inspecting piece execution + +### Testing +Run the test suite to validate evaluator behavior: +```bash +# TODO: Add test runner instructions +``` + +### Contributing +When modifying the evaluator: +1. Test with existing pieces to ensure compatibility +2. Update this documentation for new features +3. Add examples for new language constructs +4. For new transformation functions, follow the established patterns: + - Add to `graph.mjs` with mask support and wrapping behavior + - Add to `kidlisp.mjs` with deferred execution support + - Include in embedded layer special function lists + - Test with both immediate and deferred execution contexts + +**Recent Additions (2025-09-06):** +- Complete documentation organization with 118-function API map +- Development tools for API analysis and function discovery + +## 🚀 Summary + +KidLisp is a comprehensive creative coding language with **118 functions** designed for real-time visual art. Key strengths include: + +- **Rich API**: 12 function categories covering graphics, math, 3D, audio, and system control +- **Live coding**: Interactive development with immediate visual feedback +- **Advanced transformations**: 11 pixel-level effects for visual manipulation +- **Embedded systems**: Code composition and layer management +- **Performance**: Optimized evaluation with caching and deferred execution + +### Quick Start +```lisp +(wipe "navy") ; Set background +(ink "yellow") ; Set drawing color +(repeat 50 i ; Draw 50 circles + (circle (wiggle width) (wiggle height) 10)) +``` + +### Resources +- **[Complete API Map](COMPLETE_API_MAP.md)** - All 118 functions categorized +- **[Function Drilldowns](docs/functions/)** - Detailed guides for specific functions +- **[Development Tools](tools/)** - Analysis and debugging utilities +- **[Technical Reports](reports/)** - Implementation research and comparisons + +## 📚 Related Documentation + +- [Embedding System](docs/features/embedding-system.md) +- [Feed System](docs/features/feed-system.md) +- [Architecture Analysis Reports](reports/) - Technical deep-dives and comparisons + +## 🎯 Philosophy + +KidLisp embodies the aesthetic computer philosophy of simplicity and expressiveness. It provides just enough power to create compelling visual experiences while remaining approachable for beginners and powerful for experts. + +The language is designed to feel immediate and responsive, with changes reflected in real-time. This makes it ideal for live coding, artistic exploration, and interactive installations. + +*KidLisp: Expressive creative coding for the Aesthetic Computer platform.* diff --git a/easel/context/pieces.md b/easel/context/pieces.md new file mode 100644 index 0000000000..0ff4328a82 --- /dev/null +++ b/easel/context/pieces.md @@ -0,0 +1,161 @@ + + +# Pieces — Authoring Guide + +Pieces are the fundamental unit of content in AC. Each piece is a single +`.mjs` or `.lisp` file in this directory. This guide loads when you work +here; the repo-wide rules live in the root `CLAUDE.md`. + +## Piece Structure (JavaScript) + +Pieces export lifecycle functions that receive an API object: + +```javascript +// boot: runs once when the piece loads +function boot({ wipe, screen, params, colon, api }) { + // Initialize state +} + +// paint: runs every frame +function paint({ wipe, ink, line, circle, screen }) { + // Render graphics +} + +// act: handles user input and events +function act({ event: e }) { + if (e.is("keyboard:down:space")) { + // Handle spacebar press + } +} + +// sim: simulation/game logic that runs every frame +function sim() { + // Update game state +} + +export { boot, paint, act, sim }; +``` + +Common lifecycle functions: +- `boot` - Initialization, runs once +- `paint` - Rendering, runs every frame +- `act` - Event handling (input, network, etc.) +- `sim` - Simulation/logic, runs every frame +- `leave` - Cleanup when exiting the piece +- `preview` - Static preview image generation + +## Piece API Surface + +The API is provided through function parameters. Common APIs: +- **Graphics**: `wipe`, `ink`, `line`, `box`, `circle`, `plot`, `paste`, etc. +- **Text**: `write`, `type`, `paste`, `help` +- **Input**: `event`, `pen`, `hand`, `gamepad` +- **Audio**: `sound`, `speaker`, `microphone` +- **UI**: `ui.Button`, `ui.TextInput`, `cursor` +- **System**: `screen`, `params`, `colon`, `store`, `net`, `jump`, `send` + +## Event Handling + +Events use a string-based pattern matching system: +```javascript +event.is("keyboard:down:a") // 'a' key pressed +event.is("touch") // Any touch event +event.is("lift") // Touch/click released +event.is("draw") // Drag with pen down +event.is("keyboard:down:arrowup") // Arrow key +``` + +## State Management + +Pieces maintain state in module-level variables: +```javascript +let score = 0; +let enemies = []; + +function boot() { + // Initialize state +} + +function sim() { + // Update state + score += 1; +} +``` + +## API Requests from Pieces + +Use the `net` API for HTTP requests: +```javascript +function boot({ net }) { + net.pieces("@user/list").then((data) => { + // Handle response + }); +} +``` + +## Multiplayer Networking (Dual-Channel Pattern) + +Multiplayer pieces use both WebSocket (reliable) and UDP (low-latency) +channels. **`squash.mjs` is the canonical implementation — read it before +writing multiplayer code.** Other references: `1v1.mjs` (3D FPS), +`udp.mjs` (minimal UDP test). + +The shape: in `boot({ net: { socket, udp }, handle })`, +- `udp(handler)` opens the low-latency channel for high-frequency position + sync (may drop packets); send with `udpChannel.send("game:move", {...})`. + UDP content may arrive as a JSON string — parse defensively. +- `socket(handler)` opens the reliable channel for join/leave, scoring, + round control; send with `server.send("game:join", {...})`. Handler + receives `(id, type, content)`; watch for `connected*`, `left`, and your + own `game:*` types. + +Note both channels relay through the server — geckos.io is not peer-to-peer. +For frame-critical 1v1 (rollback netcode), see `docs/rollback-netcode.md` and +the `fight` piece; its simulation lives in `../lib/fight/`. + +Session-server routing (`session-server/session.mjs`): +- UDP handlers: add `channel.on("game:move", ...)` in the geckos section +- WebSocket: position messages use `others()` (relay to all except sender), + game events use `everyone()` (catch-all relay) +- Chat invites: typing `'piece-name'` in chat creates a clickable join link + +## UI Components + +```javascript +function boot({ ui: { Button, TextInput } }) { + const btn = new Button("Click me", { box: [10, 10, 100, 40] }); +} + +function act({ event: e }) { + if (btn.trigger(e)) { + // Button was clicked + } +} +``` + +## Creating and Publishing + +```bash +npm run new piece-name "Description" # scaffold from blank.mjs template +``` + +In the AC prompt: +``` +publish # Publish current piece +publish piece-name # Publish with custom name +source # Download blank template +source piece-name # Fork existing piece +``` + +## Notes + +- Prefer `const` destructuring for API parameters to minimize imports +- Graphics are immediate-mode (no retained scene graph); coordinates in pixels +- Default color depth is 8-bit RGB (0-255 per channel) +- `wipe` clears the screen and should be called first in `paint` +- Pieces are URL-addressable: `aesthetic.computer/piece-name`, params via + `piece-name:param1:param2`, user pieces via `@handle/piece-name`, + QR sharing via `share piece-name` +- Leaves (pieces) stay small and can be loop-generated — see `HAND.md` diff --git a/easel/context/screen.md b/easel/context/screen.md new file mode 100644 index 0000000000..71dc272cd9 --- /dev/null +++ b/easel/context/screen.md @@ -0,0 +1,50 @@ + + +# The Screen — a drawing guide for Aesthetic Computer pieces + +companion to `HAND.md`, `GRAPHICS.md` and `papers/VOICE.md`. HAND governs how the code reads, GRAPHICS how a doc draws, VOICE how the papers sound; this governs how a **piece draws on the AC canvas**. + +it exists because of a failure mode that has no other home. a piece can be perfectly written by HAND's rules and still land wrong — a readout under the corner label, a button that eats the back tap, a layout that only fits the screen it was authored on. those are not code-style mistakes. they're screen mistakes, and until now nothing in the repo wrote them down, so every agent rediscovered them by covering the label. + +## the one rule + +**the piece does not own the whole screen.** the system draws on it too, and what the system draws is load-bearing — the label is how you know where you are and how you get back. draw *around* it, or hide it on purpose. never on top of it by accident. + +## the corner label + +the system paints it at `(6, 6)` in the default 6×10 typeface (`lib/disk.mjs`, `defo = 6`). one line of it occupies roughly `y` 6–16. + +- **reserve the top-left ~20 rows.** a top bar is fine if it starts below that; text at `y: 10` is not. +- **the zone grows.** `hud.qr(url)` draws a QR at `(leftPad + 2, 4)` and pushes the label right — same corner, much taller. swiping the label reveals `share`, which extends it rightward. +- **it's interactive.** tapping it navigates back. a button underneath it fights the user for the same pixels and the user loses. +- **you can take it over** rather than dodge it: `hud.label(text, color, offset)` replaces it, `hud.label()` hides it, `hud.tinyLabel()` switches to MatrixChunky8, `hud.suffix(".com")` adds a superscript, `hud.currentLabel()` reads it back. + +so: **readouts go along the bottom, or right-aligned.** that's the default. a top bar is a decision, not a reflex. + +## paint + +- return nothing to keep animating; return `false` to paint once and freeze (`disk.mjs`: `noPaint = paintOut === false || …`). +- `wipe` every frame or never. a half-wiped frame reads as a bug, not as a trail — if you want a trail, draw it. + +## layout + +- **never hardcode a screen size.** phones, desktop panes and the frameless preview are all different, and the same piece meets all three. +- recompute geometry on `reframed`. that event is the only signal you get. +- when rebuilding a `ui.Button` there, reassign `btn.box = new geo.Box(...)` — don't `new ui.Button` over it. a fresh button drops `down`/`over`, and anything it was holding (a note, a drag) hangs. + +## touch + +- `ui.Button` + `btn.act(e, { down, push, over, out, cancel }, pens?.())`. **`pens()` is what makes multitouch work** — leave it off and the piece is one-finger-only on a phone, which is where it's being watched. +- `cancel` is not optional when a button holds something. `lib/ui.mjs` fires it on edge-cancel and global reset; without it, held state survives a gesture that already ended. +- `over` with `btn.up = false` is the idiom for sliding onto a pad mid-gesture. + +## sound + +- a held voice is `synth({ duration: "🔁", … })` and ends with `.kill(fade)`. a fade of `0` clicks. +- **kill every voice in `leave`.** a piece that hands off with a note still ringing rings into the next one. + +## the phone is the review + +pieces are pushed live to a phone as they're saved. that makes two things true: small edits beat one big rewrite, and **the small screen is the real target** — if the layout only works at desktop width, it doesn't work. diff --git a/easel/package.json b/easel/package.json index 2e890b9143..0463813f8b 100644 --- a/easel/package.json +++ b/easel/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { - "test": "node --test test/*.test.mjs && bash test/cli.sh" + "test": "node bin/sync-context.mjs --check && node --test test/*.test.mjs && bash test/cli.sh", + "context": "node bin/sync-context.mjs" } } diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index d0cdb4e0ad..cdee8e0ae5 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import process from "node:process"; import { ACSession } from "./ac-session.mjs"; import { Audience } from "./audience.mjs"; @@ -134,14 +135,36 @@ const STYLE_GUIDES = [ ["HAND.md", "how the code reads"], ]; +// The same knowledge, carried inside the install. A session opened in the +// Aesthetic Computer repository reads the repo's own copies, which are newer by +// definition; a session opened anywhere else — which is every session, once this +// is installed rather than cloned — reads these. Without them Easel is a general +// editor that happens to publish to a URL, and there is no reason to install it +// over the vendor CLI it is already driving. +const BUNDLED_CONTEXT = [ + ["context/pieces.md", "the piece authoring guide"], + ["context/screen.md", "how a piece draws on the AC canvas"], + ["context/hand.md", "how the code reads"], + ["context/kidlisp.md", "the KidLisp language"], +]; + +const easelRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + function styleInstructions() { + // The working directory wins when it has the guides: inside the monorepo they + // are the living documents and the bundle is a stale copy of them. const present = STYLE_GUIDES.filter(([file]) => existsSync(path.join(cwd, file))); - if (present.length === 0) return []; - const named = present + const source = present.length + ? present.map(([file, subject]) => [file, subject]) + : BUNDLED_CONTEXT.map(([file, subject]) => [path.join(easelRoot, file), subject]).filter( + ([file]) => existsSync(file), + ); + if (source.length === 0) return []; + const named = source .map(([file, subject]) => `${file} (${subject})`) .join(" and "); const lines = [ - `Style: this repo's guides are ${named} — read them before writing a piece, and follow them over your own defaults.`, + `Style: the Aesthetic Computer guides are ${named} — read them before writing a piece, and follow them over your own defaults.`, ]; // The one rule that gets broken on a first draft, inlined because a model // that skips the read still has to know it. Lua pieces draw through @@ -1139,9 +1162,14 @@ publishBlankOnce(); // someone types is worse than a published blank. The local file is still // discarded on exit if it was never edited; the published copy stays. live.on("push", () => { + slabSession.flow("live"); if (live.pristine || autopublishBlocker()) return; autopublish.note(live.source()); }); +// A save has landed and the channel has not heard about it yet. The rock's +// neighbour — the preview of the very address the rock encodes — says so, so +// that an old frame never passes for the current one. +live.on("dirty", () => slabSession.flow("ahead")); state.piece = `${live.slug}${live.runtime.extension}`; refreshQr(); audience.start(); diff --git a/easel/test/context.test.mjs b/easel/test/context.test.mjs new file mode 100644 index 0000000000..810f14c032 --- /dev/null +++ b/easel/test/context.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { BUNDLE } from "../bin/sync-context.mjs"; + +const EASEL = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// The bundle is the whole reason an installed Easel is worth having over the +// vendor CLI it drives. A missing file is not a cosmetic problem — it is the +// tool silently becoming general-purpose. +test("every bundled guide is present and carries its provenance", () => { + for (const [from, to, subject] of BUNDLE) { + const path = join(EASEL, "context", to); + assert.ok(existsSync(path), `context/${to} is missing — run npm run context`); + const body = readFileSync(path, "utf8"); + assert.ok(body.includes(from), `context/${to} should name where it came from`); + assert.ok(body.includes(subject), `context/${to} should say what it is for`); + assert.ok(body.length > 500, `context/${to} is suspiciously short`); + } +}); + +test("the bundle is small enough to travel", () => { + const total = BUNDLE.reduce( + (sum, [, to]) => sum + readFileSync(join(EASEL, "context", to), "utf8").length, + 0, + ); + // Not a style rule — a tripwire. If the bundle ever approaches the size of the + // source it ships beside, someone has started shipping the repository. + assert.ok(total < 120_000, `context bundle is ${(total / 1024).toFixed(0)} KB, which is too much to carry`); +}); diff --git a/lith/Caddyfile b/lith/Caddyfile index dca191254a..47f6d982d4 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -728,6 +728,26 @@ redir https://prompt.ac/prompt~channel%20{re.promptacchannel.1}~!autorun 302 } + # The installer and its tarball, served as files from both hosts. Must sit + # above @promptac, which otherwise hands every non-root path to the resolver + # and would turn `curl prompt.ac/easel.sh` into a redirect to a piece. + # + # prompt.ac because it is nine bytes shorter than aesthetic.computer and an + # install line is read aloud and typed by hand more often than it is copied. + @easel_install { + host prompt.ac aesthetic.computer + path /easel.sh /easel.tar.gz + } + handle @easel_install { + root * /opt/ac/system/public + header /easel.sh Content-Type "text/plain; charset=utf-8" + header /easel.tar.gz Content-Type "application/gzip" + # A stale installer is worse than a slow one: it points at a tarball + # that may no longer be there. + header Cache-Control "public, max-age=300" + file_server + } + # --- prompt.ac routes --- # Non-root paths pass through lith's conflict-aware Whistlegraph resolver. # It sends live codes to whistlegraph.org and preserves the historical diff --git a/lith/deploy.fish b/lith/deploy.fish index 79e826f8ad..6910e635ee 100644 --- a/lith/deploy.fish +++ b/lith/deploy.fish @@ -289,6 +289,13 @@ else if test -f $SPACES_ENV_GPG end end +# The tarball easel.sh downloads is built from easel/, not committed, so it is +# packed on the box after the pull. Building it here rather than locally means +# the installer can never point at a version older than the source that shipped +# with it. +echo -e "$GREEN-> Packing the Easel installer tarball...$NC" +ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && node easel/bin/pack.mjs" 2>&1 | tail -2 + echo -e "$GREEN-> Refreshing notepat.com.amxd build stream...$NC" if test $SPACES_READY = true scp -i $SSH_KEY -q $TMP_SPACES $LITH_USER@$TARGET_HOST:/tmp/notepat-spaces.env diff --git a/system/public/easel.sh b/system/public/easel.sh new file mode 100644 index 0000000000..e4bef60322 --- /dev/null +++ b/system/public/easel.sh @@ -0,0 +1,105 @@ +#!/bin/sh +# Easel — the Aesthetic Computer terminal editor. +# +# curl -fsSL https://prompt.ac/easel.sh | sh +# +# What this does, in order, so that reading it takes less time than running it: +# +# 1. checks for node +# 2. downloads one tarball from aesthetic.computer and unpacks it to +# ~/.local/share/easel +# 3. links `ac` and `easel` into ~/.local/bin +# 4. offers to add ~/.local/bin to your PATH, and says how if you decline +# +# It writes nothing outside your home folder, asks for no privileges, and runs +# no `sudo`. If you would rather not pipe a script into a shell — a reasonable +# position — save it, read it, and run it: it is deliberately short enough to +# read in a minute. +# +# POSIX sh on purpose: this has to run under dash, ash, and busybox, not only +# bash, and a login shell on a stripped container is the least forgiving place +# an install can land. + +set -eu + +SITE="${EASEL_SITE:-https://aesthetic.computer}" +PREFIX="${EASEL_PREFIX:-$HOME/.local/share/easel}" +BIN="${EASEL_BIN:-$HOME/.local/bin}" +# Node 18 is where fetch, and therefore both engine bridges, stopped needing a +# flag. Below it nothing works and the failure is obscure, so check up front. +MIN_NODE=18 + +say() { printf '%s\n' "$*"; } +die() { printf '\n %s\n\n' "$*" >&2; exit 1; } + +say "" +say " Easel — the Aesthetic Computer terminal editor" +say "" + +# --- node ------------------------------------------------------------------ +command -v node >/dev/null 2>&1 || die "Easel needs Node $MIN_NODE or newer. https://nodejs.org" +NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0) +[ "$NODE_MAJOR" -ge "$MIN_NODE" ] 2>/dev/null \ + || die "Easel needs Node $MIN_NODE or newer; found $(node -v 2>/dev/null || echo none)." +say " node $(node -v)" + +# --- fetch ----------------------------------------------------------------- +# curl and wget are both common and neither is universal; accept whichever is +# here rather than making the user install one to install this. +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO "$2" "$1"; } +else + die "Need curl or wget to download Easel." +fi + +TMP=$(mktemp -d 2>/dev/null || mktemp -d -t easel) +# Leave nothing behind on any exit path, including a failed download. +trap 'rm -rf "$TMP"' EXIT INT TERM + +say " downloading…" +fetch "$SITE/easel.tar.gz" "$TMP/easel.tar.gz" \ + || die "Could not download $SITE/easel.tar.gz" + +# --- install --------------------------------------------------------------- +# Unpack beside the target and swap, so an interrupted install cannot leave a +# half-written Easel where a working one used to be. +mkdir -p "$TMP/unpack" +tar -xzf "$TMP/easel.tar.gz" -C "$TMP/unpack" || die "Could not unpack the download." +[ -f "$TMP/unpack/bin/easel" ] || die "That download does not look like Easel." + +mkdir -p "$(dirname "$PREFIX")" "$BIN" +rm -rf "$PREFIX.old" +[ -d "$PREFIX" ] && mv "$PREFIX" "$PREFIX.old" +mv "$TMP/unpack" "$PREFIX" +rm -rf "$PREFIX.old" + +chmod +x "$PREFIX/bin/easel" +ln -sfn "$PREFIX/bin/easel" "$BIN/easel" +ln -sfn "$PREFIX/bin/easel" "$BIN/ac" + +VERSION=$(node -p "require('$PREFIX/package.json').version" 2>/dev/null || echo "?") +say " installed Easel $VERSION to $PREFIX" + +# --- path ------------------------------------------------------------------ +# Never edit a shell profile without saying so. The check is on the literal +# directory rather than `command -v`, so a stale hash table cannot fake it. +case ":$PATH:" in + *":$BIN:"*) ON_PATH=1 ;; + *) ON_PATH=0 ;; +esac + +if [ "$ON_PATH" = "0" ]; then + say "" + say " $BIN is not on your PATH. Add this to your shell profile:" + say "" + say " export PATH=\"\$HOME/.local/bin:\$PATH\"" + say "" + say " …or run Easel by its full path: $BIN/ac" +fi + +say "" +say " Run ac to start. Inside it, /login connects your Aesthetic Computer" +say " handle — or makes you one — and /help lists everything else." +say ""