From 03069c54c3cc8f1fcebfe93c02573176389810c8 Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 19:53:38 +0700 Subject: [PATCH 1/8] feat(web): a config editor that runs the real engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing awan.json blind is the friction that's left. The Action already renders and commits the GIF; what nobody can do is picture the reel before they have one, so scene order and wording get chosen by guessing and then never revisited. This is a static page — arrange the beats, watch it play, take three files. It has to stay static. The Action's whole promise is that a banner keeps working whether or not we do: the config lives in the reader's repo and the workflow runs on their runner. A page with a server behind it is a page that can go down and take READMEs with it. The preview is not a mock-up. web/crate is a wasm-bindgen bridge over awan-core, which survives the trip because the engine is a pure function of (tick, character) — no clock, no RNG, no I/O. The canvas draws the frames CI draws. overlays.ts does for the canvas what profile/src does for the GIF, since the engine publishes a scene's shapes and leaves its words to whoever is rendering; without that, the two headline acts play to an empty stage. The length meter exists because a reel gets too long one innocent beat at a time, and the sample had reached 96 seconds without anyone deciding to. Every act carries its cost on the label now, and the total says out loud when nobody will watch it. The calendar behind the year wall is the one thing the page can't know: it lives in GraphQL, GraphQL wants a token, and no cartoon is worth pasting a token into a web page for. It shows a seeded year and says so. --- .github/workflows/web.yml | 60 ++ web/.gitignore | 11 + web/README.md | 44 + web/crate/Cargo.lock | 240 +++++ web/crate/Cargo.toml | 23 + web/crate/src/lib.rs | 169 ++++ web/index.html | 13 + web/package-lock.json | 1826 +++++++++++++++++++++++++++++++++++++ web/package.json | 28 + web/src/App.tsx | 115 +++ web/src/Caption.tsx | 27 + web/src/Identity.tsx | 49 + web/src/Meter.tsx | 42 + web/src/Output.tsx | 116 +++ web/src/Preview.tsx | 138 +++ web/src/SceneList.tsx | 141 +++ web/src/Shelf.tsx | 27 + web/src/acts.ts | 73 ++ web/src/main.tsx | 10 + web/src/overlays.ts | 87 ++ web/src/sample.ts | 55 ++ web/src/theme.css | 57 ++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 18 + web/vite.config.ts | 13 + 25 files changed, 3383 insertions(+) create mode 100644 .github/workflows/web.yml create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/crate/Cargo.lock create mode 100644 web/crate/Cargo.toml create mode 100644 web/crate/src/lib.rs create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/Caption.tsx create mode 100644 web/src/Identity.tsx create mode 100644 web/src/Meter.tsx create mode 100644 web/src/Output.tsx create mode 100644 web/src/Preview.tsx create mode 100644 web/src/SceneList.tsx create mode 100644 web/src/Shelf.tsx create mode 100644 web/src/acts.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/overlays.ts create mode 100644 web/src/sample.ts create mode 100644 web/src/theme.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 0000000..4001a32 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,60 @@ +name: web + +# Builds the config editor and publishes it to Pages. It is a static bundle by +# design: the page can never be the reason someone's README breaks, because +# there is nothing running for it to break. Their config lives in their repo and +# the workflow runs there. +on: + push: + branches: [main] + paths: ["web/**", "crates/awan-core/**", ".github/workflows/web.yml"] + pull_request: + paths: ["web/**", "crates/awan-core/**"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + workspaces: web/crate + - uses: jetli/wasm-pack-action@v0.4.0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + working-directory: web + - run: npm run wasm + working-directory: web + - run: npm run build + working-directory: web + + - uses: actions/upload-pages-artifact@v3 + if: github.ref == 'refs/heads/main' + with: + path: web/dist + + deploy: + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..7bf9e26 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ + +# wasm-pack output — rebuilt by `npm run wasm` +src/wasm/ + +# the crate is its own workspace, so the root's /target rule misses this one +crate/target/ + +# tsc incremental cache +tsconfig.tsbuildinfo diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..6386171 --- /dev/null +++ b/web/README.md @@ -0,0 +1,44 @@ +# awan web — build your banner + +The config editor. Arrange the scenes, watch it play, take the three files. + +**It is a static page and must stay one.** The whole promise of the Action is +that your banner keeps working whether or not we do: your `awan.json` lives in +your repo and the workflow runs on your runner. The moment this page grows a +server, it becomes something that can go down and take READMEs with it. So: +no API routes, no database, no accounts, no stored links. + +## The preview is the engine + +`crate/` is a thin `wasm-bindgen` bridge over `awan-core`. The engine is a pure +function of `(tick, character)` — no clock, no RNG, no I/O — which is why it +survives the trip to wasm, and why the canvas here draws the same frames CI +does rather than an impression of them. + +That split matters for the overlays too. The engine draws a scene's *shapes* +and leaves its *words and numbers* to the renderer; `overlays.ts` is that +renderer, doing for the canvas what `profile/src/` does for the GIF. + +## What the preview can't know + +The stats a profile shows are one unauthenticated call away, but the +contribution calendar lives only in GraphQL, and GraphQL wants a token. Nobody +should paste a token into a web page to look at a cartoon, so the preview uses +a plausible year from a fixed seed and says so on the page. CI has a token of +its own and fills in the real one. + +## Running it + +```sh +npm install +npm run wasm # wasm-pack build → src/wasm (gitignored) +npm run dev +``` + +`npm run wasm` needs [`wasm-pack`](https://rustwasm.github.io/wasm-pack/) and +the `wasm32-unknown-unknown` target. + +`crate/` is its own workspace on purpose: wasm-bindgen's dependency tree is +heavier than the engine's MSRV, and the root workspace builds every member on +Rust 1.85. Keeping it out means the web app moves without dragging the engine's +floor up with it. diff --git a/web/crate/Cargo.lock b/web/crate/Cargo.lock new file mode 100644 index 0000000..0d6ca6e --- /dev/null +++ b/web/crate/Cargo.lock @@ -0,0 +1,240 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "awan-core" +version = "0.0.5" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "awan-wasm" +version = "0.0.5" +dependencies = [ + "awan-core", + "wasm-bindgen", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/web/crate/Cargo.toml b/web/crate/Cargo.toml new file mode 100644 index 0000000..3b9ef7b --- /dev/null +++ b/web/crate/Cargo.toml @@ -0,0 +1,23 @@ +# Its own workspace on purpose: wasm-bindgen's dependency tree is heavier than +# the engine's MSRV allows, and the root workspace's CI matrix builds every +# member on Rust 1.85. Keeping this out means the web app can move fast without +# dragging the engine's floor up with it. +[workspace] + +[package] +name = "awan-wasm" +version = "0.0.5" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +awan-core = { path = "../../crates/awan-core" } +wasm-bindgen = "0.2" + +[profile.release] +opt-level = "s" +lto = true diff --git a/web/crate/src/lib.rs b/web/crate/src/lib.rs new file mode 100644 index 0000000..dca05ba --- /dev/null +++ b/web/crate/src/lib.rs @@ -0,0 +1,169 @@ +//! The engine, in the browser. +//! +//! A thin bridge over [`awan_core`] so the config editor can show a real reel +//! rather than a mock-up. The engine is a pure function of `(tick, character)` +//! — no clock, no RNG, no I/O — which is exactly why it survives the trip to +//! wasm at all, and why the frames drawn here are the same frames CI draws. +//! +//! It hands JavaScript flat buffers rather than objects: one allocation per +//! frame instead of 384, which keeps a 60fps canvas loop honest. + +use awan_core::{Act, Character, Reel}; +use wasm_bindgen::prelude::*; + +/// A reel built from a story, ready to draw. +#[wasm_bindgen] +pub struct Preview { + reel: Reel, + cols: usize, + rows: usize, +} + +#[wasm_bindgen] +impl Preview { + /// Build a reel from act names — the same strings a reader writes in + /// `awan.json`. Unknown names fall back to `present`, so a typo costs you a + /// beat rather than the whole preview. + #[wasm_bindgen(constructor)] + pub fn new(acts: Vec) -> Preview { + let acts: Vec = acts.iter().map(|a| act_of(a)).collect(); + let reel = Reel::story(Character::default(), &acts); + let (cols, rows, _) = reel.pixel_grid(0); + Preview { reel, cols, rows } + } + + /// Frames in one loop. The last frame is byte-identical to the first, so + /// the canvas can wrap without a seam. + pub fn ticks(&self) -> i32 { + self.reel.ticks() + } + + pub fn cols(&self) -> usize { + self.cols + } + + pub fn rows(&self) -> usize { + self.rows + } + + /// The canvas at tick `t`, as `cols * rows * 4` bytes of RGBA. An empty + /// cell is transparent, so the page's own background shows through and the + /// caller doesn't have to know ours. + pub fn frame(&self, t: i32) -> Vec { + let (cols, rows, cells) = self.reel.pixel_grid(t); + let mut out = vec![0u8; cols * rows * 4]; + for (i, cell) in cells.iter().enumerate() { + if let Some([r, g, b]) = *cell { + out[i * 4] = r; + out[i * 4 + 1] = g; + out[i * 4 + 2] = b; + out[i * 4 + 3] = 255; + } + } + out + } + + /// Which beat is playing at tick `t`, or `-1` while he's walking on or off. + /// The editor uses it to highlight the scene being watched. + pub fn beat_at(&self, t: i32) -> i32 { + self.reel.act_at(t).map_or(-1, |(i, _)| i as i32) + } + + /// The tick within the current beat, or `-1` off-beat. Overlays that time + /// themselves — the readout typing, the wall rising — key off this. + pub fn beat_tick(&self, t: i32) -> i32 { + self.reel.act_at(t).map_or(-1, |(_, k)| k) + } + + /// True while he's walking out at the end, when the reel says its goodbye + /// line instead of the beat's. + pub fn is_leaving(&self, t: i32) -> bool { + self.reel.is_leaving(t) + } +} + +/// How long a story runs, without building it — so the editor can price a beat +/// before you commit to it. That is the whole reason a reel gets too long: you +/// cannot feel the cost of an act while you're adding it. +#[wasm_bindgen] +pub fn story_ticks(acts: Vec) -> i32 { + Preview::new(acts).ticks() +} + +/// Where the overlays go, and how far along they are. +/// +/// The engine draws a scene's *shapes* and leaves its *words and numbers* to +/// whoever is rendering — the GIF encoder does exactly this, and so must the +/// preview, or the two headline acts play to an empty stage. These mirror what +/// `awan-core` publishes for the profile generator. +#[wasm_bindgen] +pub fn stats_panel() -> Vec { + let (x, y, w, h) = awan_core::stats::PANEL; + vec![x, y, w, h] +} + +/// How many characters of readout line `i` have typed out at tick `k`. +#[wasm_bindgen] +pub fn stats_chars_at(k: i32, i: usize) -> usize { + awan_core::stats::chars_at(k, i) +} + +/// True while line `i` is still typing, so the preview parks a cursor there. +#[wasm_bindgen] +pub fn stats_typing(k: i32, i: usize) -> bool { + awan_core::stats::typing(k, i) +} + +#[wasm_bindgen] +pub fn stats_slots() -> usize { + awan_core::stats::SLOTS +} + +/// The wall's band, in cells: `[x, y, w, h]`. +#[wasm_bindgen] +pub fn wall_band() -> Vec { + let (x, y, w, h) = awan_core::contributions::WALL; + vec![x, y, w, h] +} + +/// How far up the wall is at tick `k`, 0-100. +#[wasm_bindgen] +pub fn wall_fade(k: i32) -> u32 { + awan_core::contributions::fade_pct(k) +} + +/// How lit the last thirty days are at tick `k`, 0-100. +#[wasm_bindgen] +pub fn wall_glow(k: i32) -> u32 { + awan_core::contributions::glow_pct(k) +} + +/// Columns, rows, and how many days on the end get the spotlight. +#[wasm_bindgen] +pub fn wall_shape() -> Vec { + vec![ + awan_core::contributions::WEEKS, + awan_core::contributions::DAYS, + awan_core::contributions::RECENT, + ] +} + +/// The act vocabulary, mirrored from the profile generator. Kept as strings on +/// purpose: `awan.json` is the contract, not our enum. +fn act_of(name: &str) -> Act { + match name { + "wave" => Act::Wave, + "stroll" => Act::Stroll, + "rocket" => Act::RocketBuild, + "launch" => Act::RocketLaunch, + "bake" => Act::Bake, + "sing" => Act::Sing, + "campfire" => Act::Campfire, + "stats" => Act::Stats, + "contributions" => Act::Contributions, + "sleep" => Act::Sleep, + "dance" => Act::Dance, + "soccer" => Act::Soccer, + _ => Act::Present, + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5f54c9f --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + awan — build your banner + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..5e2285b --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1826 @@ +{ + "name": "awan-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "awan-web", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "vite-plugin-wasm": "^3.6.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", + "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..9772718 --- /dev/null +++ b/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "awan-web", + "private": true, + "type": "module", + "scripts": { + "wasm": "wasm-pack build --release --target web --out-dir ../src/wasm crate", + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "vite-plugin-wasm": "^3.6.0" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "react": "^19.2.7", + "react-dom": "^19.2.7" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..871f97c --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { DEFAULT_STORY, type Scene } from "./acts"; +import { Preview } from "./Preview"; +import { SceneList } from "./SceneList"; +import { Shelf } from "./Shelf"; +import { Meter } from "./Meter"; +import { Identity } from "./Identity"; +import { Output, type Identity as Id } from "./Output"; + +const BLANK: Id = { + handle: "", + name: "", + role: "", + location: "", + stack: "", + song: "", + artist: "", + lyrics: [], +}; + +export function App() { + const [story, setStory] = useState(DEFAULT_STORY); + const [id, setId] = useState(BLANK); + const [beat, setBeat] = useState(-1); + + return ( +
+
+ +
+
+ + +
+ +
+ + + + + setStory([...story, s])} /> + + + + +
+
+ +
+ + + +
+ +
+
+ ); +} + +function Header() { + return ( +
+

+ awan +

+

+ a tiny living character for your GitHub profile +

+ + source ↗ + +
+ ); +} + +function Panel({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {hint && {hint}} +
+ {children} +
+ ); +} + +function Footer() { + return ( +
+

+ The preview runs the real engine, compiled to wasm — the frames above are the frames CI + draws. Its numbers are stand-ins: the calendar behind the year wall lives in an API that + wants a token, and no cartoon is worth pasting a token into a web page for. CI has one of + its own, and fills in yours. +

+

+ Nothing here is stored, and nothing is sent anywhere — the page has no server to send it to. + That's also why it can't break your README: your config lives in your repo, and the workflow + runs there. +

+
+ ); +} diff --git a/web/src/Caption.tsx b/web/src/Caption.tsx new file mode 100644 index 0000000..b9ff412 --- /dev/null +++ b/web/src/Caption.tsx @@ -0,0 +1,27 @@ +import type { Scene } from "./acts"; +import { fill } from "./sample"; + +/** The line under the ground. The wall's `then` takes over the moment the + * spotlight lands — tick 40 of that beat, where `glow_pct` first rises — so + * the preview tells the same joke at the same time CI does. */ +const GLOW_AT = 40; + +export function Caption({ scene, leaving, k }: { scene?: Scene; leaving: boolean; k: number }) { + const text = leaving + ? "thanks for stopping by ~" + : !scene + ? "" + : scene.act === "sing" + ? 'my fav song "…" — sung karaoke-style' + : scene.then && k >= GLOW_AT + ? scene.then + : (scene.say ?? ""); + + return ( +
+ + {fill(text)} + {text && } +
+ ); +} diff --git a/web/src/Identity.tsx b/web/src/Identity.tsx new file mode 100644 index 0000000..901f5ab --- /dev/null +++ b/web/src/Identity.tsx @@ -0,0 +1,49 @@ +import type { Identity as Id } from "./Output"; + +const FIELDS: { key: keyof Id; label: string; hint: string }[] = [ + { key: "handle", label: "handle", hint: "codewithwan" }, + { key: "name", label: "name", hint: "what he calls you" }, + { key: "role", label: "role", hint: "fullstack engineer" }, + { key: "location", label: "location", hint: "Indonesia" }, + { key: "stack", label: "stack", hint: "Rust, Go & TypeScript" }, + { key: "song", label: "song", hint: "your favourite" }, + { key: "artist", label: "artist", hint: "who sings it" }, +]; + +/** Who he's talking about. These fill the `{tokens}` a caption carries; the + * numbers are CI's job, not yours. */ +export function Identity({ id, onChange }: { id: Id; onChange: (id: Id) => void }) { + return ( +
+ {FIELDS.map((f) => ( + + ))} +
+ lyrics — he sings these, one line at a time + {[0, 1, 2].map((i) => ( + { + const lyrics = [...id.lyrics]; + lyrics[i] = e.target.value; + onChange({ ...id, lyrics }); + }} + className="border-2 border-edge bg-void px-2 py-1 text-xs text-ink + outline-none placeholder:text-edge focus:border-sky" + /> + ))} +
+
+ ); +} diff --git a/web/src/Meter.tsx b/web/src/Meter.tsx new file mode 100644 index 0000000..aa17e51 --- /dev/null +++ b/web/src/Meter.tsx @@ -0,0 +1,42 @@ +import { TICK_MS, actInfo, type Scene } from "./acts"; + +/** How long the loop runs, and roughly what it weighs. + * + * A profile banner is not a film. Nobody scrolls past your README and waits + * fifty seconds to find out you like football — and every second is bytes the + * reader pays for. The bands are drawn from real renders: 20s ≈ 444 KB, 31s ≈ + * 532 KB, 53s ≈ 705 KB, 77s ≈ 936 KB. */ +const BANDS = [ + { max: 25, label: "tight", tone: "text-lime", note: "people watch this one to the end" }, + { max: 40, label: "comfortable", tone: "text-sky", note: "a good length for a profile" }, + { max: 60, label: "long", tone: "text-gold", note: "the last beats rarely get seen" }, + { max: Infinity, label: "too long", tone: "text-punch", note: "nobody waits this long — cut a beat or two" }, +]; + +export function Meter({ story }: { story: Scene[] }) { + const ticks = story.reduce((n, s) => n + actInfo(s.act).ticks, 0) + 22; // walk on + off + const secs = (ticks * TICK_MS) / 1000; + const band = BANDS.find((b) => secs <= b.max)!; + const kb = Math.round(180 + secs * 9.8); // measured: GIF grows ~9.8 KB a second + + return ( +
+
+ {secs.toFixed(0)}s + {band.label} + ≈ {kb} KB +
+
+ {story.map((s, i) => ( +
+ ))} +
+

{band.note}

+
+ ); +} diff --git a/web/src/Output.tsx b/web/src/Output.tsx new file mode 100644 index 0000000..9d9c769 --- /dev/null +++ b/web/src/Output.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import type { Scene } from "./acts"; + +/** Everything CI writes for you. Shipping them as zeroes rather than invented + * numbers is the point: a made-up streak is decoration, and this whole thing + * is supposed to draw the real one. */ +const CI_FILLED = { + streak: 0, + stats: [], + contributions: "", + contrib_year: 0, + contrib_recent: 0, +}; + +export type Identity = { + handle: string; + name: string; + role: string; + location: string; + stack: string; + song: string; + artist: string; + lyrics: string[]; +}; + +export const buildConfig = (id: Identity, story: Scene[]) => + JSON.stringify( + { + ...id, + lyrics: id.lyrics.filter(Boolean), + ...CI_FILLED, + output: "assets/awan.gif", + scenes: story.map((s) => ({ + act: s.act, + ...(s.say ? { say: s.say } : {}), + ...(s.then ? { then: s.then } : {}), + })), + }, + null, + 2, + ) + "\n"; + +export const WORKFLOW = `name: awan profile +on: + push: + branches: [main, master] + paths: ["awan.json"] + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + awan: + uses: codewithwan/awan/.github/workflows/profile.yml@v0 + permissions: + contents: write + with: + brag_over: 100 + brag_say: "i'm so excited!" + cope_say: "...i'll fix that, promise" +`; + +export function Output({ id, story }: { id: Identity; story: Scene[] }) { + const config = buildConfig(id, story); + return ( +
+

+ Three files in the repo named after you — {id.handle || "you"}/ + {id.handle || "you"}. No secrets to set up: the token Actions already gives you + reads everything this needs. +

+ + + +

+ Push, and it draws itself — then again every night, with your real numbers. Want it frozen + instead? Point at @v0.0.5 rather than{" "} + @v0: that tag pins the renderer too, so nothing changes + under you. +

+
+ ); +} + +function File({ name, body, note }: { name: string; body: string; note?: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + await navigator.clipboard.writeText(body); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }; + const download = () => { + const url = URL.createObjectURL(new Blob([body], { type: "text/plain" })); + const a = document.createElement("a"); + a.href = url; + a.download = name.split("/").pop()!; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ {name} + {note && {note}} + + +
+
{body}
+
+ ); +} diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx new file mode 100644 index 0000000..b4e9cf4 --- /dev/null +++ b/web/src/Preview.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import init, { Preview as Reel } from "./wasm/awan_wasm"; +import { TICK_MS, type Scene, actInfo } from "./acts"; +import { Caption } from "./Caption"; +import { drawStats, drawWall } from "./overlays"; + +/** Pixels per canvas cell — the same 33×30 the GIF renderer fills, so what you + * see here is what CI draws, not an impression of it. */ +const CELL_W = 33; +const CELL_H = 30; + +let ready: Promise | null = null; +const loadEngine = () => (ready ??= init()); + +/** The reel, playing. Everything drawn on the canvas comes straight out of the + * engine compiled to wasm — same ticks, same cells, same loop. The caption + * rides underneath, because that's a renderer's job in the GIF too. */ +export function Preview({ story, onBeat }: { story: Scene[]; onBeat: (i: number) => void }) { + const canvas = useRef(null); + const [reel, setReel] = useState(null); + const [playing, setPlaying] = useState(true); + const [tick, setTick] = useState(0); + const acts = story.map((s) => s.act).join(","); + + useEffect(() => { + let dead = false; + loadEngine().then(() => { + if (dead || !story.length) return; + setReel(new Reel(story.map((s) => s.act))); + setTick(0); + }); + return () => { + dead = true; + }; + // rebuilt only when the running order changes; editing a line must not + // restart the reel under someone's cursor + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [acts]); + + useEffect(() => { + if (!reel || !canvas.current) return; + const ctx = canvas.current.getContext("2d"); + if (!ctx) return; + const [cols, rows, total] = [reel.cols(), reel.rows(), reel.ticks()]; + let raf = 0; + let last = performance.now(); + let t = tick; + + const paint = (now: number) => { + raf = requestAnimationFrame(paint); + if (playing && now - last >= TICK_MS) { + last = now; + t = (t + 1) % total; + setTick(t); + } + const frame = reel.frame(playing ? t : tick); + ctx.clearRect(0, 0, cols * CELL_W, rows * CELL_H); + for (let i = 0; i < cols * rows; i++) { + if (!frame[i * 4 + 3]) continue; + ctx.fillStyle = `rgb(${frame[i * 4]},${frame[i * 4 + 1]},${frame[i * 4 + 2]})`; + ctx.fillRect((i % cols) * CELL_W, Math.floor(i / cols) * CELL_H, CELL_W, CELL_H); + } + // Overlays, exactly as the GIF renderer layers them: cells, then the + // wall, then the ground line, then the readout. + const shown = playing ? t : tick; + const k = reel.beat_tick(shown); + const act = k >= 0 ? story[reel.beat_at(shown)]?.act : undefined; + if (act === "contributions") drawWall(ctx, k); + ctx.fillStyle = "#50545f"; + ctx.fillRect(0, rows * CELL_H - 2, cols * CELL_W, 2); + if (act === "stats") drawStats(ctx, k); + }; + raf = requestAnimationFrame(paint); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reel, playing, story]); + + // one source of truth for "which beat is on screen", so scrubbing highlights + // the same row that playing does + useEffect(() => { + if (reel) onBeat(reel.is_leaving(tick) ? -1 : reel.beat_at(tick)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reel, tick]); + + const total = reel?.ticks() ?? 0; + const beat = reel && !reel.is_leaving(tick) ? reel.beat_at(tick) : -1; + const scene = beat >= 0 ? story[beat] : undefined; + + return ( +
+ + +
+ + { + setPlaying(false); + setTick(+e.target.value); + }} + className="h-2 flex-1 appearance-none rounded-none bg-edge accent-lime" + aria-label="Scrub" + /> + + {((tick * TICK_MS) / 1000).toFixed(1)}s / {((total * TICK_MS) / 1000).toFixed(0)}s + +
+

+ {scene ? ( + <> + now playing {actInfo(scene.act).label} + + ) : ( + "walking on" + )} + {" · "}the engine itself, compiled to wasm — these are the frames CI draws +

+
+ ); +} diff --git a/web/src/SceneList.tsx b/web/src/SceneList.tsx new file mode 100644 index 0000000..7b13270 --- /dev/null +++ b/web/src/SceneList.tsx @@ -0,0 +1,141 @@ +import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { CAPTION_LIMIT, type Scene, actInfo } from "./acts"; +import { fill } from "./sample"; + +type Props = { + story: Scene[]; + playing: number; + onChange: (story: Scene[]) => void; +}; + +/** The running order, draggable. Everything a beat carries is edited in place: + * reordering and rewording are the same task, so they shouldn't live in two + * places. */ +export function SceneList({ story, playing, onChange }: Props) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const ids = story.map((_, i) => String(i)); + + const onDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + onChange(arrayMove(story, +active.id, +over.id)); + }; + + return ( + + +
    + {story.map((scene, i) => ( + onChange(story.map((s, j) => (i === j ? next : s)))} + onDrop={() => onChange(story.filter((_, j) => j !== i))} + /> + ))} +
