diff --git a/README.md b/README.md index 377f9db..15161c2 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,24 @@ # awan ☁️ -[![CI](https://github.com/codewithwan/awan/actions/workflows/ci.yml/badge.svg)](https://github.com/codewithwan/awan/actions/workflows/ci.yml) -[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license) -[![Build your banner](https://img.shields.io/badge/build%20your%20banner-codewithwan.github.io%2Fawan-39d353.svg)](https://codewithwan.github.io/awan/) +[![Build yours](https://img.shields.io/badge/build%20yours-codewithwan.github.io%2Fawan-39d353.svg?style=flat-square)](https://codewithwan.github.io/awan/) +[![CI](https://img.shields.io/github/actions/workflow/status/codewithwan/awan/ci.yml?label=ci&style=flat-square)](https://github.com/codewithwan/awan/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/awan-cli?style=flat-square)](https://crates.io/crates/awan-cli) +[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg?style=flat-square)](#license) -> A tiny living character for your terminal — and a **personality layer** any -> CLI can embed: `wait`, `ask`, `react`. +A tiny pixel character that walks your GitHub contribution year.

- awan introducing awan, with this repo's live numbers + awan walking a contribution year on a GitHub profile

+He reads your real numbers, brags when the month went well, and goes to sleep +when it didn't. A workflow in **your** repo redraws him nightly — nothing to +host, no account, no token. +

- - Yes — awan drew that, about itself. It's rendered from - awan.json by profile/, - and the numbers on that terminal are this repo's own, refreshed nightly by - a workflow. Make your own ↓ - + → build yours in the browser

-He strolls, bonks into crates, dozes off mid-sit, chases butterflies, freezes -at falling gems, fetches his little oven to bake (and devour) a cake, dances -to a silent beat, juggles a ball until it bonks him dizzy, builds a rocket, -and watches it explode — then shakes off the soot and strolls on. On the very -first run, he hatches out of an egg. 🥚 - -## Quick start +## Put him on your profile + +Three files in the repo named after you. No secrets to set up: the token GitHub +Actions already gives you reads everything he needs. + +

+ awan telling a profile's story, with that profile's real numbers +

+ +

+ + A real profile, not a mock-up: the streak, the readout and the contribution + year are all live numbers a workflow fetched. It's a still preview, so it + isn't refreshed — yours would be, nightly. + +

