Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 52 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

<p align="center">
<img src="assets/banner.gif" alt="awan introducing awan, with this repo's live numbers" width="700">
<img src="assets/banner.gif" alt="awan walking a contribution year on a GitHub profile" width="700">
</p>

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.

<p align="center">
<sub>
Yes — awan drew that, about itself. It's rendered from
<a href="awan.json"><code>awan.json</code></a> by <a href="profile"><code>profile/</code></a>,
and the numbers on that terminal are this repo's own, refreshed nightly by
<a href=".github/workflows/banner.yml">a workflow</a>. <a href="#profile-gif">Make your own ↓</a>
</sub>
<a href="https://codewithwan.github.io/awan/"><b>→ build yours in the browser</b></a>
</p>

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. 🥚

<!-- The banner up top covers this for now; assets/demo.gif is still here if the
raw terminal loop ever earns the spot back.

Expand All @@ -36,7 +29,47 @@ first run, he hatches out of an egg. 🥚
<p align="center"><sub>One loop of the show, in a real terminal.</sub></p>
-->

## 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.

<p align="center">
<img src="assets/profile-sample.gif" alt="awan telling a profile's story, with that profile's real numbers" width="700">
</p>

<p align="center">
<sub>
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 — <a href="profile">yours would be</a>, nightly.
</sub>
</p>

**[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)
Expand Down Expand Up @@ -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.

<p align="center">
<img src="assets/profile-sample.gif" alt="awan telling a profile's story, with that profile's real numbers" width="700">
</p>

<p align="center">
<sub>
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 — <a href="profile">yours would be</a>, nightly.
</sub>
</p>

### 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.

<p align="center">
<a href="https://codewithwan.github.io/awan/"><b>→ open the editor</b></a>
</p>

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**:
Expand Down
18 changes: 16 additions & 2 deletions web/crate/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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())
Expand Down
28 changes: 26 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -22,13 +25,26 @@ export function App() {
const [cast, setCast] = useDraft<string>("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<Character | null>("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 (
<div className="mx-auto flex min-h-screen max-w-6xl flex-col gap-6 px-4 py-8">
<Header />
<Stepper at={at} onGo={setAt} />

<main className="min-w-0 flex-1">
{drawing && mine ? (
<Studio char={mine} onChange={setMine} onClose={() => setDrawing(false)} />
) : (
<>
{at === 0 && <StepIdentity id={id} onChange={setId} />}
{at === 1 && (
<StepStory
Expand All @@ -37,19 +53,27 @@ export function App() {
cast={cast}
solo={solo}
id={id}
drawn={drawn}
onStory={setStory}
onBeat={setBeat}
onCast={setCast}
onSolo={setSolo}
onDraw={() => {
if (!mine) setMine(starter("blob"));
setCast("mine");
setDrawing(true);
}}
/>
)}
{at === 2 && <StepExport id={id} story={story} cast={cast} />}
{at === 2 && <StepExport id={id} story={story} cast={cast} drawn={drawn} />}
</>
)}
</main>

{/* 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. */}
<nav className="flex gap-2">
<nav className={`flex gap-2 ${drawing ? "hidden" : ""}`}>
{at > 0 && <Button onClick={() => setAt((i) => i - 1)}>‹ back</Button>}
{at < STEPS.length - 1 && (
<Button tone="lime" onClick={() => setAt((i) => i + 1)} className="ml-auto">
Expand Down
105 changes: 105 additions & 0 deletions web/src/character/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useRef, useState } from "react";
import { GLYPHS, H, W, type Character, type Glyph } from "../lib/spec";

/** Each glyph, painted the way the engine paints it: the body colour, thinned.
* `-` and `+` aren't separate colours, they're less of him — so the swatch
* can't be a fixed grey without lying about what you're drawing. */
const ALPHA: Record<Glyph, number> = { " ": 0, "-": 0.38, "+": 0.68, "#": 1, "@": 1 };

export const paintOf = (g: Glyph, body: string, eye: string) =>
g === "@" ? eye : g === " " ? "transparent" : body;

type Props = { char: Character; brush: Glyph; onRows: (rows: string[]) => void };

/** The 10×6 he's made of. Drag to paint; right-drag to erase.
*
* Ten by six is not a limitation to work around — it's the whole grammar. The
* engine blows each pixel up to a 33×30 block, so a stray dot is a brick. */
export function Canvas({ char, brush, onRows }: Props) {
const down = useRef<Glyph | null>(null);
const [hover, setHover] = useState<string | null>(null);

const put = (x: number, y: number, g: Glyph) => {
if (char.rows[y][x] === g) return;
onRows(char.rows.map((row, i) => (i === y ? row.slice(0, x) + g + row.slice(x + 1) : row)));
};

return (
<div
className="nb-tight inline-block bg-void p-2 select-none"
onPointerUp={() => (down.current = null)}
onPointerLeave={() => {
down.current = null;
setHover(null);
}}
onContextMenu={(e) => e.preventDefault()}
>
<div className="grid gap-px" style={{ gridTemplateColumns: `repeat(${W}, minmax(0, 1fr))` }}>
{Array.from({ length: H }).flatMap((_, y) =>
Array.from({ length: W }).map((_, x) => {
const g = char.rows[y][x] as Glyph;
return (
<button
key={`${x}-${y}`}
// a touch that starts on a pixel means paint, never scroll
style={{
touchAction: "none",
aspectRatio: "1",
background: paintOf(g, char.body, char.eye),
opacity: ALPHA[g] || undefined,
}}
className={`min-w-5 border ${
hover === `${x}-${y}` ? "border-sky-ink" : "border-line/40"
} ${g === " " ? "bg-slab/40" : ""}`}
onPointerDown={(e) => {
e.preventDefault();
down.current = e.button === 2 ? " " : brush;
put(x, y, down.current);
}}
onPointerEnter={() => {
setHover(`${x}-${y}`);
if (down.current) put(x, y, down.current);
}}
aria-label={`pixel ${x + 1}, ${y + 1}`}
/>
);
}),
)}
</div>
</div>
);
}

/** 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 (
<div className="flex flex-wrap gap-2">
{GLYPHS.map((g) => (
<button
key={g}
onClick={() => onPick(g)}
className={`nb-btn flex items-center gap-2 px-2 py-1 text-[10px] uppercase ${
brush === g ? "bg-sky text-line" : "bg-slab text-ink"
}`}
>
<span
className="inline-block h-3 w-3 border border-line"
style={{
background: paintOf(g, char.body, char.eye),
opacity: ALPHA[g] || undefined,
}}
/>
{g === " " ? "erase" : g}
</button>
))}
</div>
);
}
Loading
Loading