+
+
+ ); +} + +type RowProps = { + id: string; + scene: Scene; + live: boolean; + onEdit: (s: Scene) => void; + onDrop: () => void; +}; + +function Row({ id, scene, live, onEdit, onDrop }: RowProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const info = actInfo(scene.act); + + return ( +
  • +
    + + {info.icon} + {info.label} + {info.live && live} + + {(info.ticks * 0.09).toFixed(1)}s + + +
    + + {info.mute ? ( +

    plays your lyrics — no caption of its own

    + ) : ( +
    + onEdit({ ...scene, say })} + /> + {info.splits && ( + onEdit({ ...scene, then })} + /> + )} +
    + )} +
  • + ); +} + +/** One caption line, with the only rule the renderer actually enforces: past + * ~42 characters it runs off the canvas. Better to know while typing. */ +function Line({ + value, + placeholder, + onChange, +}: { + value: string; + placeholder: string; + onChange: (v: string) => void; +}) { + const len = fill(value).length; + const over = len > CAPTION_LIMIT; + return ( + + ); +} + +function Tag({ children, className }: { children: React.ReactNode; className: string }) { + return {children}; +} diff --git a/web/src/Shelf.tsx b/web/src/Shelf.tsx new file mode 100644 index 0000000..1081671 --- /dev/null +++ b/web/src/Shelf.tsx @@ -0,0 +1,27 @@ +import { ACTS, type Scene } from "./acts"; + +/** The shelf of acts you can add. Duration sits on every card on purpose: a + * reel gets too long one innocent-looking beat at a time, and nobody feels + * that cost unless the price is on the label. */ +export function Shelf({ onAdd }: { onAdd: (s: Scene) => void }) { + return ( +
    + {ACTS.map((a) => ( + + ))} +
    + ); +} diff --git a/web/src/acts.ts b/web/src/acts.ts new file mode 100644 index 0000000..2642169 --- /dev/null +++ b/web/src/acts.ts @@ -0,0 +1,73 @@ +/** One beat of the story, exactly as it lands in `awan.json`. */ +export type Scene = { act: string; say?: string; then?: string }; + +/** What an act is, for people picking one off a shelf. */ +export type ActInfo = { + id: string; + label: string; + blurb: string; + icon: string; + /** Ticks, straight from the engine's `scene_for`. 11 ticks ≈ 1 second. */ + ticks: number; + /** Whether the act reads numbers CI fetches, rather than words you write. */ + live?: boolean; + /** A `then` line takes the caption over mid-beat. Only the wall has a moment + * worth splitting on: the year as it rises, the month as it lights. */ + splits?: boolean; + /** Beats with nothing to say — `sing` plays your lyrics instead. */ + mute?: boolean; +}; + +export const TICK_MS = 90; + +/** The shelf. Durations mirror `scene_for` in awan-core; if that changes, this + * lies, so the preview clock is the thing that would catch it. */ +export const ACTS: ActInfo[] = [ + { id: "wave", label: "Wave", blurb: "bounces in an excited hello", icon: "👋", ticks: 30 }, + { id: "present", label: "Present", blurb: "stands and introduces himself", icon: "🎁", ticks: 60 }, + { id: "stroll", label: "Stroll", blurb: "walks along, ground scrolling past", icon: "🚶", ticks: 30 }, + { id: "stats", label: "Stats", blurb: "types your numbers into a terminal", icon: "📊", ticks: 150, live: true }, + { id: "contributions", label: "Year wall", blurb: "walks his contribution year", icon: "🟩", ticks: 150, live: true, splits: true }, + { id: "rocket", label: "Rocket", blurb: "builds a rocket", icon: "🛠️", ticks: 40 }, + { id: "launch", label: "Launch", blurb: "...and watches it explode", icon: "🚀", ticks: 50 }, + { id: "bake", label: "Bake", blurb: "fetches an oven, bakes, devours", icon: "🎂", ticks: 118 }, + { id: "campfire", label: "Campfire", blurb: "drags in wood, the fire catches", icon: "🔥", ticks: 90 }, + { id: "sing", label: "Sing", blurb: "karaoke — plays your lyrics", icon: "🎤", ticks: 150, mute: true }, + { id: "soccer", label: "Soccer", blurb: "juggles until it bonks him", icon: "⚽", ticks: 66 }, + { id: "dance", label: "Dance", blurb: "a little dance", icon: "🕺", ticks: 48 }, + { id: "sleep", label: "Sleep", blurb: "yawns, dozes, wakes up", icon: "😴", ticks: 80 }, + { id: "{verdict}", label: "Verdict", blurb: "CI picks: dance if the month was good, sleep if not", icon: "🎲", ticks: 48 }, +]; + +export const actInfo = (id: string): ActInfo => + ACTS.find((a) => a.id === id) ?? { id, label: id, blurb: "", icon: "❓", ticks: 60 }; + +/** Placeholders CI fills in. Shown so nobody wonders where the numbers come + * from — and so a preview can stand in for them. */ +export const TOKENS: Record = { + "{name}": "your name", + "{role}": "your role", + "{location}": "where you are", + "{stack}": "what you build with", + "{handle}": "your handle", + "{streak}": "days in a row, counted from your calendar", + "{contrib_year}": "contributions this year", + "{contrib_recent}": "contributions in the last 30 days", +}; + +/** The story we open with — the one from the sample, which is the one we'd + * defend. Reordering it is the whole point of the page. */ +export const DEFAULT_STORY: Scene[] = [ + { act: "wave", say: "hi there! i'm {name}" }, + { act: "present", say: "{role}" }, + { act: "stats", say: "the numbers, if you're curious" }, + { act: "contributions", say: "i'm very happy, {contrib_year} this year", then: "and {contrib_recent} in the last 30 days" }, + { act: "{verdict}", say: "CI decides" }, + { act: "sing" }, + { act: "sleep", say: "okay... nap time, zzz" }, +]; + +/** The caption is drawn at 8×3 px a glyph across a 1056px canvas, so it runs + * off the edge past this. Worth saying out loud while you type, not after CI + * has already cropped it. */ +export const CAPTION_LIMIT = 42; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..9fc21e7 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./theme.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web/src/overlays.ts b/web/src/overlays.ts new file mode 100644 index 0000000..b7586fe --- /dev/null +++ b/web/src/overlays.ts @@ -0,0 +1,87 @@ +import * as w from "./wasm/awan_wasm"; +import { SAMPLE_STATS, SAMPLE_WALL } from "./sample"; + +/** Pixels per canvas cell — the GIF renderer's own numbers. */ +const CELL_W = 33; +const CELL_H = 30; +const BG: [number, number, number] = [13, 17, 23]; + +/** GitHub's five contribution shades, quietest first. */ +const SHADES = ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"]; +const PITCH = 18; +const SQUARE = 14; +const SPOT = [32, 40, 52] as const; +const YEAR_FADE = 45; + +const mix = (a: readonly number[], b: readonly number[], pct: number) => + `rgb(${a.map((v, i) => Math.round((v * (100 - pct) + b[i] * pct) / 100)).join(",")})`; + +/** The readout, typing itself into the window the engine opened. The engine + * says how much has printed; the words are the reader's, so they're ours to + * draw — the same division the GIF renderer works to. */ +export function drawStats(ctx: CanvasRenderingContext2D, k: number) { + const [px, py, pw, ph] = w.stats_panel(); + const glyph = 24; + const innerW = (pw - 2) * CELL_W; + const innerH = (ph - 2) * CELL_H; + const room = Math.max(Math.floor(innerW / glyph) - 2, 8); + const x = px * CELL_W + CELL_W + (innerW - room * glyph) / 2; + const step = glyph + 12; + const slots = w.stats_slots(); + const y0 = py * CELL_H + CELL_H + Math.max(innerH - ((slots - 1) * step + glyph), 0) / 2; + + ctx.font = `${glyph}px ui-monospace, monospace`; + ctx.textBaseline = "top"; + ctx.fillStyle = "#96969f"; + + SAMPLE_STATS.slice(0, slots).forEach((entry, i) => { + const shown = w.stats_chars_at(k, i); + if (!shown) return; + const [label, value] = entry.split(":"); + const gap = Math.max(room - label.length - value.length - 1, 0); + const line = `${label}${".".repeat(gap)} ${value}`.slice(0, shown); + const y = y0 + i * step; + ctx.fillText(line, x, y); + if (w.stats_typing(k, i)) ctx.fillRect(x + line.length * glyph * 0.6, y, glyph / 2, glyph); + }); +} + +/** The contribution year, rising behind him. Every square is real geometry — + * 18px pitch, not one flat colour per cell — because that difference is the + * whole reason the wall reads as a calendar rather than a smear. */ +export function drawWall(ctx: CanvasRenderingContext2D, k: number) { + const up = w.wall_fade(k); + if (!up) return; + const [, by, , bh] = w.wall_band(); + const [weeks, rows, recent] = w.wall_shape(); + const glow = w.wall_glow(k); + + const x0 = (32 * CELL_W - weeks * PITCH) / 2; + const y0 = by * CELL_H + (bh * CELL_H - rows * PITCH) / 2; + const first = Math.floor((SAMPLE_WALL.length - recent) / rows); + + if (glow) { + ctx.fillStyle = mix(BG, SPOT, (glow * up) / 100); + ctx.fillRect(x0 + first * PITCH - 5, y0 - 5, (weeks - first) * PITCH + 5, rows * PITCH + 5); + } + for (let c = 0; c < weeks; c++) { + for (let d = 0; d < rows; d++) { + const level = SAMPLE_WALL[c * rows + d]; + if (level < 0) continue; // a day the calendar doesn't cover + const old = c * rows + d < SAMPLE_WALL.length - recent; + const base = hexToRgb(SHADES[level]); + const stepped = old ? rgb(mix(base, BG, (glow * YEAR_FADE) / 100)) : base; + ctx.fillStyle = mix(BG, stepped, up); + ctx.fillRect(x0 + c * PITCH, y0 + d * PITCH, SQUARE, SQUARE); + } + } +} + +const hexToRgb = (h: string): [number, number, number] => [ + parseInt(h.slice(1, 3), 16), + parseInt(h.slice(3, 5), 16), + parseInt(h.slice(5, 7), 16), +]; + +const rgb = (s: string): [number, number, number] => + s.match(/\d+/g)!.slice(0, 3).map(Number) as [number, number, number]; diff --git a/web/src/sample.ts b/web/src/sample.ts new file mode 100644 index 0000000..79ab663 --- /dev/null +++ b/web/src/sample.ts @@ -0,0 +1,55 @@ +/** Stand-in numbers for the preview. + * + * The stats a profile shows are one unauthenticated REST call away, but the + * contribution calendar lives only in GraphQL, and GraphQL wants a token. We + * are not going to ask anyone to paste a token into a web page to look at a + * cartoon. So the preview shows a plausible year and says so, and CI fills in + * the real one — which it can, because it already has a token of its own. + */ +export const SAMPLE = { + name: "your name", + role: "your role", + location: "where you are", + stack: "what you build with", + handle: "your handle", + streak: 4, + contrib_year: 2060, + contrib_recent: 183, +}; + +/** Fill the `{tokens}` a caption carries, so the preview reads like a sentence + * instead of a template. */ +export function fill(text: string): string { + return text.replace(/\{(\w+)\}/g, (whole, key: string) => + key in SAMPLE ? String(SAMPLE[key as keyof typeof SAMPLE]) : whole, + ); +} + +/** The readout's stand-in lines. Real ones arrive as "label:value" from CI. */ +export const SAMPLE_STATS = [ + "repos:71", + "stars earned:82", + "followers:42", + "following:36", + "streak:4", +]; + +/** A stand-in year: 53 weeks x 7 days, a GitHub quartile per day, -1 where the + * calendar has no day. Generated from a fixed seed rather than random, so the + * preview looks the same on every reload — a banner that changes shape while + * you're deciding on it is worse than a fake one. */ +export const SAMPLE_WALL: number[] = (() => { + const days: number[] = []; + let seed = 20260716; + const next = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + for (let i = 0; i < 53 * 7; i++) { + const r = next(); + // busier lately, and quieter at weekends — enough shape to read as a life + const weekend = i % 7 === 0 || i % 7 === 6; + const recent = i > 53 * 7 - 40; + const lift = (recent ? 0.28 : 0) - (weekend ? 0.2 : 0); + days.push(r + lift < 0.34 ? 0 : Math.min(4, 1 + Math.floor((r + lift) * 3.6))); + } + for (let i = 53 * 7 - 2; i < 53 * 7; i++) days[i] = -1; // the week isn't over + return days; +})(); diff --git a/web/src/theme.css b/web/src/theme.css new file mode 100644 index 0000000..e71b4d9 --- /dev/null +++ b/web/src/theme.css @@ -0,0 +1,57 @@ +@import "tailwindcss"; + +/* A pixel arcade, but a legible one. The character is 33x30px blocks of flat + colour; the page should feel like it belongs to him without fighting him for + attention. Colour comes from the palette below, structure from hard edges and + chunky shadows — no gradients, no blur, nothing that a CRT couldn't do. */ +@theme { + --color-void: #0d1117; + --color-panel: #161b22; + --color-edge: #2d333b; + --color-ink: #e6edf3; + --color-mute: #8b949e; + + --color-cloud: #7c88f0; + --color-lime: #39d353; + --color-punch: #ff6b6b; + --color-gold: #ffc94d; + --color-sky: #4dd4ff; + --color-grape: #b072ff; + + --font-pixel: "Courier New", ui-monospace, monospace; +} + +@layer base { + html { background: var(--color-void); } + body { + color: var(--color-ink); + font-family: var(--font-pixel); + font-weight: 700; + /* the whole page is pixel art; never let a browser soften it */ + image-rendering: pixelated; + -webkit-font-smoothing: none; + } + ::selection { background: var(--color-lime); color: var(--color-void); } +} + +@layer components { + /* A hard 4px shadow instead of a soft one: the only depth cue a pixel + display ever had, and it reads at any zoom. */ + .px-box { + border: 2px solid var(--color-edge); + box-shadow: 4px 4px 0 0 #05070a; + background: var(--color-panel); + } + .px-btn { + border: 2px solid var(--color-edge); + box-shadow: 3px 3px 0 0 #05070a; + transition: transform 80ms steps(2), box-shadow 80ms steps(2); + } + .px-btn:hover { transform: translate(1px, 1px); box-shadow: 2px 2px 0 0 #05070a; } + .px-btn:active { transform: translate(3px, 3px); box-shadow: 0 0 0 0 #05070a; } + .px-btn:disabled { opacity: .4; transform: none; box-shadow: 3px 3px 0 0 #05070a; } +} + +/* Steps(), not ease. Nothing in this world moves on a curve. */ +@keyframes px-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } } +.px-caret { animation: px-blink 1s steps(1) infinite; } diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..69fcc00 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..3652726 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwind from "@tailwindcss/vite"; +import wasm from "vite-plugin-wasm"; + +// Static build, deployed to GitHub Pages. Nothing here may grow a server: +// the whole point is that this page can never go down in a way that breaks +// somebody's README. +export default defineConfig({ + base: "./", + plugins: [react(), tailwind(), wasm()], + build: { outDir: "dist", target: "esnext" }, +}); From 269f0e0bd6f46eb1de2fe2b87695d355cb6e08e4 Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 20:10:32 +0700 Subject: [PATCH 2/8] feat(web): pixel art, three steps, and a preview that isn't lying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last build looked close enough to ship and wrong enough to notice, which is the worst thing a preview can be: someone builds a config against it and CI hands them something else. It drew the caption in HTML underneath the canvas, in Courier. CI draws it inside the image, in font8x8, at 1056x416. So the canvas is that size now and the text comes from the engine's own glyphs, shared rather than reimplemented — icons.rs moves into awan-core, because there are two renderers now and a preview that draws its own idea of an icon is a preview that lies. Two bugs surfaced from checking it against a real render rather than trusting it. Rust computes 303 / 2 = 151 and JavaScript gives 151.5, so every glyph landed on a half-pixel and the canvas antialiased its edges — a third of the ink stopped matching. And [150, 150, 160] is #9696a0, not #96969f: I wrote the hex by eye. The caption strip is now identical to the GIF's, 4833 ink pixels and the same bounding box. The rest is what the shape should have been. Everything drawing lives in its own file, split by what it does: lib for data, ui for primitives, stage for the reel, story for the running order, steps for the wizard. Cards don't nest — a card inside a card is two borders saying the same thing. Three steps rather than one page, because one page of everything buried the preview under a form. Neobrutalism because it suits him: he's flat blocks of colour with hard edges, and a soft interface around him reads as an apology for how he's drawn. The emoji are gone — they arrived in whatever style the reader's OS ships, anti-aliased, beside a character made of 33px squares. These are 8x8 pictures written as pictures, in his own palette. --- {profile => crates/awan-core}/src/icons.rs | 10 +- crates/awan-core/src/lib.rs | 2 + profile/src/gif.rs | 2 +- profile/src/main.rs | 1 - profile/src/script.rs | 2 +- profile/src/story.rs | 2 +- web/README.md | 33 +++++ web/crate/Cargo.lock | 7 + web/crate/Cargo.toml | 3 + web/crate/src/lib.rs | 28 +++- web/public/font/silkscreen-bold.ttf | Bin 0 -> 30632 bytes web/public/font/silkscreen.ttf | Bin 0 -> 32220 bytes web/src/App.tsx | 126 +++++++----------- web/src/Caption.tsx | 27 ---- web/src/Identity.tsx | 49 ------- web/src/Meter.tsx | 42 ------ web/src/Output.tsx | 116 ----------------- web/src/Preview.tsx | 138 -------------------- web/src/SceneList.tsx | 141 --------------------- web/src/Shelf.tsx | 27 ---- web/src/{ => lib}/acts.ts | 37 +++--- web/src/lib/config.ts | 60 +++++++++ web/src/lib/engine.ts | 9 ++ web/src/lib/pixels.ts | 93 ++++++++++++++ web/src/{ => lib}/sample.ts | 0 web/src/stage/Meter.tsx | 41 ++++++ web/src/stage/Reel.tsx | 64 ++++++++++ web/src/stage/Stage.tsx | 77 +++++++++++ web/src/stage/Transport.tsx | 36 ++++++ web/src/{ => stage}/overlays.ts | 35 +++-- web/src/stage/text.ts | 81 ++++++++++++ web/src/steps/StepExport.tsx | 59 +++++++++ web/src/steps/StepIdentity.tsx | 57 +++++++++ web/src/steps/StepStory.tsx | 37 ++++++ web/src/story/SceneList.tsx | 39 ++++++ web/src/story/SceneRow.tsx | 71 +++++++++++ web/src/story/Shelf.tsx | 26 ++++ web/src/theme.css | 89 ++++++++----- web/src/ui/Button.tsx | 19 +++ web/src/ui/Card.tsx | 28 ++++ web/src/ui/Field.tsx | 35 +++++ web/src/ui/PixelIcon.tsx | 16 +++ web/src/ui/Stepper.tsx | 23 ++++ 43 files changed, 1094 insertions(+), 694 deletions(-) rename {profile => crates/awan-core}/src/icons.rs (68%) create mode 100644 web/public/font/silkscreen-bold.ttf create mode 100644 web/public/font/silkscreen.ttf delete mode 100644 web/src/Caption.tsx delete mode 100644 web/src/Identity.tsx delete mode 100644 web/src/Meter.tsx delete mode 100644 web/src/Output.tsx delete mode 100644 web/src/Preview.tsx delete mode 100644 web/src/SceneList.tsx delete mode 100644 web/src/Shelf.tsx rename web/src/{ => lib}/acts.ts (54%) create mode 100644 web/src/lib/config.ts create mode 100644 web/src/lib/engine.ts create mode 100644 web/src/lib/pixels.ts rename web/src/{ => lib}/sample.ts (100%) create mode 100644 web/src/stage/Meter.tsx create mode 100644 web/src/stage/Reel.tsx create mode 100644 web/src/stage/Stage.tsx create mode 100644 web/src/stage/Transport.tsx rename web/src/{ => stage}/overlays.ts (74%) create mode 100644 web/src/stage/text.ts create mode 100644 web/src/steps/StepExport.tsx create mode 100644 web/src/steps/StepIdentity.tsx create mode 100644 web/src/steps/StepStory.tsx create mode 100644 web/src/story/SceneList.tsx create mode 100644 web/src/story/SceneRow.tsx create mode 100644 web/src/story/Shelf.tsx create mode 100644 web/src/ui/Button.tsx create mode 100644 web/src/ui/Card.tsx create mode 100644 web/src/ui/Field.tsx create mode 100644 web/src/ui/PixelIcon.tsx create mode 100644 web/src/ui/Stepper.tsx diff --git a/profile/src/icons.rs b/crates/awan-core/src/icons.rs similarity index 68% rename from profile/src/icons.rs rename to crates/awan-core/src/icons.rs index 4dfa2e3..eeb0086 100644 --- a/profile/src/icons.rs +++ b/crates/awan-core/src/icons.rs @@ -1,6 +1,10 @@ -//! Tiny 8×8 pixel icons drawn beside profile lines. Each `u8` is a row; bit -//! `1 << col` is the pixel at column `col` (0 = left), matching the font byte -//! order so they rasterise with the same routine. +//! Tiny 8×8 pixel icons a renderer can draw beside a line. Each `u8` is a row; +//! bit `1 << col` is the pixel at column `col` (0 = left), matching font8x8's +//! byte order so they rasterise with the same routine. +//! +//! They live in the engine rather than next to one renderer because there are +//! two now — the GIF encoder and the browser preview — and a preview that +//! draws its own idea of these icons is a preview that lies. pub struct Icon(pub [u8; 8]); diff --git a/crates/awan-core/src/lib.rs b/crates/awan-core/src/lib.rs index 83a74ea..98e1fb0 100644 --- a/crates/awan-core/src/lib.rs +++ b/crates/awan-core/src/lib.rs @@ -47,6 +47,8 @@ mod statusline; /// Layout of the `stats` act, for renderers that print the numbers onto the /// bento cards the character sets out. +pub mod icons; + pub mod stats { pub use crate::scene::stats::{PANEL, SLOTS, chars_at, panel_at, typing}; } diff --git a/profile/src/gif.rs b/profile/src/gif.rs index c04088a..559a160 100644 --- a/profile/src/gif.rs +++ b/profile/src/gif.rs @@ -10,7 +10,7 @@ use image::codecs::gif::{GifEncoder, Repeat}; use image::{Delay, Frame, Rgba, RgbaImage}; use crate::draw::{draw_bits, draw_text, fill}; -use crate::icons; +use awan_core::icons; use crate::script::{Line, Profile}; use crate::wall::wall; diff --git a/profile/src/main.rs b/profile/src/main.rs index 8d51df6..ffd7d17 100644 --- a/profile/src/main.rs +++ b/profile/src/main.rs @@ -14,7 +14,6 @@ use awan_core::{Character, Reel, Size}; mod draw; mod gif; -mod icons; mod script; mod story; mod wall; diff --git a/profile/src/script.rs b/profile/src/script.rs index 68f8603..0fae955 100644 --- a/profile/src/script.rs +++ b/profile/src/script.rs @@ -4,7 +4,7 @@ use awan_core::{Act, Reel}; -use crate::icons::{self, Icon}; +use awan_core::icons::{self, Icon}; use crate::story::{act_of, default_story, icon_of}; /// Ticks each lyric line holds during a singing beat. diff --git a/profile/src/story.rs b/profile/src/story.rs index 03d7d31..97d6a0d 100644 --- a/profile/src/story.rs +++ b/profile/src/story.rs @@ -5,7 +5,7 @@ use awan_core::Act; -use crate::icons::{self, Icon}; +use awan_core::icons::{self, Icon}; use crate::script::SceneSpec; pub fn act_of(name: &str) -> Act { diff --git a/web/README.md b/web/README.md index 6386171..bc16360 100644 --- a/web/README.md +++ b/web/README.md @@ -8,6 +8,21 @@ your repo and the workflow runs on your runner. The moment this page grows a server, it becomes something that can go down and take READMEs with it. So: no API routes, no database, no accounts, no stored links. +## Layout + +``` +crate/ wasm-bindgen bridge over awan-core +src/lib/ data and pure helpers — acts, config, sample numbers, pixel art +src/ui/ primitives — Button, Card, Field, PixelIcon, Stepper +src/stage/ the reel: clock, canvas, overlays, transport, meter +src/story/ the running order: list, row, shelf +src/steps/ one file per step of the wizard +``` + +`Card` doesn't nest on purpose. A card inside a card is two borders and two +shadows saying the same thing; if something inside needs separating, space or a +rule does it. + ## The preview is the engine `crate/` is a thin `wasm-bindgen` bridge over `awan-core`. The engine is a pure @@ -19,6 +34,24 @@ That split matters for the overlays too. The engine draws a scene's *shapes* and leaves its *words and numbers* to the renderer; `overlays.ts` is that renderer, doing for the canvas what `profile/src/` does for the GIF. +**The canvas is 1056×416, including the caption strip, in the engine's own +font8x8 glyphs at the renderer's own scale.** That is not decoration. An earlier +build drew the caption in HTML underneath in Courier, and it looked close enough +to ship and wrong enough to notice — which is the worst thing a preview can be, +because someone builds a config against it and CI hands them something else. + +Two things caught that, and both are worth knowing about: + +- **Integer division.** Rust computes `303 / 2 = 151`; JavaScript gives `151.5`, + which lands a glyph on a half-pixel, and the canvas antialiases every edge. A + third of the ink stops matching. Every position here is `Math.floor`d. +- **Colour by hand.** `[150, 150, 160]` is `#9696a0`, not `#96969f`. Convert; + don't eyeball. + +You can check it: render a GIF and the preview from the same config and seed, +then compare the caption strip. It should be identical, pixel for pixel — ink +count and bounding box both. + ## What the preview can't know The stats a profile shows are one unauthenticated call away, but the diff --git a/web/crate/Cargo.lock b/web/crate/Cargo.lock index 0d6ca6e..b021b8a 100644 --- a/web/crate/Cargo.lock +++ b/web/crate/Cargo.lock @@ -15,6 +15,7 @@ name = "awan-wasm" version = "0.0.5" dependencies = [ "awan-core", + "font8x8", "wasm-bindgen", ] @@ -36,6 +37,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "font8x8" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" + [[package]] name = "hashbrown" version = "0.17.1" diff --git a/web/crate/Cargo.toml b/web/crate/Cargo.toml index 3b9ef7b..570544f 100644 --- a/web/crate/Cargo.toml +++ b/web/crate/Cargo.toml @@ -17,6 +17,9 @@ crate-type = ["cdylib", "rlib"] [dependencies] awan-core = { path = "../../crates/awan-core" } wasm-bindgen = "0.2" +# the same glyphs the GIF renderer draws — a preview with its own font is a +# preview that lies about what CI will produce +font8x8 = "0.3" [profile.release] opt-level = "s" diff --git a/web/crate/src/lib.rs b/web/crate/src/lib.rs index dca05ba..6284d2b 100644 --- a/web/crate/src/lib.rs +++ b/web/crate/src/lib.rs @@ -8,7 +8,8 @@ //! It hands JavaScript flat buffers rather than objects: one allocation per //! frame instead of 384, which keeps a 60fps canvas loop honest. -use awan_core::{Act, Character, Reel}; +use awan_core::{Act, Character, Reel, icons}; +use font8x8::{BASIC_FONTS, UnicodeFonts}; use wasm_bindgen::prelude::*; /// A reel built from a story, ready to draw. @@ -148,6 +149,31 @@ pub fn wall_shape() -> Vec { ] } +/// The 8×8 bitmap for `ch` — the exact glyph the GIF renderer draws, from the +/// same font crate. Eight bytes, one per row; bit `1 << col` is a lit pixel. +/// Empty for a character the font doesn't carry, which is what the renderer +/// does too: it skips the glyph and still advances the cursor. +#[wasm_bindgen] +pub fn glyph(ch: char) -> Vec { + BASIC_FONTS.get(ch).map(|g| g.to_vec()).unwrap_or_default() +} + +/// The 8×8 icon a caption carries, by name. Same bitmaps the GIF draws. +#[wasm_bindgen] +pub fn icon(name: &str) -> Vec { + let i = match name { + "heart" => icons::HEART, + "pin" => icons::PIN, + "code" => icons::CODE, + "star" => icons::STAR, + "fire" => icons::FIRE, + "briefcase" => icons::BRIEFCASE, + "globe" => icons::GLOBE, + _ => icons::DIAMOND, + }; + i.0.to_vec() +} + /// The act vocabulary, mirrored from the profile generator. Kept as strings on /// purpose: `awan.json` is the contract, not our enum. fn act_of(name: &str) -> Act { diff --git a/web/public/font/silkscreen-bold.ttf b/web/public/font/silkscreen-bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6771252683388362e64aaea6e0d5d991e417c9fb GIT binary patch literal 30632 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 - const [id, setId] = useState(BLANK); + const [id, setId] = useState(BLANK); const [beat, setBeat] = useState(-1); return ( -
    +
    + -
    -
    - - -
    - -
    - - - - - setStory([...story, s])} /> - - - - -
    -
    +
    + {at === 0 && } + {at === 1 && } + {at === 2 && } +
    -
    - - - -
    +
    -
    +
    ); } function Header() { return ( -
    -

    - awan -

    -

    - a tiny living character for your GitHub profile -

    - +
    + +
    +

    awan

    +

    a tiny living character for your GitHub profile

    +
    +
    source ↗
    ); } -function Panel({ - title, - hint, - children, -}: { - title: string; - hint?: string; - children: React.ReactNode; -}) { - return ( -
    -
    -

    {title}

    - {hint && {hint}} -
    - {children} -
    - ); -} - function Footer() { return ( -
    +

    - The preview runs the real engine, compiled to wasm — the frames above are the frames CI - draws. Its numbers are stand-ins: the calendar behind the year wall lives in an API that - wants a token, and no cartoon is worth pasting a token into a web page for. CI has one of - its own, and fills in yours. + The preview is the engine itself, compiled to wasm — same ticks, same cells, same font, same + 1056×416 canvas as the file CI commits. Its numbers are stand-ins: the calendar behind the + year wall lives in an API that wants a token, and no cartoon is worth pasting a token into a + web page for. CI has one of its own.

    - Nothing here is stored, and nothing is sent anywhere — the page has no server to send it to. - That's also why it can't break your README: your config lives in your repo, and the workflow - runs there. + Nothing is stored and nothing is sent — there's no server here to send it to. That's also why + this page can't break your README: your config lives in your repo, and the workflow runs + there.

    ); diff --git a/web/src/Caption.tsx b/web/src/Caption.tsx deleted file mode 100644 index b9ff412..0000000 --- a/web/src/Caption.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Scene } from "./acts"; -import { fill } from "./sample"; - -/** The line under the ground. The wall's `then` takes over the moment the - * spotlight lands — tick 40 of that beat, where `glow_pct` first rises — so - * the preview tells the same joke at the same time CI does. */ -const GLOW_AT = 40; - -export function Caption({ scene, leaving, k }: { scene?: Scene; leaving: boolean; k: number }) { - const text = leaving - ? "thanks for stopping by ~" - : !scene - ? "" - : scene.act === "sing" - ? 'my fav song "…" — sung karaoke-style' - : scene.then && k >= GLOW_AT - ? scene.then - : (scene.say ?? ""); - - return ( -
    - - {fill(text)} - {text && } -
    - ); -} diff --git a/web/src/Identity.tsx b/web/src/Identity.tsx deleted file mode 100644 index 901f5ab..0000000 --- a/web/src/Identity.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Identity as Id } from "./Output"; - -const FIELDS: { key: keyof Id; label: string; hint: string }[] = [ - { key: "handle", label: "handle", hint: "codewithwan" }, - { key: "name", label: "name", hint: "what he calls you" }, - { key: "role", label: "role", hint: "fullstack engineer" }, - { key: "location", label: "location", hint: "Indonesia" }, - { key: "stack", label: "stack", hint: "Rust, Go & TypeScript" }, - { key: "song", label: "song", hint: "your favourite" }, - { key: "artist", label: "artist", hint: "who sings it" }, -]; - -/** Who he's talking about. These fill the `{tokens}` a caption carries; the - * numbers are CI's job, not yours. */ -export function Identity({ id, onChange }: { id: Id; onChange: (id: Id) => void }) { - return ( -
    - {FIELDS.map((f) => ( - - ))} -
    - lyrics — he sings these, one line at a time - {[0, 1, 2].map((i) => ( - { - const lyrics = [...id.lyrics]; - lyrics[i] = e.target.value; - onChange({ ...id, lyrics }); - }} - className="border-2 border-edge bg-void px-2 py-1 text-xs text-ink - outline-none placeholder:text-edge focus:border-sky" - /> - ))} -
    -
    - ); -} diff --git a/web/src/Meter.tsx b/web/src/Meter.tsx deleted file mode 100644 index aa17e51..0000000 --- a/web/src/Meter.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { TICK_MS, actInfo, type Scene } from "./acts"; - -/** How long the loop runs, and roughly what it weighs. - * - * A profile banner is not a film. Nobody scrolls past your README and waits - * fifty seconds to find out you like football — and every second is bytes the - * reader pays for. The bands are drawn from real renders: 20s ≈ 444 KB, 31s ≈ - * 532 KB, 53s ≈ 705 KB, 77s ≈ 936 KB. */ -const BANDS = [ - { max: 25, label: "tight", tone: "text-lime", note: "people watch this one to the end" }, - { max: 40, label: "comfortable", tone: "text-sky", note: "a good length for a profile" }, - { max: 60, label: "long", tone: "text-gold", note: "the last beats rarely get seen" }, - { max: Infinity, label: "too long", tone: "text-punch", note: "nobody waits this long — cut a beat or two" }, -]; - -export function Meter({ story }: { story: Scene[] }) { - const ticks = story.reduce((n, s) => n + actInfo(s.act).ticks, 0) + 22; // walk on + off - const secs = (ticks * TICK_MS) / 1000; - const band = BANDS.find((b) => secs <= b.max)!; - const kb = Math.round(180 + secs * 9.8); // measured: GIF grows ~9.8 KB a second - - return ( -
    -
    - {secs.toFixed(0)}s - {band.label} - ≈ {kb} KB -
    -
    - {story.map((s, i) => ( -
    - ))} -
    -

    {band.note}

    -
    - ); -} diff --git a/web/src/Output.tsx b/web/src/Output.tsx deleted file mode 100644 index 9d9c769..0000000 --- a/web/src/Output.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useState } from "react"; -import type { Scene } from "./acts"; - -/** Everything CI writes for you. Shipping them as zeroes rather than invented - * numbers is the point: a made-up streak is decoration, and this whole thing - * is supposed to draw the real one. */ -const CI_FILLED = { - streak: 0, - stats: [], - contributions: "", - contrib_year: 0, - contrib_recent: 0, -}; - -export type Identity = { - handle: string; - name: string; - role: string; - location: string; - stack: string; - song: string; - artist: string; - lyrics: string[]; -}; - -export const buildConfig = (id: Identity, story: Scene[]) => - JSON.stringify( - { - ...id, - lyrics: id.lyrics.filter(Boolean), - ...CI_FILLED, - output: "assets/awan.gif", - scenes: story.map((s) => ({ - act: s.act, - ...(s.say ? { say: s.say } : {}), - ...(s.then ? { then: s.then } : {}), - })), - }, - null, - 2, - ) + "\n"; - -export const WORKFLOW = `name: awan profile -on: - push: - branches: [main, master] - paths: ["awan.json"] - schedule: - - cron: "0 3 * * *" - workflow_dispatch: - -jobs: - awan: - uses: codewithwan/awan/.github/workflows/profile.yml@v0 - permissions: - contents: write - with: - brag_over: 100 - brag_say: "i'm so excited!" - cope_say: "...i'll fix that, promise" -`; - -export function Output({ id, story }: { id: Identity; story: Scene[] }) { - const config = buildConfig(id, story); - return ( -
    -

    - Three files in the repo named after you — {id.handle || "you"}/ - {id.handle || "you"}. No secrets to set up: the token Actions already gives you - reads everything this needs. -

    - - - -

    - Push, and it draws itself — then again every night, with your real numbers. Want it frozen - instead? Point at @v0.0.5 rather than{" "} - @v0: that tag pins the renderer too, so nothing changes - under you. -

    -
    - ); -} - -function File({ name, body, note }: { name: string; body: string; note?: string }) { - const [copied, setCopied] = useState(false); - const copy = async () => { - await navigator.clipboard.writeText(body); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - }; - const download = () => { - const url = URL.createObjectURL(new Blob([body], { type: "text/plain" })); - const a = document.createElement("a"); - a.href = url; - a.download = name.split("/").pop()!; - a.click(); - URL.revokeObjectURL(url); - }; - - return ( -
    -
    - {name} - {note && {note}} - - -
    -
    {body}
    -
    - ); -} diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx deleted file mode 100644 index b4e9cf4..0000000 --- a/web/src/Preview.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import init, { Preview as Reel } from "./wasm/awan_wasm"; -import { TICK_MS, type Scene, actInfo } from "./acts"; -import { Caption } from "./Caption"; -import { drawStats, drawWall } from "./overlays"; - -/** Pixels per canvas cell — the same 33×30 the GIF renderer fills, so what you - * see here is what CI draws, not an impression of it. */ -const CELL_W = 33; -const CELL_H = 30; - -let ready: Promise | null = null; -const loadEngine = () => (ready ??= init()); - -/** The reel, playing. Everything drawn on the canvas comes straight out of the - * engine compiled to wasm — same ticks, same cells, same loop. The caption - * rides underneath, because that's a renderer's job in the GIF too. */ -export function Preview({ story, onBeat }: { story: Scene[]; onBeat: (i: number) => void }) { - const canvas = useRef(null); - const [reel, setReel] = useState(null); - const [playing, setPlaying] = useState(true); - const [tick, setTick] = useState(0); - const acts = story.map((s) => s.act).join(","); - - useEffect(() => { - let dead = false; - loadEngine().then(() => { - if (dead || !story.length) return; - setReel(new Reel(story.map((s) => s.act))); - setTick(0); - }); - return () => { - dead = true; - }; - // rebuilt only when the running order changes; editing a line must not - // restart the reel under someone's cursor - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [acts]); - - useEffect(() => { - if (!reel || !canvas.current) return; - const ctx = canvas.current.getContext("2d"); - if (!ctx) return; - const [cols, rows, total] = [reel.cols(), reel.rows(), reel.ticks()]; - let raf = 0; - let last = performance.now(); - let t = tick; - - const paint = (now: number) => { - raf = requestAnimationFrame(paint); - if (playing && now - last >= TICK_MS) { - last = now; - t = (t + 1) % total; - setTick(t); - } - const frame = reel.frame(playing ? t : tick); - ctx.clearRect(0, 0, cols * CELL_W, rows * CELL_H); - for (let i = 0; i < cols * rows; i++) { - if (!frame[i * 4 + 3]) continue; - ctx.fillStyle = `rgb(${frame[i * 4]},${frame[i * 4 + 1]},${frame[i * 4 + 2]})`; - ctx.fillRect((i % cols) * CELL_W, Math.floor(i / cols) * CELL_H, CELL_W, CELL_H); - } - // Overlays, exactly as the GIF renderer layers them: cells, then the - // wall, then the ground line, then the readout. - const shown = playing ? t : tick; - const k = reel.beat_tick(shown); - const act = k >= 0 ? story[reel.beat_at(shown)]?.act : undefined; - if (act === "contributions") drawWall(ctx, k); - ctx.fillStyle = "#50545f"; - ctx.fillRect(0, rows * CELL_H - 2, cols * CELL_W, 2); - if (act === "stats") drawStats(ctx, k); - }; - raf = requestAnimationFrame(paint); - return () => cancelAnimationFrame(raf); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reel, playing, story]); - - // one source of truth for "which beat is on screen", so scrubbing highlights - // the same row that playing does - useEffect(() => { - if (reel) onBeat(reel.is_leaving(tick) ? -1 : reel.beat_at(tick)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reel, tick]); - - const total = reel?.ticks() ?? 0; - const beat = reel && !reel.is_leaving(tick) ? reel.beat_at(tick) : -1; - const scene = beat >= 0 ? story[beat] : undefined; - - return ( -
    - - -
    - - { - setPlaying(false); - setTick(+e.target.value); - }} - className="h-2 flex-1 appearance-none rounded-none bg-edge accent-lime" - aria-label="Scrub" - /> - - {((tick * TICK_MS) / 1000).toFixed(1)}s / {((total * TICK_MS) / 1000).toFixed(0)}s - -
    -

    - {scene ? ( - <> - now playing {actInfo(scene.act).label} - - ) : ( - "walking on" - )} - {" · "}the engine itself, compiled to wasm — these are the frames CI draws -

    -
    - ); -} diff --git a/web/src/SceneList.tsx b/web/src/SceneList.tsx deleted file mode 100644 index 7b13270..0000000 --- a/web/src/SceneList.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; -import type { DragEndEvent } from "@dnd-kit/core"; -import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { CAPTION_LIMIT, type Scene, actInfo } from "./acts"; -import { fill } from "./sample"; - -type Props = { - story: Scene[]; - playing: number; - onChange: (story: Scene[]) => void; -}; - -/** The running order, draggable. Everything a beat carries is edited in place: - * reordering and rewording are the same task, so they shouldn't live in two - * places. */ -export function SceneList({ story, playing, onChange }: Props) { - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); - const ids = story.map((_, i) => String(i)); - - const onDragEnd = ({ active, over }: DragEndEvent) => { - if (!over || active.id === over.id) return; - onChange(arrayMove(story, +active.id, +over.id)); - }; - - return ( - - -
      - {story.map((scene, i) => ( - onChange(story.map((s, j) => (i === j ? next : s)))} - onDrop={() => onChange(story.filter((_, j) => j !== i))} - /> - ))} -
    -
    -
    - ); -} - -type RowProps = { - id: string; - scene: Scene; - live: boolean; - onEdit: (s: Scene) => void; - onDrop: () => void; -}; - -function Row({ id, scene, live, onEdit, onDrop }: RowProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); - const info = actInfo(scene.act); - - return ( -
  • -
    - - {info.icon} - {info.label} - {info.live && live} - - {(info.ticks * 0.09).toFixed(1)}s - - -
    - - {info.mute ? ( -

    plays your lyrics — no caption of its own

    - ) : ( -
    - onEdit({ ...scene, say })} - /> - {info.splits && ( - onEdit({ ...scene, then })} - /> - )} -
    - )} -
  • - ); -} - -/** One caption line, with the only rule the renderer actually enforces: past - * ~42 characters it runs off the canvas. Better to know while typing. */ -function Line({ - value, - placeholder, - onChange, -}: { - value: string; - placeholder: string; - onChange: (v: string) => void; -}) { - const len = fill(value).length; - const over = len > CAPTION_LIMIT; - return ( - - ); -} - -function Tag({ children, className }: { children: React.ReactNode; className: string }) { - return {children}; -} diff --git a/web/src/Shelf.tsx b/web/src/Shelf.tsx deleted file mode 100644 index 1081671..0000000 --- a/web/src/Shelf.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ACTS, type Scene } from "./acts"; - -/** The shelf of acts you can add. Duration sits on every card on purpose: a - * reel gets too long one innocent-looking beat at a time, and nobody feels - * that cost unless the price is on the label. */ -export function Shelf({ onAdd }: { onAdd: (s: Scene) => void }) { - return ( -
    - {ACTS.map((a) => ( - - ))} -
    - ); -} diff --git a/web/src/acts.ts b/web/src/lib/acts.ts similarity index 54% rename from web/src/acts.ts rename to web/src/lib/acts.ts index 2642169..a47395c 100644 --- a/web/src/acts.ts +++ b/web/src/lib/acts.ts @@ -6,7 +6,8 @@ export type ActInfo = { id: string; label: string; blurb: string; - icon: string; + /** Which of the engine's 8×8 caption icons this beat carries. */ + caption: string; /** Ticks, straight from the engine's `scene_for`. 11 ticks ≈ 1 second. */ ticks: number; /** Whether the act reads numbers CI fetches, rather than words you write. */ @@ -23,24 +24,28 @@ export const TICK_MS = 90; /** The shelf. Durations mirror `scene_for` in awan-core; if that changes, this * lies, so the preview clock is the thing that would catch it. */ export const ACTS: ActInfo[] = [ - { id: "wave", label: "Wave", blurb: "bounces in an excited hello", icon: "👋", ticks: 30 }, - { id: "present", label: "Present", blurb: "stands and introduces himself", icon: "🎁", ticks: 60 }, - { id: "stroll", label: "Stroll", blurb: "walks along, ground scrolling past", icon: "🚶", ticks: 30 }, - { id: "stats", label: "Stats", blurb: "types your numbers into a terminal", icon: "📊", ticks: 150, live: true }, - { id: "contributions", label: "Year wall", blurb: "walks his contribution year", icon: "🟩", ticks: 150, live: true, splits: true }, - { id: "rocket", label: "Rocket", blurb: "builds a rocket", icon: "🛠️", ticks: 40 }, - { id: "launch", label: "Launch", blurb: "...and watches it explode", icon: "🚀", ticks: 50 }, - { id: "bake", label: "Bake", blurb: "fetches an oven, bakes, devours", icon: "🎂", ticks: 118 }, - { id: "campfire", label: "Campfire", blurb: "drags in wood, the fire catches", icon: "🔥", ticks: 90 }, - { id: "sing", label: "Sing", blurb: "karaoke — plays your lyrics", icon: "🎤", ticks: 150, mute: true }, - { id: "soccer", label: "Soccer", blurb: "juggles until it bonks him", icon: "⚽", ticks: 66 }, - { id: "dance", label: "Dance", blurb: "a little dance", icon: "🕺", ticks: 48 }, - { id: "sleep", label: "Sleep", blurb: "yawns, dozes, wakes up", icon: "😴", ticks: 80 }, - { id: "{verdict}", label: "Verdict", blurb: "CI picks: dance if the month was good, sleep if not", icon: "🎲", ticks: 48 }, + { id: "wave", caption: "heart", label: "Wave", blurb: "bounces in an excited hello", ticks: 30 }, + { id: "present", caption: "briefcase", label: "Present", blurb: "stands and introduces himself", ticks: 60 }, + { id: "stroll", caption: "pin", label: "Stroll", blurb: "walks along, ground scrolling past", ticks: 30 }, + { id: "stats", caption: "diamond", label: "Stats", blurb: "types your numbers into a terminal", ticks: 150, live: true }, + { id: "contributions", caption: "code", label: "Year wall", blurb: "walks his contribution year", ticks: 150, live: true, splits: true }, + { id: "rocket", caption: "code", label: "Rocket", blurb: "builds a rocket", ticks: 40 }, + { id: "launch", caption: "star", label: "Launch", blurb: "...and watches it explode", ticks: 50 }, + { id: "bake", caption: "heart", label: "Bake", blurb: "fetches an oven, bakes, devours", ticks: 118 }, + { id: "campfire", caption: "fire", label: "Campfire", blurb: "drags in wood, the fire catches", ticks: 90 }, + { id: "sing", caption: "globe", label: "Sing", blurb: "karaoke — plays your lyrics", ticks: 150, mute: true }, + { id: "soccer", caption: "star", label: "Soccer", blurb: "juggles until it bonks him", ticks: 66 }, + { id: "dance", caption: "star", label: "Dance", blurb: "a little dance", ticks: 48 }, + { id: "sleep", caption: "heart", label: "Sleep", blurb: "yawns, dozes, wakes up", ticks: 80 }, + { id: "{verdict}", caption: "star", label: "Verdict", blurb: "CI picks: dance if the month was good, sleep if not", ticks: 48 }, ]; export const actInfo = (id: string): ActInfo => - ACTS.find((a) => a.id === id) ?? { id, label: id, blurb: "", icon: "❓", ticks: 60 }; + ACTS.find((a) => a.id === id) ?? { id, caption: "diamond", label: id, blurb: "", ticks: 60 }; + +/** The caption icon a beat carries — the engine's own bitmap, so the strip + * under the ground matches the GIF glyph for glyph. */ +export const actIcon = (id?: string): string => (id ? actInfo(id).caption : "heart"); /** Placeholders CI fills in. Shown so nobody wonders where the numbers come * from — and so a preview can stand in for them. */ diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 0000000..0ada619 --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,60 @@ +import type { Scene } from "./acts"; + +export type Identity = { + handle: string; + name: string; + role: string; + location: string; + stack: string; + song: string; + artist: string; + lyrics: string[]; +}; + +export const BLANK: Identity = { + handle: "", name: "", role: "", location: "", stack: "", song: "", artist: "", lyrics: [], +}; + +/** Everything CI writes. They ship as zeroes rather than invented numbers on + * purpose: a made-up streak is decoration, and drawing the real one is the + * entire point of the thing. */ +const CI_FILLED = { streak: 0, stats: [], contributions: "", contrib_year: 0, contrib_recent: 0 }; + +export const buildConfig = (id: Identity, story: Scene[]) => + JSON.stringify( + { + ...id, + lyrics: id.lyrics.filter(Boolean), + ...CI_FILLED, + output: "assets/awan.gif", + scenes: story.map((s) => ({ + act: s.act, + ...(s.say ? { say: s.say } : {}), + ...(s.then ? { then: s.then } : {}), + })), + }, + null, + 2, + ) + "\n"; + +export const WORKFLOW = `name: awan profile +on: + push: + branches: [main, master] + paths: ["awan.json"] + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + awan: + uses: codewithwan/awan/.github/workflows/profile.yml@v0 + permissions: + contents: write + with: + brag_over: 100 + brag_say: "i'm so excited!" + cope_say: "...i'll fix that, promise" +`; + +export const README_LINE = "![awan](assets/awan.gif)"; diff --git a/web/src/lib/engine.ts b/web/src/lib/engine.ts new file mode 100644 index 0000000..fe75d02 --- /dev/null +++ b/web/src/lib/engine.ts @@ -0,0 +1,9 @@ +import init, { Preview } from "../wasm/awan_wasm"; + +/** One init for the page. wasm-bindgen throws if you call it twice, and React + * in strict mode will absolutely try. */ +let started: Promise | null = null; +export const loadEngine = () => (started ??= init()); + +export type { Preview }; +export * as engine from "../wasm/awan_wasm"; diff --git a/web/src/lib/pixels.ts b/web/src/lib/pixels.ts new file mode 100644 index 0000000..8099f2e --- /dev/null +++ b/web/src/lib/pixels.ts @@ -0,0 +1,93 @@ +/** 8×8 icons for the act shelf, drawn rather than borrowed. + * + * System emoji were the first thing here and they were wrong: they arrive in + * whatever style the reader's OS ships, at whatever weight, anti-aliased, + * next to a character made of hard 33px squares. Two art directions in one + * row, and neither of them ours. + * + * These are written as pictures so they can be edited as pictures. `#` is on, + * `.` is off, and the row order is the same as the engine's icons — top down. + */ +export type Art = { rows: string[]; colour: string }; + +const art = (colour: string, ...rows: string[]): Art => ({ rows, colour }); + +export const PIXEL_ART: Record = { + wave: art( + "var(--color-gold)", + "..#..#..", ".#.##.#.", ".#.##.#.", ".######.", + "..####..", "...##...", "...##...", "..####..", + ), + present: art( + "var(--color-punch)", + "..#..#..", ".######.", ".#.##.#.", "########", + "#..##..#", "#..##..#", "#..##..#", "########", + ), + stroll: art( + "var(--color-sky)", + "..###...", "..###...", "...#....", "..###...", + ".#.#.#..", "...#....", "..#.#...", ".#...#..", + ), + stats: art( + "var(--color-lime)", + "########", "#......#", "#.#....#", "#.#..#.#", + "#.#.##.#", "#.#.##.#", "#......#", "########", + ), + contributions: art( + "var(--color-lime)", + "#.##.#.#", ".##.##.#", "##.#.###", "#.###.#.", + ".#.##.##", "##.#.#.#", "#.###.##", ".##.#.#.", + ), + rocket: art( + "var(--color-mute)", + "...##...", "..####..", "..#..#..", "..####..", + ".######.", "#.####.#", "...##...", "..#..#..", + ), + launch: art( + "var(--color-punch)", + "...##...", "..####..", "..####..", ".######.", + "#.####.#", "..#..#..", ".#.##.#.", "#..##..#", + ), + bake: art( + "var(--color-gold)", + "...#....", "..#.#...", "...#....", ".######.", + "########", "########", "########", ".######.", + ), + campfire: art( + "var(--color-punch)", + "...#....", "..###...", "..###...", ".#####..", + ".#####..", "..###...", "#..#...#", ".######.", + ), + sing: art( + "var(--color-grape)", + "..####..", ".#....#.", ".#....#.", ".#....#.", + "..####..", "...##...", "...##...", "..####..", + ), + soccer: art( + "var(--color-ink)", + "..####..", ".#.##.#.", "#..##..#", "##....##", + "##....##", "#..##..#", ".#.##.#.", "..####..", + ), + dance: art( + "var(--color-grape)", + "...##...", "...##...", "#.####.#", ".######.", + "...##...", "..#..#..", ".#....#.", "#......#", + ), + sleep: art( + "var(--color-cloud)", + "#####...", "....#...", "...#....", "#####...", + "...####.", "....#...", "...#....", "..####..", + ), + "{verdict}": art( + "var(--color-gold)", + "########", "#......#", "#.##...#", "#......#", + "#..##..#", "#......#", "#...##.#", "########", + ), + cloud: art( + "var(--color-cloud)", + "..####..", ".######.", "########", "########", + "########", ".######.", "..#..#..", "..#..#..", + ), +}; + +export const pixelArt = (id: string): Art => PIXEL_ART[id] ?? PIXEL_ART["{verdict}"]; diff --git a/web/src/sample.ts b/web/src/lib/sample.ts similarity index 100% rename from web/src/sample.ts rename to web/src/lib/sample.ts diff --git a/web/src/stage/Meter.tsx b/web/src/stage/Meter.tsx new file mode 100644 index 0000000..ee89c18 --- /dev/null +++ b/web/src/stage/Meter.tsx @@ -0,0 +1,41 @@ +import { TICK_MS, actInfo, type Scene } from "../lib/acts"; + +/** How long the loop runs, and roughly what it weighs. + * + * A profile banner is not a film: nobody scrolls past a README and waits a + * minute to learn you like football, and every second is bytes the reader + * pays for. Bands and the KB slope come from real renders — 20s ≈ 444 KB, + * 31s ≈ 532 KB, 53s ≈ 705 KB, 77s ≈ 936 KB. */ +const BANDS = [ + { max: 25, label: "tight", tone: "text-lime", bar: "bg-lime", note: "people watch this one to the end" }, + { max: 40, label: "good", tone: "text-sky", bar: "bg-sky", note: "a comfortable length for a profile" }, + { max: 60, label: "long", tone: "text-gold", bar: "bg-gold", note: "the last beats rarely get seen" }, + { max: Infinity, label: "too long", tone: "text-punch", bar: "bg-punch", note: "nobody waits this long — cut a beat" }, +]; + +export function Meter({ story, at }: { story: Scene[]; at: number }) { + const ticks = story.reduce((n, s) => n + actInfo(s.act).ticks, 0) + 22; // walk on + off + const secs = (ticks * TICK_MS) / 1000; + const band = BANDS.find((b) => secs <= b.max)!; + + return ( +
    +
    + {secs.toFixed(0)}s + {band.label} + ≈ {Math.round(180 + secs * 9.8)} KB +
    +
    + {story.map((s, i) => ( +
    + ))} +
    +

    {band.note}

    +
    + ); +} diff --git a/web/src/stage/Reel.tsx b/web/src/stage/Reel.tsx new file mode 100644 index 0000000..a4ec529 --- /dev/null +++ b/web/src/stage/Reel.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { loadEngine, type Preview } from "../lib/engine"; +import { Preview as Engine } from "../wasm/awan_wasm"; +import { TICK_MS, type Scene } from "../lib/acts"; +import { Stage } from "./Stage"; +import { Transport } from "./Transport"; + +/** The reel: build it, run its clock, hand each tick to the stage. Split from + * Stage so that one only ever has to paint. */ +export function Reel({ story, onBeat }: { story: Scene[]; onBeat: (i: number) => void }) { + const [reel, setReel] = useState(null); + const [playing, setPlaying] = useState(true); + const [tick, setTick] = useState(0); + const order = story.map((s) => s.act).join(","); + + useEffect(() => { + let dead = false; + loadEngine().then(() => { + if (dead || !story.length) return; + setReel(new Engine(story.map((s) => s.act))); + setTick(0); + }); + return () => void (dead = true); + // rebuilt on running order only: editing a line must not restart the reel + // under someone's cursor + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [order]); + + useEffect(() => { + if (!reel || !playing) return; + const total = reel.ticks(); + const id = setInterval(() => setTick((t) => (t + 1) % total), TICK_MS); + return () => clearInterval(id); + }, [reel, playing]); + + useEffect(() => { + if (reel) onBeat(reel.is_leaving(tick) ? -1 : reel.beat_at(tick)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reel, tick]); + + if (!reel || !story.length) { + return ( +
    + {story.length ? "waking him up..." : "add a beat to see him"} +
    + ); + } + + return ( +
    + + setPlaying((p) => !p)} + onScrub={(t) => { + setPlaying(false); + setTick(t); + }} + /> +
    + ); +} diff --git a/web/src/stage/Stage.tsx b/web/src/stage/Stage.tsx new file mode 100644 index 0000000..0014e97 --- /dev/null +++ b/web/src/stage/Stage.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from "react"; +import type { Preview } from "../lib/engine"; +import type { Scene } from "../lib/acts"; +import { actIcon } from "../lib/acts"; +import { fill } from "../lib/sample"; +import { drawCaption, drawStreak, CAPTION_H } from "./text"; +import { drawStats, drawWall } from "./overlays"; +import { SAMPLE } from "../lib/sample"; + +/** Pixels per canvas cell, and the tick the wall's second line lands on — + * every number here is the GIF renderer's, because the whole point is that + * this canvas *is* the GIF, not a picture of one. */ +const CELL_W = 33; +const CELL_H = 30; +const GLOW_AT = 40; +const GROUND = "#505460"; // [80, 84, 96] in gif.rs + +type Props = { reel: Preview; story: Scene[]; tick: number }; + +/** One frame of the reel, painted the way the encoder paints it: cells, wall, + * ground line, readout, badge, caption. Same order, same font, same 1056×416 + * as the file CI commits. */ +export function Stage({ reel, story, tick }: Props) { + const ref = useRef(null); + const cols = reel.cols(); + const rows = reel.rows(); + + useEffect(() => { + const ctx = ref.current?.getContext("2d"); + if (!ctx) return; + const w = cols * CELL_W; + const ground = rows * CELL_H; + + ctx.fillStyle = "#0d1117"; + ctx.fillRect(0, 0, w, ground + CAPTION_H); + + const frame = reel.frame(tick); + for (let i = 0; i < cols * rows; i++) { + if (!frame[i * 4 + 3]) continue; + ctx.fillStyle = `rgb(${frame[i * 4]},${frame[i * 4 + 1]},${frame[i * 4 + 2]})`; + ctx.fillRect((i % cols) * CELL_W, Math.floor(i / cols) * CELL_H, CELL_W, CELL_H); + } + + const leaving = reel.is_leaving(tick); + const beat = leaving ? -1 : reel.beat_at(tick); + const k = reel.beat_tick(tick); + const scene = beat >= 0 ? story[beat] : undefined; + + if (scene?.act === "contributions") drawWall(ctx, k); + ctx.fillStyle = GROUND; + ctx.fillRect(0, ground - 2, w, 2); + if (scene?.act === "stats") drawStats(ctx, k); + else drawStreak(ctx, SAMPLE.streak, w); + + drawCaption(ctx, actIcon(scene?.act), captionOf(scene, k, leaving), w, ground); + }, [reel, story, tick, cols, rows]); + + return ( + + ); +} + +/** Which of a beat's lines is speaking. The wall's `then` takes over the tick + * the spotlight lands, so the preview tells the joke on the same beat CI does. */ +function captionOf(scene: Scene | undefined, k: number, leaving: boolean): string { + if (leaving) return "thanks for stopping by ~"; + if (!scene) return ""; + if (scene.act === "sing") return 'my fav song "..." - the artist'; + const line = scene.then && k >= GLOW_AT ? scene.then : (scene.say ?? ""); + return fill(line); +} diff --git a/web/src/stage/Transport.tsx b/web/src/stage/Transport.tsx new file mode 100644 index 0000000..9f58ad2 --- /dev/null +++ b/web/src/stage/Transport.tsx @@ -0,0 +1,36 @@ +import { TICK_MS } from "../lib/acts"; +import { Button } from "../ui/Button"; + +export function Transport({ + tick, + total, + playing, + onPlay, + onScrub, +}: { + tick: number; + total: number; + playing: boolean; + onPlay: () => void; + onScrub: (t: number) => void; +}) { + return ( +
    + + onScrub(+e.target.value)} + className="h-3 flex-1 appearance-none border-2 border-line bg-void accent-lime" + aria-label="Scrub the reel" + /> + + {((tick * TICK_MS) / 1000).toFixed(1)}s / {((total * TICK_MS) / 1000).toFixed(0)}s + +
    + ); +} diff --git a/web/src/overlays.ts b/web/src/stage/overlays.ts similarity index 74% rename from web/src/overlays.ts rename to web/src/stage/overlays.ts index b7586fe..43b7c23 100644 --- a/web/src/overlays.ts +++ b/web/src/stage/overlays.ts @@ -1,5 +1,6 @@ -import * as w from "./wasm/awan_wasm"; -import { SAMPLE_STATS, SAMPLE_WALL } from "./sample"; +import * as w from "../wasm/awan_wasm"; +import { SAMPLE_STATS, SAMPLE_WALL } from "../lib/sample"; +import { drawText, GLYPH, INK, SCALE } from "./text"; /** Pixels per canvas cell — the GIF renderer's own numbers. */ const CELL_W = 33; @@ -16,23 +17,18 @@ const YEAR_FADE = 45; const mix = (a: readonly number[], b: readonly number[], pct: number) => `rgb(${a.map((v, i) => Math.round((v * (100 - pct) + b[i] * pct) / 100)).join(",")})`; -/** The readout, typing itself into the window the engine opened. The engine - * says how much has printed; the words are the reader's, so they're ours to - * draw — the same division the GIF renderer works to. */ +/** The readout, typing itself into the window the engine opened — in the + * engine's own font, at the renderer's own scale. The engine says how much has + * printed; the words are the reader's, so they're ours to draw. */ export function drawStats(ctx: CanvasRenderingContext2D, k: number) { const [px, py, pw, ph] = w.stats_panel(); - const glyph = 24; const innerW = (pw - 2) * CELL_W; const innerH = (ph - 2) * CELL_H; - const room = Math.max(Math.floor(innerW / glyph) - 2, 8); - const x = px * CELL_W + CELL_W + (innerW - room * glyph) / 2; - const step = glyph + 12; + const room = Math.max(Math.floor(innerW / GLYPH) - 2, 8); + const x = px * CELL_W + CELL_W + Math.floor((innerW - room * GLYPH) / 2); + const step = GLYPH + 12; const slots = w.stats_slots(); - const y0 = py * CELL_H + CELL_H + Math.max(innerH - ((slots - 1) * step + glyph), 0) / 2; - - ctx.font = `${glyph}px ui-monospace, monospace`; - ctx.textBaseline = "top"; - ctx.fillStyle = "#96969f"; + const y0 = py * CELL_H + CELL_H + Math.floor(Math.max(innerH - ((slots - 1) * step + GLYPH), 0) / 2); SAMPLE_STATS.slice(0, slots).forEach((entry, i) => { const shown = w.stats_chars_at(k, i); @@ -41,8 +37,11 @@ export function drawStats(ctx: CanvasRenderingContext2D, k: number) { const gap = Math.max(room - label.length - value.length - 1, 0); const line = `${label}${".".repeat(gap)} ${value}`.slice(0, shown); const y = y0 + i * step; - ctx.fillText(line, x, y); - if (w.stats_typing(k, i)) ctx.fillRect(x + line.length * glyph * 0.6, y, glyph / 2, glyph); + drawText(ctx, line, x, y, SCALE, INK); + if (w.stats_typing(k, i)) { + ctx.fillStyle = INK; + ctx.fillRect(x + line.length * GLYPH, y, GLYPH / 2, GLYPH); + } }); } @@ -56,8 +55,8 @@ export function drawWall(ctx: CanvasRenderingContext2D, k: number) { const [weeks, rows, recent] = w.wall_shape(); const glow = w.wall_glow(k); - const x0 = (32 * CELL_W - weeks * PITCH) / 2; - const y0 = by * CELL_H + (bh * CELL_H - rows * PITCH) / 2; + const x0 = Math.floor((32 * CELL_W - weeks * PITCH) / 2); + const y0 = by * CELL_H + Math.floor((bh * CELL_H - rows * PITCH) / 2); const first = Math.floor((SAMPLE_WALL.length - recent) / rows); if (glow) { diff --git a/web/src/stage/text.ts b/web/src/stage/text.ts new file mode 100644 index 0000000..788b34e --- /dev/null +++ b/web/src/stage/text.ts @@ -0,0 +1,81 @@ +import { glyph, icon } from "../wasm/awan_wasm"; + +/** The renderer's own numbers, from profile/src/gif.rs. A preview that picks + * its own type size is a preview that lies about what CI will produce. */ +export const SCALE = 3; +export const GLYPH = 8 * SCALE; +export const CAPTION_H = 56; +export const INK = "#9696a0"; // [150, 150, 160] in gif.rs — convert, don't eyeball +export const ACCENT = "#e6b464"; // [230, 180, 100] + +const cache = new Map(); +const bits = (ch: string) => { + let b = cache.get(ch); + if (!b) cache.set(ch, (b = glyph(ch))); + return b; +}; + +/** Draw an 8-row bitmap at `scale` px a pixel — `draw_bits` in draw.rs, moved + * across unchanged. */ +export function drawBits( + ctx: CanvasRenderingContext2D, + rows: Uint8Array, + x: number, + y: number, + scale: number, + colour: string, +) { + ctx.fillStyle = colour; + rows.forEach((byte, row) => { + for (let col = 0; col < 8; col++) { + if (byte & (1 << col)) ctx.fillRect(x + col * scale, y + row * scale, scale, scale); + } + }); +} + +/** Text in the engine's font, glyph for glyph. Unknown characters skip but + * still advance, exactly as `draw_text` does. */ +export function drawText( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + scale: number, + colour: string, +) { + let cx = x; + for (const ch of text) { + const b = bits(ch); + if (b.length) drawBits(ctx, b, cx, y, scale, colour); + cx += 8 * scale; + } +} + +/** The narration line under the ground — icon, then text, centred. */ +export function drawCaption( + ctx: CanvasRenderingContext2D, + name: string, + text: string, + w: number, + ground: number, +) { + const gap = SCALE * 3; + const iconW = 8 * SCALE + gap; + const textW = [...text].length * GLYPH; + // Rust divides integers; JS doesn't. A half-pixel x makes the canvas + // antialias every glyph edge, which is a third of the ink gone and a preview + // that quietly stops matching the file CI commits. + const x = Math.floor(Math.max(w - (iconW + textW), 0) / 2); + const y = ground + 20; + drawBits(ctx, icon(name), x, y, SCALE, ACCENT); + drawText(ctx, text, x + iconW, y, SCALE, INK); +} + +/** The pinned streak badge, top-right. Hidden at zero, same as the renderer. */ +export function drawStreak(ctx: CanvasRenderingContext2D, streak: number, w: number) { + if (!streak) return; + const num = String(streak); + const x = w - (8 * SCALE + SCALE * 2 + num.length * GLYPH + 14); + drawBits(ctx, icon("fire"), x, 12, SCALE, ACCENT); + drawText(ctx, num, x + 8 * SCALE + SCALE * 2, 12, SCALE, ACCENT); +} diff --git a/web/src/steps/StepExport.tsx b/web/src/steps/StepExport.tsx new file mode 100644 index 0000000..c456858 --- /dev/null +++ b/web/src/steps/StepExport.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { buildConfig, README_LINE, WORKFLOW, type Identity } from "../lib/config"; +import type { Scene } from "../lib/acts"; +import { Card } from "../ui/Card"; +import { Button } from "../ui/Button"; + +/** Three files, and the honest version of what happens next. */ +export function StepExport({ id, story }: { id: Identity; story: Scene[] }) { + const who = id.handle || "you"; + return ( + +
    + + + +

    + Push, and it draws itself — then again every night with your real numbers. No secrets to + set up: the token Actions already gives you reads everything this needs. Want it frozen + instead? Point at @v0.0.5 rather than{" "} + @v0 — that tag pins the renderer too, so nothing can + change under you. +

    +
    +
    + ); +} + +function File({ name, body, note }: { name: string; body: string; note?: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + await navigator.clipboard.writeText(body); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }; + const save = () => { + const url = URL.createObjectURL(new Blob([body], { type: "text/plain" })); + const a = document.createElement("a"); + a.href = url; + a.download = name.split("/").pop()!; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
    +
    + {name} + {note && {note}} + + +
    +
    {body}
    +
    + ); +} diff --git a/web/src/steps/StepIdentity.tsx b/web/src/steps/StepIdentity.tsx new file mode 100644 index 0000000..c022f7c --- /dev/null +++ b/web/src/steps/StepIdentity.tsx @@ -0,0 +1,57 @@ +import type { Identity } from "../lib/config"; +import { Card } from "../ui/Card"; +import { Field } from "../ui/Field"; + +const FIELDS: { key: keyof Identity; label: string; hint: string }[] = [ + { key: "handle", label: "handle", hint: "codewithwan" }, + { key: "name", label: "name", hint: "what he calls you" }, + { key: "role", label: "role", hint: "fullstack engineer" }, + { key: "location", label: "location", hint: "Indonesia" }, + { key: "stack", label: "stack", hint: "Rust, Go & TypeScript" }, + { key: "song", label: "song", hint: "your favourite" }, + { key: "artist", label: "artist", hint: "who sings it" }, +]; + +/** Who he's talking about. Numbers are deliberately absent: those are CI's job, + * and a field for them would only invite someone to invent one. */ +export function StepIdentity({ id, onChange }: { id: Identity; onChange: (id: Identity) => void }) { + return ( +
    + +
    + {FIELDS.map((f) => ( + onChange({ ...id, [f.key]: v })} + /> + ))} +
    +
    + + +
    + {[0, 1, 2].map((i) => ( + { + const lyrics = [...id.lyrics]; + lyrics[i] = v; + onChange({ ...id, lyrics }); + }} + /> + ))} +
    +

    + They light up word by word while he holds a mic. Skip the sing beat and you can leave + these empty. +

    +
    +
    + ); +} diff --git a/web/src/steps/StepStory.tsx b/web/src/steps/StepStory.tsx new file mode 100644 index 0000000..a11589d --- /dev/null +++ b/web/src/steps/StepStory.tsx @@ -0,0 +1,37 @@ +import type { Scene } from "../lib/acts"; +import { Reel } from "../stage/Reel"; +import { Meter } from "../stage/Meter"; +import { SceneList } from "../story/SceneList"; +import { Shelf } from "../story/Shelf"; +import { Card } from "../ui/Card"; + +/** The reel, and the two things that change 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, + onStory, + onBeat, +}: { + story: Scene[]; + beat: number; + onStory: (s: Scene[]) => void; + onBeat: (i: number) => void; +}) { + return ( +
    +
    + + +
    +
    + + + + + onStory([...story, s])} /> + +
    +
    + ); +} diff --git a/web/src/story/SceneList.tsx b/web/src/story/SceneList.tsx new file mode 100644 index 0000000..5bfddc3 --- /dev/null +++ b/web/src/story/SceneList.tsx @@ -0,0 +1,39 @@ +import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import type { Scene } from "../lib/acts"; +import { SceneRow } from "./SceneRow"; + +export function SceneList({ + story, + playing, + onChange, +}: { + story: Scene[]; + playing: number; + onChange: (s: Scene[]) => void; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const onDragEnd = ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) onChange(arrayMove(story, +active.id, +over.id)); + }; + + return ( + + String(i))} strategy={verticalListSortingStrategy}> +
      + {story.map((scene, i) => ( + onChange(story.map((s, j) => (i === j ? next : s)))} + onDrop={() => onChange(story.filter((_, j) => j !== i))} + /> + ))} +
    +
    +
    + ); +} diff --git a/web/src/story/SceneRow.tsx b/web/src/story/SceneRow.tsx new file mode 100644 index 0000000..91ad9c5 --- /dev/null +++ b/web/src/story/SceneRow.tsx @@ -0,0 +1,71 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { CAPTION_LIMIT, actInfo, type Scene } from "../lib/acts"; +import { fill } from "../lib/sample"; +import { PixelIcon } from "../ui/PixelIcon"; +import { Field } from "../ui/Field"; + +type Props = { + id: string; + scene: Scene; + live: boolean; + onEdit: (s: Scene) => void; + onDrop: () => void; +}; + +/** One beat: what it is, what it costs, and what it says. Reordering and + * rewording are the same task, so they belong in the same place. */ +export function SceneRow({ id, scene, live, onEdit, onDrop }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const info = actInfo(scene.act); + + return ( +
  • +
    + + + {info.label} + {info.live && live} + {(info.ticks * 0.09).toFixed(1)}s + +
    + + {info.mute ? ( +

    plays your lyrics — no caption of its own

    + ) : ( +
    + onEdit({ ...scene, say })} + /> + {info.splits && ( + onEdit({ ...scene, then })} + /> + )} + {(scene.say ?? "").includes("{") && ( +

    → {fill(scene.say ?? "")}

    + )} +
    + )} +
  • + ); +} diff --git a/web/src/story/Shelf.tsx b/web/src/story/Shelf.tsx new file mode 100644 index 0000000..be73468 --- /dev/null +++ b/web/src/story/Shelf.tsx @@ -0,0 +1,26 @@ +import { ACTS, type Scene } from "../lib/acts"; +import { PixelIcon } from "../ui/PixelIcon"; + +/** The acts you can add. Every card wears its duration, because a reel gets too + * long one innocent-looking beat at a time and nobody feels that cost unless + * the price is on the label. */ +export function Shelf({ onAdd }: { onAdd: (s: Scene) => void }) { + return ( +
    + {ACTS.map((a) => ( + + ))} +
    + ); +} diff --git a/web/src/theme.css b/web/src/theme.css index e71b4d9..1539c0e 100644 --- a/web/src/theme.css +++ b/web/src/theme.css @@ -1,57 +1,88 @@ @import "tailwindcss"; -/* A pixel arcade, but a legible one. The character is 33x30px blocks of flat - colour; the page should feel like it belongs to him without fighting him for - attention. Colour comes from the palette below, structure from hard edges and - chunky shadows — no gradients, no blur, nothing that a CRT couldn't do. */ +/* Self-hosted on purpose: a CDN is a third party that can go down, and this + page's whole claim is that it can't take anything with it. */ +@font-face { + font-family: "Silkscreen"; + src: url("/font/silkscreen.ttf") format("truetype"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "Silkscreen"; + src: url("/font/silkscreen-bold.ttf") format("truetype"); + font-weight: 700; + font-display: swap; +} + +/* Neobrutalism, played straight: thick black outlines, hard offset shadows, + flat saturated colour, nothing rounded, nothing blurred. It suits him — he is + 33x30px blocks of flat colour, and a soft interface around him would read as + an apology for how he's drawn. */ @theme { --color-void: #0d1117; - --color-panel: #161b22; - --color-edge: #2d333b; - --color-ink: #e6edf3; + --color-slab: #161b22; + --color-line: #05070a; + --color-ink: #f4f7fb; --color-mute: #8b949e; + /* His own palette, straight off the sprite and the scenes. */ --color-cloud: #7c88f0; --color-lime: #39d353; - --color-punch: #ff6b6b; + --color-punch: #ff5c5c; --color-gold: #ffc94d; --color-sky: #4dd4ff; --color-grape: #b072ff; - --font-pixel: "Courier New", ui-monospace, monospace; + --font-pixel: "Silkscreen", "Courier New", monospace; } @layer base { - html { background: var(--color-void); } + html { + /* a faint tile grid, like a level you're building on */ + background: + linear-gradient(#161b2244 1px, transparent 1px) 0 0 / 24px 24px, + linear-gradient(90deg, #161b2244 1px, transparent 1px) 0 0 / 24px 24px, + var(--color-void); + } body { color: var(--color-ink); font-family: var(--font-pixel); - font-weight: 700; - /* the whole page is pixel art; never let a browser soften it */ image-rendering: pixelated; -webkit-font-smoothing: none; } - ::selection { background: var(--color-lime); color: var(--color-void); } + ::selection { background: var(--color-lime); color: var(--color-line); } + /* focus has to survive a page with no rounded corners to soften it */ + :focus-visible { outline: 3px solid var(--color-sky); outline-offset: 2px; } } @layer components { - /* A hard 4px shadow instead of a soft one: the only depth cue a pixel - display ever had, and it reads at any zoom. */ - .px-box { - border: 2px solid var(--color-edge); - box-shadow: 4px 4px 0 0 #05070a; - background: var(--color-panel); + /* Hard 6px shadow, no blur — the only depth cue a sprite ever had. */ + .nb { + border: 3px solid var(--color-line); + box-shadow: 6px 6px 0 0 var(--color-line); + background: var(--color-slab); } - .px-btn { - border: 2px solid var(--color-edge); - box-shadow: 3px 3px 0 0 #05070a; - transition: transform 80ms steps(2), box-shadow 80ms steps(2); + .nb-tight { border: 3px solid var(--color-line); box-shadow: 3px 3px 0 0 var(--color-line); } + + /* Buttons press into the page like a physical key. steps(), never ease: + nothing in his world moves on a curve. */ + .nb-btn { + border: 3px solid var(--color-line); + box-shadow: 4px 4px 0 0 var(--color-line); + transition: transform 60ms steps(2), box-shadow 60ms steps(2); + } + .nb-btn:hover:not(:disabled) { transform: translate(2px, 2px); box-shadow: 2px 2px 0 0 var(--color-line); } + .nb-btn:active:not(:disabled) { transform: translate(4px, 4px); box-shadow: 0 0 0 0 var(--color-line); } + .nb-btn:disabled { opacity: .35; } + + .nb-input { + border: 3px solid var(--color-line); + background: var(--color-void); + box-shadow: inset 2px 2px 0 0 #00000066; } - .px-btn:hover { transform: translate(1px, 1px); box-shadow: 2px 2px 0 0 #05070a; } - .px-btn:active { transform: translate(3px, 3px); box-shadow: 0 0 0 0 #05070a; } - .px-btn:disabled { opacity: .4; transform: none; box-shadow: 3px 3px 0 0 #05070a; } + .nb-input:focus { border-color: var(--color-sky); outline: none; } } -/* Steps(), not ease. Nothing in this world moves on a curve. */ -@keyframes px-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } } -.px-caret { animation: px-blink 1s steps(1) infinite; } +@keyframes nb-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } } +.nb-caret { animation: nb-blink 1s steps(1) infinite; } diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx new file mode 100644 index 0000000..d148eda --- /dev/null +++ b/web/src/ui/Button.tsx @@ -0,0 +1,19 @@ +import type { ButtonHTMLAttributes } from "react"; + +type Tone = "lime" | "sky" | "gold" | "punch" | "slab"; + +const TONES: Record = { + lime: "bg-lime text-line", + sky: "bg-sky text-line", + gold: "bg-gold text-line", + punch: "bg-punch text-line", + slab: "bg-slab text-ink", +}; + +export function Button({ + tone = "slab", + className = "", + ...rest +}: { tone?: Tone } & ButtonHTMLAttributes) { + return + {i < STEPS.length - 1 && } + + ); + })} + + ); +} From 8859513d4d83308d226a653f3234d03c70be3cdb Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 20:13:10 +0700 Subject: [PATCH 3/8] style: order the imports the icons move disturbed cargo fmt, missed after moving icons.rs into the engine. --- profile/src/gif.rs | 2 +- profile/src/script.rs | 2 +- profile/src/story.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/profile/src/gif.rs b/profile/src/gif.rs index 559a160..6deea07 100644 --- a/profile/src/gif.rs +++ b/profile/src/gif.rs @@ -10,9 +10,9 @@ use image::codecs::gif::{GifEncoder, Repeat}; use image::{Delay, Frame, Rgba, RgbaImage}; use crate::draw::{draw_bits, draw_text, fill}; -use awan_core::icons; use crate::script::{Line, Profile}; use crate::wall::wall; +use awan_core::icons; /// Pixels per canvas cell (32 cols × this ≈ 1050 px wide — safe in VHS too). pub const CELL_W: u32 = 33; diff --git a/profile/src/script.rs b/profile/src/script.rs index 0fae955..9a53f5d 100644 --- a/profile/src/script.rs +++ b/profile/src/script.rs @@ -4,8 +4,8 @@ use awan_core::{Act, Reel}; -use awan_core::icons::{self, Icon}; use crate::story::{act_of, default_story, icon_of}; +use awan_core::icons::{self, Icon}; /// Ticks each lyric line holds during a singing beat. pub const LYRIC_HOLD: i32 = 30; diff --git a/profile/src/story.rs b/profile/src/story.rs index 97d6a0d..5a8e942 100644 --- a/profile/src/story.rs +++ b/profile/src/story.rs @@ -5,8 +5,8 @@ use awan_core::Act; -use awan_core::icons::{self, Icon}; use crate::script::SceneSpec; +use awan_core::icons::{self, Icon}; pub fn act_of(name: &str) -> Act { match name { From 5c0cc36ba4dbe73b23144c3dc4b1e84928f8bfe1 Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 20:20:55 +0700 Subject: [PATCH 4/8] feat(web): pick a character, keep your draft, and read the config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten things, most of them small, one of them not. The character picker is the one that needed the engine to move: spec::load read a file, and a browser has the text but no filesystem. It's spec::parse now, with load as that plus a read. The cast comes from the repo's own TOML at build time, so the preview restyles from the same specs the CLI does and adding a character stays a TOML-only job. 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. The draft survives a refresh. Rewriting seven captions because you reached for reload out of habit is a page you don't come back to. It's localStorage: no account to make, nothing sent anywhere, and clearing your history clears it, which is what clearing your history is supposed to mean. Solo plays one beat alone, because deleting the rest to look at a scene means rebuilding the story afterwards. Every act now wears its own colour on the timeline, and the blocks are buttons, so the bar reads as the story and doubles as a way into it. Light mode repaints the furniture and never the stage: the canvas keeps #0d1117 because that is the colour the GIF has, and a white stage would be showing someone a banner they aren't going to get. Plus: an example filler that uses the project's own handle rather than a stranger's details, a GitHub mark drawn as a path instead of fetched, and just enough highlighting to stop the config being a wall of grey — a tokeniser, not a syntax library, to colour twenty lines. --- crates/awan-core/src/spec.rs | 15 +++++++--- web/crate/src/lib.rs | 15 ++++++++-- web/src/App.tsx | 39 ++++++++++++++++++++----- web/src/lib/acts.ts | 33 +++++++++++---------- web/src/lib/characters.ts | 26 +++++++++++++++++ web/src/lib/config.ts | 5 +++- web/src/lib/example.ts | 27 +++++++++++++++++ web/src/lib/hues.ts | 15 ++++++++++ web/src/lib/store.ts | 37 ++++++++++++++++++++++++ web/src/stage/Meter.tsx | 43 +++++++++++++++++---------- web/src/stage/Reel.tsx | 14 +++++++-- web/src/stage/Stage.tsx | 2 +- web/src/steps/StepExport.tsx | 8 +++-- web/src/steps/StepIdentity.tsx | 12 ++++++++ web/src/steps/StepStory.tsx | 53 +++++++++++++++++++++++++--------- web/src/story/SceneList.tsx | 6 ++++ web/src/story/SceneRow.tsx | 12 +++++++- web/src/theme.css | 18 ++++++++++-- web/src/ui/Code.tsx | 47 ++++++++++++++++++++++++++++++ web/src/ui/GithubMark.tsx | 15 ++++++++++ web/src/ui/SkinToggle.tsx | 25 ++++++++++++++++ 21 files changed, 398 insertions(+), 69 deletions(-) create mode 100644 web/src/lib/characters.ts create mode 100644 web/src/lib/example.ts create mode 100644 web/src/lib/hues.ts create mode 100644 web/src/lib/store.ts create mode 100644 web/src/ui/Code.tsx create mode 100644 web/src/ui/GithubMark.tsx create mode 100644 web/src/ui/SkinToggle.tsx diff --git a/crates/awan-core/src/spec.rs b/crates/awan-core/src/spec.rs index 1d2c270..101a888 100644 --- a/crates/awan-core/src/spec.rs +++ b/crates/awan-core/src/spec.rs @@ -109,14 +109,21 @@ impl fmt::Display for SpecError { impl std::error::Error for SpecError {} -/// Load and validate a character spec from a TOML file. -pub fn load(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(SpecError::Io)?; - let spec: CharacterSpec = toml::from_str(&raw).map_err(SpecError::Parse)?; +/// Parse and validate a character spec from TOML text. +/// +/// Split out from [`load`] because the browser has the text but no filesystem, +/// and a preview that can't restyle itself can't show what a character does. +pub fn parse(raw: &str) -> Result { + let spec: CharacterSpec = toml::from_str(raw).map_err(SpecError::Parse)?; spec.validate()?; Ok(spec) } +/// Load and validate a character spec from a TOML file. +pub fn load(path: &Path) -> Result { + parse(&std::fs::read_to_string(path).map_err(SpecError::Io)?) +} + impl CharacterSpec { pub fn validate(&self) -> Result<(), SpecError> { let fail = |msg: String| Err(SpecError::Invalid(msg)); diff --git a/web/crate/src/lib.rs b/web/crate/src/lib.rs index 6284d2b..571b384 100644 --- a/web/crate/src/lib.rs +++ b/web/crate/src/lib.rs @@ -26,9 +26,9 @@ impl Preview { /// `awan.json`. Unknown names fall back to `present`, so a typo costs you a /// beat rather than the whole preview. #[wasm_bindgen(constructor)] - pub fn new(acts: Vec) -> Preview { + pub fn new(acts: Vec, character_toml: Option) -> Preview { let acts: Vec = acts.iter().map(|a| act_of(a)).collect(); - let reel = Reel::story(Character::default(), &acts); + let reel = Reel::story(character_of(character_toml.as_deref()), &acts); let (cols, rows, _) = reel.pixel_grid(0); Preview { reel, cols, rows } } @@ -83,12 +83,21 @@ impl Preview { } } +/// 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. +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()) + .unwrap_or_default() +} + /// How long a story runs, without building it — so the editor can price a beat /// before you commit to it. That is the whole reason a reel gets too long: you /// cannot feel the cost of an act while you're adding it. #[wasm_bindgen] pub fn story_ticks(acts: Vec) -> i32 { - Preview::new(acts).ticks() + Preview::new(acts, None).ticks() } /// Where the overlays go, and how far along they are. diff --git a/web/src/App.tsx b/web/src/App.tsx index 5bf58b7..c5b7155 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,12 @@ import { useState } from "react"; import { DEFAULT_STORY, type Scene } from "./lib/acts"; import { BLANK, type Identity } from "./lib/config"; +import { useDraft } from "./lib/store"; import { Stepper, STEPS } from "./ui/Stepper"; import { Button } from "./ui/Button"; import { PixelIcon } from "./ui/PixelIcon"; +import { SkinToggle } from "./ui/SkinToggle"; +import { GithubMark } from "./ui/GithubMark"; import { StepIdentity } from "./steps/StepIdentity"; import { StepStory } from "./steps/StepStory"; import { StepExport } from "./steps/StepExport"; @@ -12,9 +15,13 @@ import { StepExport } from "./steps/StepExport"; * share. Everything that draws lives somewhere else. */ export function App() { const [at, setAt] = useState(0); - const [story, setStory] = useState(DEFAULT_STORY); - const [id, setId] = useState(BLANK); + // the draft survives a refresh — rewriting seven captions because you + // reached for reload out of habit is a page you don't come back to + const [story, setStory] = useDraft("story", DEFAULT_STORY); + const [id, setId] = useDraft("identity", BLANK); + const [cast, setCast] = useDraft("cast", "awan"); const [beat, setBeat] = useState(-1); + const [solo, setSolo] = useState(-1); return (
    @@ -23,8 +30,19 @@ export function App() {
    {at === 0 && } - {at === 1 && } - {at === 2 && } + {at === 1 && ( + + )} + {at === 2 && }
    - - source ↗ - +
    ); } diff --git a/web/src/lib/acts.ts b/web/src/lib/acts.ts index a47395c..7539186 100644 --- a/web/src/lib/acts.ts +++ b/web/src/lib/acts.ts @@ -8,6 +8,9 @@ export type ActInfo = { blurb: string; /** Which of the engine's 8×8 caption icons this beat carries. */ caption: string; + /** Its colour on the timeline, matching its icon — the bar should read as + * the story, not as a progress meter. */ + hue: string; /** Ticks, straight from the engine's `scene_for`. 11 ticks ≈ 1 second. */ ticks: number; /** Whether the act reads numbers CI fetches, rather than words you write. */ @@ -24,24 +27,24 @@ export const TICK_MS = 90; /** The shelf. Durations mirror `scene_for` in awan-core; if that changes, this * lies, so the preview clock is the thing that would catch it. */ export const ACTS: ActInfo[] = [ - { id: "wave", caption: "heart", label: "Wave", blurb: "bounces in an excited hello", ticks: 30 }, - { id: "present", caption: "briefcase", label: "Present", blurb: "stands and introduces himself", ticks: 60 }, - { id: "stroll", caption: "pin", label: "Stroll", blurb: "walks along, ground scrolling past", ticks: 30 }, - { id: "stats", caption: "diamond", label: "Stats", blurb: "types your numbers into a terminal", ticks: 150, live: true }, - { id: "contributions", caption: "code", label: "Year wall", blurb: "walks his contribution year", ticks: 150, live: true, splits: true }, - { id: "rocket", caption: "code", label: "Rocket", blurb: "builds a rocket", ticks: 40 }, - { id: "launch", caption: "star", label: "Launch", blurb: "...and watches it explode", ticks: 50 }, - { id: "bake", caption: "heart", label: "Bake", blurb: "fetches an oven, bakes, devours", ticks: 118 }, - { id: "campfire", caption: "fire", label: "Campfire", blurb: "drags in wood, the fire catches", ticks: 90 }, - { id: "sing", caption: "globe", label: "Sing", blurb: "karaoke — plays your lyrics", ticks: 150, mute: true }, - { id: "soccer", caption: "star", label: "Soccer", blurb: "juggles until it bonks him", ticks: 66 }, - { id: "dance", caption: "star", label: "Dance", blurb: "a little dance", ticks: 48 }, - { id: "sleep", caption: "heart", label: "Sleep", blurb: "yawns, dozes, wakes up", ticks: 80 }, - { id: "{verdict}", caption: "star", label: "Verdict", blurb: "CI picks: dance if the month was good, sleep if not", ticks: 48 }, + { id: "wave", hue: "gold", caption: "heart", label: "Wave", blurb: "bounces in an excited hello", ticks: 30 }, + { id: "present", hue: "punch", caption: "briefcase", label: "Present", blurb: "stands and introduces himself", ticks: 60 }, + { id: "stroll", hue: "sky", caption: "pin", label: "Stroll", blurb: "walks along, ground scrolling past", ticks: 30 }, + { id: "stats", hue: "lime", caption: "diamond", label: "Stats", blurb: "types your numbers into a terminal", ticks: 150, live: true }, + { id: "contributions", hue: "lime", caption: "code", label: "Year wall", blurb: "walks his contribution year", ticks: 150, live: true, splits: true }, + { id: "rocket", hue: "mute", caption: "code", label: "Rocket", blurb: "builds a rocket", ticks: 40 }, + { id: "launch", hue: "punch", caption: "star", label: "Launch", blurb: "...and watches it explode", ticks: 50 }, + { id: "bake", hue: "gold", caption: "heart", label: "Bake", blurb: "fetches an oven, bakes, devours", ticks: 118 }, + { id: "campfire", hue: "punch", caption: "fire", label: "Campfire", blurb: "drags in wood, the fire catches", ticks: 90 }, + { id: "sing", hue: "grape", caption: "globe", label: "Sing", blurb: "karaoke — plays your lyrics", ticks: 150, mute: true }, + { id: "soccer", hue: "ink", caption: "star", label: "Soccer", blurb: "juggles until it bonks him", ticks: 66 }, + { id: "dance", hue: "grape", caption: "star", label: "Dance", blurb: "a little dance", ticks: 48 }, + { id: "sleep", hue: "cloud", caption: "heart", label: "Sleep", blurb: "yawns, dozes, wakes up", ticks: 80 }, + { id: "{verdict}", hue: "gold", caption: "star", label: "Verdict", blurb: "CI picks: dance if the month was good, sleep if not", ticks: 48 }, ]; export const actInfo = (id: string): ActInfo => - ACTS.find((a) => a.id === id) ?? { id, caption: "diamond", label: id, blurb: "", ticks: 60 }; + ACTS.find((a) => a.id === id) ?? { id, hue: "mute", caption: "diamond", label: id, blurb: "", ticks: 60 }; /** The caption icon a beat carries — the engine's own bitmap, so the strip * under the ground matches the GIF glyph for glyph. */ diff --git a/web/src/lib/characters.ts b/web/src/lib/characters.ts new file mode 100644 index 0000000..c3659d9 --- /dev/null +++ b/web/src/lib/characters.ts @@ -0,0 +1,26 @@ +import awanToml from "../../../characters/awan.toml?raw"; +import oyenToml from "../../../characters/oyen.toml?raw"; + +/** The cast, read straight out of the repo's own TOML at build time — so the + * preview restyles from the same specs the CLI does, and a new character in + * characters/ only has to be listed here. */ +export type Cast = { id: string; label: string; blurb: string; toml: string; path: string }; + +export const CAST: Cast[] = [ + { + id: "awan", + label: "Awan", + blurb: "the reference cloud buddy", + toml: awanToml, + path: "", + }, + { + id: "oyen", + label: "Oyen", + blurb: "a chunky orange cat", + toml: oyenToml, + path: "characters/oyen.toml", + }, +]; + +export const castOf = (id: string): Cast => CAST.find((c) => c.id === id) ?? CAST[0]; diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 0ada619..72b0287 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -20,10 +20,13 @@ export const BLANK: Identity = { * entire point of the thing. */ const CI_FILLED = { streak: 0, stats: [], contributions: "", contrib_year: 0, contrib_recent: 0 }; -export const buildConfig = (id: Identity, story: Scene[]) => +export const buildConfig = (id: Identity, story: Scene[], character = "") => JSON.stringify( { ...id, + // omitted for the built-in buddy: an empty key is a question the reader + // has to answer before they know it isn't one + ...(character ? { character } : {}), lyrics: id.lyrics.filter(Boolean), ...CI_FILLED, output: "assets/awan.gif", diff --git a/web/src/lib/example.ts b/web/src/lib/example.ts new file mode 100644 index 0000000..9605b26 --- /dev/null +++ b/web/src/lib/example.ts @@ -0,0 +1,27 @@ +import type { Identity } from "./config"; +import type { Scene } from "./acts"; + +/** One click to a filled-in page, so nobody has to invent seven captions before + * they can tell whether they want this at all. It's the project's own handle + * rather than a stranger's: a placeholder that looks like a real person's + * details is a placeholder somebody ships by accident. */ +export const EXAMPLE: Identity = { + handle: "codewithwan", + name: "codewithwan", + role: "fullstack engineer, crafting smooth UX", + location: "Indonesia", + stack: "Rust, Go & TypeScript", + song: "your favourite song", + artist: "the artist", + lyrics: ["humming a tune only i can hear", "la-la, off we go again", "the melody walks me home"], +}; + +export const EXAMPLE_STORY: Scene[] = [ + { act: "wave", say: "hi there! i'm {name}" }, + { act: "present", say: "{role}" }, + { act: "stats", say: "the numbers, if you're curious" }, + { act: "contributions", say: "i'm very happy, {contrib_year} this year", then: "and {contrib_recent} in the last 30 days" }, + { act: "{verdict}", say: "CI decides" }, + { act: "sing" }, + { act: "sleep", say: "okay... nap time, zzz" }, +]; diff --git a/web/src/lib/hues.ts b/web/src/lib/hues.ts new file mode 100644 index 0000000..c39de37 --- /dev/null +++ b/web/src/lib/hues.ts @@ -0,0 +1,15 @@ +/** Tailwind can't build a class from a runtime string, so the map is explicit. + * Both columns come from the same act data, which is what keeps a beat's + * timeline block the same colour as its icon. */ +export const BAR: Record = { + gold: "bg-gold", + punch: "bg-punch", + sky: "bg-sky", + lime: "bg-lime", + grape: "bg-grape", + cloud: "bg-cloud", + mute: "bg-mute", + ink: "bg-ink", +}; + +export const barOf = (hue: string) => BAR[hue] ?? "bg-mute"; diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts new file mode 100644 index 0000000..7293f2f --- /dev/null +++ b/web/src/lib/store.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; + +/** State that survives a refresh. + * + * Somebody rewrites seven captions, reaches for the reload out of habit, and + * loses the lot — that is a page you don't come back to. It stays in the + * browser: no account to make, nothing sent anywhere, and clearing your + * history clears it, which is exactly what someone clearing their history + * means. + */ +const KEY = "awan.draft.v1"; + +type Draft = Record; + +const read = (): Draft => { + try { + return JSON.parse(localStorage.getItem(KEY) ?? "{}"); + } catch { + return {}; // a corrupt draft is not worth a blank page + } +}; + +export function useDraft(field: string, initial: T) { + const [value, setValue] = useState(() => (read()[field] as T) ?? initial); + + useEffect(() => { + try { + localStorage.setItem(KEY, JSON.stringify({ ...read(), [field]: value })); + } catch { + // private mode, or a full quota — losing the draft beats losing the page + } + }, [field, value]); + + return [value, setValue] as const; +} + +export const clearDraft = () => localStorage.removeItem(KEY); diff --git a/web/src/stage/Meter.tsx b/web/src/stage/Meter.tsx index ee89c18..91abb9a 100644 --- a/web/src/stage/Meter.tsx +++ b/web/src/stage/Meter.tsx @@ -1,19 +1,20 @@ import { TICK_MS, actInfo, type Scene } from "../lib/acts"; +import { barOf } from "../lib/hues"; -/** How long the loop runs, and roughly what it weighs. +/** How long the loop runs, what it weighs, and which beat you're in. * * A profile banner is not a film: nobody scrolls past a README and waits a * minute to learn you like football, and every second is bytes the reader * pays for. Bands and the KB slope come from real renders — 20s ≈ 444 KB, * 31s ≈ 532 KB, 53s ≈ 705 KB, 77s ≈ 936 KB. */ const BANDS = [ - { max: 25, label: "tight", tone: "text-lime", bar: "bg-lime", note: "people watch this one to the end" }, - { max: 40, label: "good", tone: "text-sky", bar: "bg-sky", note: "a comfortable length for a profile" }, - { max: 60, label: "long", tone: "text-gold", bar: "bg-gold", note: "the last beats rarely get seen" }, - { max: Infinity, label: "too long", tone: "text-punch", bar: "bg-punch", note: "nobody waits this long — cut a beat" }, + { max: 25, label: "tight", tone: "text-lime", note: "people watch this one to the end" }, + { max: 40, label: "good", tone: "text-sky", note: "a comfortable length for a profile" }, + { max: 60, label: "long", tone: "text-gold", note: "the last beats rarely get seen" }, + { max: Infinity, label: "too long", tone: "text-punch", note: "nobody waits this long — cut a beat" }, ]; -export function Meter({ story, at }: { story: Scene[]; at: number }) { +export function Meter({ story, at, onPick }: { story: Scene[]; at: number; onPick?: (i: number) => void }) { const ticks = story.reduce((n, s) => n + actInfo(s.act).ticks, 0) + 22; // walk on + off const secs = (ticks * TICK_MS) / 1000; const band = BANDS.find((b) => secs <= b.max)!; @@ -25,17 +26,27 @@ export function Meter({ story, at }: { story: Scene[]; at: number }) { {band.label} ≈ {Math.round(180 + secs * 9.8)} KB -
    - {story.map((s, i) => ( -
    - ))} + + {/* every beat wears its own colour, so the bar reads as the story */} +
    + {story.map((s, i) => { + const info = actInfo(s.act); + return ( +
    -

    {band.note}

    + +

    + {band.note} · click a block to jump to it +

    ); } diff --git a/web/src/stage/Reel.tsx b/web/src/stage/Reel.tsx index a4ec529..784d2bd 100644 --- a/web/src/stage/Reel.tsx +++ b/web/src/stage/Reel.tsx @@ -7,7 +7,15 @@ import { Transport } from "./Transport"; /** The reel: build it, run its clock, hand each tick to the stage. Split from * Stage so that one only ever has to paint. */ -export function Reel({ story, onBeat }: { story: Scene[]; onBeat: (i: number) => void }) { +export function Reel({ + story, + toml, + onBeat, +}: { + story: Scene[]; + toml: string; + onBeat: (i: number) => void; +}) { const [reel, setReel] = useState(null); const [playing, setPlaying] = useState(true); const [tick, setTick] = useState(0); @@ -17,14 +25,14 @@ export function Reel({ story, onBeat }: { story: Scene[]; onBeat: (i: number) => let dead = false; loadEngine().then(() => { if (dead || !story.length) return; - setReel(new Engine(story.map((s) => s.act))); + setReel(new Engine(story.map((s) => s.act), toml || undefined)); setTick(0); }); return () => void (dead = true); // rebuilt on running order only: editing a line must not restart the reel // under someone's cursor // eslint-disable-next-line react-hooks/exhaustive-deps - }, [order]); + }, [order, toml]); useEffect(() => { if (!reel || !playing) return; diff --git a/web/src/stage/Stage.tsx b/web/src/stage/Stage.tsx index 0014e97..4ec8500 100644 --- a/web/src/stage/Stage.tsx +++ b/web/src/stage/Stage.tsx @@ -60,7 +60,7 @@ export function Stage({ reel, story, tick }: Props) { ref={ref} width={cols * CELL_W} height={rows * CELL_H + CAPTION_H} - className="block w-full [image-rendering:pixelated]" + className="stage block w-full [image-rendering:pixelated]" aria-label="Preview of your banner" /> ); diff --git a/web/src/steps/StepExport.tsx b/web/src/steps/StepExport.tsx index c456858..df726c0 100644 --- a/web/src/steps/StepExport.tsx +++ b/web/src/steps/StepExport.tsx @@ -3,14 +3,16 @@ import { buildConfig, README_LINE, WORKFLOW, type Identity } from "../lib/config import type { Scene } from "../lib/acts"; import { Card } from "../ui/Card"; import { Button } from "../ui/Button"; +import { Code } from "../ui/Code"; +import { castOf } from "../lib/characters"; /** Three files, and the honest version of what happens next. */ -export function StepExport({ id, story }: { id: Identity; story: Scene[] }) { +export function StepExport({ id, story, cast }: { id: Identity; story: Scene[]; cast: string }) { const who = id.handle || "you"; return (
    - +

    @@ -53,7 +55,7 @@ function File({ name, body, note }: { name: string; body: string; note?: string save

    -
    {body}
    +
    ); } diff --git a/web/src/steps/StepIdentity.tsx b/web/src/steps/StepIdentity.tsx index c022f7c..f603845 100644 --- a/web/src/steps/StepIdentity.tsx +++ b/web/src/steps/StepIdentity.tsx @@ -1,6 +1,9 @@ import type { Identity } from "../lib/config"; +import { BLANK } from "../lib/config"; +import { EXAMPLE } from "../lib/example"; import { Card } from "../ui/Card"; import { Field } from "../ui/Field"; +import { Button } from "../ui/Button"; const FIELDS: { key: keyof Identity; label: string; hint: string }[] = [ { key: "handle", label: "handle", hint: "codewithwan" }, @@ -15,9 +18,18 @@ const FIELDS: { key: keyof Identity; label: string; hint: string }[] = [ /** Who he's talking about. Numbers are deliberately absent: those are CI's job, * and a field for them would only invite someone to invent one. */ export function StepIdentity({ id, onChange }: { id: Identity; onChange: (id: Identity) => void }) { + const filled = Object.values(id).some((v) => (Array.isArray(v) ? v.length : v)); return (
    +
    + + +
    {FIELDS.map((f) => ( void; onBeat: (i: number) => void; -}) { + onCast: (id: string) => void; + onSolo: (i: number) => 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, onStory, onBeat, onCast, onSolo }: 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; + return (
    - - + onBeat(solo >= 0 ? solo : i)} /> +
    +
    - - + +
    + {CAST.map((c) => ( + + ))} +
    +

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

    + + + + + onStory([...story, s])} /> diff --git a/web/src/story/SceneList.tsx b/web/src/story/SceneList.tsx index 5bfddc3..5f3b2c0 100644 --- a/web/src/story/SceneList.tsx +++ b/web/src/story/SceneList.tsx @@ -7,11 +7,15 @@ import { SceneRow } from "./SceneRow"; export function SceneList({ story, playing, + solo, onChange, + onSolo, }: { story: Scene[]; playing: number; + solo: number; onChange: (s: Scene[]) => void; + onSolo: (i: number) => void; }) { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const onDragEnd = ({ active, over }: DragEndEvent) => { @@ -28,8 +32,10 @@ export function SceneList({ id={String(i)} scene={scene} live={i === playing} + solo={i === solo} onEdit={(next) => onChange(story.map((s, j) => (i === j ? next : s)))} onDrop={() => onChange(story.filter((_, j) => j !== i))} + onSolo={() => onSolo(i === solo ? -1 : i)} /> ))} diff --git a/web/src/story/SceneRow.tsx b/web/src/story/SceneRow.tsx index 91ad9c5..1eb6ff2 100644 --- a/web/src/story/SceneRow.tsx +++ b/web/src/story/SceneRow.tsx @@ -9,13 +9,15 @@ type Props = { id: string; scene: Scene; live: boolean; + solo: boolean; onEdit: (s: Scene) => void; onDrop: () => void; + onSolo: () => void; }; /** One beat: what it is, what it costs, and what it says. Reordering and * rewording are the same task, so they belong in the same place. */ -export function SceneRow({ id, scene, live, onEdit, onDrop }: Props) { +export function SceneRow({ id, scene, live, solo, onEdit, onDrop, onSolo }: Props) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const info = actInfo(scene.act); @@ -38,6 +40,14 @@ export function SceneRow({ id, scene, live, onEdit, onDrop }: Props) { {info.label} {info.live && live} {(info.ticks * 0.09).toFixed(1)}s + diff --git a/web/src/theme.css b/web/src/theme.css index 1539c0e..c467d6f 100644 --- a/web/src/theme.css +++ b/web/src/theme.css @@ -37,12 +37,23 @@ --font-pixel: "Silkscreen", "Courier New", monospace; } +/* Light mode repaints the furniture, never the stage. + The canvas is a GIF for a dark README — recolouring it would be showing + someone a banner they aren't going to get. So the page turns and he doesn't. */ +:root[data-skin="light"] { + --color-void: #f2f4f8; + --color-slab: #ffffff; + --color-ink: #0d1117; + --color-mute: #5b6472; + --color-line: #0d1117; +} + @layer base { html { /* a faint tile grid, like a level you're building on */ background: - linear-gradient(#161b2244 1px, transparent 1px) 0 0 / 24px 24px, - linear-gradient(90deg, #161b2244 1px, transparent 1px) 0 0 / 24px 24px, + linear-gradient(color-mix(in srgb, var(--color-mute) 12%, transparent) 1px, transparent 1px) 0 0 / 24px 24px, + linear-gradient(90deg, color-mix(in srgb, var(--color-mute) 12%, transparent) 1px, transparent 1px) 0 0 / 24px 24px, var(--color-void); } body { @@ -73,6 +84,9 @@ transition: transform 60ms steps(2), box-shadow 60ms steps(2); } .nb-btn:hover:not(:disabled) { transform: translate(2px, 2px); box-shadow: 2px 2px 0 0 var(--color-line); } + /* the stage keeps its own dark backdrop whatever the page is doing — that is + the colour the GIF actually has */ + .stage { background: #0d1117; } .nb-btn:active:not(:disabled) { transform: translate(4px, 4px); box-shadow: 0 0 0 0 var(--color-line); } .nb-btn:disabled { opacity: .35; } diff --git a/web/src/ui/Code.tsx b/web/src/ui/Code.tsx new file mode 100644 index 0000000..4dcc81d --- /dev/null +++ b/web/src/ui/Code.tsx @@ -0,0 +1,47 @@ +/** Just enough highlighting to read a config at a glance — keys, strings, + * numbers, comments. A tokeniser, not a parser: it never has to *understand* + * the file, only make a wall of grey stop being a wall of grey, and shipping a + * syntax library to colour twenty lines is not a trade worth making. */ +const RULES: [RegExp, string][] = [ + [/(^|\s)(#.*)$/gm, "text-mute/50"], // yaml comment + [/"([^"\\]|\\.)*"(?=\s*:)/g, "text-sky"], // json key + [/^\s*[\w.-]+(?=:)/gm, "text-sky"], // yaml key + [/"([^"\\]|\\.)*"/g, "text-gold"], // string + [/\b\d+\b/g, "text-grape"], // number +]; + +type Tok = { text: string; cls?: string }; + +export function Code({ body }: { body: string }) { + return ( +
    +      {paint(body).map((t, i) => (
    +        
    +          {t.text}
    +        
    +      ))}
    +    
    + ); +} + +/** Walk the rules in order, keeping whatever a rule has already claimed. */ +function paint(src: string): Tok[] { + let toks: Tok[] = [{ text: src }]; + for (const [re, cls] of RULES) { + toks = toks.flatMap((tok) => (tok.cls ? [tok] : split(tok.text, re, cls))); + } + return toks; +} + +function split(text: string, re: RegExp, cls: string): Tok[] { + const out: Tok[] = []; + let last = 0; + for (const m of text.matchAll(re)) { + const at = m.index!; + if (at > last) out.push({ text: text.slice(last, at) }); + out.push({ text: m[0], cls }); + last = at + m[0].length; + } + if (last < text.length) out.push({ text: text.slice(last) }); + return out; +} diff --git a/web/src/ui/GithubMark.tsx b/web/src/ui/GithubMark.tsx new file mode 100644 index 0000000..1ede031 --- /dev/null +++ b/web/src/ui/GithubMark.tsx @@ -0,0 +1,15 @@ +/** GitHub's mark, drawn as a path rather than pulled from a CDN — one less + * third party, and it takes the surrounding text's colour. */ +export function GithubMark({ size = 14 }: { size?: number }) { + return ( + + + + ); +} diff --git a/web/src/ui/SkinToggle.tsx b/web/src/ui/SkinToggle.tsx new file mode 100644 index 0000000..0df306a --- /dev/null +++ b/web/src/ui/SkinToggle.tsx @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import { useDraft } from "../lib/store"; + +/** Light or dark, remembered. + * + * It only ever repaints the page. The canvas keeps its dark backdrop because + * that is the backdrop the GIF has — turning the stage white would be showing + * someone a banner they aren't going to get. */ +export function SkinToggle() { + const [skin, setSkin] = useDraft<"dark" | "light">("skin", "dark"); + + useEffect(() => { + document.documentElement.dataset.skin = skin; + }, [skin]); + + return ( + + ); +} From 4b1b9a50b8a1d126b9154788850d2f21242877f4 Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 20:22:54 +0700 Subject: [PATCH 5/8] refactor: split the two files that outgrew the cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both crossed 200 lines while I was adding to them, and I committed anyway, which is how a line cap quietly stops meaning anything. spec.rs was doing two jobs: serde says whether the TOML is a document, validate says whether the document is a character. The second is where all the friendly errors live — someone writing pixel art in a text file should get the line they got wrong — so it earns its own file rather than being the tail of another one. The wasm bridge grew the same way. layout.rs is what a renderer needs to draw a scene's overlays: the panel, the wall, the font, the icons. The reel isn't involved, and neither should the file about the reel be. --- crates/awan-core/src/spec.rs | 84 +------------------------ crates/awan-core/src/spec/validate.rs | 90 +++++++++++++++++++++++++++ web/crate/src/layout.rs | 88 ++++++++++++++++++++++++++ web/crate/src/lib.rs | 88 +------------------------- 4 files changed, 183 insertions(+), 167 deletions(-) create mode 100644 crates/awan-core/src/spec/validate.rs create mode 100644 web/crate/src/layout.rs diff --git a/crates/awan-core/src/spec.rs b/crates/awan-core/src/spec.rs index 101a888..6ffcac2 100644 --- a/crates/awan-core/src/spec.rs +++ b/crates/awan-core/src/spec.rs @@ -8,6 +8,8 @@ use std::collections::BTreeMap; use std::fmt; use std::path::Path; +mod validate; + use serde::Deserialize; /// Supported spec revision; declared by every character so old specs never @@ -123,85 +125,3 @@ pub fn parse(raw: &str) -> Result { pub fn load(path: &Path) -> Result { parse(&std::fs::read_to_string(path).map_err(SpecError::Io)?) } - -impl CharacterSpec { - pub fn validate(&self) -> Result<(), SpecError> { - let fail = |msg: String| Err(SpecError::Invalid(msg)); - - if self.spec_version != SPEC_VERSION { - return fail(format!( - "spec_version {} is not supported (engine supports {SPEC_VERSION})", - self.spec_version - )); - } - let s = &self.sprite; - for (set, name, len) in [ - (&s.rows, "rows", SPEC_H), - (&s.sit_rows, "sit_rows", SPEC_H), - (&s.leg_frames, "leg_frames", 4), - ] { - if set.len() != len { - return fail(format!("sprite.{name} must have exactly {len} rows")); - } - for (i, row) in set.iter().enumerate() { - if row.chars().count() != SPEC_W { - return fail(format!("sprite.{name}[{i}] must be {SPEC_W} pixels wide")); - } - } - } - if let Some(row) = &s.shimmer_row { - if row.chars().count() != SPEC_W { - return fail(format!("sprite.shimmer_row must be {SPEC_W} pixels wide")); - } - } - // Face rows shift one row down when sitting, so they can't be last. - for (idx, name, max) in [ - (s.eye_row, "eye_row", SPEC_H - 1), - (s.mouth_row, "mouth_row", SPEC_H - 1), - (s.legs_row, "legs_row", SPEC_H), - ] { - if idx >= max { - return fail(format!("sprite.{name} {idx} out of range (max {max})")); - } - } - if !s.rows[s.eye_row].contains('@') { - return fail(format!( - "sprite.rows[{}] (eye_row) must contain '@' eyes", - s.eye_row - )); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn reference_spec_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../characters/awan.toml") - } - - #[test] - fn reference_character_loads_and_validates() { - let spec = load(&reference_spec_path()).expect("characters/awan.toml must be valid"); - assert_eq!(spec.character.name, "Awan"); - assert_eq!(spec.sprite.rows.len(), 6); - assert!(spec.character.palette.contains_key("body")); - } - - #[test] - fn version_mismatch_is_rejected() { - let mut spec = load(&reference_spec_path()).unwrap(); - spec.spec_version = 999; - assert!(matches!(spec.validate(), Err(SpecError::Invalid(_)))); - } - - #[test] - fn eyeless_eye_row_is_rejected() { - let mut spec = load(&reference_spec_path()).unwrap(); - spec.sprite.rows[spec.sprite.eye_row] = "##########".into(); - assert!(matches!(spec.validate(), Err(SpecError::Invalid(_)))); - } -} diff --git a/crates/awan-core/src/spec/validate.rs b/crates/awan-core/src/spec/validate.rs new file mode 100644 index 0000000..bd04632 --- /dev/null +++ b/crates/awan-core/src/spec/validate.rs @@ -0,0 +1,90 @@ +//! What makes a spec usable, as opposed to merely parseable. +//! +//! Separate from the spec's shape because they're two jobs: serde says whether +//! the TOML is a document, this says whether the document is a character. The +//! errors are the whole point — someone writing pixel art in a text file gets +//! the line they got wrong, not "invalid spec". + +use super::*; + +impl CharacterSpec { + pub fn validate(&self) -> Result<(), SpecError> { + let fail = |msg: String| Err(SpecError::Invalid(msg)); + + if self.spec_version != SPEC_VERSION { + return fail(format!( + "spec_version {} is not supported (engine supports {SPEC_VERSION})", + self.spec_version + )); + } + let s = &self.sprite; + for (set, name, len) in [ + (&s.rows, "rows", SPEC_H), + (&s.sit_rows, "sit_rows", SPEC_H), + (&s.leg_frames, "leg_frames", 4), + ] { + if set.len() != len { + return fail(format!("sprite.{name} must have exactly {len} rows")); + } + for (i, row) in set.iter().enumerate() { + if row.chars().count() != SPEC_W { + return fail(format!("sprite.{name}[{i}] must be {SPEC_W} pixels wide")); + } + } + } + if let Some(row) = &s.shimmer_row { + if row.chars().count() != SPEC_W { + return fail(format!("sprite.shimmer_row must be {SPEC_W} pixels wide")); + } + } + // Face rows shift one row down when sitting, so they can't be last. + for (idx, name, max) in [ + (s.eye_row, "eye_row", SPEC_H - 1), + (s.mouth_row, "mouth_row", SPEC_H - 1), + (s.legs_row, "legs_row", SPEC_H), + ] { + if idx >= max { + return fail(format!("sprite.{name} {idx} out of range (max {max})")); + } + } + if !s.rows[s.eye_row].contains('@') { + return fail(format!( + "sprite.rows[{}] (eye_row) must contain '@' eyes", + s.eye_row + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn reference_spec_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../characters/awan.toml") + } + + #[test] + fn reference_character_loads_and_validates() { + let spec = load(&reference_spec_path()).expect("characters/awan.toml must be valid"); + assert_eq!(spec.character.name, "Awan"); + assert_eq!(spec.sprite.rows.len(), 6); + assert!(spec.character.palette.contains_key("body")); + } + + #[test] + fn version_mismatch_is_rejected() { + let mut spec = load(&reference_spec_path()).unwrap(); + spec.spec_version = 999; + assert!(matches!(spec.validate(), Err(SpecError::Invalid(_)))); + } + + #[test] + fn eyeless_eye_row_is_rejected() { + let mut spec = load(&reference_spec_path()).unwrap(); + spec.sprite.rows[spec.sprite.eye_row] = "##########".into(); + assert!(matches!(spec.validate(), Err(SpecError::Invalid(_)))); + } +} diff --git a/web/crate/src/layout.rs b/web/crate/src/layout.rs new file mode 100644 index 0000000..b075a4f --- /dev/null +++ b/web/crate/src/layout.rs @@ -0,0 +1,88 @@ +//! Where a scene's overlays go, and how far along they are. +//! +//! The engine draws a scene's *shapes* and leaves its *words and numbers* to +//! whoever is rendering — the GIF encoder does exactly this, and so must the +//! preview, or the two headline acts play to an empty stage. These mirror what +//! `awan-core` publishes for the profile generator; nothing is decided here. + +use awan_core::icons; +use font8x8::{BASIC_FONTS, UnicodeFonts}; +use wasm_bindgen::prelude::*; + +/// The readout's window, in cells: `[x, y, w, h]`. +#[wasm_bindgen] +pub fn stats_panel() -> Vec { + let (x, y, w, h) = awan_core::stats::PANEL; + vec![x, y, w, h] +} + +/// How many characters of readout line `i` have typed out at tick `k`. +#[wasm_bindgen] +pub fn stats_chars_at(k: i32, i: usize) -> usize { + awan_core::stats::chars_at(k, i) +} + +/// True while line `i` is still typing, so the preview parks a cursor there. +#[wasm_bindgen] +pub fn stats_typing(k: i32, i: usize) -> bool { + awan_core::stats::typing(k, i) +} + +#[wasm_bindgen] +pub fn stats_slots() -> usize { + awan_core::stats::SLOTS +} + +/// The wall's band, in cells: `[x, y, w, h]`. +#[wasm_bindgen] +pub fn wall_band() -> Vec { + let (x, y, w, h) = awan_core::contributions::WALL; + vec![x, y, w, h] +} + +/// How far up the wall is at tick `k`, 0-100. +#[wasm_bindgen] +pub fn wall_fade(k: i32) -> u32 { + awan_core::contributions::fade_pct(k) +} + +/// How lit the last thirty days are at tick `k`, 0-100. +#[wasm_bindgen] +pub fn wall_glow(k: i32) -> u32 { + awan_core::contributions::glow_pct(k) +} + +/// Columns, rows, and how many days on the end get the spotlight. +#[wasm_bindgen] +pub fn wall_shape() -> Vec { + vec![ + awan_core::contributions::WEEKS, + awan_core::contributions::DAYS, + awan_core::contributions::RECENT, + ] +} + +/// The 8×8 bitmap for `ch` — the exact glyph the GIF renderer draws, from the +/// same font crate. Eight bytes, one per row; bit `1 << col` is a lit pixel. +/// Empty for a character the font doesn't carry, which is what the renderer +/// does too: it skips the glyph and still advances the cursor. +#[wasm_bindgen] +pub fn glyph(ch: char) -> Vec { + BASIC_FONTS.get(ch).map(|g| g.to_vec()).unwrap_or_default() +} + +/// The 8×8 icon a caption carries, by name. Same bitmaps the GIF draws. +#[wasm_bindgen] +pub fn icon(name: &str) -> Vec { + let i = match name { + "heart" => icons::HEART, + "pin" => icons::PIN, + "code" => icons::CODE, + "star" => icons::STAR, + "fire" => icons::FIRE, + "briefcase" => icons::BRIEFCASE, + "globe" => icons::GLOBE, + _ => icons::DIAMOND, + }; + i.0.to_vec() +} diff --git a/web/crate/src/lib.rs b/web/crate/src/lib.rs index 571b384..9733096 100644 --- a/web/crate/src/lib.rs +++ b/web/crate/src/lib.rs @@ -8,8 +8,9 @@ //! It hands JavaScript flat buffers rather than objects: one allocation per //! frame instead of 384, which keeps a 60fps canvas loop honest. -use awan_core::{Act, Character, Reel, icons}; -use font8x8::{BASIC_FONTS, UnicodeFonts}; +mod layout; + +use awan_core::{Act, Character, Reel}; use wasm_bindgen::prelude::*; /// A reel built from a story, ready to draw. @@ -100,89 +101,6 @@ pub fn story_ticks(acts: Vec) -> i32 { Preview::new(acts, None).ticks() } -/// Where the overlays go, and how far along they are. -/// -/// The engine draws a scene's *shapes* and leaves its *words and numbers* to -/// whoever is rendering — the GIF encoder does exactly this, and so must the -/// preview, or the two headline acts play to an empty stage. These mirror what -/// `awan-core` publishes for the profile generator. -#[wasm_bindgen] -pub fn stats_panel() -> Vec { - let (x, y, w, h) = awan_core::stats::PANEL; - vec![x, y, w, h] -} - -/// How many characters of readout line `i` have typed out at tick `k`. -#[wasm_bindgen] -pub fn stats_chars_at(k: i32, i: usize) -> usize { - awan_core::stats::chars_at(k, i) -} - -/// True while line `i` is still typing, so the preview parks a cursor there. -#[wasm_bindgen] -pub fn stats_typing(k: i32, i: usize) -> bool { - awan_core::stats::typing(k, i) -} - -#[wasm_bindgen] -pub fn stats_slots() -> usize { - awan_core::stats::SLOTS -} - -/// The wall's band, in cells: `[x, y, w, h]`. -#[wasm_bindgen] -pub fn wall_band() -> Vec { - let (x, y, w, h) = awan_core::contributions::WALL; - vec![x, y, w, h] -} - -/// How far up the wall is at tick `k`, 0-100. -#[wasm_bindgen] -pub fn wall_fade(k: i32) -> u32 { - awan_core::contributions::fade_pct(k) -} - -/// How lit the last thirty days are at tick `k`, 0-100. -#[wasm_bindgen] -pub fn wall_glow(k: i32) -> u32 { - awan_core::contributions::glow_pct(k) -} - -/// Columns, rows, and how many days on the end get the spotlight. -#[wasm_bindgen] -pub fn wall_shape() -> Vec { - vec![ - awan_core::contributions::WEEKS, - awan_core::contributions::DAYS, - awan_core::contributions::RECENT, - ] -} - -/// The 8×8 bitmap for `ch` — the exact glyph the GIF renderer draws, from the -/// same font crate. Eight bytes, one per row; bit `1 << col` is a lit pixel. -/// Empty for a character the font doesn't carry, which is what the renderer -/// does too: it skips the glyph and still advances the cursor. -#[wasm_bindgen] -pub fn glyph(ch: char) -> Vec { - BASIC_FONTS.get(ch).map(|g| g.to_vec()).unwrap_or_default() -} - -/// The 8×8 icon a caption carries, by name. Same bitmaps the GIF draws. -#[wasm_bindgen] -pub fn icon(name: &str) -> Vec { - let i = match name { - "heart" => icons::HEART, - "pin" => icons::PIN, - "code" => icons::CODE, - "star" => icons::STAR, - "fire" => icons::FIRE, - "briefcase" => icons::BRIEFCASE, - "globe" => icons::GLOBE, - _ => icons::DIAMOND, - }; - i.0.to_vec() -} - /// The act vocabulary, mirrored from the profile generator. Kept as strings on /// purpose: `awan.json` is the contract, not our enum. fn act_of(name: &str) -> Act { From ea74207835edf7c1bff2d186894a3f58eda91f51 Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 20:29:52 +0700 Subject: [PATCH 6/8] fix(web): veil the sky, speak the reader's words, solo from the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parity bugs, both spotted by looking rather than by any test I'd written. The clouds drifted straight through the gaps between days. The engine stopped clearing that patch of sky when the wall started fading in — the fade is the clearing now — and wall.rs veils the band before drawing a square. overlays.ts never ported that, so the preview had weather behind its calendar. It reads as broken rather than atmospheric, and the spotlit month is exactly where you'd notice. And the caption ignored everything you typed. fill() only ever read the sample numbers, so a page whose whole job is showing you *your* banner was quietly showing you ours. It takes your fields first now and falls back to the samples only where you've left a blank — the numbers stay ours regardless, because those are CI's to fetch. The row's play button goes. The timeline already had blocks that knew which beat they were; two ways to do one thing, and the bar is the better of them because it shows you where the beat sits in the loop while you play it. --- web/src/App.tsx | 1 + web/src/lib/sample.ts | 22 ++++++++++++++++------ web/src/stage/Meter.tsx | 14 +++++++++----- web/src/stage/Reel.tsx | 5 ++++- web/src/stage/Stage.tsx | 19 ++++++++++++------- web/src/stage/overlays.ts | 22 +++++++++++++++++++++- web/src/steps/StepStory.tsx | 12 +++++++----- web/src/story/SceneList.tsx | 6 ------ web/src/story/SceneRow.tsx | 12 +----------- 9 files changed, 71 insertions(+), 42 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index c5b7155..3e7f81f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,6 +36,7 @@ export function App() { beat={beat} cast={cast} solo={solo} + id={id} onStory={setStory} onBeat={setBeat} onCast={setCast} diff --git a/web/src/lib/sample.ts b/web/src/lib/sample.ts index 79ab663..778d215 100644 --- a/web/src/lib/sample.ts +++ b/web/src/lib/sample.ts @@ -17,12 +17,22 @@ export const SAMPLE = { contrib_recent: 183, }; -/** Fill the `{tokens}` a caption carries, so the preview reads like a sentence - * instead of a template. */ -export function fill(text: string): string { - return text.replace(/\{(\w+)\}/g, (whole, key: string) => - key in SAMPLE ? String(SAMPLE[key as keyof typeof SAMPLE]) : whole, - ); +/** Whatever the reader has typed so far — every field is optional because a + * half-filled form still deserves a preview. */ +export type Tokens = Partial> & { song?: string; artist?: string }; + +/** Fill the `{tokens}` a caption carries. + * + * Yours first, ours only where you've left a blank. A preview that ignores + * what you just typed is a preview of somebody else's banner — and the numbers + * stay ours regardless, because those are CI's to fetch. + */ +export function fill(text: string, id?: Tokens): string { + return text.replace(/\{(\w+)\}/g, (whole, key: string) => { + const mine = id?.[key]; + if (typeof mine === "string" && mine.trim()) return mine; + return key in SAMPLE ? String(SAMPLE[key as keyof typeof SAMPLE]) : whole; + }); } /** The readout's stand-in lines. Real ones arrive as "label:value" from CI. */ diff --git a/web/src/stage/Meter.tsx b/web/src/stage/Meter.tsx index 91abb9a..010a248 100644 --- a/web/src/stage/Meter.tsx +++ b/web/src/stage/Meter.tsx @@ -14,7 +14,7 @@ const BANDS = [ { max: Infinity, label: "too long", tone: "text-punch", note: "nobody waits this long — cut a beat" }, ]; -export function Meter({ story, at, onPick }: { story: Scene[]; at: number; onPick?: (i: number) => void }) { +export function Meter({ story, at, solo, onPick }: { story: Scene[]; at: number; solo: number; onPick?: (i: number) => void }) { const ticks = story.reduce((n, s) => n + actInfo(s.act).ticks, 0) + 22; // walk on + off const secs = (ticks * TICK_MS) / 1000; const band = BANDS.find((b) => secs <= b.max)!; @@ -35,9 +35,10 @@ export function Meter({ story, at, onPick }: { story: Scene[]; at: number; onPic

    - {band.note} · click a block to jump to it + {band.note} ·{" "} + + {solo >= 0 ? "playing one beat — click it again for the whole story" : "click a block to play that beat alone"} +

    ); diff --git a/web/src/stage/Reel.tsx b/web/src/stage/Reel.tsx index 784d2bd..b9278cf 100644 --- a/web/src/stage/Reel.tsx +++ b/web/src/stage/Reel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { loadEngine, type Preview } from "../lib/engine"; import { Preview as Engine } from "../wasm/awan_wasm"; import { TICK_MS, type Scene } from "../lib/acts"; +import type { Tokens } from "../lib/sample"; import { Stage } from "./Stage"; import { Transport } from "./Transport"; @@ -10,10 +11,12 @@ import { Transport } from "./Transport"; export function Reel({ story, toml, + id, onBeat, }: { story: Scene[]; toml: string; + id: Tokens; onBeat: (i: number) => void; }) { const [reel, setReel] = useState(null); @@ -56,7 +59,7 @@ export function Reel({ return (
    - + (null); const cols = reel.cols(); const rows = reel.rows(); @@ -52,8 +53,8 @@ export function Stage({ reel, story, tick }: Props) { if (scene?.act === "stats") drawStats(ctx, k); else drawStreak(ctx, SAMPLE.streak, w); - drawCaption(ctx, actIcon(scene?.act), captionOf(scene, k, leaving), w, ground); - }, [reel, story, tick, cols, rows]); + drawCaption(ctx, actIcon(scene?.act), captionOf(scene, k, leaving, id), w, ground); + }, [reel, story, tick, cols, rows, id]); return ( = GLOW_AT ? scene.then : (scene.say ?? ""); - return fill(line); + return fill(line, id); } diff --git a/web/src/stage/overlays.ts b/web/src/stage/overlays.ts index 43b7c23..6b1c10e 100644 --- a/web/src/stage/overlays.ts +++ b/web/src/stage/overlays.ts @@ -51,7 +51,14 @@ export function drawStats(ctx: CanvasRenderingContext2D, k: number) { export function drawWall(ctx: CanvasRenderingContext2D, k: number) { const up = w.wall_fade(k); if (!up) return; - const [, by, , bh] = w.wall_band(); + const [bx, by, bw, bh] = w.wall_band(); + + // Sink the band toward the page before drawing a single square. The engine + // stopped clearing this patch of sky when the wall started fading in — the + // fade *is* the clearing — so without this the clouds drift straight through + // the gaps between days, and a calendar you can see weather behind reads as + // broken rather than atmospheric. + veil(ctx, bx * CELL_W, by * CELL_H, bw * CELL_W, bh * CELL_H, up); const [weeks, rows, recent] = w.wall_shape(); const glow = w.wall_glow(k); @@ -76,6 +83,19 @@ export function drawWall(ctx: CanvasRenderingContext2D, k: number) { } } +/** Mix a whole region `pct` of the way to the page background — `veil` in + * wall.rs, moved across unchanged. */ +function veil(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, pct: number) { + const img = ctx.getImageData(x, y, w, h); + const d = img.data; + for (let i = 0; i < d.length; i += 4) { + d[i] = (d[i] * (100 - pct) + BG[0] * pct) / 100; + d[i + 1] = (d[i + 1] * (100 - pct) + BG[1] * pct) / 100; + d[i + 2] = (d[i + 2] * (100 - pct) + BG[2] * pct) / 100; + } + ctx.putImageData(img, x, y); +} + const hexToRgb = (h: string): [number, number, number] => [ parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), diff --git a/web/src/steps/StepStory.tsx b/web/src/steps/StepStory.tsx index 67a6336..95dba91 100644 --- a/web/src/steps/StepStory.tsx +++ b/web/src/steps/StepStory.tsx @@ -1,4 +1,5 @@ import type { Scene } from "../lib/acts"; +import type { Tokens } from "../lib/sample"; import { CAST, castOf } from "../lib/characters"; import { Reel } from "../stage/Reel"; import { Meter } from "../stage/Meter"; @@ -11,6 +12,7 @@ type Props = { beat: number; cast: string; solo: number; + id: Tokens; onStory: (s: Scene[]) => void; onBeat: (i: number) => void; onCast: (id: string) => void; @@ -19,7 +21,7 @@ type Props = { /** 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, onStory, onBeat, onCast, onSolo }: Props) { +export function StepStory({ story, beat, cast, solo, id, onStory, onBeat, onCast, onSolo }: 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; @@ -27,8 +29,8 @@ export function StepStory({ story, beat, cast, solo, onStory, onBeat, onCast, on return (
    - onBeat(solo >= 0 ? solo : i)} /> - + onBeat(solo >= 0 ? solo : i)} /> + onSolo(i === solo ? -1 : i)} />
    @@ -51,8 +53,8 @@ export function StepStory({ story, beat, cast, solo, onStory, onBeat, onCast, on

    - - + + diff --git a/web/src/story/SceneList.tsx b/web/src/story/SceneList.tsx index 5f3b2c0..5bfddc3 100644 --- a/web/src/story/SceneList.tsx +++ b/web/src/story/SceneList.tsx @@ -7,15 +7,11 @@ import { SceneRow } from "./SceneRow"; export function SceneList({ story, playing, - solo, onChange, - onSolo, }: { story: Scene[]; playing: number; - solo: number; onChange: (s: Scene[]) => void; - onSolo: (i: number) => void; }) { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const onDragEnd = ({ active, over }: DragEndEvent) => { @@ -32,10 +28,8 @@ export function SceneList({ id={String(i)} scene={scene} live={i === playing} - solo={i === solo} onEdit={(next) => onChange(story.map((s, j) => (i === j ? next : s)))} onDrop={() => onChange(story.filter((_, j) => j !== i))} - onSolo={() => onSolo(i === solo ? -1 : i)} /> ))} diff --git a/web/src/story/SceneRow.tsx b/web/src/story/SceneRow.tsx index 1eb6ff2..91ad9c5 100644 --- a/web/src/story/SceneRow.tsx +++ b/web/src/story/SceneRow.tsx @@ -9,15 +9,13 @@ type Props = { id: string; scene: Scene; live: boolean; - solo: boolean; onEdit: (s: Scene) => void; onDrop: () => void; - onSolo: () => void; }; /** One beat: what it is, what it costs, and what it says. Reordering and * rewording are the same task, so they belong in the same place. */ -export function SceneRow({ id, scene, live, solo, onEdit, onDrop, onSolo }: Props) { +export function SceneRow({ id, scene, live, onEdit, onDrop }: Props) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const info = actInfo(scene.act); @@ -40,14 +38,6 @@ export function SceneRow({ id, scene, live, solo, onEdit, onDrop, onSolo }: Prop {info.label} {info.live && live} {(info.ticks * 0.09).toFixed(1)}s - From 5f56b319c843fed8d282e6f5ccad16819e819dd0 Mon Sep 17 00:00:00 2001 From: codewithwan Date: Thu, 16 Jul 2026 20:41:53 +0700 Subject: [PATCH 7/8] feat(web): hand over a folder, and call a username a username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three copy buttons made you the build system. You had to know that awan.yml belongs in .github/workflows/ and not beside it, which is exactly the step someone gets wrong at midnight and then can't debug, because nothing errors — the workflow simply never runs. A zip carries the paths, so unzipping is the setup. The files are still there to read, folded away, because the button was the whole answer for most people. handle becomes username. It's what GitHub calls it, it's what names the repo this goes in, and 'handle' was jargon we picked. The old key still parses: every field is serde(default), so a plain rename wouldn't have errored on the configs already out there — it would have quietly blanked them, which is not a rename, it's a trap. {handle} still fills too. Checked both spellings render. And Back on the first step is gone. A button whose only job is to be greyed out is furniture, and this page had enough of that already. Unzipped the real download and rendered from it: paths land right, YAML and JSON parse, 723 frames out the other side. --- awan.json | 2 +- docs/PROFILE.md | 4 +- profile/README.md | 4 +- profile/sample/awan.json | 2 +- profile/sample/project.json | 2 +- profile/src/main.rs | 2 +- profile/src/script.rs | 15 ++++-- web/index.html | 3 +- web/package-lock.json | 7 +++ web/package.json | 1 + web/src/App.tsx | 6 +-- web/src/lib/acts.ts | 2 +- web/src/lib/config.ts | 15 +++++- web/src/lib/example.ts | 4 +- web/src/lib/pixels.ts | 26 ++++----- web/src/lib/sample.ts | 2 +- web/src/lib/zip.ts | 23 ++++++++ web/src/stage/Meter.tsx | 10 ++-- web/src/steps/StepExport.tsx | 96 ++++++++++++++++++---------------- web/src/steps/StepIdentity.tsx | 4 +- web/src/steps/StepStory.tsx | 6 +-- web/src/story/SceneRow.tsx | 6 +-- web/src/story/Shelf.tsx | 2 +- web/src/theme.css | 37 +++++++++++-- web/src/ui/Card.tsx | 2 +- web/src/ui/Code.tsx | 12 ++--- web/src/ui/Field.tsx | 4 +- web/src/ui/Stepper.tsx | 4 +- 28 files changed, 197 insertions(+), 106 deletions(-) create mode 100644 web/src/lib/zip.ts diff --git a/awan.json b/awan.json index d067d2d..81c5553 100644 --- a/awan.json +++ b/awan.json @@ -1,5 +1,5 @@ { - "handle": "codewithwan/awan", + "username": "codewithwan/awan", "name": "awan", "role": "a tiny living character for your terminal", "stack": "Rust", diff --git a/docs/PROFILE.md b/docs/PROFILE.md index 624c0df..b2de7fb 100644 --- a/docs/PROFILE.md +++ b/docs/PROFILE.md @@ -27,12 +27,12 @@ core personality-layer CLI is untouched. Identity fields, a streak, a song + lyrics, an `output` path, and a `scenes` array of `{ act, say }` beats. `say` supports `{name} {role} {location} {stack} -{streak} {handle}`; the `sing` beat plays `lyrics` instead. See the full, +{streak} {username}`; the `sing` beat plays `lyrics` instead. See the full, copy-ready file in [`profile/sample/awan.json`](../profile/sample/awan.json): ```jsonc { - "handle": "codewithwan", + "username": "codewithwan", "name": "Muhammad Ridwan", "role": "fullstack engineer", "stack": "Rust, Go & TypeScript", diff --git a/profile/README.md b/profile/README.md index 1248e77..6f91739 100644 --- a/profile/README.md +++ b/profile/README.md @@ -54,7 +54,7 @@ its own — no Ctrl+C. ```jsonc { - "handle": "codewithwan", + "username": "codewithwan", "character": "", // path to a character TOML; empty = the buddy "name": "Muhammad Ridwan", "role": "fullstack engineer", @@ -112,7 +112,7 @@ its own — no Ctrl+C. | `sleep` | yawns, dozes (`zzz`), wakes up | | `dance` | a little dance | -- **`say`** is the caption; `{name} {role} {location} {stack} {streak} {handle}` +- **`say`** is the caption; `{name} {role} {location} {stack} {streak} {username}` are filled in, plus `{contrib_year}` and `{contrib_recent}`. The `sing` beat needs no `say` — it plays your `lyrics`. - Omit `scenes` entirely for a sensible default story. diff --git a/profile/sample/awan.json b/profile/sample/awan.json index 6d7c1d9..f01d8b5 100644 --- a/profile/sample/awan.json +++ b/profile/sample/awan.json @@ -1,5 +1,5 @@ { - "handle": "codewithwan", + "username": "codewithwan", "name": "Muhammad Ridwan", "role": "fullstack engineer, crafting smooth UX", "location": "Indonesia", diff --git a/profile/sample/project.json b/profile/sample/project.json index fda093d..668ecd4 100644 --- a/profile/sample/project.json +++ b/profile/sample/project.json @@ -1,5 +1,5 @@ { - "handle": "codewithwan/awan", + "username": "codewithwan/awan", "name": "awan", "role": "a tiny living character for your terminal", "stack": "Rust", diff --git a/profile/src/main.rs b/profile/src/main.rs index ffd7d17..89a1204 100644 --- a/profile/src/main.rs +++ b/profile/src/main.rs @@ -69,7 +69,7 @@ fn load(path: &str) -> Profile { /// Build a profile from command-line flags. fn from_flags(args: &[String]) -> Profile { Profile { - handle: args.get(1).cloned().unwrap_or_default(), + username: args.get(1).cloned().unwrap_or_default(), character: flag(args, "-c").unwrap_or_default(), name: flag(args, "--name").unwrap_or_default(), role: flag(args, "--role").unwrap_or_default(), diff --git a/profile/src/script.rs b/profile/src/script.rs index 9a53f5d..b82e9b3 100644 --- a/profile/src/script.rs +++ b/profile/src/script.rs @@ -26,7 +26,14 @@ pub struct SceneSpec { #[derive(Default, serde::Deserialize)] #[serde(default)] pub struct Profile { - pub handle: String, + /// Your GitHub username — what he calls you by, and the account CI reads. + /// + /// Accepts `handle` too. That's what this was called first, and a rename + /// that silently blanks somebody's config is not a rename, it's a trap: + /// every field here is `#[serde(default)]`, so the old key wouldn't error, + /// it would just quietly go missing. + #[serde(alias = "handle")] + pub username: String, /// Path to a character TOML spec. Empty = the built-in buddy. pub character: String, pub name: String, @@ -133,7 +140,7 @@ impl Profile { /// Substitute `{name} {role} {location} {stack} {streak} {handle}`. fn fill(&self, s: &str) -> String { let name = if self.name.is_empty() { - &self.handle + &self.username } else { &self.name }; @@ -142,7 +149,9 @@ impl Profile { .replace("{location}", &self.location) .replace("{stack}", &self.stack) .replace("{streak}", &self.streak.to_string()) - .replace("{handle}", &self.handle) + .replace("{username}", &self.username) + // the old spelling still fills, for configs written before the rename + .replace("{handle}", &self.username) .replace("{contrib_year}", &self.contrib_year.to_string()) .replace("{contrib_recent}", &self.contrib_recent.to_string()) } diff --git a/web/index.html b/web/index.html index 5f54c9f..53d3814 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,6 @@ - + + diff --git a/web/package-lock.json b/web/package-lock.json index 5e2285b..85b71ec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "fflate": "^0.8.3", "react": "^19.2.7", "react-dom": "^19.2.7" }, @@ -1201,6 +1202,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/web/package.json b/web/package.json index 9772718..b40c93f 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "fflate": "^0.8.3", "react": "^19.2.7", "react-dom": "^19.2.7" } diff --git a/web/src/App.tsx b/web/src/App.tsx index 3e7f81f..2d308f1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -47,9 +47,9 @@ export function App() {