+ +**[Build it in the browser →](https://codewithwan.github.io/awan/)** Arrange the +beats, watch it play, download the folder. The preview runs the real engine +compiled to wasm, so the frames you see are the frames CI draws — and you can +draw your own character while you're there. + +Nothing is stored and there's no server behind it. Your config lives in your +repo and the workflow runs there, which is also why this page can't break your +banner. + +Prefer files? Copy the ready-made setup and edit one: + +```sh +cp -r profile/sample/. my-profile/ # awan.json + a GitHub Action + a profile README +cargo run -p awan-profile -- whoami --config my-profile/awan.json +``` + +Full walkthrough and the `awan.json` format: **[`profile/`](profile)**. Built as +a separate, opt-in crate, so the core `awan` stays untouched. + +## He also lives in your terminal + +The banner is a side effect: awan is a character engine first, and the same +character runs in a terminal, reacts to your shell, and can be embedded by any +CLI that can spawn a process. ```sh npx @codewithwan/awan demo # try it, no install (needs Node) @@ -125,50 +158,6 @@ Runnable, self-contained examples for each language are in [**`usage/`**](usage) — `cd usage/node && npm install && npm start`, and so on. From your code it's just `awan.react("task.done")`; you never spawn anything. -## Profile GIF - -Turn awan into a **seam-free looping banner for your GitHub profile** — he walks -in and tells your story (builds a rocket, bakes, warms up by a campfire, prints -your numbers, walks his own contribution year, sings karaoke, kicks a ball, -naps), then loops. It's all driven by one editable `awan.json`, scene order -included. - -

- awan telling a profile's story, with that profile's real numbers -

- -

- - A real profile, not a mock-up: the streak, the readout and the contribution - year are all live numbers a workflow fetched. It's a still preview, so it - isn't refreshed — yours would be, nightly. - -

- -### Build one without writing any JSON - -**[codewithwan.github.io/awan](https://codewithwan.github.io/awan/)** — arrange -the beats, watch it play, take the three files. The preview is the engine itself -compiled to wasm, so the frames you're looking at are the frames CI will draw. - -Nothing is stored and there's no server behind it: your config lives in your -repo, and the workflow runs there. That's also why the page can't break your -banner. - -

- → open the editor -

- -Prefer files? Copy the ready-made setup and edit one: - -```sh -cp -r profile/sample/. my-profile/ # awan.json + a GitHub Action + a profile README -cargo run -p awan-profile -- whoami --config my-profile/awan.json -``` - -Full walkthrough and the `awan.json` format: **[`profile/`](profile)**. Built as -a separate, opt-in crate, so the core `awan` stays untouched. - ## Characters Characters are plain TOML — pixel rows plus a palette, **zero Rust**: diff --git a/web/crate/src/lib.rs b/web/crate/src/lib.rs index 9733096..aa37125 100644 --- a/web/crate/src/lib.rs +++ b/web/crate/src/lib.rs @@ -84,9 +84,23 @@ impl Preview { } } +/// Why a spec won't load, in the engine's own words — or `None` if it will. +/// +/// The editor used to re-implement these rules in JavaScript, which meant two +/// sources of truth and one of them wrong: a character with no eyes fell back +/// to the built-in buddy *silently*, so you drew a cat and watched a cloud walk +/// past. The rules live in one place. Ask them. +#[wasm_bindgen] +pub fn check_spec(toml: &str) -> Option { + match awan_core::spec::parse(toml) { + Err(e) => Some(e.to_string()), + Ok(spec) => Character::from_spec(&spec).err().map(|e| e.to_string()), + } +} + /// A character from its TOML spec, or the built-in buddy. A spec that doesn't -/// parse falls back rather than failing: the editor should keep drawing while -/// someone is halfway through breaking their own file. +/// parse falls back rather than failing — callers who care whether that +/// happened should ask [`check_spec`] first, which is what the editor does. fn character_of(toml: Option<&str>) -> Character { toml.and_then(|t| awan_core::spec::parse(t).ok()) .and_then(|s| Character::from_spec(&s).ok()) diff --git a/web/src/App.tsx b/web/src/App.tsx index 5bb68de..47bc7bb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,9 @@ import { GithubMark } from "./ui/GithubMark"; import { StepIdentity } from "./steps/StepIdentity"; import { StepStory } from "./steps/StepStory"; import { StepExport } from "./steps/StepExport"; +import { Studio, slug } from "./character/Studio"; +import { starter, } from "./character/starters"; +import { toToml, type Character } from "./lib/spec"; /** The shell: which step you're on, and the two pieces of state the steps * share. Everything that draws lives somewhere else. */ @@ -22,6 +25,15 @@ export function App() { const [cast, setCast] = useDraft("cast", "awan"); const [beat, setBeat] = useState(-1); const [solo, setSolo] = useState(-1); + // a character you drew, kept with the draft — nobody should lose a drawing to + // a reload either + const [mine, setMine] = useDraft("mine", null); + const [drawing, setDrawing] = useState(false); + + const drawn = mine + ? { id: "mine", label: mine.name || "Yours", blurb: mine.description || "drawn by you", + toml: toToml(mine), path: `characters/${slug(mine.name)}.toml` } + : undefined; return (
@@ -29,6 +41,10 @@ export function App() {
+ {drawing && mine ? ( + setDrawing(false)} /> + ) : ( + <> {at === 0 && } {at === 1 && ( { + if (!mine) setMine(starter("blob")); + setCast("mine"); + setDrawing(true); + }} /> )} - {at === 2 && } + {at === 2 && } + + )}
{/* Neither arrow appears where it has nowhere to go. A button whose only job is to be greyed out is furniture, and the last step's Next was worse than furniture: it implied a fourth step that doesn't exist. */} -
+ + ); +} + +/** The brush. Ordered the way you'd reach for them: nothing, then more of him. */ +export function Brushes({ + brush, + char, + onPick, +}: { + brush: Glyph; + char: Character; + onPick: (g: Glyph) => void; +}) { + return ( +
+ {GLYPHS.map((g) => ( + + ))} +
+ ); +} diff --git a/web/src/character/Studio.tsx b/web/src/character/Studio.tsx new file mode 100644 index 0000000..a365f4b --- /dev/null +++ b/web/src/character/Studio.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { type Character, type Glyph, nits, toToml } from "../lib/spec"; +import { check_spec } from "../wasm/awan_wasm"; +import { Reel } from "../stage/Reel"; +import { Canvas, Brushes } from "./Canvas"; +import { Swatch } from "./Swatch"; +import { STARTERS, starter } from "./starters"; +import { Button } from "../ui/Button"; +import { Card } from "../ui/Card"; +import { Field } from "../ui/Field"; +import { Code } from "../ui/Code"; + +/** Four beats that show what a character has to survive: standing still, + * walking, sitting down, and being pleased with itself. If it reads in these, + * it reads anywhere — every scene works with every character. */ +const AUDITION = [ + { act: "present", say: "this is me" }, + { act: "stroll", say: "walking" }, + { act: "sleep", say: "sitting, dozing" }, + { act: "dance", say: "and pleased about it" }, +]; + +type Props = { char: Character; onChange: (c: Character) => void; onClose: () => void }; + +/** Draw him, and watch him go. + * + * The repo has said "characters are plain TOML — zero Rust" from the start, + * and meant it. But zero Rust still meant typing `#` and `+` into a text file, + * guessing at the shape, and running a build to find out. Two characters + * exist. That's the tell. + */ +export function Studio({ char, onChange, onClose }: Props) { + const [brush, setBrush] = useState("#"); + const [showToml, setShowToml] = useState(false); + const toml = toToml(char); + // the engine's own verdict, in the engine's own words + const broken = check_spec(toml); + const wrong = nits(char); + + return ( +
+ +
+ + onChange({ ...char, rows })} /> + +
+ onChange({ ...char, body })} /> + onChange({ ...char, eye })} /> +
+ +
+ start from + {STARTERS.map((s) => ( + + ))} +
+
+
+ +
+ {broken ? ( +
+
+

he won't load yet

+

{broken}

+

+ That's the engine talking, not us — it's the same check CI runs. +

+
+
+ ) : ( + {}} /> + )} + + +
+ onChange({ ...char, name })} /> + onChange({ ...char, author })} /> + onChange({ ...char, description })} + /> +
+ + {broken || wrong.length ? ( +
    + {broken &&
  • ✕ {broken}
  • } + {wrong.map((w) => ( +
  • + · {w} +
  • + ))} +
+ ) : ( +

✓ he loads — every scene works with him

+ )} + +
+ + +
+ +

