From 79874ca5c13cf90d81522abdbdbd72d75a9421b5 Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 23:16:35 +0700 Subject: [PATCH 1/2] feat(web): draw a character, watch him walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo has promised 'characters are plain TOML — zero Rust' since the start, in three places, 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, both mine. That's the tell. The spec asks for six fields that look like work — sit_rows, leg_frames, eye_row, mouth_row, legs_row, and the art. Only the art is work. Both existing characters derive from alone, exactly, and I checked that against them rather than assuming: sit_rows is [blank] + rows[:5], leg_frames is [legs, ←1, legs, →1], eye_row is the first row with an '@', legs_row is the last. So the editor asks for a drawing and hands back a character. The first build silently lied. A drawing with no eyes fails the engine's check, and character_of falls back to the built-in buddy — so you drew a cat and watched a cloud walk past, with nothing to tell you why. I'd re-implemented the engine's rules in JavaScript, badly, which is how you get two sources of truth and neither of them trusted. check_spec now hands the engine's own verdict back across the wasm boundary, and the preview refuses to draw a stand-in: it prints what the engine said, which is the same check CI runs. He rides in the zip as characters/.toml with awan.json pointing at him. A config naming a file you don't have isn't a setup. --- web/crate/src/lib.rs | 18 ++++- web/src/App.tsx | 28 +++++++- web/src/character/Canvas.tsx | 105 ++++++++++++++++++++++++++++ web/src/character/Studio.tsx | 128 ++++++++++++++++++++++++++++++++++ web/src/character/Swatch.tsx | 31 ++++++++ web/src/character/starters.ts | 50 +++++++++++++ web/src/lib/characters.ts | 5 +- web/src/lib/config.ts | 7 +- web/src/lib/spec.ts | 111 +++++++++++++++++++++++++++++ web/src/steps/StepExport.tsx | 7 +- web/src/steps/StepStory.tsx | 23 +++--- 11 files changed, 495 insertions(+), 18 deletions(-) create mode 100644 web/src/character/Canvas.tsx create mode 100644 web/src/character/Studio.tsx create mode 100644 web/src/character/Swatch.tsx create mode 100644 web/src/character/starters.ts create mode 100644 web/src/lib/spec.ts 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. +

+
From 684e7fb03526f87280b852c583d4c602b712e9ea Mon Sep 17 00:00:00 2001 From: codewithwan Date: Fri, 17 Jul 2026 00:11:04 +0700 Subject: [PATCH 2/2] docs: say what it is in one sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit snk gets 5,951 stars for 'Generates a snake game from a github user contributions graph'. Nine words on line eight, and you're done reading. Ours opened with 'a tiny living character for your terminal — and a personality layer any CLI can embed: wait, ask, react'. Two products joined by an 'and', jargon nobody searches for, API names in the first breath — and not one mention of GitHub, for a thing whose whole point is a GitHub profile. It's one sentence now: a tiny pixel character that walks your GitHub contribution year. The profile section moves to the front because that's what someone came for, and the terminal moves under it and says out loud that it's the second act. Every badge in the header resolves — checked, including the crates.io one, which reads v0.0.5. The engine is still the interesting part. It just isn't the pitch. --- README.md | 115 ++++++++++++++++++++++++------------------------------ 1 file changed, 52 insertions(+), 63 deletions(-) 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**: