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/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/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/crates/awan-core/src/spec.rs b/crates/awan-core/src/spec.rs index 1d2c270..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 @@ -109,92 +111,17 @@ 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) } -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(_)))); - } +/// 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)?) } 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/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/gif.rs b/profile/src/gif.rs index c04088a..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 crate::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/main.rs b/profile/src/main.rs index 8d51df6..89a1204 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; @@ -70,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 68f8603..b82e9b3 100644 --- a/profile/src/script.rs +++ b/profile/src/script.rs @@ -4,8 +4,8 @@ use awan_core::{Act, Reel}; -use crate::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; @@ -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/profile/src/story.rs b/profile/src/story.rs index 03d7d31..5a8e942 100644 --- a/profile/src/story.rs +++ b/profile/src/story.rs @@ -5,8 +5,8 @@ use awan_core::Act; -use crate::icons::{self, Icon}; use crate::script::SceneSpec; +use awan_core::icons::{self, Icon}; pub fn act_of(name: &str) -> Act { match name { 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..bc16360 --- /dev/null +++ b/web/README.md @@ -0,0 +1,77 @@ +# 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. + +## 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 +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. + +**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 +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..b021b8a --- /dev/null +++ b/web/crate/Cargo.lock @@ -0,0 +1,247 @@ +# 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", + "font8x8", + "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 = "font8x8" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" + +[[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..570544f --- /dev/null +++ b/web/crate/Cargo.toml @@ -0,0 +1,26 @@ +# 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" +# 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" +lto = true 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 new file mode 100644 index 0000000..9733096 --- /dev/null +++ b/web/crate/src/lib.rs @@ -0,0 +1,122 @@ +//! 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. + +mod layout; + +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, character_toml: Option) -> Preview { + let acts: Vec = acts.iter().map(|a| act_of(a)).collect(); + let reel = Reel::story(character_of(character_toml.as_deref()), &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) + } +} + +/// 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, None).ticks() +} + +/// 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..53d3814 --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + awan — build your banner + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..85b71ec --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1833 @@ +{ + "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", + "fflate": "^0.8.3", + "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/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", + "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..b40c93f --- /dev/null +++ b/web/package.json @@ -0,0 +1,29 @@ +{ + "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", + "fflate": "^0.8.3", + "react": "^19.2.7", + "react-dom": "^19.2.7" + } +} diff --git a/web/public/font/silkscreen-bold.ttf b/web/public/font/silkscreen-bold.ttf new file mode 100644 index 0000000..6771252 Binary files /dev/null and b/web/public/font/silkscreen-bold.ttf differ diff --git a/web/public/font/silkscreen.ttf b/web/public/font/silkscreen.ttf new file mode 100644 index 0000000..8caff81 Binary files /dev/null and b/web/public/font/silkscreen.ttf differ diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..84d2f06 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,104 @@ +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"; + +/** The shell: which step you're on, and the two pieces of state the steps + * share. Everything that draws lives somewhere else. */ +export function App() { + const [at, setAt] = useState(0); + // 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 ( +
+
+ + +
+ {at === 0 && } + {at === 1 && ( + + )} + {at === 2 && } +
+ + {/* Neither arrow appears where it has nowhere to go. A button whose only + job is to be greyed out is furniture, and the last step's Next was + worse than furniture: it implied a fourth step that doesn't exist. */} + + +
+
+ ); +} + +function Header() { + return ( +
+ +
+

awan

+

a tiny living character for your GitHub profile

+
+ +
+ ); +} + +function Footer() { + return ( +
+

+ 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 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/lib/acts.ts b/web/src/lib/acts.ts new file mode 100644 index 0000000..f9ae842 --- /dev/null +++ b/web/src/lib/acts.ts @@ -0,0 +1,81 @@ +/** 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; + /** 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. */ + 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", 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, 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. */ +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. */ +export const TOKENS: Record = { + "{name}": "your name", + "{role}": "your role", + "{location}": "where you are", + "{stack}": "what you build with", + "{username}": "your GitHub username", + "{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/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 new file mode 100644 index 0000000..caa35c3 --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,74 @@ +import type { Scene } from "./acts"; + +export type Identity = { + /** Your GitHub username. It names the repo this goes in, it's who CI reads + * the numbers for, and it's what he calls you when `name` is blank. */ + username: string; + name: string; + role: string; + location: string; + stack: string; + song: string; + artist: string; + lyrics: string[]; +}; + +export const BLANK: Identity = { + username: "", 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[], 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", + 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)"; + +/** The three files, at the paths they belong at. Handing over a zip beats + * handing over three clipboards: the paths *are* the instructions, and + * ".github/workflows/" is exactly the bit someone gets wrong at midnight. */ +export const files = (id: Identity, story: Scene[], character = "") => ({ + "awan.json": buildConfig(id, story, character), + ".github/workflows/awan.yml": WORKFLOW, + "README.md": `${README_LINE}\n\n# hi, i'm ${id.name || id.username || "you"}\n`, +}); 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/example.ts b/web/src/lib/example.ts new file mode 100644 index 0000000..61766da --- /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 username + * 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 = { + username: "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/pixels.ts b/web/src/lib/pixels.ts new file mode 100644 index 0000000..0095521 --- /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-ink)", + "..#..#..", ".#.##.#.", ".#.##.#.", ".######.", + "..####..", "...##...", "...##...", "..####..", + ), + present: art( + "var(--color-punch-ink)", + "..#..#..", ".######.", ".#.##.#.", "########", + "#..##..#", "#..##..#", "#..##..#", "########", + ), + stroll: art( + "var(--color-sky-ink)", + "..###...", "..###...", "...#....", "..###...", + ".#.#.#..", "...#....", "..#.#...", ".#...#..", + ), + stats: art( + "var(--color-lime-ink)", + "########", "#......#", "#.#....#", "#.#..#.#", + "#.#.##.#", "#.#.##.#", "#......#", "########", + ), + contributions: art( + "var(--color-lime-ink)", + "#.##.#.#", ".##.##.#", "##.#.###", "#.###.#.", + ".#.##.##", "##.#.#.#", "#.###.##", ".##.#.#.", + ), + rocket: art( + "var(--color-mute)", + "...##...", "..####..", "..#..#..", "..####..", + ".######.", "#.####.#", "...##...", "..#..#..", + ), + launch: art( + "var(--color-punch-ink)", + "...##...", "..####..", "..####..", ".######.", + "#.####.#", "..#..#..", ".#.##.#.", "#..##..#", + ), + bake: art( + "var(--color-gold-ink)", + "...#....", "..#.#...", "...#....", ".######.", + "########", "########", "########", ".######.", + ), + campfire: art( + "var(--color-punch-ink)", + "...#....", "..###...", "..###...", ".#####..", + ".#####..", "..###...", "#..#...#", ".######.", + ), + sing: art( + "var(--color-grape-ink)", + "..####..", ".#....#.", ".#....#.", ".#....#.", + "..####..", "...##...", "...##...", "..####..", + ), + soccer: art( + "var(--color-ink)", + "..####..", ".#.##.#.", "#..##..#", "##....##", + "##....##", "#..##..#", ".#.##.#.", "..####..", + ), + dance: art( + "var(--color-grape-ink)", + "...##...", "...##...", "#.####.#", ".######.", + "...##...", "..#..#..", ".#....#.", "#......#", + ), + sleep: art( + "var(--color-cloud-ink)", + "#####...", "....#...", "...#....", "#####...", + "...####.", "....#...", "...#....", "..####..", + ), + "{verdict}": art( + "var(--color-gold-ink)", + "########", "#......#", "#.##...#", "#......#", + "#..##..#", "#......#", "#...##.#", "########", + ), + cloud: art( + "var(--color-cloud-ink)", + "..####..", ".######.", "########", "########", + "########", ".######.", "..#..#..", "..#..#..", + ), +}; + +export const pixelArt = (id: string): Art => PIXEL_ART[id] ?? PIXEL_ART["{verdict}"]; diff --git a/web/src/lib/sample.ts b/web/src/lib/sample.ts new file mode 100644 index 0000000..39757fe --- /dev/null +++ b/web/src/lib/sample.ts @@ -0,0 +1,65 @@ +/** 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", + username: "your username", + streak: 4, + contrib_year: 2060, + contrib_recent: 183, +}; + +/** 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. */ +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/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/lib/zip.ts b/web/src/lib/zip.ts new file mode 100644 index 0000000..3d46abe --- /dev/null +++ b/web/src/lib/zip.ts @@ -0,0 +1,23 @@ +import { zipSync, strToU8 } from "fflate"; + +/** Hand the whole thing over as a folder, not as three clipboards. + * + * Copy buttons make you the build system: you have to know that awan.yml goes + * in .github/workflows/ and not next to it, and that's the step people get + * wrong at midnight. A zip carries the paths, so unzipping *is* the setup. + * + * Stored, not deflated — three text files, and the browser's own download + * layer compresses the transfer anyway. + */ +export function downloadZip(name: string, files: Record) { + const entries = Object.fromEntries( + Object.entries(files).map(([path, body]) => [path, strToU8(body)]), + ); + const blob = new Blob([zipSync(entries, { level: 0 })], { type: "application/zip" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} 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/stage/Meter.tsx b/web/src/stage/Meter.tsx new file mode 100644 index 0000000..c02f6dd --- /dev/null +++ b/web/src/stage/Meter.tsx @@ -0,0 +1,56 @@ +import { TICK_MS, actInfo, type Scene } from "../lib/acts"; +import { barOf } from "../lib/hues"; + +/** 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-ink", note: "people watch this one to the end" }, + { max: 40, label: "good", tone: "text-sky-ink", note: "a comfortable length for a profile" }, + { max: 60, label: "long", tone: "text-gold-ink", note: "the last beats rarely get seen" }, + { max: Infinity, label: "too long", tone: "text-punch-ink", note: "nobody waits this long — cut a beat" }, +]; + +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)!; + + return ( +
+
+ {secs.toFixed(0)}s + {band.label} + ≈ {Math.round(180 + secs * 9.8)} KB +
+ + {/* 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} ·{" "} + + {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 new file mode 100644 index 0000000..b9278cf --- /dev/null +++ b/web/src/stage/Reel.tsx @@ -0,0 +1,75 @@ +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"; + +/** 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, + toml, + id, + onBeat, +}: { + story: Scene[]; + toml: string; + id: Tokens; + 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), 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, toml]); + + 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..acad258 --- /dev/null +++ b/web/src/stage/Stage.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef } from "react"; +import type { Preview } from "../lib/engine"; +import type { Scene } from "../lib/acts"; +import type { Tokens } from "../lib/sample"; +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; id: Tokens }; + +/** 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, id }: 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, id), w, ground); + }, [reel, story, tick, cols, rows, id]); + + 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, id: Tokens): string { + if (leaving) return "thanks for stopping by ~"; + if (!scene) return ""; + if (scene.act === "sing") { + const song = id.song?.trim() || "an old favourite"; + const artist = id.artist?.trim() || "someone great"; + return fill(`my fav song "${song}" - ${artist}`, id); + } + const line = scene.then && k >= GLOW_AT ? scene.then : (scene.say ?? ""); + return fill(line, id); +} 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/stage/overlays.ts b/web/src/stage/overlays.ts new file mode 100644 index 0000000..6b1c10e --- /dev/null +++ b/web/src/stage/overlays.ts @@ -0,0 +1,106 @@ +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; +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 — 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 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 + Math.floor((innerW - room * GLYPH) / 2); + const step = GLYPH + 12; + const slots = w.stats_slots(); + 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); + 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; + 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); + } + }); +} + +/** 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 [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); + + 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) { + 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); + } + } +} + +/** 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), + 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/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..7a87f03 --- /dev/null +++ b/web/src/steps/StepExport.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { files, type Identity } from "../lib/config"; +import { downloadZip } from "../lib/zip"; +import { castOf } from "../lib/characters"; +import type { Scene } from "../lib/acts"; +import { Card } from "../ui/Card"; +import { Button } from "../ui/Button"; +import { Code } from "../ui/Code"; + +/** The whole setup, as a folder — or a file at a time, if that's your way of + * working. The zip is the fast path, not the only one. */ +export function StepExport({ id, story, cast }: { id: Identity; story: Scene[]; cast: string }) { + const you = id.username.trim(); + const bundle = files(id, story, castOf(cast).path); + + return ( + +
+
+ +

+ Unzip into{" "} + + {you || "you"}/{you || "you"} + {" "} + — the repo named after you — and push. +
+ The paths come with it: {".github/workflows/"} is the bit that's easy to get wrong. +

+
+ +
    + {Object.entries(bundle).map(([path, body]) => ( + + ))} +
+ +

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

+
+
+ ); +} + +/** One file: copy it, save it, or open it up and read it first. */ +function FileRow({ path, body }: { path: string; body: string }) { + const [open, setOpen] = useState(false); + 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 = path.split("/").pop()!; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
  • +
    + + {path} + {body.trimEnd().split("\n").length} lines + + +
    + {open && } +
  • + ); +} diff --git a/web/src/steps/StepIdentity.tsx b/web/src/steps/StepIdentity.tsx new file mode 100644 index 0000000..9971499 --- /dev/null +++ b/web/src/steps/StepIdentity.tsx @@ -0,0 +1,69 @@ +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: "username", label: "username", 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 }) { + const filled = Object.values(id).some((v) => (Array.isArray(v) ? v.length : v)); + 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..487ca3f --- /dev/null +++ b/web/src/steps/StepStory.tsx @@ -0,0 +1,66 @@ +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"; +import { SceneList } from "../story/SceneList"; +import { Shelf } from "../story/Shelf"; +import { Card } from "../ui/Card"; + +type Props = { + story: Scene[]; + beat: number; + cast: string; + solo: number; + id: Tokens; + onStory: (s: Scene[]) => 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, 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; + + return ( +
    +
    + onBeat(solo >= 0 ? solo : i)} /> + onSolo(i === solo ? -1 : 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 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..1a101bd --- /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..2cfd279 --- /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 new file mode 100644 index 0000000..8b771bd --- /dev/null +++ b/web/src/theme.css @@ -0,0 +1,133 @@ +@import "tailwindcss"; + +/* 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-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: #ff5c5c; + --color-gold: #ffc94d; + --color-sky: #4dd4ff; + --color-grape: #b072ff; + + /* An accent does two jobs and they want opposite things. As a *fill* it + carries dark text, so it has to stay bright in both skins. As *text* it + sits on the slab, so on white it has to go dark. One token can't be both — + hence the -ink pair. In dark mode they're the same value. */ + --color-cloud-ink: #7c88f0; + --color-lime-ink: #39d353; + --color-punch-ink: #ff5c5c; + --color-gold-ink: #ffc94d; + --color-sky-ink: #4dd4ff; + --color-grape-ink: #b072ff; + + /* One token for the quiet text instead of ten opacities. An opacity that + reads as "subdued" on a dark slab reads as "gone" on a white one, and the + hint you can't see is a hint you didn't write. */ + --color-faint: #6b7482; + + --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. + + The accents are re-tuned rather than reused. His palette is built to glow on + #0d1117, and on white it stops being a colour: the gold measured 1.5:1 + against a 4.5:1 requirement, the sky 1.7:1, the lime 2.0:1. Same identity, + same job, enough ink to survive the paper. */ +:root[data-skin="light"] { + --color-void: #eef1f6; + --color-slab: #ffffff; + --color-ink: #0d1117; + --color-mute: #4a5462; /* 7.7:1 */ + --color-faint: #5f6875; /* 5.6:1 — still quiet, still there */ + --color-line: #0d1117; + + /* Fills keep their glow: a bright block carries #0d1117 text at ~10:1 + whichever skin you're in. Only the text versions move. */ + --color-cloud-ink: #4049b8; /* 7.3:1 on white */ + --color-lime-ink: #137a33; /* 5.4:1 */ + --color-punch-ink: #c31d29; /* 6.0:1 */ + --color-gold-ink: #8a5a00; /* 5.9:1 */ + --color-sky-ink: #0860c4; /* 6.0:1 */ + --color-grape-ink: #6b31c9; /* 7.2:1 */ +} + +@layer base { + html { + /* a faint tile grid, like a level you're building on */ + background: + 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 { + color: var(--color-ink); + font-family: var(--font-pixel); + image-rendering: pixelated; + -webkit-font-smoothing: none; + } + ::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 { + /* 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); + } + .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); } + /* 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; } + + .nb-input { + border: 3px solid var(--color-line); + background: var(--color-void); + box-shadow: inset 2px 2px 0 0 #00000066; + } + .nb-input:focus { border-color: var(--color-sky); outline: none; } +} + +@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 + ); +} diff --git a/web/src/ui/Stepper.tsx b/web/src/ui/Stepper.tsx new file mode 100644 index 0000000..dc2259f --- /dev/null +++ b/web/src/ui/Stepper.tsx @@ -0,0 +1,23 @@ +export const STEPS = ["who you are", "the story", "take it home"] as const; + +/** Three steps, because one page of everything is how the last version buried + * the preview under a form. Steps are clickable both ways: nothing here is + * destructive, so there's no reason to trap anyone going back. */ +export function Stepper({ at, onGo }: { at: number; onGo: (i: number) => void }) { + return ( +
      + {STEPS.map((label, i) => { + const state = i === at ? "bg-lime text-line" : i < at ? "bg-slab text-lime-ink" : "bg-slab text-mute"; + return ( +
    1. + + {i < STEPS.length - 1 && } +
    2. + ); + })} +
    + ); +} 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" }, +});