+ He rides along in the zip as characters/{slug(char.name)}.toml, + and your awan.json points at him. Nothing else to wire. +

+
+ + {showToml && ( +
+ +
+ )} +
+
+ ); +} + +/** A filename someone can type. */ +export const slug = (name: string) => + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "mine"; diff --git a/web/src/character/Swatch.tsx b/web/src/character/Swatch.tsx new file mode 100644 index 0000000..41db128 --- /dev/null +++ b/web/src/character/Swatch.tsx @@ -0,0 +1,31 @@ +/** One colour of him. Two is the whole palette: body and eye. The engine + * derives the rest — the thinner shades are the body with less of it, and the + * charred/blink/happy faces come out of the eye row. */ +export function Swatch({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + return ( + + ); +} diff --git a/web/src/character/starters.ts b/web/src/character/starters.ts new file mode 100644 index 0000000..9fcaa54 --- /dev/null +++ b/web/src/character/starters.ts @@ -0,0 +1,50 @@ +import { type Character, blankRows } from "../lib/spec"; + +/** Somewhere to start from. + * + * A blank 10×6 is a cruel opening move: you can't tell what the grammar wants + * until you've seen one work. These are shapes, not characters — a body plan + * to push around. The real ones live in characters/ and are the reference. + */ +export const STARTERS: { id: string; label: string; rows: string[]; body: string; eye: string }[] = [ + { + id: "blob", + label: "Blob", + body: "#7C88F0", + eye: "#2D303C", + rows: [" #### ".padEnd(10), " ######## ".slice(0, 10), "##@@##@@##", "###----###", "+########+", " ## ## ## "], + }, + { + id: "cat", + label: "Cat", + body: "#E8963C", + eye: "#3A2A1E", + rows: [" #+ +# ", "+########+", "##@@##@@##", "###----###", "+########+", " # # # # "], + }, + { + id: "bot", + label: "Bot", + body: "#4DD4FF", + eye: "#0D1117", + rows: [" ## ", " ######## ", "#@@####@@#", "#--------#", "+########+", " ## ## "], + }, + { + id: "empty", + label: "Blank", + body: "#39D353", + eye: "#0D1117", + rows: blankRows(), + }, +]; + +export const starter = (id: string): Character => { + const s = STARTERS.find((x) => x.id === id) ?? STARTERS[0]; + return { + name: "", + author: "", + description: "", + body: s.body, + eye: s.eye, + rows: [...s.rows], + }; +}; diff --git a/web/src/lib/characters.ts b/web/src/lib/characters.ts index c3659d9..b19590a 100644 --- a/web/src/lib/characters.ts +++ b/web/src/lib/characters.ts @@ -23,4 +23,7 @@ export const CAST: Cast[] = [ }, ]; -export const castOf = (id: string): Cast => CAST.find((c) => c.id === id) ?? CAST[0]; +/** The cast, plus whoever you've drawn. A drawn character isn't a special case + * in the preview — it's a spec like any other, which is the point of specs. */ +export const castOf = (id: string, mine?: Cast): Cast => + (mine && mine.id === id ? mine : CAST.find((c) => c.id === id)) ?? CAST[0]; diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index caa35c3..f77b540 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -67,8 +67,11 @@ export const README_LINE = "![awan](assets/awan.gif)"; /** The three files, at the paths they belong at. Handing over a zip beats * handing over three clipboards: the paths *are* the instructions, and * ".github/workflows/" is exactly the bit someone gets wrong at midnight. */ -export const files = (id: Identity, story: Scene[], character = "") => ({ - "awan.json": buildConfig(id, story, character), +export const files = (id: Identity, story: Scene[], character = "", drawn?: { path: string; toml: string }) => ({ + "awan.json": buildConfig(id, story, drawn?.path ?? character), ".github/workflows/awan.yml": WORKFLOW, "README.md": `${README_LINE}\n\n# hi, i'm ${id.name || id.username || "you"}\n`, + // a character you drew travels with the config that names it: the zip is the + // setup, and a config pointing at a file you don't have isn't a setup + ...(drawn ? { [drawn.path]: drawn.toml } : {}), }); diff --git a/web/src/lib/spec.ts b/web/src/lib/spec.ts new file mode 100644 index 0000000..4a0a779 --- /dev/null +++ b/web/src/lib/spec.ts @@ -0,0 +1,111 @@ +/** A character, as a picture and two colours — and the TOML that falls out. + * + * The spec asks for six fields that look like work: `sit_rows`, `leg_frames`, + * `eye_row`, `mouth_row`, `legs_row`, plus the standing art. They aren't work. + * Both characters in the repo derive from `rows` alone, exactly, and that was + * checked against them rather than assumed: + * + * sit_rows = [blank] + rows[:5] · he drops a row and loses his legs + * leg_frames = [legs, ←1, legs, →1] · the whole walk cycle + * eye_row = the first row with an '@' + * legs_row = the last row + * + * So the editor asks for a drawing, and hands back a character. + */ + +export const W = 10; +export const H = 6; + +/** The glyph language, in the order a brush should offer them: nothing, then + * progressively more of him, then the eyes. */ +export const GLYPHS = [" ", "-", "+", "#", "@"] as const; +export type Glyph = (typeof GLYPHS)[number]; + +export const GLYPH_NAME: Record = { + " ": "empty", + "-": "light", + "+": "dense", + "#": "solid", + "@": "eye", +}; + +export type Character = { + name: string; + author: string; + description: string; + body: string; + eye: string; + /** H rows of W glyphs. The only thing anyone actually draws. */ + rows: string[]; +}; + +export const blankRows = () => Array.from({ length: H }, () => " ".repeat(W)); + +/** Shift a row one pixel and pad, so the far foot lands where it should. */ +const left = (r: string) => r.slice(1) + " "; +const right = (r: string) => " " + r.slice(0, -1); + +export const eyeRow = (rows: string[]) => rows.findIndex((r) => r.includes("@")); + +/** Nudges the engine has no opinion about — a nameless character loads fine, + * it just isn't finished. Whether a spec is *valid* is the engine's call, not + * ours: see `check_spec`. Two sources of truth is how you end up drawing a cat + * and watching a cloud walk past. */ +export function nits(c: Character): string[] { + const out: string[] = []; + if (!c.name.trim()) out.push("give him a name"); + if (c.rows.every((r) => !r.trim())) out.push("nothing drawn yet"); + return out; +} + +const quote = (s: string) => `"${s.replace(/["\\]/g, "\\$&")}"`; +const rowList = (rows: string[]) => rows.map((r) => ` ${quote(r)},`).join("\n"); + +/** The spec file, ready to commit. */ +export function toToml(c: Character): string { + // -1 when nothing has eyes yet. Emitted as-is rather than papered over with + // 0: the engine will say "rows[0] must contain '@' eyes", which is true and + // useful, where a quietly-wrong 0 was neither. + const er = eyeRow(c.rows); + const legs = c.rows[H - 1]; + return `# ${c.name} — drawn at codewithwan.github.io/awan +# Glyph language: '#' solid · '+' dense · '-' light · '@' eye · ' ' empty. +# Face variants (blinks, glances, happy eyes, open mouth) are derived by the +# engine from the eye/mouth rows. + +spec_version = 1 + +[character] +name = ${quote(c.name)} +author = ${quote(c.author)} +description = ${quote(c.description)} + +[character.palette] +body = ${quote(c.body.toUpperCase())} +eye = ${quote(c.eye.toUpperCase())} + +[sprite] +rows = [ +${rowList(c.rows)} +] +sit_rows = [ +${rowList([" ".repeat(W), ...c.rows.slice(0, H - 1)])} +] +leg_frames = [ +${rowList([legs, left(legs), legs, right(legs)])} +] +eye_row = ${er} +mouth_row = ${Math.min(Math.max(er, 0) + 1, H - 2)} +legs_row = ${H - 1} + +[personality] +blink_rate = 1.0 +walk_speed = 1.0 +chaos = 0.2 + +[reactions] +"cmd.failed" = "charred" +"task.done" = "celebrate" +"idle" = "sleep" +`; +} diff --git a/web/src/steps/StepExport.tsx b/web/src/steps/StepExport.tsx index 7a87f03..beb2ff6 100644 --- a/web/src/steps/StepExport.tsx +++ b/web/src/steps/StepExport.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { files, type Identity } from "../lib/config"; import { downloadZip } from "../lib/zip"; -import { castOf } from "../lib/characters"; +import { castOf, type Cast } from "../lib/characters"; import type { Scene } from "../lib/acts"; import { Card } from "../ui/Card"; import { Button } from "../ui/Button"; @@ -9,9 +9,10 @@ import { Code } from "../ui/Code"; /** The whole setup, as a folder — or a file at a time, if that's your way of * working. The zip is the fast path, not the only one. */ -export function StepExport({ id, story, cast }: { id: Identity; story: Scene[]; cast: string }) { +export function StepExport({ id, story, cast, drawn }: { id: Identity; story: Scene[]; cast: string; drawn?: Cast }) { const you = id.username.trim(); - const bundle = files(id, story, castOf(cast).path); + const useMine = cast === "mine" && drawn; + const bundle = files(id, story, castOf(cast, drawn).path, useMine ? { path: drawn.path, toml: drawn.toml } : undefined); return ( diff --git a/web/src/steps/StepStory.tsx b/web/src/steps/StepStory.tsx index e08b942..977386b 100644 --- a/web/src/steps/StepStory.tsx +++ b/web/src/steps/StepStory.tsx @@ -1,6 +1,6 @@ import type { Scene } from "../lib/acts"; import type { Tokens } from "../lib/sample"; -import { CAST, castOf } from "../lib/characters"; +import { CAST, castOf, type Cast } from "../lib/characters"; import { Reel } from "../stage/Reel"; import { Meter } from "../stage/Meter"; import { SceneList } from "../story/SceneList"; @@ -13,15 +13,17 @@ type Props = { cast: string; solo: number; id: Tokens; + drawn?: Cast; onStory: (s: Scene[]) => void; onBeat: (i: number) => void; onCast: (id: string) => void; onSolo: (i: number) => void; + onDraw: () => void; }; /** The reel, and everything that changes it. The preview leads: it's the only * thing here that tells you whether any of this was a good idea. */ -export function StepStory({ story, beat, cast, solo, id, onStory, onBeat, onCast, onSolo }: Props) { +export function StepStory({ story, beat, cast, solo, id, drawn, onStory, onBeat, onCast, onSolo, onDraw }: Props) { // solo plays one beat on its own — deleting the rest to see a scene means // rebuilding the story afterwards, which is a rotten way to look at anything const shown = solo >= 0 && story[solo] ? [story[solo]] : story; @@ -29,14 +31,14 @@ export function StepStory({ story, beat, cast, solo, id, onStory, onBeat, onCast return (
- onBeat(solo >= 0 ? solo : i)} /> + onBeat(solo >= 0 ? solo : i)} /> onSolo(i === solo ? -1 : i)} />
- {CAST.map((c) => ( + {[...CAST, ...(drawn ? [drawn] : [])].map((c) => ( ))}
-

- Every scene works with every character. Pick one and the whole reel restyles — adding to - the cast is TOML only. -

+
+ +

+ Every scene works with every character — bake, sing, juggle, nap. A character is ten + by six pixels and two colours; the engine derives the rest. Yours travels in the zip. +

+